凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity でオブジェクトスペースのレイマーチをやってみた

はじめに

以前、UnityCommand Buffer を使ってレイマーチを行う方法を紹介しました。

しかしながらこの手法では異なる多数の立体を同時に描画したり、それらのオブジェクトを自由に操作したりするには計算コストが高く難しいです。そこで id:i-saint さんがオブジェクトスペースにおいてレイマーチを行うことでこれを軽減する手法を提案されていました。

本エントリでは、提案されたいた手法を前回のエントリに続く形として実装してみましたので、具体的にどんなことが行われているか差分の形で紹介したいと思います。

デモ

f:id:hecomi:20160925200822g:plain

角丸立方体の格子で出来たキューブと球のモーフィングです。トランスフォームを C# からいじれる(↑では回転と位置の移動をスクリプトからしている)のと、mod() による繰り返し処理がポリゴン内にとどまるため、色々と面白い表現が簡単に出来ます。距離関数は以下になります。

float DistanceFunction(float3 pos)
{
    float t = _Time.x;
    float a = 6 * PI * t;
    float s = pow(sin(a), 2.0);
    float d1 = sphere(pos, 0.75);
    float d2 = roundBox(
        repeat(pos, 0.2),
        0.1 - 0.1 * s,
        0.1 / length(pos * 2.0));
    return lerp(d1, d2, s);
}

環境

プロジェクト

概要

G-Buffer の距離関数による生成をポリゴンで描画される領域に限定するのがオブジェクトスペースのレイマーチになります。レイマーチの支配的なコストはピクセルシェーダ、特にその中のループ処理です。ポリゴンで描画領域を限定することにより、描画面積そのものが減り、かつ背後にあるオブジェクトのピクセルは Z カリングによって破棄され、結果としてピクセルシェーダが走るピクセルが減り、負荷の軽減が望めます。

また、ループに関しても、スクリーン全体で行うときはレイの開始点がカメラの Near Clip 位置でしたが、オブジェクトスペースの場合これをポリゴン表面の位置にすることができ、より少ないループで数式で表現される図形の近傍までレイが到達できます。また、裏側の判定を行うことで、過剰なループ処理となってしまう余分な奥側の計算も抑えることが出来ます(逆にやらないと無限遠まで裏側が続いてしまうので図形によっては見た目がちょっと変になります)。

裏側判定は、ポリゴン形状に因らないようにしたいのであれば、先のスクリーンスペースブーリアンの記事のように Cull Front して背面のデプスを予め算出しておくか、スクリーンスペースリフレクションの記事のように、ある程度の厚さを立体が持っていると仮定して計算するか、という形になります。しかしながら球や直方体など、ある程度単純な図形に限定することで、数式によりこれらの裏側を判定することが可能になります。

その他にも、図形に応じてループ数やスレッショルドを変えたりといった細かいチューニングも可能になります。

解説

それでは具体的にコードを交えてみていきましょう。

頂点シェーダ

まずは、頂点シェーダを書き直します。以前はスクリーン全体を覆う矩形ポリゴンを描画するため、以下のような形でした。

struct VertInput
{
    float4 vertex : POSITION;
};

struct VertOutput
{
    float4 vertex    : SV_POSITION;
    float4 screenPos : TEXCOORD0;
};

VertOutput vert(VertInput v)
{
    VertOutput o;
    o.vertex = v.vertex;
    o.screenPos = o.vertex;
    return o;
}

セマンティクスとして SV_POSITION および TEXCOORD0 をつけた頂点位置を出力しています。話が逸れますが以前の記事では書かなかったので詳しく説明すると、頂点シェーダ内ではこれらの値は同じですが、SV_POSITION をつけた vertex はフラグメントシェーダでは [0, 1] の範囲の値ではなく、ビューポートの座標として渡ってきます(例えば (640, 480))。それに対し、TEXCOORD0 をつけた screenPos の方は、ラスタライザを経由して [0, 1] のスクリーン座標として渡ってきています。画面の大きさが固定であれば vertex のみから screenPos と同じ値を作成することは簡単ですが、そうでない場合はこうして用意するのが良いでしょう。

話を戻して、次にオブジェクトスペースの方で使う頂点シェーダを見てみます。こちらは通常の頂点シェーダのようにモデル・ビュー・プロジェクション行列(UNITY_MATRIX_MVP)をローカルな頂点座標に掛け、カメラから見た画面上の位置へと変換します。これにより、これまでの全面のレイマーチとは異なり、適切な範囲のピクセルだけで後段のフラグメントシェーダが走るようになります。ただし、この処理だけでは未だレイマーチしたオブジェクトは移動・回転・スケールされません。

struct VertObjectOutput
{
    float4 vertex    : SV_POSITION;
    float4 screenPos : TEXCOORD0;
    float4 worldPos  : TEXCOORD1;
};

VertObjectOutput vert_object(VertInput v)
{
    VertObjectOutput o;
    o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
    o.screenPos = o.vertex;
    o.worldPos = mul(unity_ObjectToWorld, v.vertex);
    return o;
}

それともう一つ、worldPos というワールド座標を保存する変数を用意しています。モデル行列(unity_ObjectToWorld(旧 _Object2World))をローカル座標に掛けることでワールド座標を得ています。これは後段のラスタライザで補間されて連続する値となり、フラグメントシェーダに渡ります。概要の項で説明したように、このワールド座標は与えられたポリゴンの表面の位置、つまりレイマーチの開始点となります。

フラグメントシェーダ

次にフラグメントシェーダの差分を見ていきます。まずは頂点シェーダから渡ってきた screenPos を以下のように加工します。

float2 GetScreenPos(float4 screenPos)
{
#if UNITY_UV_STARTS_AT_TOP
    screenPos.y *= -1.0;
#endif
    screenPos.x *= _ScreenParams.x / _ScreenParams.y;
    return screenPos.xy / screenPos.w;
}

最後の w 成分で割っているところが差分です。これは id:es_program さんの記事でも紹介されていますが、カメラの視錐台の z 成分を正規化し、[0, 1] の範囲の座標へと xy 座標を落とし込む役割をします。前回の方法では画面を常に覆う矩形ポリゴンだったため視錐台の形状を意識しなくても良かったのですが、今回は立体の図形のため、この計算が必要になります。

次にレイの開始点をポリゴン表面へと変更します。

GBufferOut frag(VertOutput i)
{
    ...
    // float3 pos = GetCameraPosition() + _ProjectionParams.y * rayDir;
    float3 pos = i.worldPos;
    ...
}

そしてポリゴンのトランスフォームを反映させるため、レイマーチがオブジェクト空間で行われるようにします。具体的には、unity_WorldToObject を掛けた座標を距離関数で評価するように、距離関数の入力位置に処理を施します。

inline float3 ToLocal(float3 pos)
{
    return mul(unity_WorldToObject, float4(pos, 1.0)).xyz;
}

float DistanceFunction(float3 pos)
{
    pos = repeat(pos, 0.3);
    return roundBox(pos, 0.1, 0.01);
}

inline float ObjectSpaceDistanceFunction(float3 pos)
{
    return DistanceFunction(ToLocal(pos));
}

また、法線の計算もオブジェクトスペースになるようにします。

float3 GetNormalOfObjectSpaceDistanceFunction(float3 pos)
{
    float d = 0.001;
    return 0.5 + 0.5 * normalize(float3(
        ObjectSpaceDistanceFunction(pos + float3(  d, 0.0, 0.0)) - ObjectSpaceDistanceFunction(pos + float3( -d, 0.0, 0.0)),
        ObjectSpaceDistanceFunction(pos + float3(0.0,   d, 0.0)) - ObjectSpaceDistanceFunction(pos + float3(0.0,  -d, 0.0)),
        ObjectSpaceDistanceFunction(pos + float3(0.0, 0.0,   d)) - ObjectSpaceDistanceFunction(pos + float3(0.0, 0.0,  -d))));
}

これで描画するオブジェクトのトランスフォーム(位置、回転、スケール)にオブジェクトおよびシェーディングが追従するようになります。いったんここまでの画を見てみましょう。

f:id:hecomi:20160921141407g:plain

無限遠まで図形が続いてしまっています、これはこれで面白いですが...。原因は概要の項で説明した裏側判定を行っていないからです。今回は立方体ポリゴンを使っているのでその内部判定を行えるようにします。より一般的な式にするため直方体で計算する式を以下に示します。

inline bool IsInnerBox(float3 pos, float3 scale)
{
    return 
        abs(pos.x) < scale.x * 0.5 && 
        abs(pos.y) < scale.y * 0.5 && 
        abs(pos.z) < scale.z * 0.5;
}

for (int n = 0; n < 100; ++n) {
    distance = ObjectSpaceDistanceFunction(pos);
    ...
    if (!IsInnerBox(ToLocal(pos), 1.0)) break;
    ...
}

短くするとこうです。

inline bool IsInnerBox(float3 pos, float3 scale)
{
    return all(max(scale * 0.5 - abs(pos), 0.0));
}

これを行うと次のようになります。

f:id:hecomi:20160921142542g:plain

無事、ポリゴン内に収まりました。

スケールを考慮

トランスフォームをそのまま反映させていたのでスケールも追従しています。ユースケースに応じて使い分けるべきだとは思いますが、i-saint さんのページで紹介されていたようにスケールを考慮してレイマーチの世界のスケールは変化しないようにしても面白いと思います。スケールは組み込み変数では用意されていないのでスクリプトから与えます。

using UnityEngine;

[ExecuteInEditMode, RequireComponent(typeof(Renderer))]
public class RaymarchingObject : MonoBehaviour
{
    [SerializeField] string shaderName = "Raymarching/Object";

    private Material material_;
    private int scaleId_;

    void Awake()
    {
        material_ = new Material(Shader.Find(shaderName));
        GetComponent<Renderer>().material = material_;
        scaleId_ = Shader.PropertyToID("_Scale");
    }
    
    void Update()
    {
        material_.SetVector(scaleId_, transform.localScale);
    }
}

このスクリプトを対象のオブジェクトにアタッチします。そして、シェーダを 2 箇所変更します。1 箇所目は ToLocal() 関数で、スケール成分がかからないように _Scale を掛けます。

float4 _Scale;

inline float3 ToLocal(float3 pos)
{
    return mul(unity_WorldToObject, float4(pos, 1.0)).xyz * abs(_Scale);
}

負数になっても良いように abs() を適用しています。もう 1 箇所は IsInnerBox() の判定です。

for (int n = 0; n < 100; ++n) {
    ...
    if (!IsInnerBox(ToLocal(pos), abs(_Scale))) break;
    ...
}

これで以下のようになります。

f:id:hecomi:20160921152548g:plain

球の場合

別のバリエーションとして球も見てみます。やることは簡単で、以下のように IsInnerBox()IsInnerSphere() に置き換えるだけです。

inline bool IsInnerSphere(float3 pos, float3 scale)
{
    return length(pos) < abs(scale) * 0.5;
}

for (int n = 0; n < 100; ++n) {
    ...
    if (!IsInnerSphere(ToLocal(pos), abs(_Scale))) break;
    ...
}

これで球の形でクリッピングされます(距離関数も球にしました)。

f:id:hecomi:20160921154718p:plain

スケールや形状はシェーダバリアントで変更できるようにすると良いのではないでしょうか。

法線の反転問題

i-saint さんのブログで言及されている法線の反転問題について見ていきます。フレームデバッガから法線を見てみます。

f:id:hecomi:20160921183018p:plain

開始点と接している面の法線が正しく出力されていません。これはレイの開始点が物体の内側になっているためです。以下のようにワールド座標の法線にしてフラグメントシェーダへと送ります。

struct VertInput
{
    float4 vertex : POSITION;
};

struct VertObjectOutput
{
    float4 vertex      : SV_POSITION;
    float4 screenPos   : TEXCOORD0;
    float4 worldPos    : TEXCOORD1;
    float4 worldNormal : TEXCOORD2;
};

VertObjectOutput vert_object(VertInput v)
{
    VertObjectOutput o;
    ...
    worldNormal = mul(unity_Object2World, v.normal);
    return o;
}

GBufferOut frag(VertObjectOutput i)
{
    ....
    float3 normal = i.worldNormal * 0.5 + 0.5;
    if (distance > 0.0) {
        normal = GetNormalOfObjectSpaceDistanceFunction(pos);
    }
    ....
}

これで正常に出力されます。

f:id:hecomi:20160921184126p:plain

影の描画

ShadowCaster パスがないためこのままでは影が表示されません。Fallback "Diffuse" するともともとのポリゴンの形状で影が描かれてしまいます。

f:id:hecomi:20160921160109p:plain

そこで同じようにレイマーチするコードを ShadowCaster パスの中で書いてあげれば影も描画されるようになります。ただし、ポイントライト、スポットライト、ディレクショナルライトと微妙に扱いが異なるため、少し面倒です。

Pass
{

Tags { "LightMode" = "ShadowCaster" }

CGPROGRAM
#pragma target 3.0
#pragma vertex vert_shadow
#pragma fragment frag_shadow
#pragma multi_compile_shadowcaster
#pragma fragmentoption ARB_precision_hint_fastest

struct VertShadowInput
{
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float2 uv1    : TEXCOORD1;
};

struct VertShadowOutput
{
    V2F_SHADOW_CASTER;
    float4 screenPos : TEXCOORD1;
    float4 worldPos  : TEXCOORD2;
    float3 normal    : TEXCOORD3;
};

void Raymarch(inout float3 pos, out float distance, float3 rayDir, float minDistance, int loop)
{
    float len = 0.0;

    for (int n = 0; n < loop; ++n) {
        distance = ObjectSpaceDistanceFunction(pos);
        len += distance;
        pos += rayDir * distance;
        if (!IsInnerBox(ToLocal(pos), _Scale) || distance < minDistance) break;
    }

    if (distance > minDistance) discard;
}

VertShadowOutput vert_shadow(VertShadowInput v)
{
    VertShadowOutput o;
    TRANSFER_SHADOW_CASTER_NORMALOFFSET(o);
    o.screenPos = o.pos;
    o.worldPos = mul(unity_ObjectToWorld, v.vertex);
    o.normal = v.normal;
    return o;
}

float3 GetRayDirectionForShadow(float4 screenPos)
{
    float4 sp = screenPos;

#if UNITY_UV_STARTS_AT_TOP
    sp.y *= -1.0;
#endif
    sp.xy /= sp.w;

    float3 camPos      = GetCameraPosition();
    float3 camDir      = GetCameraForward();
    float3 camUp       = GetCameraUp();
    float3 camSide     = GetCameraRight();
    float  focalLen    = GetCameraFocalLength();
    float  maxDistance = GetCameraMaxDistance();

    return normalize((camSide * sp.x) + (camUp * sp.y) + (camDir * focalLen));
}

#ifdef SHADOWS_CUBE

float4 frag_shadow(VertShadowOutput i) : SV_Target
{
    float3 rayDir = GetRayDirectionForShadow(i.screenPos);
    float3 pos = i.worldPos;
    float distance = 0.0;
    Raymarch(pos, distance, rayDir, 0.001, 10);

    i.vec = pos - _LightPositionRange.xyz;
    SHADOW_CASTER_FRAGMENT(i);
}

#else

void frag_shadow(
    VertShadowOutput i, 
    out float4 outColor : SV_Target, 
    out float  outDepth : SV_Depth)
{
    // light direction of directional light 
    float3 rayDir = -UNITY_MATRIX_V[2].xyz;

    // light direction of spot light
    if ((UNITY_MATRIX_P[3].x != 0.0) || 
        (UNITY_MATRIX_P[3].y != 0.0) || 
        (UNITY_MATRIX_P[3].z != 0.0)) {
        rayDir = GetRayDirectionForShadow(i.screenPos);
    }

    float3 pos = i.worldPos;
    float distance = 0.0;
    Raymarch(pos, distance, rayDir, 0.001, 10);

    float4 opos = mul(unity_WorldToObject, float4(pos, 1.0));
    opos = UnityClipSpaceShadowCasterPos(opos, i.normal);
    opos = UnityApplyLinearShadowBias(opos);

    outColor = outDepth = opos.z / opos.w;
}

#endif

ENDCG

}

f:id:hecomi:20160925202930g:plain

レイマーチで discard された場所に影が落ちなくなります。また深度値もシャドウマップのルールに従い上書きします。コードが重複するので、レイマーチ部は Raymarch() 関数に分離しています。

まず、SHADOWS_CUBE で場合分けをしています。これはポイントライトでは RFloatRenderTexture へとキューブマップとして書き出される一方、スポットライトとディレクショナルライトではデプステクスチャへと書き出されるからです。この関係で、ポイントライトでは SV_Target への書き出しのみを行うフラグメントシェーダを使用しますが、スポットライトおよびディレクショナルライトでは SV_Depth への書き出しを行うフラグメントシェーダとなり、それぞれのパターンに分けています。また、スポットライトとディレクショナルライトはライトのプロジェクション行列である UNITY_MATRIX_P が、平行投影か透視投影かを見て判断しています。

次に、GerRayDirection() して得ていたレイの方向ですが、専用のもの(GetRayDirectionForShadow())を用意して、_ScreenParams を使ってアスペクト比を掛けていたところを省いています。これを掛けてしまうとカメラの比率がシャドウマップに掛かってしまい、形が歪んでしまいます。シャドウマップのアスペクト比は 1 なので特に何もしなくても問題ありません。

ポイントライト側では V2F_SHADOW_CASTER に各頂点のライトローカル座標が格納された float3 vec : TEXCOORD0 が追加されています。通常はこれを SHADOW_CASTER_FRAGMENT()エンコードしてキューブマップへと保存するのですが、ここではレイマーチの結果で上書きしてから保存しています。

スポットライトおよびディレクショナルライト側では、SHADOW_CASTER_FRAGMENT() の内容を展開して出力しています。ポイントライト側では展開した結果がそのまま所望のデプス値を出力してくれるのですが、こちらではそのまま SHADOW_CASTER_FRAGMENT() すると単に return 0; へと展開されて SV_Target が出力され、SV_Depth は頂点シェーダ側で計算した SV_POSITION が出力されるからです。自前で上書きしてあげるには少し面倒ですが UnityCG.cginc を見て該当の処理を引っ張ってくる必要がありました。

なお、シャドウの処理はライトの個数分だけこの計算が走るため非常に重いものとなっています。実際に使う場合はクオリティと相談で、適当にループ数を落としたり、Fallback して通常のポリゴンの影をそのまま使ったりと軽量化する必要があると思います。本サンプルでは通常よりも少ない 10 ループとしてみています。

おわりに

ジオメトリをシンプルな数式で自由度高く変形できるのはかなり面白いです。現状では負荷はかなり高いので、色々と最適化も試してみたいと思います。