レイマーチングで遊ぶ (2)

2019-11-07

背景

以下の続き:

成果物

(視点が動くのは Unity 側で操作している)

モバイルで動かしてみた

  • 2017 年末くらいに出た中堅 Android (HUAWEI Mate 10 lite) で動作確認
  • さすがにそのままでは重いので、計算を一部簡易的なものにして、レンダリングの解像度を 400 x 225 まで落としている
    • 中堅 Android で動いていれば iOS 端末でも動くだろう
    • 試しに iPad Pro (2017) で 800 x 600 でレンダリングしてみたが、余裕だった。まあ Pro なので
  • あまりモバイルゲームでの実用性は考えていなかったが、軽いロジックで解像度を落とせば、 背景のエフェクトなどにレイマーチングを使うのもアリかもしれない
    • (コンシューマゲームでは、レイマーチングは雲などのボリュームレンダリングに使われたりする)

ソースコード

モバイルで動かした軽量版を掲載する:

Shader "Raymarching/PurpleRay-light" {
  Properties {
    _DiffuseColor ("Diffuse Color", Color) = (1, 1, 1, 1)
  }
  SubShader {
    Tags {"Queue" = "Transparent" "LightMode" = "ForwardBase"}
    LOD 100
    Cull Off ZWrite On ZTest Always

    Pass {
      CGPROGRAM
      #pragma vertex vert
      #pragma fragment frag

      #include "UnityCG.cginc"
      #include "Lighting.cginc"

      #define PI 3.14159265359f

      // Properties
      uniform fixed4 _DiffuseColor;

      // Vertex input
      struct appdata {
        float4 vertex : POSITION;
        float2 uv     : TEXCOORD0;
      };

      // Vertex to Fragment
      struct v2f {
        float4 vertex : SV_POSITION;
        float2 uv     : TEXCOORD0;
        float4 pos    : POSITION1;
      };

      //------------------------------------------------------------------------
      // Distance function helpers
      //------------------------------------------------------------------------
      float2 mod2(float2 a, float2 b) {
        return a - b * floor(a / b);
      }

      float2 divSpace2d(float2 pos, float interval, float offset = 0) {
        return mod2(pos + offset, interval) - (0.5 * interval);
      }

      float2 rotate2d(float2 pos, float angle) {
        float s = sin(angle);
        float c = cos(angle);
        return mul(float2x2(c, s, -s, c), pos);
      }

      //------------------------------------------------------------------------
      // Distance functions
      //------------------------------------------------------------------------
      float dPrism(float3 pos, float2 h) {
        float3 q = abs(pos);
        return max(q.z - h.y, max(q.x * 0.866025 + pos.y * 0.5, -pos.y) - h.x * 0.5);
      }

      float distanceFunc(float3 pos) {
        pos.xz = abs(pos.yx);
        float interval = 15 + (cos(_Time * 16) + 1.0) * 5;
        float offset = 0;
        float height = 1.0 + sin(floor(pos.z / 8) + _Time * 16) * 90;
        pos.zx = divSpace2d(pos.zx, interval, offset);
        pos.yz = rotate2d(pos.yz, PI / 2);
        return dPrism(pos, float2(1, height));
      }

      //------------------------------------------------------------------------
      // Vertex shader
      //------------------------------------------------------------------------
      v2f vert(appdata v) {
        v2f o;
        o.vertex = UnityObjectToClipPos(v.vertex);
        o.pos = mul(unity_ObjectToWorld, v.vertex);  // local coord to world coord
        o.uv = v.uv;
        return o;
      }

      //------------------------------------------------------------------------
      // Rendering functions
      //------------------------------------------------------------------------
      // 簡易法線計算
      float3 calcEasyNormal(float3 pos) {
        const float eps = 0.001;
        float2 e = float2(1.0, -1.0) * 0.5773 * eps;

        return normalize(
          e.xyy * distanceFunc(pos + e.xyy) +
          e.yyx * distanceFunc(pos + e.yyx) +
          e.yxy * distanceFunc(pos + e.yxy) +
          e.xxx * distanceFunc(pos + e.xxx)
        );
      }

      fixed4 calcColor(float3 pos, float3 rayVec, float totalDistance) {
        half3 lightVec = _WorldSpaceLightPos0.xyz;
        half3 normal   = calcEasyNormal(pos);

        half NdotL = saturate(dot(normal, lightVec));

        fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb * _DiffuseColor.rgb;
        fixed3 diffuse = _LightColor0.rgb * _DiffuseColor * NdotL;
        fixed4 color   = fixed4(ambient + diffuse, 1.0);

        // Simple Fog
        fixed4 fogColor = fixed4(0.15, 0.1, 0.5, 1.0);
        float fogFactor = saturate(totalDistance / 150);
        return lerp(color, fogColor, fogFactor);
      }

      //------------------------------------------------------------------------
      // Fragment shader : Raymarching
      //------------------------------------------------------------------------
      fixed4 frag(v2f i) : SV_Target {
        float3 pos    = i.pos.xyz;
        float3 rayVec = normalize(pos.xyz - _WorldSpaceCameraPos);

        const int MAX_MARCH = 32;
        const float EPSILON = 0.001;

        fixed4 color = 0;
        float totalDistance = 0;
        float minDistance = 1e+32;
        for (int i = 0; i < MAX_MARCH; ++i) {
          // Advance ray until it reaches the object
          float progress = distanceFunc(pos);
          float farFactor = (totalDistance / 150);
          if (minDistance > progress + farFactor) {
            minDistance = progress + farFactor;
          }
          if (progress > EPSILON) {
            pos.xyz += progress * rayVec.xyz;
            totalDistance += progress;
            continue;
          }

          // Fill fragment with lighting
          color = calcColor(pos, rayVec, totalDistance);
        }

        // Emission
        fixed4 emColor = fixed4(1.8, 0.3, 1.0, 1.0);
        fixed4 emission = pow(minDistance + 0.8, -2.0);
        return color + (emission * emColor);
        return emission * emColor;
      }

      ENDCG
    }
  }
}

解説

シェーダのロジック

基本的には以下のコードをベースに、少し調整したもの:

距離関数の冒頭で以下のような座標変換をすると、今回作ったもののような放射状っぽい見た目になる:

      float distanceFunc(float3 pos) {
        pos.xz = abs(pos.yx);
        float interval = 15 + (cos(_Time * 16) + 1.0) * 5;
        float offset = 0;
        float height = 1.0 + sin(floor(pos.z / 8) + _Time * 16) * 90;
        pos.zx = divSpace2d(pos.zx, interval, offset);
        pos.yz = rotate2d(pos.yz, PI / 2);
        return dPrism(pos, float2(1, height));
      }

なぜそうなるのかはうまく説明できないので聞かないでほしい。

Unity を活かしたカメラの制御

  • 上記のシェーダを当てた Material は、カメラの子オブジェクトにした Plane に当てている
    • カメラが映す Plane をカメラの子オブジェクトにしておくと、 カメラを動かした際に常にカメラの前面に来るようになる:
  • シェーダ内では頂点をワールド座標で扱っているので、カメラを動かすことで 「レイマーチングの描画空間を自由に移動できる」 ような状態になる
    • 視点の移動や回転をシェーダのロジックではなく Unity 側で制御できるようになるので扱いやすい

モバイル向けにレイマーチング部分だけ解像度を落とす

  • レイマーチングはフラグメント(画素)ごとに for ループを回して距離関数をたくさん評価する処理なので、 当然ながら解像度が高いほど処理負荷が高い
  • モバイル端末は物理的に小さいディスプレイでも高解像度のものが多い (dpi が高いものが多い) ので、 レンダリングの解像度を落とすのは負荷を下げる点で有効である
    • (Android 端末は種類が多いのもあり、スペックに対して dpi が無駄に高いものがちらほらある印象)

  • アプリ全体ではなく、レイマーチング部分の解像度だけを落とすには RenderTexture を使う方法がある
    • サイズを指定した RenderTexture を用意し、カメラの Target Texture として設定することで、 カメラのレンダリング対象がその RenderTexture になる
    • レンダリング後の RenderTexture を uGUI の RawImage で画面いっぱいに表示してやれば、 レイマーチング部分だけを任意の解像度にできる
  • (Unity でやるもっとスマートな方法があるかもしれないが、とりあえずやりたいことの実現には至った)

所感

  • これはレイマーチングを勉強し始めて 1 週間くらいで作ったものだが、意外と自分にもやれる気がしてきた
  • ゲームプログラマ的には軽めの負荷で面白い見た目が得られるような、実用性のあるものを探求したい
  • 調整すれば普通にモバイルでも動いたので、人に見せたりしやすくなって楽しい