Stencil Examples 解説

画像4

Shader Fes 2021 に出展した作品の解説(っぽいこと)を書きます。サンプルデータをGithubに公開していますので参考にしてください。


発端

Shader Fes に出展するものについて考えていたところ、

𝑺𝒕𝒆𝒏𝒄𝒊𝒍𝑭𝒆𝒔、とても面白そうです。というか自分がみたいので、とりあえずStencilを使った作品を作ろうと考えました。

ひとまず板を重ねるごとに映る絵が変わるような物を作ったところ、「これひとつに全部詰め込めるじゃん」となったので、このようになりました。

1作品?
3年以上も前に流行っていたこの機能で未だに遊んでるやつなんてもう私しかいないでしょうし。ネタ被りはしないだろうと思っていました。わはは。


ステンシルバッファ

2018年頃のVRChatでは、ステンシルバッファを使って描画範囲を限定する機能を使い、特定のオブジェクトを経由したときのみ中にあるものが見える、いわゆる窓枠芸と呼ばれるものが流行っていました。

こういうやつです。どこかで一度は見たことがあるのではないでしょうか。

ステンシルバッファについての基本的な解説については、以下のサイトに非常に分かりやすく紹介されています。

ので、ここで私が解説することはあまりありません。要するに「描画順とオブジェクトの(カメラに対する)前後に気を付けましょう」ということです。上記の窓枠芸は、思っていたよりも簡単に再現できることがわかると思います。

ここで改めて、Unityの公式マニュアルを読んでみましょう。

いろいろと機能があることはわかります。が、私も正直 Read/WriteMaskやFailのことはよくわかっていなくて。今回は直観的にもわかりやすい StencilOperation のほうを活用することにしました。


仕組み

まず、いろいろな絵を描画する板(Base)について考えます。ステンシルバッファに書き込まれた値に応じた絵を出したいので、9Pass (+ShadowCaster Pass)書いてそれぞれのPassで出したい絵を記述しています。力技ポイント①です。

//Base
SubShader
{
Tags { "Queue"="AlphaTest+39"
      "DisableBatching"="True"
      "IgnoreProjector"="True"
}

Pass //1つ目
{
Stencil{
   Ref 5
   Comp Less
}
CGPROGRAM
//略
ENDCG
}

Pass //2つ目
{
Stencil{
   Ref 0
   Comp Equal
}
CGPROGRAM
//略
ENDCG
}

Pass //3つ目
{
Stencil{
   Ref 49
   Comp Equal
}
CGPROGRAM
//略
ENDCG
}

//以下続く
やたら重い原因はこれだと思います。フラグメントシェーダーを実行する前に該当しないステンシルバッファを持つピクセルを棄却するはずなので、実際に実行されるフラグメントシェーダーは少ないはずなのですが。テスト用ワールドでは自環境(1080)で90fps出てたし問題ないと判断していました。 もしくはMirror Reflectionが変なことしてるのか…

今回は板に全部RenderQueueを記載しています。2489とかいう普段はあまり見ない数値が入っていますが、単純に隣にどんなRenderQueueを使用したShaderが並ぶかわからないので、きっと誰ともぶつからないであろう値を選択したというだけのことです。不透明扱いとなる2500未満であるのには理由があります。これについては後述。

続いてこのBaseの最初の絵を Ref 51 に固定するため、Baseの手前に Ref 51 を書き込む板(View)を置いています。

画像1

Hierarchy上はこんな感じ。TransformのZをちょっとずらしているだけなので、めっちゃ近づくと Ref 0 の絵である CountryRoad がちらっと見えると思います。


続いてPickupできる板(Mask)のほう。まず1Pass目でステンシルバッファを書き込み、2Pass目でテクスチャを描画するようになっています。

//Mask
Properties
{
   [NoScaleOffset]_MainTex ("Texture", 2D) = "black" {}
   [Enum(IncrSat,3,DecrSat,4,IncrWarp,6,DecrWarp,7)]_Operation("", Int) = 3
}
SubShader
{
Tags { "Queue"="AlphaTest+38"
      "DisableBatching"="True"
      "IgnoreProjector"="True"
}
Cull Off
//ステンシルバッファを書き込む
Pass
{
ZWrite Off
ColorMask 0
Stencil{
   Comp Always
   Pass [_Operation]
}
}
//テクスチャを普通に描画する
Pass
{
AlphaToMask On
CGPROGRAM
//略
ENDCG
}
}

StencilOperation は Enum で選択することで管理が楽になると思います。Invert や Zero についてはRenderQueueを下げたいのでマテリアルの設定でRenderQueueを1ずつ下げています。別Shaderファイルに分けた方が安全だと思うので、提出時は分けています。

ここで、 StencilOperation の仕組みを改めて解説すると、Stencil Examples では常時書き込まれているステンシルバッファの値は 51 です。ここに、例えば Operation が IncrSat(またはIncrWrap) の板を1枚重ねれば、ステンシルバッファの値は1増えて 52 となり、そのバッファに対応した絵が描画される、ということになります。2枚重ねれば 53 になります。逆に DecrSat(またはDecrWrap) であれば 50 に下がるわけですね。Zero であれば文字通り 0 に、Invertはbitの反転なので…雑に言えば現在のステンシルバッファの値を 255 から引いた値となります。
Sat と Wrap の違いですが、ステンシルバッファの値は0~255(8bit)の範囲の値を扱います。そして、例えば現状の値が 0 のとき、DecrSat を重ねると 0 のままですが、DecrWrap を重ねれば 255 に値が書き換わることになる、という違いがあります。言い方を変えればClampかRepeat(またはMod)かどうかの違いと同じようなものです。たぶん。この辺のことは上に乗せた公式マニュアルにも書かれています。

こんな感じのShaderを適用した板ポリを配置すれば概ね完成です。


描画の整合性

ここで言う整合性というのは、ほぼほぼ気持ちの問題という意味でしかありません。VRChatでは概ねいつも仕様通りに描画されています。おかしいと思うのは私たちの主観でしかありません。

しかしここでひとつの問題が発生します。それはステンシルバッファを書き込む板を先に描画する必要があるため、Baseより奥にあった場合にオブジェクトの前後感が破綻しているように見えることです。

画像2

こういう状況。個人的にはステンシルを扱う上ではよくある話だと思っていて、なので最初に載せた記事ではオブジェクトの前後とRenderQueueに応じたShaderの書き方を説明していました。しかしVRChat上ではそうはいきません。Pickupを許すということは、自由さと見た目の正しさを担保しなければならないという意味でもあります。そういう違和感を少しでも無くすため、これもどうにかする必要がありました。

ShaderFesの会場では _CameraDepthTexture の使用が許可されています。これを使ってオブジェクトの前後を自前で計算し、適切な Culling を行うことで上記の問題を解決しました。力技ポイント②です。

具体的には、Mask用Shaderの1Pass目の頂点シェーダー内で計算するMask自身の深度値と、 _CameraDepthTexture で取得できる他オブジェクトの深度値をフラグメントシェーダー内で比較し、自分が奥側に居るなら 描画しないという処理を追加します。これでBaseより奥側となった場所にはステンシルバッファの値が書き込まれなくなるため、見た目の前後感が正しそうに見えます。BaseのRenderQueueが2500未満であり、ShadowCasterPassを書いている理由がこれです。

//Mask 1Pass目に追加
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
   float4 vertex : POSITION;
};
struct v2f
{
   float4 vertex : SV_POSITION;
   float4 grabPos : TEXCOORD1;
   float depth : TEXCOORD2;
};

sampler2D _CameraDepthTexture;

v2f vert (appdata v)
{
   v2f o;
   o.vertex = UnityObjectToClipPos(v.vertex);
   o.grabPos = UNITY_PROJ_COORD(ComputeGrabScreenPos(o.vertex));
   COMPUTE_EYEDEPTH(o.depth.x);
   return o;
}
float4 frag (v2f i) : SV_Target
{
   float2 projuv = i.grabPos / (i.grabPos.w + 0.00000000001);
   float eyeDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, projuv));
   clip((eyeDepth-i.depth)-0.001);
   
   return 0;
}
ENDCG

これで、

画像3

こうなります。いい感じです。深度に関することについては以下のサイトを参考にしました。

実際にShaderFesに提出したデータでは、何故か _CameraDepthTexture からワールド座標を復元してカメラからの距離を比較する方法をとっていましたが、普通に深度値を比較するほうがいいですよね。反省点です。

昨日説明してたときに"ZTest Always"してるって言いましたが、正しくは"ZWrite Off"のことでした。すみません。

自前で深度処理してるのは説明に書かなきゃいけなかった気がしています。Exampleを名乗るのなら。 申し訳なさを感じています。

他にも描画される絵毎に、いろいろと工夫は仕込まれていますが、ここでは省略させていただきます。



終わりに

割と無茶な作品を置かせていただきました。ShaderFes運営の皆様、本当にありがとうございました。

そして、

どなたかよろしくおねがいします。