そろそろShaderをやるパート67 ステンシルバッファを使ってオブジェクトをマスクする


そろそろShaderをやります

そろそろShaderをやります。そろそろShaderをやりたいからです。
パート100までダラダラ頑張ります。10年かかってもいいのでやります。
100記事分くらい学べば私レベルの初心者でもまあまあ理解できるかなと思っています。

という感じでやってます。

※初心者がメモレベルで記録するので
 技術記事としてはお力になれないかもしれません。

下準備

下記参考
そろそろShaderをやるパート1 Unite 2017の動画を見る(基礎知識~フラグメントシェーダーで色を変える)

デモ

タイトルのイメージが伝わりにくいデモですが、オブジェクトをマスクすることによって成している表現です。

仕組み

ボックスの面越しにしか映らないオブジェクトを用意しています。
このデモは、ボックスなので側面が4面あり、それぞれにしか映らない4体のネコが配置されています。
ここまでの説明をもう少しわかりやすくしたデモが以下です。

そして、タイトル通りこの仕組みにはステンシルバッファを利用します。

ステンシルバッファ

ステンシルバッファとは画面の描画とは別で確保されたバッファ(記憶領域)です。
超簡単に説明すると、ピクセルに値を設定し、その値を参照できます。

例えば、"もし描画予定のピクセルのステンシルバッファの値が1だったら黒塗りにする、それ以外は白"といった塗分けを行うことができます。

この理屈を利用し、"特定のオブジェクトの描画後のピクセル"にしか表示されないオブジェクトを表現することができます。

そのためには以下の2つを作成します。
・ステンシルバッファに値を書き込むだけのShader
・"描画予定であるピクセルのステンシルバッファの値"を指定した値と比較し、合致していた場合に任意の描画処理を行うShader

Shaderサンプル

まずはステンシルバッファに値を書き込むだけのShaderです。

Shader "Custom/WriteStencil"
{
    Properties
    {
        _Ref("Ref", Int) = 1
    }

    SubShader
    {
        Tags
        {
            "RenderType" = "Opaque"
            "Queue" = "Geometry-1"
        }
        
        Pass
        {
            //カラーチャンネルに書き込むレンダーターゲットを設定する
            //0の場合、全てのカラーチャンネルが無効化され何も書き込まれない
            ColorMask 0
            ZWrite Off
            //ステンシルバッファに関して
            Stencil
            {
                //ステンシルの値
                Ref [_Ref]

                //ステンシルバッファの値の判定方法
                //Alwaysなのでステンシルバッファのテストは常に通過する
                Comp Always

                //ステンシルバッファに値を書き込むかどうか
                //Replaceなので既存の値をRefの値に置き換える
                Pass Replace
            }
        }
    }
}

ColorMask

ColorMaskで描画チャンネル(RGBA)を指定できます。
今回は"ステンシルバッファに値を書き込むだけ"がこのShaderの役割のため、
値に0を指定し、描画チャンネルを全てオフにしています。

Stencilコマンド

ステンシルバッファに関してはStencilコマンド内で行います。
まず、Refに値を設定します。

そして、Compという比較関数を使用して先ほどのRefの値と"描画予定であるピクセルのデプスバッファの値"の比較を行い、対象のピクセルを判定します。

Passは比較関数が真の際に動作します。
ReplaceでステンシルバッファにRefの値を書き込む処理を行います。

【参考リンク】:ShaderLab command: Stencil

この一連の処理は"ステンシルバッファの値を参照する予定の描画処理"よりも先に行う必要があります。なぜなら、ステンシルバッファの値が書き込まれた後でないと、"ステンシルバッファの値が1だから○○する"という処理を行うことができないからです。

そのため、Queueに1999を指定し、他のオブジェクトより一足早く描画処理を走らせています。

※ちなみにこのShaderでは何も描画していないのに、デモではうっすら白くなっているのは別途半透明オブジェクトを配置しているからです。


次に"描画予定であるピクセルのステンシルバッファの値"を指定した値と比較し、合致していた場合に任意の描画処理を行うShaderです。

Shader "Custom/SimpleGeometryStencil"
{
    Properties
    {
        //ここに書いたものがInspectorに表示される
        _MainTex ("Texture", 2D) = "white" {}
        _Ref("Ref", Int) = 1
        _ScaleFactor ("Scale Factor", Range(0,1.0)) = 0.5
        _PositionFactor("Position Factor", Range(0,1.0)) = 0.5
        _RotationFactor ("Rotation Factor", Range(0,1.0)) = 0.5
    }
    SubShader
    {
        //ステンシルバッファに関して
        Stencil
        {
            //ステンシルの値
            Ref [_Ref]

            //ステンシルバッファの値の判定方法
            //Equalなので"描画しようとしているピクセルのステンシルバッファ"がRefと同じ場合、そのピクセルを描画の処理対象とする
            Comp Equal
        }

        Tags
        {
            "RenderType"="Opaque"
        }

        //両面描画
        Cull Off

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma geometry geom
            #pragma fragment frag

            #include "UnityCG.cginc"

            float _PositionFactor;
            float _RotationFactor;
            float _ScaleFactor;
            sampler2D _MainTex;

            struct appdata
            {
                float4 vertex : POSITION;
                float3 localPos : TEXCOORD0;
                float2 uv : TEXCOORD1;
            };

            //頂点シェーダー
            appdata vert(appdata v)
            {
                appdata o;
                o.localPos = v.vertex.xyz; //ジオメトリーシェーダーで頂点を動かす前に"描画しようとしているピクセル"のローカル座標を保持しておく
                o.uv = v.uv;
                return v;
            }

            struct g2f
            {
                float4 vertex : SV_POSITION;
                float2 uv : TEXCOORD1;
            };

            //回転させる
            //pは回転させたい座標 angleは回転させる角度 axisはどの軸を元に回転させるか 
            float3 rotate(float3 p, float angle, float3 axis)
            {
                float3 a = normalize(axis);
                float s = sin(angle);
                float c = cos(angle);
                float r = 1.0 - c;
                float3x3 m = float3x3(
                    a.x * a.x * r + c, a.y * a.x * r + a.z * s, a.z * a.x * r - a.y * s,
                    a.x * a.y * r - a.z * s, a.y * a.y * r + c, a.z * a.y * r + a.x * s,
                    a.x * a.z * r + a.y * s, a.y * a.z * r - a.x * s, a.z * a.z * r + c
                );

                return mul(m, p);
            }

            //ランダムな値を返す
            float rand(float2 co)
            {
                return frac(sin(dot(co.xy, float2(12.9898, 78.233))) * 43758.5453);
            }

            // ジオメトリシェーダー
            [maxvertexcount(3)]
            void geom(triangle appdata input[3], uint pid : SV_PrimitiveID, inout TriangleStream<g2f> stream)
            {
                // 法線を計算
                float3 vec1 = input[1].vertex - input[0].vertex;
                float3 vec2 = input[2].vertex - input[0].vertex;
                float3 normal = normalize(cross(vec1, vec2));

                //1枚のポリゴンの中心
                float3 center = (input[0].vertex + input[1].vertex + input[2].vertex) / 3;
                float random = 2.0 * rand(center.xy) - 0.5;
                float3 r3 = random.xxx;

                [unroll]
                for (int i = 0; i < 3; i++)
                {
                    appdata v = input[i];
                    g2f o;

                    //ジオメトリーの移動・回転・拡大縮小処理
                    v.vertex.xyz = center + rotate(v.vertex.xyz - center, (pid + _Time.y) * _RotationFactor, r3);
                    v.vertex.xyz = center + (v.vertex.xyz - center) * (1.0 - _ScaleFactor);
                    v.vertex.xyz += normal * _PositionFactor * abs(r3);

                    o.vertex = UnityObjectToClipPos(v.vertex);
                    o.uv = v.uv;

                    stream.Append(o);
                }
            }

            //フラグメントシェーダー
            fixed4 frag(g2f i) : SV_Target
            {
                float4 col = tex2D(_MainTex, i.uv);
                return col;
            }
            ENDCG
        }
    }
}

ジオメトリーシェーダーを長々と書いているのでややこしく見えますが、注目すべきはStencilコマンドです。

//ステンシルバッファに関して
Stencil
{
    //ステンシルの値
    Ref [_Ref]

    //ステンシルバッファの値の判定方法
    //Equalなので"描画しようとしているピクセルのステンシルバッファ"がRefと同じ場合、そのピクセルを描画の処理対象とする
    Comp Equal
}

Compで比較関数のEqualを指定します。
これにより、"描画しようとしているピクセルのステンシルバッファ"がRefと同じ場合にのみ描画処理が走ります。

参考リンク

【Unityシェーダ入門】ステンシルバッファを使って隠れた部分を描く
窓シェーダーを使った仕掛けの作り方
HoloLens で向こう側が見える窓を動的に追加してみる
【Unity】ステンシルバッファを使って窓を作る