読者です 読者をやめる 読者になる 読者になる

playing with Unity 5's deferred shading pipeline

WaterSurface2
Unity4 の時に書いた自作 deferred shading 用の機能を Unity5 用に再実装しています。その経過の記録です。成果物はこちら https://github.com/i-saint/Unity5Effects

Screen Space Boolean

https://raw.githubusercontent.com/i-saint/Unity5Effects/master/doc/Boolean.gifhttps://raw.githubusercontent.com/i-saint/Unity5Effects/master/doc/BooleanDesc.gif
ステンシルや ZTest Greater の組み合わせでスクリーンスペースでブーリアン演算をやるというトリック。以前解説したものですが、Unity5 でこれをやるのは苦労しました。
実装の方針として、ブーリアン演算の段階では depth だけを出力し、実際に G-Buffer を書き出す段階では ZTest Equal を使用するように変更を加えた Standard Shader を用います。これは後述のステンシルの制限の回避や、必要なシェーダのバリエーションを最小限にするためです。代償として drawcall の数が増えています。

まずステンシル。deferred の G-Buffer 生成からライティングの段階では、カメラのデフォルトのレンダーターゲットに対するステンシルの書き込み指定は無視されるようです。(おそらく Unity が内部的にライトとオブジェクトの組み合わせをステンシルで実現しているため) これをなんとかするため、レンダーターゲットを別に用意してそこでブーリアン演算を行い、depth を元のレンダーターゲットにマージしています。

ZTest Equal を使う場合注意が必要で、Unity で SV_Depth で depth を出力する場合、デフォルトの depth と出力を一致させることはたぶんできないようです。そんなはずはないと思いたいのですが、方法を見つけられませんでした。(D3D11 に限れば SV_POSITION の z を使えば一致しましたが、他プラットフォームでは使えません) このせいでずいぶん悩むことになりました。
ブーリアンの減算は、貫通していたら depth を初期化する処理が必要になります。これをストレートに実装すると

if(減算する側のモデルの depth > 減算される側のモデルの裏面の depth) {
    output_depth = 1.0; // 貫通しているので depth 初期化
}
else {
    output_depth = 減算する側のモデルの depth;
}

こんな感じになります。しかし、depth を自力で算出して出力すると精度がズレるようで、その後 ZTest Equal で同モデルを書くと細かい穴があいてしまいました。しょうがないので貫通処理は 2 パスに分けることで対処しました。最初のパスではごく普通に depth を出力。2 パス目では

if(減算する側のモデルの depth > 減算される側のモデルの裏面の depth) {
    output_depth = 1.0;
}
else {
    discard;
}

とすることで、貫通箇所以外は SV_Depth を経由しないようにします。

あとは影。これは対処不能という結論に至りました。
Unity の影は描画順がランダムのようで、こういう 不透明だけど描画順に依存する 代物はまともな方法では対処できないようです。CommandBuffer にも影バッファ生成の前後に差し込めるイベントはなく、たぶんライティング処理を全部自力で書いて影バッファ生成をフルコントロールしないと実現不可能だと思われます。

Screen Space Shadow

https://raw.githubusercontent.com/i-saint/Unity5Effects/master/doc/ScreenSpaceShadows.gif
スクリーンスペース全方位影。これを実現するにはライティング処理を自力で書く必要がありますが、幸いそこはいい公式サンプルがあるのでごっそりコピペしました。あとはごく普通にライトの中心からピクセル位置までレイマーチして途中で遮られたら光量を減らすだけです。

一つ問題になったのが、カメラが HDR ではないときへの対処。これは上記公式サンプルでも未対処でした。
HDR が有効なときとそうでないときでは内部処理が変わります。HDR が有効な場合、G-Buffer の emission buffer にはカメラのデフォルトのターゲットが使われており、HDR 無効の場合専用のバッファが用意されます。よって、レンダーターゲットを自力で G-Buffer に設定したいような場合、以下のような場合分けが必要になります。

Camera cam = GetComponent<Camera>();
CommandBuffer cb = new CommandBuffer();
cb.SetRenderTarget(new RenderTargetIdentifier[] {
    BuiltinRenderTextureType.GBuffer0,
    BuiltinRenderTextureType.GBuffer1,
    BuiltinRenderTextureType.GBuffer2,
    cam.hdr ? BuiltinRenderTextureType.CameraTarget : BuiltinRenderTextureType.GBuffer3
}, BuiltinRenderTextureType.CameraTarget);

また、HDR が無効な場合、emission は logarithmic encoding という方法でエンコードされた状態で保持されます。これはより精度を保つためのエンコード方法だそうで、色を exp2(-color) で加工した状態で保持し、後で -log2() で復元する、というものです。(詳細)
この影響で、HDR の有無でブレンド方法とピクセルシェーダの出力を変える必要があります。HDR 無効な場合のブレンド方法は Blend DstColor Zero、有効な場合 Blen One One です。Properties で外部から変えられるようにした方がいいでしょう。ピクセルシェーダには以下のような処理を加えます。

half4 frag(v2f I]N) : SV_Target
{
    half4 color;
    // ...
#ifndef UNITY_HDR_ON
    color = exp2(-color);
#endif
    return color;
}
#pragma multi_compile ___ UNITY_HDR_ON

まあしかし、古いモバイルデバイスを視野に入れない限りは "強制的に HDR をオンにする" が一番簡単な対処法であると思われます。

あと、今回はやっていませんが、こういう放射状のレイマーチには epipolar sampling という強力な最適化方法があるのを最近知りました。
アルゴリズムの詳細はこの記事の 3. Epipolar sampling 以降で詳しく解説されています。こちらの Unity 用 Light shaft はこのテクニックを用いてるそうです。いずれ試してみたいところです。

Screen Space Reflections

https://raw.githubusercontent.com/i-saint/Unity5Effects/master/doc/ScreenSpaceReflections.jpg
以前実装の詳細を書いたやつです。幸いほぼそのまま移植できました。
障害になったのが、depth からピクセルの位置を算出する方法。ライティングの処理の中に同等処理があるのですが、なんだかよくわからない難解な処理になってる上、ポストエフェクトの場合これは機能しないように見えます。
結局ストレートに view projection の逆行列をシェーダに渡して対処しました。 projection 行列は Unity が内部的に加工を施すので、逆行列を求める際にも同じことをやる必要があります。

// C# 側処理
var cam = GetComponent<Camera>();
Matrix4x4 view = cam.worldToCameraMatrix;
Matrix4x4 proj = cam.projectionMatrix;
// Unity が内部でやってる projection 行列の加工
proj[2, 0] = proj[2, 0] * 0.5f + proj[3, 0] * 0.5f;
proj[2, 1] = proj[2, 1] * 0.5f + proj[3, 1] * 0.5f;
proj[2, 2] = proj[2, 2] * 0.5f + proj[3, 2] * 0.5f;
proj[2, 3] = proj[2, 3] * 0.5f + proj[3, 3] * 0.5f;
Matrix4x4 inv_view_proj = (proj * view).inverse;
Shader.SetGlobalMatrix("_InvViewProj", inv_view_proj);
// shader 側処理
sampler2D_float _CameraDepthTexture;
float4x4 _InvViewProj;

float4 GetPosition(float2 uv)
{
    float2 screen_position = uv * 2.0 - 1.0;
    float depth = tex2D(_CameraDepthTexture, uv).x;
    float4 pos4 = mul(_InvViewProj, float4(screen_position, depth, 1.0));
    return pos4 / pos4.w;
}

また、D3D9 ではなぜか _CameraDepthTexture はバイリニアフィルタがかかっているようで、そのままだとなんかヘンな結果になります。ポイントフィルタに切り替えたいところですが、まともな方法ではできそうにないので、ピクセルの中心をサンプリングすることで対処しました。(_ScreenParams.zw-1.0)*0.5 がピクセルサイズの半分になるのでこれを利用しています。

ちなみに Screen Space Reflection は近い先に標準搭載される予定です。この分野でたぶん世界一詳しい人が実装を担当しているのでそれを待っていたのですが、当初の予定から伸びに伸びている上、近々に必要になったので結局自作のを移植しました。
Roadmap によると 5.2 (2015/09/08 リリース) にリストされていますが、delayed になっています…。

Rim Light

https://raw.githubusercontent.com/i-saint/Unity5Effects/master/doc/RimLight.jpg
法線と カメラ -> ピクセル位置 の角度が浅いところを明るくするアレです。全く根拠レスな処理なのになんかカッコよく見えるようになります。
前述のピクセル位置の復元さえできれば特に難しいところはないですが、今更ながらフレネル反射の式 (正確にはそれの簡易版) を今回導入してみました。

// ...
float3 N = tex2D(_CameraGBufferTexture2, uv) * 2.0 - 1.0;
float3 I = normalize(pixel_pos.xyz - _WorldSpaceCameraPos.xyz);
float fresnel = saturate(_FresnelBias + pow(dot(I, N) + 1.0, _FresnelPow) * _FresnelScale);

いい感じになった気がしますが、最大のメリットはパラメータの調整がアーティストフレンドリーになることでしょうか。

Water Surface & Caustics Field

https://raw.githubusercontent.com/i-saint/Unity5Effects/master/doc/WaterSurface.gif
Tokyo Demo Fest 2015 用に作った demo の水面のリファイン版。3D ノイズとレイマーチでいい感じに見せかけるストレートな実装です。
こちらもピクセル位置の復元以外 Unity5 固有の難しい問題はありませんでした。フラクタル図形や Rim Light と組み合わせると実にいい感じになります。


そんなわけで、予想より大分苦労しましたが、主要な機能は大体移植できました。既存プロジェクトへの組み込みが簡単になったのが大きな成果です。

あと、夏コミ参加します。場所は 日曜日 R 23b です。
今回も exception reboot の製作途中版でソース同梱の予定です。Unity 5 移行に伴い、レンダリング部分を全面的に書きなおしています。結果としてゲーム内容は前回からあまり変わらないものになりそうです…。