e.blog

主にUnity周りのことについてまとめていきます

異空間から転送されてきたように演出するマスクシェーダ

概要

今作っているコンテンツで、なにもない空間からオブジェクトが転送されてきたような演出をしたいと思い、そのために色々シェーダを書いたのでそのメモです。
以下の画像を見てもらうとどういう効果かイメージしやすいと思います。

https://i.gyazo.com/e357fe60d5927ed0c1eec2e97da3f644.gif

今回はこれを実装するにあたって色々ハマったり勉強になったりした点を書いていきたいと思います。

まずは方針決め

Deferred Shadingだと、最初にMeshをレンダリングして、その後にBoolean演算を行ってくり抜く、みたいなことができるようです。
(↓こんな感じの。みんな大好き凹みTips)

tips.hecomi.com

ただ、作っているコンテンツがVRなのでDeferredとは相性が悪く、Forward Renderingで行うために、今回はステンシルバッファを利用してレンダリングするように挑戦してみました。
(まぁ結果としてはPassが増えて、そもそも重そうになってしまったのでこれを採用するかは未知数ですが・・;)

方針

方針は、前述のように「ステンシルバッファ」を利用して、マスク対象のオブジェクトの「背面だけ」をどうにかしてレンダリングするようにします。
図で言うと以下の部分ですね。

f:id:edo_m18:20170307023731p:plain

最初に考え始めたときは、カリングやら深度テストやらをごにょごにょすればすぐだろーくらいの感じで考えていたのですが、これがなかなかどうして、色々と考慮しないとならないことが多く、地味にハマりました;
が、おかげでだいぶ深度テストとステンシルバッファの扱いのイメージがしっかりとついた気がします。

基本は3マテリアル

今回のサンプルは3つのマテリアル(=3つのシェーダ)を作成しました。
ただ、各々のシェーダ内で複数パス利用しているので、全体ではだいぶパスが増えています。

登場人物としては「マスクオブジェクト」とマスク対象の「ターゲットオブジェクト」のふたつ。

各シェーダが連携しながらうまくマスクデータを作るようにしています。
レンダリングの順番としては、

  1. マスクオブジェクトをレンダリングして、マスクエリアにマークを付ける … Mask.shader *1
  2. ターゲットオブジェクトをレンダリングして、ターゲットオブジェクトのクリップ面とそれ以外を分ける … TargetMash.shader
  3. マスクオブジェクト側で、クリップ面(断面)をレンダリング … MaskRender.shader
  4. マスクオブジェクトの表面を、ColorMask 0レンダリング(つまり深度値のみ書き込み) … MaskRender.shader
  5. (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を指定してマスクエリアの「内側」をレンダリングしている点です。

ターゲットをマスクする「ターゲット用シェーダ」

次に、ターゲットとなるオブジェクトを数回レンダリングし、表面と背面をそれぞれ描き分けてマスクを生成します。

f:id:edo_m18:20170307100656p:plain

それぞれマスクをかけて、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した部分にのみマークをつけています。
上でも書いた通り、マスクエリアのレンダリングは、マスクエリアの「内側」をレンダリングしたものでした。

つまり、結果として「マスクエリア内」にある領域に、ステンシルバッファが書き込まれることになります。

f:id:edo_m18:20170307131105p:plain

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したところにマークを付けます。
要は、ターゲットオブジェクトが描かれるべき場所に対して裏側をレンダリングし、かつ「背面」となる部分にマークを付けるわけです。

f:id:edo_m18:20170307131529p:plain

それを実行して、各ステンシルの値ごとに色を塗ると上記のようになります。
この時点で、ターゲットオブジェクトの「背面」に対してマスクが生成されているのが分かるかと思います。

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した箇所に対して、今度はステンシルの値を減らします。
ここまでを実行すると、最終的には以下のようなマスクの状況になります。

f:id:edo_m18:20170307131852p:plain

最後に、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の箇所は「ターゲットオブジェクトの背面」部分です。

なので、ここはクリップされた断面を出力するパスになります。
これを実行すると以下のようになります。

f:id:edo_m18:20170307132605p:plain

まだ他のパスを描いていないので若干分かりづらいかもしれませんが、断面にだけ、指定した色が塗られているのが分かるかと思います。

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目は、マスクオブジェクトの「表面」をレンダリングします。
ただレンダリングといっても完全透明になるようにレンダリングするため、ぱっと見はなにが起きたか分からないかもしれません。
(そして同時に、深度値も更新しています。というか、深度値を更新することが主な目的)

それを実行すると、以下のように、ターゲットオブジェクトがマスクオブジェクトの背面に隠れて消えるのが分かるかと思います。

f:id:edo_m18:20170307132741p:plain

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と同じ見た目になるのが確認できます。

f:id:edo_m18:20170307133025p:plain

ターゲットとなるオブジェクトは、最終的なパスで普通にオブジェクトをレンダリングしてやれば無事、マスク領域でクリップされた断面が描かれるようになる、というわけです。

このシェーダの全文も以下に載せておきます。

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です)

出会った問題点

深度

プラットフォームごとの深度値の扱い

今回のサンプルでは、上の深度テストでの問題でも触れたように、オブジェクトごとの重なり以外にもカメラからの視点方向によるマスキングなどが行われ、色々と試行錯誤しました。
最終的には深度値をシェーダ側でクリアすることによって対策したのですが、その際に知った点として、バッファで使われている深度値がプラットフォーム(OpenGLDirectXPS4など)ごとに異なる、という点でした。

以下のUnityのドキュメントにも記載があり、またそれぞれのプラットフォーム向けに適切にコンパイルされるようマクロなども用意されています。

docs.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」はひとつのシェーダ内でひとつのみ有効

最初、TagsQueueでレンダーキューをいじってひとつのシェーダ内でごにょごにょしようとしていたら、QueueSubShaderにのみ適用されて、Passには影響しないことを知りませんでした。(Passそれぞれに影響するTagsもあります)
ただ、Pass自体は書かれた順に実行されるので、Queueの中でレンダリング順を制御する場合にはPassの順番を入れ変えることで順番を制御することができます。
@hecomiさんに指摘されて追記しました)

調べてみたら以下の投稿同じ質問をしている人がいました。

answers.unity3d.com

そこでの回答が以下。

The reason the code above doesn’t work is that “tags” apply only to subshaders, whereas culling/depth testing options apply to passes.

参考にした記事

*1:今回作成したシェーダ名