【Unity】Unityでレイマーチング入門 - 球を表示してライティングする

レイトマーチングの概念を理解するためにUnityで実装してみます。
球を表示して簡単なライティングを行うところまで実装します。

Unity2019.1.10

レイマーチングとは?

レイマーチングは視点からスクリーンに対してレイを飛ばし、
視点からの経路をトレースすることで物体を描画するレンダリング手法です。

いま、次のように視点といくつかの球があるとします。
またカメラからレイを飛ばす方向を一つ決めておきます。

f:id:halya_11:20190720130216p:plain

レイマーチングではまず、視点から各オブジェクトとの最短距離のうち最短のものを求めます。

f:id:halya_11:20190720130318p:plain

そしてこの長さの分だけレイを前に進めます。

f:id:halya_11:20190720130405p:plain

次のその位置からまた、最短距離を求めます。

f:id:halya_11:20190720130449p:plain

そしてまたこの長さの分だけレイを前に進めます。

f:id:halya_11:20190720130525p:plain

これを繰り返していくと一つの球にかなり近くまで接近します。

f:id:halya_11:20190720130706p:plain

このような状況の場合、つまり最短距離がかなり短くなったら、衝突したと判定します。
衝突があった場合にはこのレイに対応するピクセルの色を、この球の色を使って計算します。

これがレイマーチングの基本的な考え方です。
以降、これをUnityで実装する方法を紹介していきます。

準備

まずは準備としてベースとなる仕組みを作ります。
画面全体に対して効果を掛けるシェーダを書きたいので、Unityのポストエフェクトの仕組みを使います。

まずはポストエフェクト用のスクリプトを作ります。

using UnityEngine;

[ExecuteInEditMode]
public class PostEffect : MonoBehaviour {

    [SerializeField]
    private Shader _shader = default;

    private Material _material;

    private void OnRenderImage(RenderTexture source, RenderTexture dest){
        if (_shader != null) {
            if (_material == null) {
                _material = new Material(_shader);
            }
            // Materialを使って描画
            Graphics.Blit(source, dest, _material);
        }
        else {
            // 何もしない
            Graphics.Blit(source, dest);
        }
    }
}

このあたりの技術的詳細については以下の記事を参照してください。

light11.hatenadiary.com

次にシェーダのベースを作ります。

Shader "RayTracing"
{
    SubShader
    {
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            void vert(float4 vertex : POSITION, out float4 position : SV_POSITION)
            {
                position = UnityObjectToClipPos(vertex);
            }

            fixed4 frag(UNITY_VPOS_TYPE vpos : VPOS) : SV_Target
            {
                return 1;
            }
            ENDCG
        }
    }
}

頂点シェーダでは今回は何も行わず、処理は主にフラグメントシェーダで行います。
レイトレーシングにはスクリーン座標を使うため、VPOSによりスクリーン座標を使えるようにしています。

距離関数を作る

さてそれではレイマーチングのためのシェーダコードを書いていきます。

レイマーチングではレイの起点とオブジェクトとの最短距離を使うというのは上述の通りです。
したがってまずはこの最短距離を求める関数を作りましょう。

今回はシンプルに、レイの起点の座標を与えて原点に置かれた球との最短距離を返す関数を作ります。
わかりやすく二次元の図で考えるとこんな感じです。

f:id:halya_11:20190720131422p:plain

上の図から考えると、球の距離関数はこうなることがわかります。

// 球の距離関数
float sphereDist(float3 position, float radius)
{
    return length(position) - radius;
}

// シーンの距離関数
float sceneDist(float3 position)
{
    return sphereDist(position, 3.5);
}

ついでに、すべてのオブジェクト(=シーンと呼びます)の中での最短距離を求めるための関数も定義しています。
これは今回は球を一個置いてあるだけなのでシーンの距離関数の結果を返しているだけですが、
例えば球の他に平面も表示したい場合には床も踏まえた最短距離をこの関数で返します。

この距離関数という考え方はレイマーチングの大きな特徴です。
衝突判定関数を使うレイトレーシングとは違い距離だけが関数で書ければレンダリングできるので複雑な形状も表現しやすいです。

法線を求める関数を作る

さて距離関数が作れたので衝突判定はできるようになりましたが、
ライティング計算をするためには衝突位置における法線の情報が必要です。

ここで、改めて距離関数を数式で表してみます。
距離をdとすると次のように表せます。

 { \displaystyle
d= \sqrt{ x^{2}+y^{2}+z^{2} }-r
}

次にこれを変形して陰関数にしてみます。

 { \displaystyle
x^{2}+y^{2}+z^{2}-(r+d)^{2}=0
}

ここで、原点に配置された球の方程式は次のように表せます。

 { \displaystyle
x^{2}+y^{2}+z^{2}-r^{2}=0
}

これらの式を見比べると、距離関数と球の方程式をそれぞれx、y、zに対して偏微分した値はどちらも同じ結果になることがわかります。

 { \displaystyle
f_{x}=2x
}

 { \displaystyle
f_{y}=2y
}

 { \displaystyle
f_{z}=2z
}

そしてこのように陰関数を偏微分した結果から法線が求められます。
これに関しては別の記事でまとめていますので必要に応じて参照してください。

light11.hatenadiary.com

ここまでの話をまとめると、要するに距離関数を偏微分すると球の法線が求められます。
これをプログラムで書くと次のようになります。

// レイがぶつかった位置における法線を取得
float3 getNormal(float3 position)
{
    float delta = 0.0001;
    float fx = sceneDist(position) - sceneDist(float3(position.x - delta, position.y, position.z));
    float fy = sceneDist(position) - sceneDist(float3(position.x, position.y - delta, position.z));
    float fz = sceneDist(position) - sceneDist(float3(position.x, position.y, position.z - delta));
    return normalize(float3(fx, fy, fz));
}

これで法線を求める関数も作れました。

レイマーチングする

さてあとはこれらを使ってフラグメントシェーダでレイマーチングを行うだけです。
以下にシェーダの全文を記載します。

Shader "RayMarching"
{
    SubShader
    {
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            float3 lightDir = float3(1.0, 1.0, 1.0);

            struct Ray
            {
                float3 origin;
                float3 direction;
            };

            // 球の距離関数
            float sphereDist(float3 position, float radius)
            {
                return length(position) - radius;
            }

            // シーンの距離関数
            float sceneDist(float3 position)
            {
                return sphereDist(position, 3.5);
            }

            // レイがぶつかった位置における法線を取得
            float3 getNormal(float3 position)
            {
                float delta = 0.0001;
                float fx = sceneDist(position) - sceneDist(float3(position.x - delta, position.y, position.z));
                float fy = sceneDist(position) - sceneDist(float3(position.x, position.y - delta, position.z));
                float fz = sceneDist(position) - sceneDist(float3(position.x, position.y, position.z - delta));
                return normalize(float3(fx, fy, fz));
            }

            void vert(float4 vertex : POSITION, out float4 position : SV_POSITION)
            {
                position = UnityObjectToClipPos(vertex);
            }

            fixed4 frag(UNITY_VPOS_TYPE vpos : VPOS) : SV_Target
            {
                float3 lightDir = normalize(float3(1.0, -1.0, 1.0) * -1);
                // 縦横のうち短いほうが-1~1になるような値を計算する
                float2 pos = (vpos.xy * 2.0 - _ScreenParams.xy) / min(_ScreenParams.x, _ScreenParams.y);
                // プラットフォームの違いを吸収
                #if UNITY_UV_STARTS_AT_TOP
                    pos.y *= -1;
                #endif

                // レイを定義
                Ray ray;
                ray.origin = float3(0.0, 0.0, -50.0);
                ray.direction = normalize(float3(pos.x, pos.y, 6.0));

                float3 color = 0;
                for (int i = 0; i < 16; i++)
                {
                    float dist = sceneDist(ray.origin);
                    if (dist < 0.0001)
                    {
                        // 距離が十分に短かったら衝突したと判定して色を計算する
                        float3 normal = getNormal(ray.origin);
                        float diff = dot(normal, lightDir);
                        color = diff;
                        break;
                    }
                    // レイを進める
                    ray.origin += ray.direction * dist;
                }

                return float4(color, 1.0);
            }
            ENDCG
        }
    }
}

フラグメントシェーダのfor文でループを回してレイを少しずつ進めています。
衝突していると判定されたら法線を取得し、ライティング計算をしています。

結果

レンダリング結果は次の通りです。

f:id:halya_11:20190720135517p:plain

正常に球が描画されていることが確認できました。

参考

qiita.com

関連

light11.hatenadiary.com