ゆかたつくった pic.twitter.com/w1JV8Dda7A
— phi16 (@phi16_) 2022年8月7日
いちおう解説を書きます。
まずコレは「アバターを透過させる下地」「糸っぽいメッシュ」の2つから成り立っています。それぞれ作り方が全然違うというか、まず無関係なものです。
下地
— phi16 (@phi16_) 2022年8月13日
お分かりの通りステンシルです。大昔に「輪郭線だけ」はやったことがあったんですが、アバターと共存させるのが微妙に大変でした。
大まかな仕組みは次の通り:
- RenderQueue は 1200 くらいを使ってます (つまり他全ての描画より先に描画処理を行います)
- 浴衣をとりあえず描画 (Depthだけ・色は描画しない)、このときにステンシルに書き込み
- 浴衣のアウトラインを描画 (ついでにステンシルを 0 にする)
- ステンシルを初期化しつつアバターを描画 (浴衣に被ってる部分は Depth によって正しく遮蔽される) (コレもついでにステンシルを 0 にする)
- 最後に浴衣をもう一度描画、ステンシルを 0 にしつつ、この時に ZTest Always にして SV_POSITION の z を強制的に 0 (reversed Z における far clip) にすることで Depth も初期化
- あとは普通にシーンを描画するだけ
これでわかる人には説明が終わっているとは思いますが、折角なのでもうちょっと解説をします。
StencilとDepth
おおよそ箇条書きで書きます。
- レンダリング時には「色」「深度」「ステンシル」という3種類のデータを内部的に保持している
こんな感じ。(DepthとStencilの色付けには特に意味はない)

- 色はまぁ当然、視界を映すためのデータ
- 深度は物体の前後関係を正しく処理する為のデータ
- ステンシルはポリゴンによる範囲選択によってポリゴンを消したりする為のデータ
- 「現在の Stencil Buffer を参照してテストを行い」「シェーダ設定に基づいて書き込みを行う」ようになっています
- 何もしなければ一切使われません
というわけで、まず輪郭だけが出るシェーダを考えてみます。
- 膨らんだ部分から元の部分を消せばよい!
- まずオリジナルの領域をステンシルでマークする
- ステンシルの書き込みをします (
Ref 17とPass Replaceってやれば「実際に描画を行う時、Stencil Buffer の値を 17 に更新する」という命令になる) - 背景の色を残しておきたいので ShaderLab で
ColorMask 0を指定します (Blend入れるよりも (無駄な処理をしないので) 良いと思う)
- ステンシルの書き込みをします (
- 膨らんだモデルを描画する時にステンシルに被ってたら消す
Ref 17にして、Comp NotEqualってやれば「Stencil Buffer の値が 17 じゃないときにテストを通る」ようになります
できました。ちなみにこの2段階の処理はシェーダで2Pass書いてもいいですが、実際に GameObject・Material・シェーダを2個分用意して、異なる RenderQueue で実行させるのでも大丈夫です (複数オブジェクト使うときにはこの方が良さそう)
1個目

2個目

膨らんだモデルはまぁその辺のToonShaderで作れると思います。今回の私の浴衣はlilToonです。
本題
ではメインの話に戻りますが、まずVRChatのアバターとして持っていくことを考えると考えなきゃいけないことがいくつかでてきます。
- どんな順番で世界・他のアバターが描画されるかわからない
- 先程の例は単体ではそれっぽく動くが、他のオブジェクトの位置関係によっては破綻する
- 特に、深度を何も弄ってないので「輪郭線だけにみえるが、中身が (深度的には) 実在する」感じになってる
- つまり手を突っ込むと消失するように見えるということ
- ついでに言うと服のRenderQueueが高い (半透明など) 人はこのオブジェクト越しだと服が消える
- RenderQueue を高くしても半透明系と仲が悪くなるだけ
- ZWrite On をやめれば深度は出なくなりますが、自分自身との遮蔽計算がぐちゃぐちゃになる (見るに堪えない感じになる)
- → 深度は正確にしておく・とりあえずめっちゃ早めに処理しておく必要があるということ
- 他にもステンシル使っている人がいるかもしれない
- → 競合しないタイミングで描画を実行・処理が終わったらステンシル値を元に戻す
ぶっちゃけステンシルの大変さの9割は「如何に破綻を少なくするか」だと思います。
まぁ今回に関しては最終的には「RenderQueueをめちゃ低めにして」「深度をちゃんと出す」だけです。
ちなみに先述の「ステンシルに応じて消す」のはできるはできるんですが、あまりにも消えすぎて勿体ないので「普通に前後関係で消させる」「最後に、下地が残ってたら全部貫通させる」ようにしています。

というわけで:
- (1回目) 最初のステンシル書き込みはそのまま
- (2回目) 輪郭線を描画するときに、ステンシルに 0 を書き込む (
Pass Zeroを指定する)- 下地じゃない部分のステンシルを全てリセットしていきます
- この間にアバターをもちもち描画する (上と同様にステンシルに 0 を書き込む)
- これは「浴衣より手前にアバターが来た時に、不可視となった領域のステンシルをリセットする」ため
- (3回目) ステンシルが所定の値になっている場合、ステンシルに 0 を書き込みつつ (リセットしつつ) 深度に 0 を書き込む。
- 尚この時 Depth Test を切る (遮蔽物があっても描画させる)
- どうせステンシルが所定の値になっているのは可視な領域だけなので大丈夫
- この 0 というのは「一番遠い値」を指していて、つまり「そこには何もなかったフリをさせる」ということです
- そうするとその後でも上書きして描画できるようになる
- 尚この時 Depth Test を切る (遮蔽物があっても描画させる)
深度に 0 を書き込むのは、vertex shader の出力 SV_POSITION の z の値を直接弄ることで行っています。
v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); #if UNITY_REVERSED_Z o.vertex.z = 0; #else o.vertex.z = o.vertex.w; // w/w = 1 #endif return o; }
まとめると:
- (1回目, Queue = 1205)
Stencil { Ref 17 Pass Replace }ColorMask 0 - (2回目, Queue = 1206)
- オリジナル領域
Stencil { Comp Never }(消すだけ) - 輪郭線
Stencil { Pass Zero }
- オリジナル領域
- アバター描画 (Queue = 1207, 1208)
Stencil { Pass Zero } - (3回目, Queue = 1210)
Stencil { Ref 17 Comp Equal Pass Zero }ZTest AlwaysColorMask 0- (
vertex.zを far clip へ飛ばす)
- (




以上です。ちなみに実際の Ref の値は違いますがまぁ。(Ref 80 で ReadMask 240 WriteMask 240 を使ってます)
ここまでやれば後は何をやっても大丈夫 (深度が適切なので破綻することはない) です。
糸メッシュ
なんかいい感じに模様入れようと思って作りました。Houdini製です。
おもちの使い回し pic.twitter.com/kByjdSkuSf
— phi16 (@phi16_) 2022年8月7日

- Blender に浴衣メッシュを持っていって余計な情報 (weight系) を全て削除
- それを Houdini に持っていって糸メッシュを生やす
- それを Blender に持っていって Transfer Mesh Data で weight を乗せる
- Project From View で適当に UV を乗せる
- Unity に持っていって着せるだけ
面白いのはメッシュ生成くらいで、他は全部やるだけです。
メッシュ生成も上のツイートがほぼ全てを説明していますが、まぁちょっとだけ書きます。
やってることは:
- ある点を始点、適当な向きを移動方向として
- 次を繰り返す
- 現在の移動方向に向かってちょっと進む
- 現在の点に最も近いメッシュ上の点を算出、そこへ移動
- 移動方向を「現在居る面」に射影
- 現在の点に頂点を追加、前回の点と結ぶ
こうすると「メッシュに沿ってまっすぐ進む曲線」がつくれます。

ちゃんと書くと:
// Attribute Wrangle, Run Over は Detail (only once) // 1番目の入力に浴衣メッシュが突っ込んであります vector firstPos = ...; vector firstTangent = ...; vector cp = firstPos; vector tangent = firstTangent; int c = addpoint(0, cp); for(int i=0;i<100;i++) { cp += tangent * 0.01; // ちょっと進む int prim; vector uv; xyzdist(1, cp, prim, uv, 1); // 最近点を計算 cp = primuv(1, "P", prim, uv); vector cn = primuv(1, "N", prim, uv); tangent = normalize(tangent - dot(tangent, cn) * cn); // 面に射影 int p = addpoint(0, cp); // 頂点を追加 addprim(0, "polyline", c, p); // 結ぶ c = p; }
実際には、加えて「毎ステップ、進行方向を法線を軸にちょっと回す」処理が入ってます。(matrix m = ident(); rotate(m, 0.0155, cn); tangent *= m;) この回転角度がガチャ成分になっていて。

いい感じの値を見つけるのに苦労しました。初期位置もちょっとずらすとめちゃくちゃ変わります。まぁこれくらいならガチャしてもいいと思います。
ちなみに左右の下駄にもそれぞれ線を別で引いてます。
最後にPolyWireに突っ込んで終わりです。便利やね。

というわけで糸メッシュができました。13万ポリくらいあります。多いけど描画範囲狭いしまぁ大丈夫でしょ。
おわり
まぁわかればシンプル。糸生成器は実は omochi museum で使ったもの (導線を引くために) だったので作業量は多くなかったです。
元々は他にもやりたいことがあった (浴衣を通すとなんか輪郭線で出来た海みたいなのが広がってるとか) んですが、無理であることに気づいてやめました。
なにかの参考になれば幸いです。
あ、大事なことなんですが、複数人が同じようにステンシルを使うと影響しあってしまうので何が起きるかわかりません。
例えば私のアバターはもしも RenderQueue 1206 でステンシルを全消しするシェーダがあったとすると貫通部分がぐちゃぐちゃになります。
運用には気をつけてね。
追記: Unityにおける深度は実は2種類あります。1つはさっき言ったもの。もう1つは、「シェーダで取得できる _CameraDepthTexture」です。これは似て異なるものです。
カメラのbokehの実装などに使われるこの Depth Texture は「他全ての描画を行う前に ShadowCaster パスを使って生成された Depth Buffer をテクスチャにしたもの」です。
つまり「描画途中の Depth Buffer が参照できる」わけではありません。(それは GPU の仕組み的に難しいと思います)
で、ShadowCaster パスは Render Queue の影響を受けない。そして複数の ShadowCaster パスを Shader に記述しても1つしか認識されない。
つまり色々と先程ステンシルで頑張って組んだ仕組みは ShadowCaster 上では動きません。つまり、あの浴衣は正しくボケてくれません。
なんか方法あるのかしら。しらない。
まぁ。複雑な描画を完璧にやるのはむずかしいなというところです。