凹みTips

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

Unity のソフトパーティクルのシェーダについて調べてみた

はじめに

現在 Forward Rendering における Raymarching を試していて、その際にポリゴンとの交差判定でデプス情報を使う必要があるのですが、デプスを使ったシェーダが Unity ではどういう風に書かれているのか気になり、パッと思いつくソフトパーティクルを調べようと思い立ちました。

ソフトパーティクルとはパーティクル用のビルボードが他のポリゴンにめり込んだ際、ビルボード感が出ないようにするため、他のポリゴンとの距離を使ってフェードする方法です。以下はビルボードではないですがキューブに適用して交差する境界付近を観察したものになります。

f:id:hecomi:20180915000747g:plain

ソフトパーティクル自体についてはテラシュールウェアさんにて詳しく解説されているのでこちらをご参照ください。

tsubakit1.hateblo.jp

本エントリでは、このソフトパーティクルの実装について解説します。

調査環境

  • Windows 10
  • Unity 2017.4.10f1 (LTS)

シェーダコード外観

Particle/Additive シェーダを元にコードを見ていきます。以下のコードはソフトパーティクルに関係のある場所を抜粋したものです(コードも分かりやすいように改変しています)。

...
struct v2f
{
    float4 vertex : SV_POSITION;
    fixed4 color : COLOR;
    float2 texcoord : TEXCOORD0;
    UNITY_FOG_COORDS(1)

    // ① 画面の UV 取得用の変数
    #ifdef SOFTPARTICLES_ON
    float4 projPos : TEXCOORD2;
    #endif

    UNITY_VERTEX_OUTPUT_STEREO
};

v2f vert (appdata_t v)
{
    ...
    o.vertex = UnityObjectToClipPos(v.vertex);
    #ifdef SOFTPARTICLES_ON

    // ② 画面の UV の計算
    o.projPos = ComputeScreenPos(o.vertex);
    COMPUTE_EYEDEPTH(o.projPos.z);

    #endif
    ...
}

...
// ③ デプステクスチャの宣言
UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);
float _InvFade;
...

fixed4 frag (v2f i) : SV_Target
{
    ...
    #ifdef SOFTPARTICLES_ON

    // ④ デプスの算出
    float sceneZ = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.projPos)));
    float partZ = i.projPos.z;

    // ⑤ フェード処理
    float fade = saturate(_InvFade * (sceneZ - partZ));
    i.color.a *= fade;

    #endif
    ...
}
...

では次の章で分解して細かく見ていきましょう。

シェーダコード詳細

フラグ

ソフトパーティクルの処理を行うか行わないかが SOFTPARTICLES_ON フラグで括られています。これは Quality Settings から設定できる Soft Particlesチェックボックスで ON/OFF が可能なフラグです。

docs.unity3d.com

① 画面の UV 取得用の変数(v2f 構造体)

v2f の中で projPos という変数がフラグが ON になったときだけ定義されます。これは、画面の UV 座標を頂点シェーダで計算して格納しておきフラグメントシェーダへ渡すための役割をします。

#ifdef SOFTPARTICLES_ON
float4 projPos : TEXCOORD2;
#endif

では次に代入部分を見てみましょう。

② 画面の UV の計算(頂点シェーダ)

o.vertex = UnityObjectToClipPos(v.vertex);
#ifdef SOFTPARTICLES_ON
o.projPos = ComputeScreenPos(o.vertex);
COMPUTE_EYEDEPTH(o.projPos.z);
#endif

ここではクリップ空間をスクリーン座標にする ComputeScreenPos() という関数とカメラから見たデプスを求める COMPUTE_EYEDEPTH() というマクロが出てきます。やっていることはそういうことなのですが、具体的に中で何をやっているのかそれぞれたどってみてきましょう。

まず、ComputeScreenPos()UnityCG.cginc に次のように定義されています。

inline float4 ComputeScreenPos(float4 pos)
{
    float4 o = ComputeNonStereoScreenPos(pos);
#if defined(UNITY_SINGLE_PASS_STEREO)
    o.xy = TransformStereoScreenSpaceTex(o.xy, pos.w);
#endif
    return o;
}

VR 用 Single-Pass Stereo Rendering 向けの分岐が入っていますが、まずは非 Single-Pass Stereo な環境でのスクリーン座標を求める ComputeNonStereoScreenPos() を見てみましょう。

inline float4 ComputeNonStereoScreenPos(float4 pos)
{
    float4 o = pos * 0.5f;
    o.xy = float2(o.x, o.y * _ProjectionParams.x) + o.w;
    o.zw = pos.zw;
    return o;
}

ちょっと式がアレなので一旦立ち止まって考えてみましょう。

入力の pos はオブジェクト空間の頂点座標を UnityObjectToClipPos() したもの、つまりクリップ空間の座標でした。_ProjectionParams.x にはプラットフォーム毎の y の向きの扱いを吸収するために +1.0 または -1.0 が入っています。これを y 座標に掛けると y の向きを統一することができます。プラットフォーム間の差については以下のマニュアルに書いてあります。

さて、pos はクリップ空間座標なので -w ~ +w の値になっています。これに 0.5 を掛けると o.xy-0.5w ~ +0.5w になり、同様に半分になった wo.w = 0.5 * pos.w)を 2 行目で足すことで 0 ~ +w の範囲に o.xy を変換できます。これは後で w で除算して 0 ~ 1 となる UV 座標として使いたいためです。なお、wxy を除算するとクリップ空間を正規化した立方体の座標系が得られるのですが、クリップ空間は z 方向が非線形なので、フラグメントシェーダ側で行わないと誤差が出ます。よってこの時点では w が乗った値にしておきます。

o.zw は半分になってしまっているので元々の pos.zw を入れ直しておきます。なお、ここ辺りのスクリーン座標やクリップ空間についての話は、以下の記事が大変素晴らしくまとまっているので、理解が難しい場合はこちらをご一読ください。

さて、ComputeScreenPos() に戻ってシングルパスステレオ向けに更に xy を移動スケールする TransformStereoScreenSpaceTex() を見てみましょう。これは UnityCG.cginc に定義されています。

#if defined(UNITY_SINGLE_PASS_STEREO)
float2 TransformStereoScreenSpaceTex(float2 uv, float w)
{
    float4 scaleOffset = unity_StereoScaleOffset[unity_StereoEyeIndex];
    return uv.xy * scaleOffset.xy + scaleOffset.zw * w;
}
#else
#define TransformStereoScreenSpaceTex(uv, w) uv
#endif

FrameDebugger で値は確認出来なかったので推測になりますが...、scaleOffset.zw にオフセット、scaleOffset.xy にスケールが入っていて、例えば左目用だったらオフセットは 0、スケールが 0.5 なら左側、右目用ならオフセットが 0.5 でスケールが 0.5 なら右側といった具合になります(実際はデバイスに合わせて違う値になると思います)。シングルパスステレオでない場合はそのまま UV を返却します。

さて、大分忘れてきてしまったかもしれないですが、大本の計算に戻ります。ComputeScreenPos() では xy を修正したのですが、COMPUTE_EYEDEPTH() では z を修正します。これは次のように展開されます。

#define COMPUTE_EYEDEPTH(o) o = -UnityObjectToViewPos(v.vertex).z

inline float3 UnityObjectToViewPos(in float3 pos)
{
    return mul(UNITY_MATRIX_V, mul(unity_ObjectToWorld, float4(pos, 1.0))).xyz;
}

ワールド座標変換した後にビュー行列を掛けているので、カメラローカルな位置が求まります。この z を取り出しているので、カメラから見たデプスが求まるわけです。

ということでまとめると、xy をシングルパスステレオを考慮しつつスクリーン座標に変換し、z はデプスを代入、wxy を正規化するようにクリップ座標と同じ値を入れている、という形になります。

③ デプステクスチャの宣言

UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);
float _InvFade;

UNITY_DECLARE_DEPTH_TEXTURE は通常は sampler2D_float に展開されるようです。_CameraDepthTexture はプライマリカメラの深度テクスチャを取ってくることが出来ます(それ以外は _LastCameraDepthTexture)。

なお、Stereo Rendering MethodSingle Pass Instanced (Preview) の場合は少しだけ変わるようです。

VR 向けの分岐は色々な手法が入る度に増えていて、シェーダが複雑化しています。一度この辺りは調査して別記事にまとめたいところです。。

_InvFade はプロパティ変数で、インスペクタでは Soft Particles Factor として見えているものになります。⑤で使います。

④ デプスの算出

float sceneZ = LinearEyeDepth(
    SAMPLE_DEPTH_TEXTURE_PROJ(
        _CameraDepthTexture,
        UNITY_PROJ_COORD(i.projPos)
    )
);
float partZ = i.projPos.z;

さて、ようやくデプスの算出です。順番に見ていきましょう。

i.projPos には UNITY_PROJECT_COORD を通しています。これは HLSLSupport.cginc で定義されていて、PS Vita の場合の対応が入っているだけで、他のプラットフォームではそのまま値を返すのでスキップして見て問題ありません。

SAMPLE_DEPTH_TEXTURE_PROJ は以下に展開されます。

#define SAMPLE_DEPTH_TEXTURE_PROJ(sampler, uv) (tex2Dproj(sampler, uv).r)

tex2Dproj は与えられた uv.xyuv.w で割ってサンプリングするものです。クリッピング座標を正規化して 0 ~ 1 にします。こうして該当のピクセルの深度が得られます。ただ、ここでいう深度はテクスチャから取ってきたものなので、空間での深度ではありません。これをカメラからの深度に変換するために UnityCG.cginc に定義されている LinearEyeDepth() を使います。

inline float LinearEyeDepth(float z)
{
    return 1.0 / (_ZBufferParams.z * z + _ZBufferParams.w);
}

_ZBufferParamsx(1-far/near)yfar/nearzx/farwy/far が格納されています。...が、代入して展開しても直感的にすぐわかる数式になりません。というのもプロジェクション行列の関係で、_CameraDepthTexture に格納されている値が非線形だからです(Linear という名前がついた関数が用意されているのもそういうわけです)。ちなみに 0 ~ 1 の値が欲しかったら Linear01Depth() という関数が用意されています。

大元のブロックに戻ると、partZi.projPos.z なっています。この i.projPos.z は②で COMPUTE_EYEDEPTH() して求めたカメラからのデプスでした。つまり、sceneZ にも partZ にも同じカメラからのデプスが入っており比較できるようになったわけです。

⑤ フェード処理

さて、デプスが求まれば後はめり込み具合を計算してフェードするだけです。コードを見てみましょう。

float fade = saturate(_InvFade * (sceneZ - partZ));
i.color.a *= fade;

sceneZ - partZ が十分に小さい時に fade が小さくなり、これを i.color.a に掛けることで境界面をぼやかせる形になっています。partZsceneZ より大きい場合(ソフトパーティクルが Opaque なオブジェクトより奥側にあるとき)はこの値はマイナスになり、逆にかなり手前にある場合(partZsceneZ より十分小さい場合)は 1.0 よりも大きな値になります。この手前にある際にどれくらい離れていたらというフェード具合を調整するのが _InvFade で、値が大きいとフェードが狭い範囲で、逆に小さいと広い範囲で起きることになります。最後にこうして補正された値を 0 ~ 1 の範囲でクリッピングするために、saturate() を使っています。具体的に見栄えは次のようになります。

f:id:hecomi:20180915010430g:plain

まとめ

さて、これで全貌がわかりました。ざっと流れをまとめると、

  1. ソフトパーティクルのメッシュに対して、ComputeScreenPos() で画面上に対応する座標を取得
  2. COMPUTE_EYEDEPTH() でカメラからみた深度を計算
  3. 1 を使って _CameraDepthTexture をデコードし、描画済オブジェクトのカメラからの深度を取得
  4. 2 と 3 を比較して近い場合はアルファを小さくする

というものです。ここのパラメタや関数の意味は追うと面倒ですが、全体的な流れはシンプルですね。

おわりに

手法としては基本的なものなのですが、しっかり調べようと思うとシェーダ側への理解も Unity が色々とよしなにやっているところへの理解も両方とも必要になりちょっと大変ですね。今後も色々とこういった題材を深掘りしていく記事シリーズを書いていければと思います。