【Unity】2Dゲームでプレイヤーがオブジェクトに回り込んだ時にシルエットを表示する

トップダウンやクォータービューなどの見下ろし型の2Dゲームで、プレイヤーの操作キャラクター(もしくは敵キャラなど)が、高さのあるオブジェクトの後ろに回り込んだ時にオブジェクトに隠れて見えなくなった時、どこに居るのかが分からなくなってしまう事があります。この記事では後ろに回り込んだキャラクターにはシルエットを表示する方法を紹介したいと思います。

f:id:Takachan:20220212181233p:plain

確認環境

この記事の動作確認環境は以下の通りです。

  • Unity 2021.2.11f1
  • ビルトインレンダーパイプライン(非URP環境です)
  • Editor上のみで確認

また、トップダウンの 2Dゲーム用に Edit > Project Settings > Graphics から

  • Transparency Sort Mode = Custom Asix
  • Transparency Sort Axis = X: 0, Y: 1, Z: 1

を設定した状態にしています(こうする事で Y が下の方がより前面に描画される、Z が手前の方がより前面描画されるようになります)

概要・考え方

この表現方法は以下の2段の手順で成り立っています。

  • (1) ステンシルバッファーにキャラクターの位置を書き込む
  • (2) オブジェクトは描画する際にキャラクターの位置だけ透明 + 色表示する

ざっくりとしたイメージですが、まずは以下のようにキャラクターを描画すると同時に、キャラクターがいる位置をステンシルに書き込みます(白い部分が書き込んだ場所です)

f:id:Takachan:20220212182354p:plain

次に、オブジェクトの描画の際にステンシルバッファーに何も書き込まれていない部分を通常通り描画します。

f:id:Takachan:20220212182923p:plain

そして、ステンシルバッファーに書き込みがある位置を透明かつ黒色で描画します。

f:id:Takachan:20220212183403p:plain

そすうると両方が合成された結果と、他の領域が画面に書き込まれ最終定期にこのような表示が出来上がります。

f:id:Takachan:20220212183455p:plain

ステンシルに書き込みがある場所と無い場所をif文で判定することができないので、オブジェクトは通常の描画 → 影の描画の2回の描画を行って表示しています。では、詳しく見ていきたいと思います。

描画用のシェーダーとマテリアルを準備する

まず以下のようにキャラクター・オブジェクト用のシェーダーとマテリアルを2セット用意します。

f:id:Takachan:20220212184620p:plain

Chara がキャラクター用で、Objects がオブジェクト用になります。各々マテリアルは対応するシェーダーを参照し、ヒエラルキーの要素に各々マテリアルを割り当てておきます。キャラクター用は以下の通り。

f:id:Takachan:20220212184919p:plain

オブジェクト用は以下の通りです。

f:id:Takachan:20220212184947p:plain

キャラクター用のシェーダーを作成する

まず、キャラクター用のシェーダーを見ていきましょう。

標準のシェーダー「Sprites-Default」の内容をコピペして必要な個所だけ変更する形にしています。

Shader "MyShader/Chara"
{
    Properties
    {
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (1,1,1,1)
        [MaterialToggle] PixelSnap ("Pixel snap", Float) = 0
        [HideInInspector] _RendererColor ("RendererColor", Color) = (1,1,1,1)
        [HideInInspector] _Flip ("Flip", Vector) = (1,1,1,1)
        [PerRendererData] _AlphaTex ("External Alpha", 2D) = "white" {}
        [PerRendererData] _EnableExternalAlpha ("Enable External Alpha", Float) = 0
    }

    SubShader
    {
        Tags
        {
            "Queue"="Transparent"
            "IgnoreProjector"="True"
            "RenderType"="Transparent"
            "PreviewType"="Plane"
            "CanUseSpriteAtlas"="True"
        }

        Cull Off
        Lighting Off
        ZWrite Off
        Blend SrcAlpha OneMinusSrcAlpha

        // 自分自身をマスクしてステンシルに書き込むパス
        Pass
        {
            Stencil
            {
                Ref 1
                Comp Always
                Pass Replace
            }

        CGPROGRAM
            #pragma vertex SpriteVert
            #pragma fragment MySpriteFrag
            #pragma target 2.0
            #pragma multi_compile_instancing
            #pragma multi_compile_local _ PIXELSNAP_ON
            #pragma multi_compile _ ETC1_EXTERNAL_ALPHA
            #include "UnitySprites.cginc"

            fixed4 MySpriteFrag(v2f IN) : SV_Target
            {
                fixed4 c = SampleSpriteTexture (IN.texcoord) * IN.color;
                clip(c.a - 0.1);
                c.rgb *= c.a;
                return c;
            }
        ENDCG
        }
    }
}

まずステンシルに書き込むために以下のセクションを追記しています。

Stencil
{
    Ref 1 // (1) 書き込む値
    Comp Always // (2) 比較関数、この場合Alwaysを指定し常に書き込むを指定
    Pass Replace // (3) 比較関数をパスしたときの動き、ReplaceはRefで指定した値を書き込む
}

2Dの描画順序は奥から手前の順に処理されるため「このオブジェクトが描画されときにステンシルバッファに自分の位置を書き込む」ため、自分より後ろに描画されるオブジェクトには影響がない一方で、前面に描画されるオブジェクトに対してはステンシルが書き込まれた状態になります。

clip(c.a - 0.1);

この関数を指定する事でキャラクターの形状に切り抜かれるように指示ができます。Texture2D の Mesh Type を Tight にしていてもポリゴンの形がキャラクターに完全に一致しないため完全に透過している部分は捨てる処理をしています。

f:id:Takachan:20220212190603p:plain

これを指定しないと上記のようにキャラクターより大きな影ができてしまいます。

オブジェクト用のシェーダーを作成する

次にオブジェクト用のシェーダーを作成します。

Shader "MyShader/Objects"
{
    Properties
    {
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (1,1,1,1)
        [MaterialToggle] PixelSnap ("Pixel snap", Float) = 0
        [HideInInspector] _RendererColor ("RendererColor", Color) = (1,1,1,1)
        [HideInInspector] _Flip ("Flip", Vector) = (1,1,1,1)
        [PerRendererData] _AlphaTex ("External Alpha", 2D) = "white" {}
        [PerRendererData] _EnableExternalAlpha ("Enable External Alpha", Float) = 0
    }

    SubShader
    {
        Tags
        {
            "Queue"="Transparent"
            "IgnoreProjector"="True"
            "RenderType"="Transparent"
            "PreviewType"="Plane"
            "CanUseSpriteAtlas"="True"
        }

        Cull Off
        Lighting Off
        ZWrite Off
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            Stencil
            {
                Ref 0
                Comp Equal
            }

        CGPROGRAM
            #pragma vertex SpriteVert
            #pragma fragment SpriteFrag
            #pragma target 2.0
            #pragma multi_compile_instancing
            #pragma multi_compile_local _ PIXELSNAP_ON
            #pragma multi_compile _ ETC1_EXTERNAL_ALPHA
            #include "UnityCG.cginc"
            #include "UnitySprites.cginc"
        ENDCG
        }

        Pass
        {
            
            Stencil
            {
                Ref 1
                Comp Equal
            }

        CGPROGRAM
            #pragma vertex SpriteVert
            #pragma fragment frag
            #pragma target 2.0
            #pragma multi_compile_instancing
            #pragma multi_compile_local _ PIXELSNAP_ON
            #pragma multi_compile _ ETC1_EXTERNAL_ALPHA
            #include "UnityCG.cginc"
            #include "UnitySprites.cginc"

            fixed4 frag(v2f i) : SV_Target
            {
                fixed4 _col = tex2D(_MainTex, i.texcoord);
                _col.a = 1;
                _col.rgb = 0;
                return _col;
            }
        ENDCG
        }
    }
}

このシェーダー内で概要で説明した2回の描画を行っています。

まず、最初のパスで、以下の指定を行い、ステンシルが書き込まれていない部分は通常通り描画する処理を行います。シェーダーの内容はステンシルの判定を行っている以外は標準の処理と同じです。

Stencil
{
    Ref 0
    Comp Equal // もしステンシルが0と等しかったらパスを描画する
}

f:id:Takachan:20220212182923p:plain

つぎのパスで以下の指定を行い、ステンシルが1の場合の書き込み処理を実行します。

Stencil
{
    Ref 1
    Comp Equal // ステンシルが1と等しかったらパスを描画する
}

フラグメントシェーダーは以下の通り、ステンシルのある位置を透過して黒を書き込んでいます。

fixed4 frag(v2f i) : SV_Target
{
    fixed4 _col = tex2D(_MainTex, i.texcoord);
    _col.a = 1;
    _col.rgb = 0;
    return _col;
}

f:id:Takachan:20220212183403p:plain

このように2回パスを実行する事でオブジェクトの後ろにキャラクターが回り込んだときだけシルエットを表示することできました。

f:id:Takachan:20220212191623g:plain

オブジェクトの前面にいるときはシルエット表示にならず、背面に回ったときだけシルエット表示になります。

但し、1点注意があり、オブジェクトがマルチパス描画となっているため、バッチングが効かない、構成によってはコールパスがまとめられない状態になるケースがあり、表示するオブジェクト数が増加すると相応に描画が重たくなる可能性があります。特に Sorting Gropu を使用してオブジェクトのシェーダーをまとめた場合はパスがインクリメントされるケースがあり使用時にはそこらへんは注意する必要があります。

また、黒色以外の半透明の表示(col.a = 0.8; col.rpg *= 0.5)もできますが、手前にオブジェクトが複数ある場合、アルファが合成されて影が濃くなるのを除外できていません。3つくらい重なるともう完全にシルエットが影になったりします。ここらへん解決できているゲームを見かけたことがありますがいったいどうやっているんでしょうね…(汗

ちなみに、もっといい方法があれば教えてください。お待ちしています。

参考

この記事は以下のサイトを参考にさせて頂きました。ありがとうございます。

nn-hokuson.hatenablog.com

forum.unity.com

以上です。