概要
今作っているコンテンツで、なにもない空間からオブジェクトが転送されてきたような演出をしたいと思い、そのために色々シェーダを書いたのでそのメモです。
以下の画像を見てもらうとどういう効果かイメージしやすいと思います。
今回はこれを実装するにあたって色々ハマったり勉強になったりした点を書いていきたいと思います。
なお、こちらで解説しているシェーダなどについてはGithubで公開しているので動作を見たい方はそちらをダウンロードして確認ください。
まずは方針決め
Deferred Shadingだと、最初にMeshをレンダリングして、その後にBoolean演算を行ってくり抜く、みたいなことができるようです。
(↓こんな感じの。みんな大好き凹みTips)
ただ、作っているコンテンツがVRなのでDeferredとは相性が悪く、Forward Renderingで行うために、今回はステンシルバッファを利用してレンダリングするように挑戦してみました。
(まぁ結果としてはPassが増えて、そもそも重そうになってしまったのでこれを採用するかは未知数ですが・・;)
方針
方針は、前述のように「ステンシルバッファ」を利用して、マスク対象のオブジェクトの「背面だけ」をどうにかしてレンダリングするようにします。
図で言うと以下の部分ですね。
最初に考え始めたときは、カリングやら深度テストやらをごにょごにょすればすぐだろーくらいの感じで考えていたのですが、これがなかなかどうして、色々と考慮しないとならないことが多く、地味にハマりました;
が、おかげでだいぶ深度テストとステンシルバッファの扱いのイメージがしっかりとついた気がします。
基本は3マテリアル
今回のサンプルは3つのマテリアル(=3つのシェーダ)を作成しました。
ただ、各々のシェーダ内で複数パス利用しているので、全体ではだいぶパスが増えています。
登場人物としては「マスクオブジェクト」とマスク対象の「ターゲットオブジェクト」のふたつ。
各シェーダが連携しながらうまくマスクデータを作るようにしています。
レンダリングの順番としては、
- マスクオブジェクトをレンダリングして、マスクエリアにマークを付ける ... Mask.shader *1
- ターゲットオブジェクトをレンダリングして、ターゲットオブジェクトのクリップ面とそれ以外を分ける ... TargetMash.shader
- マスクオブジェクト側で、クリップ面(断面)をレンダリング ... MaskRender.shader
- マスクオブジェクトの表面を、
ColorMask 0
でレンダリング(つまり深度値のみ書き込み) ... MaskRender.shader - (2)で収集した「マスクオブジェクト外の、カメラから見てマスクオブジェクトから隠れている部分」以外の部分のDepthをクリア ... MaskRender.shader
という手順でレンダリングを行い、マスクエリアを特定します。
マスクエリアを定義する「マスク用シェーダ」
これは単純に、マスクオブジェクトの領域を示すStencil値を書き込み、あとで範囲を限定するために利用します。
コードはとてもシンプルです。
Shader "Custom/Mask" { Properties { // } SubShader { Tags { "RenderType"="Transparent" "Queue"="Transparent+10" } LOD 200 Pass { Stencil { Ref 10 Comp Always Pass Replace } Cull Front ZWrite On ZTest LEqual ColorMask 0 CGPROGRAM #include "Assets/Mask/Utility/Mask.cginc" #pragma vertex vert #pragma fragment frag #pragma target 3.0 float4 frag(v2f i) : SV_Target { return half4(0, 1, 0, 1); } ENDCG } } FallBack "Diffuse" }
見ての通り、ColorMask 0
にしてマスクエリアにStencil値と深度値を書き込んでいるのみです。
ただ注意点はCull Front
を指定してマスクエリアの「内側」をレンダリングしている点です。
ターゲットをマスクする「ターゲット用シェーダ」
次に、ターゲットとなるオブジェクトを数回レンダリングし、表面と背面をそれぞれ描き分けてマスクを生成します。
それぞれマスクをかけて、Viewerで色を表示したところ。
(Viewerはそれ用のシェーダを書いて(後述)、Stencilの値によって色を出力するPlaneを配置しているだけです)
赤い部分が一番最初にマスクを掛けた部分。(つまりマスク対象エリア)
青い部分が、マスクオブジェクトの「内側」に存在するターゲットオブジェクトの部分。
緑色の部分が、ちょうどマスクオブジェクトと交差しているクリップ面(断面)。
そして(やや見づらいですが)グレーの部分が、マスクオブジェクト外だけれどカメラの視点から見るとマスクオブジェクトの向こう側(つまり深度テストに合格していないところ)となります。
このシェーダは全部で3passを利用してマスクを生成しています。
(すべてのpassは、最初のマスクエリアとしてマークした中でのみ処理を行うようにしています)
1pass目
Pass { Stencil { Ref 10 Comp Equal ZFail IncrSat } Cull Back Zwrite Off ZTest GEqual ColorMask 0 CGPROGRAM #include "Assets/Mask/Utility/Mask.cginc" #pragma vertex vert #pragma fragment frag #pragma target 3.0 float4 frag(v2f i) : SV_Target { return half4(1, 0, 0, 1); } ENDCG }
1pass目は、ターゲットオブジェクトの「表面」を、ZTest GEqual
でレンダリングします。
加えてStencilはZFail IncrSat
を指定します。
最初にマスクエリアとしてマークした部分の中で、ZTestを反転した上で、さらにそれがFailした部分にのみマークをつけています。
上でも書いた通り、マスクエリアのレンダリングは、マスクエリアの「内側」をレンダリングしたものでした。
つまり、結果として「マスクエリア内」にある領域に、ステンシルバッファが書き込まれることになります。
1pass目までをレンダリングした結果のStencilの状態を表示したところ。青い部分が該当箇所。
2pass目
Pass { Stencil { Ref 11 Comp Equal ZFail IncrSat } Cull Front Zwrite Off ZTest LEqual ColorMask 0 CGPROGRAM #include "Assets/Mask/Utility/Mask.cginc" #pragma vertex vert #pragma fragment frag #pragma target 3.0 float4 frag(v2f i) : SV_Target { return half4(1, 0, 0, 1); } ENDCG }
続いて2passは、1pass目で増加したステンシル値と比較し、同じ箇所に対してレンダリングを行います。
ただ2pass目はZTestを元に戻し、かつCull Front
にしてレンダリングを行い、さらにその上でZFailしたところにマークを付けます。
要は、ターゲットオブジェクトが描かれるべき場所に対して裏側をレンダリングし、かつ「背面」となる部分にマークを付けるわけです。
それを実行して、各ステンシルの値ごとに色を塗ると上記のようになります。
この時点で、ターゲットオブジェクトの「背面」に対してマスクが生成されているのが分かるかと思います。
3pass目
Pass { Stencil { Ref 10 Comp Equal ZFail DecrSat } Cull Back Zwrite Off ZTest LEqual ColorMask 0 CGPROGRAM #include "Assets/Mask/Utility/Mask.cginc" #pragma vertex vert #pragma fragment frag #pragma target 3.0 float4 frag(v2f i) : SV_Target { return half4(1, 0, 0, 1); } ENDCG }
そして最後の3pass目。
最後はまた、Stencilの値が最初にマスクした部分に対して実行し、Cull Back
かつZTest LEqual
でレンダリングします。(つまり普通のレンダリング)
その際、これまたZFail
した箇所に対して、今度はステンシルの値を減らします。
ここまでを実行すると、最終的には以下のようなマスクの状況になります。
最後に、3passを含んだすべてのシェーダコードを載せておきます。
Shader "Custom/TargetMask" { Properties { // } SubShader { Tags { "RenderType"="Transparent" "Queue"="Transparent+11" } LOD 200 // -------------------------------------------------- Pass { Stencil { Ref 10 Comp Equal ZFail IncrSat } Cull Back Zwrite Off ZTest GEqual ColorMask 0 CGPROGRAM #include "Assets/Mask/Utility/Mask.cginc" #pragma vertex vert #pragma fragment frag #pragma target 3.0 float4 frag(v2f i) : SV_Target { return half4(1, 0, 0, 1); } ENDCG } // -------------------------------------------------- Pass { Stencil { Ref 11 Comp Equal ZFail IncrSat } Cull Front Zwrite Off ZTest LEqual ColorMask 0 CGPROGRAM #include "Assets/Mask/Utility/Mask.cginc" #pragma vertex vert #pragma fragment frag #pragma target 3.0 float4 frag(v2f i) : SV_Target { return half4(1, 0, 0, 1); } ENDCG } // -------------------------------------------------- Pass { Stencil { Ref 10 Comp Equal ZFail DecrSat } Cull Back Zwrite Off ZTest LEqual ColorMask 0 CGPROGRAM #include "Assets/Mask/Utility/Mask.cginc" #pragma vertex vert #pragma fragment frag #pragma target 3.0 float4 frag(v2f i) : SV_Target { return half4(1, 0, 0, 1); } ENDCG } } FallBack "Diffuse" }
マスクエリアをレンダリングする「マスク用レンダリングシェーダ」
さて、以上で今回のサンプルで利用するマスク情報が手に入りました。次は、実際にマスクエリアをレンダリング(カラー出力)し、見た目を構築していきます。
1pass目
Pass { Stencil { Ref 12 Comp Equal } CGPROGRAM #pragma vertex vert #pragma fragment frag float4 frag(v2f i) : SV_Target { return _MaskColor; } ENDCG }
1pass目はとてもシンプルです。
Stencil値が12
のエリアに対してレンダリングを行います。
Stencil値が12
の箇所は「ターゲットオブジェクトの背面」部分です。
なので、ここはクリップされた断面を出力するパスになります。
これを実行すると以下のようになります。
まだ他のパスを描いていないので若干分かりづらいかもしれませんが、断面にだけ、指定した色が塗られているのが分かるかと思います。
2pass目
Pass { Blend SrcAlpha OneMinusSrcAlpha Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 3.0 float4 frag(v2f i) : SV_Target { return half4(0, 0.5, 1.0, 0.0); } ENDCG }
2pass目は、マスクオブジェクトの「表面」をレンダリングします。
ただレンダリングといっても完全透明になるようにレンダリングするため、ぱっと見はなにが起きたか分からないかもしれません。
(そして同時に、深度値も更新しています。というか、深度値を更新することが主な目的)
それを実行すると、以下のように、ターゲットオブジェクトがマスクオブジェクトの背面に隠れて消えるのが分かるかと思います。
3pass目
Pass { Stencil { Ref 9 Comp Equal Pass Keep } Blend SrcAlpha OneMinusSrcAlpha Cull Back ZTest Always CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 3.0 FragOut frag(v2f i) { FragOut o = (FragOut)0; o.color = half4(0.0, 0.5, 1.0, 0.0); o.depth = 1 - UNITY_NEAR_CLIP_VALUE; // #if UNITY_REVERSED_Z // o.depth = 0; // #else // o.depth = 1; // #endif return o; } ENDCG }
最後の3pass目です。
ここは少しだけ特殊な処理が入っています。
まず、このパスに関してはZTest Always
でレンダリングを行います。
かつ、ステンシルの値は、ターゲットオブジェクトでマスクのデータを収集した際に「減算」した部分のみに行います。
そしてその箇所の「深度値をクリア」します。
該当コードは以下の部分です。
o.depth = 1 - UNITY_NEAR_CLIP_VALUE;
この後の補足で書きますが、プラットフォームごとに深度値の扱いが変わるため、それを考慮した記述になっています。
やっていることはシンプルに、深度値を一番遠い部分(つまりまだなにも描かれてない状態)に初期化します。
なぜそうするかというと、2pass目で深度値を描いてしまっているがために、ターゲットとなるオブジェクトが透明なマスク領域に「隠れて」しまうため、そこの部分をくり抜くために実行しているわけです。
それを踏まえて実行すると、以下のように冒頭のアニメーションGifと同じ見た目になるのが確認できます。
ターゲットとなるオブジェクトは、最終的なパスで普通にオブジェクトをレンダリングしてやれば無事、マスク領域でクリップされた断面が描かれるようになる、というわけです。
このシェーダの全文も以下に載せておきます。
Shader "Custom/MaskRender" { Properties { _Color ("Color", Color) = (1,1,1,1) _MaskColor ("Mask color", Color) = (0.0, 0.9, 1.0, 1.0) _MainTex ("Albedo (RGB)", 2D) = "white" {} } SubShader { Tags { "RenderType"="Transparent" "Queue"="Transparent+12" } LOD 200 // -------------------------- CGINCLUDE sampler2D _MainTex; sampler2D _MaskGrabTexture; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float3 normal : TEXCOORD1; float4 uvgrab : TEXCOORD2; }; struct FragOut { float4 color : SV_Target; float depth : SV_Depth; }; fixed4 _Color; fixed4 _MaskColor; v2f vert(appdata i) { v2f o; o.pos = mul(UNITY_MATRIX_MVP, i.vertex); #if UNITY_UV_STARTS_AT_TOP float scale = -1.0; #else float scale = 1.0; #endif // Compute screen pos to UV. o.uvgrab.xy = (float2(o.pos.x, o.pos.y * scale) + o.pos.w) * 0.5; o.uvgrab.zw = o.pos.zw; return o; } ENDCG // -------------------------- // ------------------------------------------------------ // Target back face. Pass { Stencil { Ref 12 Comp Equal } CGPROGRAM #pragma vertex vert #pragma fragment frag float4 frag(v2f i) : SV_Target { return _MaskColor; } ENDCG } // ------------------------------------------------------ Pass { Blend SrcAlpha OneMinusSrcAlpha Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 3.0 float4 frag(v2f i) : SV_Target { return half4(0, 0.5, 1.0, 0.0); } ENDCG } // ------------------------------------------------------ Pass { Stencil { Ref 9 Comp Equal Pass Keep } Blend SrcAlpha OneMinusSrcAlpha Cull Back ZTest Always CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 3.0 FragOut frag(v2f i) { FragOut o = (FragOut)0; o.color = half4(0.0, 0.5, 1.0, 0.0); o.depth = 1 - UNITY_NEAR_CLIP_VALUE; // #if UNITY_REVERSED_Z // o.depth = 0; // #else // o.depth = 1; // #endif return o; } ENDCG } } FallBack "Diffuse" }
番外編 - Stencil Viewer
解説で書いていた「Stencil Viewer」ですが、こちらの記事(Unity 4.2 - Stencils for portal rendering)で紹介されているコードを読んでいたときに知ったものです。
といっても大した内容ではなく、以下のように、できるだけレンダリング順を後に回し、該当のステンシル値が書き込まれたエリアに対して「確実に」色を出力するシェーダを書きます。
Tags { "RenderType"="Opaque" "Queue"="Transparent+500"} ZWrite Off ZTest Always fixed4 _Color0; Pass { Stencil { Ref 9 Comp Equal Pass Keep } CGPROGRAM #pragma vertex vert #pragma fragment frag half4 frag(v2f i) : COLOR { return _Color0; } ENDCG }
まず、Tags
に"Queue"="Transparent+500"
を追加して、レンダリング順を透明オブジェクトよりもさらに後に回します。
その上で、ZTest Always
に変更して、深度テストを無視してステンシルのみでレンダリングが判断されるようにします。
あとはステンシルの参照値を設定してそこに対して色を出力すれば、冒頭の説明のキャプチャのように、ステンシル値ごとに塗り分けることができる、というわけです。
(複数のステンシル値の色を出力したい場合は上記のPass
を複数記述して、それぞれ参照するステンシル値を変更してあげればOKです)
出会った問題点
深度
プラットフォームごとの深度値の扱い
今回のサンプルでは、上の深度テストでの問題でも触れたように、オブジェクトごとの重なり以外にもカメラからの視点方向によるマスキングなどが行われ、色々と試行錯誤しました。
最終的には深度値をシェーダ側でクリアすることによって対策したのですが、その際に知った点として、バッファで使われている深度値がプラットフォーム(OpenGLやDirectX、PS4など)ごとに異なる、という点でした。
以下のUnityのドキュメントにも記載があり、またそれぞれのプラットフォーム向けに適切にコンパイルされるようマクロなども用意されています。
https://docs.unity3d.com/Manual/SL-PlatformDifferences.htmldocs.unity3d.com
サンプルでは、マスクした上でマスク対象以外のDepthをクリアしています。該当コードを抜き出すと以下。
FragOut frag(v2f i) { FragOut o = (FragOut)0; o.color = half4(0.0, 0.5, 1.0, 0.0); o.depth = 1 - UNITY_NEAR_CLIP_VALUE; // 下の書き方でも多分大丈夫 // #if UNITY_REVERSED_Z // o.depth = 0; // #else // o.depth = 1; // #endif return o; }
Unity側でどちらのタイプか、あるいはNear Clip面の値を持つdefineがあるので、それを利用して適切に値を設定しています。
(やっていることは、該当の箇所のDepthを、なにも描かれていない状態(=一番遠い値)にしています)
Tagsの「Queue」はひとつのシェーダ内でひとつのみ有効
最初、Tags
のQueue
でレンダーキューをいじってひとつのシェーダ内でごにょごにょしようとしていたら、Queue
はSubShader
にのみ適用されて、Pass
には影響しないことを知りませんでした。(Passそれぞれに影響するTagsもあります)
ただ、Pass自体は書かれた順に実行されるので、Queueの中でレンダリング順を制御する場合にはPassの順番を入れ変えることで順番を制御することができます。
(@hecomiさんに指摘されて追記しました)
調べてみたら以下の投稿同じ質問をしている人がいました。
そこでの回答が以下。
The reason the code above doesn't work is that "tags" apply only to subshaders, whereas culling/depth testing options apply to passes.
参考にした記事
- [Unity]オブジェクトをレントゲンのように透過させて表示するシェーダ
- Possible to Make Shader that Renders at Intersections with Another Mesh?
- Platform-specific rendering differences
- Subtractive Boolean Rendering
*1:今回作成したシェーダ名