測八空

趣味レベルのUnityメモ

バーチャルマーケット2に出展した、V乱/あかるい横丁のシェーダー解説

2019/03/8~10の3日間、VRChat上でバーチャルマーケット2という大きなお祭りが開催されました。 www.v-market.work

バーチャルな出展サークルが一堂に会し、アバターや小物やスクリプトがずらりと展示される、バーチャル空間の巨大見本市、バーチャルマーケットの第2回です。前回より大幅にパワーアップしていたのですが、大きなトラブルもなく、無事終了しました。運営の皆様、他の出展者の皆様、来場者の皆様、お疲れさまでした。

そのバーチャルマーケット2に、今回、出展を行いました(バーチャルミュージアムC, C-S08)。今回の出展の為にサークルを作り、ブースに宣伝とロマンを詰め込んで、Vケット運営に託しました。それがこちらです。

f:id:Sokuhatiku:20190314013438p:plain
外観

サークル名は"V乱"*1、ブースの名前は"あかるい横丁"です。敵の出ない夜廻*2というコンセプトで、中に入ると夜道の恐怖と孤独感を味わえる雰囲気超重視のブースです。

今回、出展サークルが400を超えており、うちのブースを見に来てもらえるか不安だったのですが、ありがたいことに多くの方に訪れていただき、Twitter等でひそかに話題になっていたようです。パズルラリー*3のピースを設置していた為、ブースに訪れる理由があったのが良かったのかもしれません。生放送やTwitterで多くの人の反応を見ることが出来て、サークル一同、とても励みになりました。改めて、ありがとうございました。

ただ、ちらほら「何を出展しているのかわからない」といった声を聞きまして……。

ブースを出展していました。

一応、もう少しブース自体に情報を載せるつもりではあったのですが、入稿するunityPackageを間違えてしまい、その結果、狂気的な張り紙と「V乱」という落書き、「あかるい横丁」という看板以外の文字情報がすべて消失してしまいました。

逆にその情報の無さがホラーっぽくて良かったという声もありましたが……(最後までブースを調整してくださったサークルメンバーの皆様、本当に申し訳ありませんでした……)。


さて、このブースの作成には意外と多くの人が関わっているのですが、各メンバーそれぞれの得意分野を少しずつ組み合わせて作られております。(モデル、シェーダー、宣伝など)

自分はその中でも、シェーダーの作成を担当しておりました。

ブースに訪れて頂いた方は、ブース内の路地がブース自体の奥行を超えて続いているように見える事に気付いたと思います。ブースの中で振り返ってみて、驚いた方もおられるかと思います。

このシェーダーは今回のブース演出を作成するために作ったシェーダーで、トンネルシェーダーと名付けました。簡単に機能を説明すると、境界となるメッシュを通して見た景色を特定のメッシュとSkyboxで上書きする機能を持っています。また、境界メッシュは内側を向いており、中に入ると周囲を完全に境界メッシュに覆われる為、まるで別のワールドに移動したかのような感覚になります。

f:id:Sokuhatiku:20190312010933g:plain
開発中に進捗報告で作ったgif画像

ブースを中から見た様子

シェーダー単体とはなってしまいますが、Pixiv BOOTHで余計な機能を省いて整理したものを無料で配布しておりますので、是非触ってみてください。 vrun.booth.pm

今回はこのシェーダーの解説をしていきます。

準備するメッシュ

  • 外装メッシュ(あかるい横丁でいう外側の小屋のメッシュ。なくてもいい)
  • 境界メッシュ(直方体が望ましい)
  • 内装メッシュ(あかるい横丁でいう中の路地のメッシュ。任意形状、Unlit推奨)

無限の奥行きの表現

これには ステンシルマスク を使います。簡単に説明すると、一般的なペイントソフトが備えるマスク機能のようなものです。 使用するには、マスクをかける側と参照する側の2種類のシェーダーが必要になってきます。それぞれHollowシェーダー、Tunnelシェーダという名前のシェーダーを用意しました。

Hollowシェーダーは境界メッシュに適用し、Tunnelシェーダーは内装メッシュに適用します。

まず、Hollowシェーダーはステンシルマスクに値を書き込みます。

// 書き込み側(Hollowシェーダー)
Stencil
{
    Ref 1
    Comp Always // 常にテストをパスする
    Pass Replace // テストをパスしたらステンシルマスクの値をRefの値(1)で上書きする
}

そして、書き込まれた値をTunnelシェーダーが読み取り、読み取りに成功した(HollowシェーダーとTunnelシェーダーが重なっている)場合にのみTunnelシェーダーのメッシュをレンダリングします。

// 読み取り側(Tunnelシェーダー)
Stencil 
{
    Ref 1
    Comp Equal // ステンシルマスクの値がRefの値(1)と同じかどうか調べる
    Pass Keep // テストをパスしてもステンシルマスクの値を維持する
}

ステンシル判定だけではオブジェクトが持つポリゴンの前後関係が正常に描画されないため、hollowシェーダーの描画時にDepthBufferを一番遠くの値に上書きすることで、TunnelシェーダーがDepthBufferを使った前後関係を正しく描画できるように準備してやります(ついでにSkyboxを描画します)。

// フラグメントシェーダーの出力構造体
struct result
{
    fixed4 color : COLOR;
    float depth : DEPTH; // Depthを出力する
};

result frag (v2f i)
{
    result o;

    ~~~~省略~~~~

    // デプスバッファに書き込む値
    // DirectXなら 0 を
    // OpenGLなら -1 を書き込むと一番遠くのDepth値になる。
    o.depth = 1 - UNITY_NEAR_CLIP_VALUE;
    return o;
}

これら2つのシェーダーが作用する事で、奥行きの描画は完成します。

手前のカリング

ステンシルマスクはスクリーンスペースの2次元のバッファであり、奥行きの判定は出来ません。普段、奥行きはDepthを使って判定するのですが、描画するメッシュがステンシルマスクを書き込んだメッシュより奥にあることをDepthで判定することは仕組み上困難です。ですが、これの対策を行わなければ前後関係が破綻してしまい、境界に閉じ込められているはずの物体が境界の外にあるメッシュを覆い隠す様な絵になってしまいます。これは特に立体視した際に違和感が顕著に出ます。

f:id:Sokuhatiku:20190315223647p:plain
←カリング無し カリング有り→

(カリング有りの場合は有りの場合でメッシュが切断されて違和感が出ていますが、あかるい横丁では外装メッシュの入り口と内装メッシュの通路の形を一致させることで誤魔化しました。)

前後関係が破綻する問題の解決策として、カメラ座標とMatrixを使ったカリング判定を実装しました。 カメラ座標と頂点座標を特定のMatrixと掛け算することで、判定用の座標系に変換し、判定座標のいずれかの要素の絶対値が1を超えた場合、境界の範囲外と判定します。さらに、判定座標とカメラ判定座標の同じ要素が1を超えていて、かつ符号が一致する場合は、判定ピクセルは境界の手前側(カメラ側)にあると判断して描画を破棄するようにしました。これで手前をきれいに切り抜くことが出来ます。

しかし、マテリアルのプロパティ(インスペクタ上で設定できる=VRChatに持ち込める、実行時にシェーダーに与えることが出来る値)はMatrixをサポートしていない為、通常はMaterialPropertyBlock等を使ったスクリプト経由の手段でしかシェーダーに与えることが出来ません。

その為、Matrixをシェーダープロパティとして扱うためのユーティリティを作成しました。

github.com

このツールの詳細は割愛しますが、機能の一つとして、Vectorプロパティを4つ束ねたものをインスペクタ及びシェーダーコード上でMatrixとして扱うヘルパー機能を用意しているので、それを利用します。

Matrixをプロパティとして宣言するには、シェーダーのPropertiesブロック内に以下のように記述します。

[SerializedMatrix]_ClipMatrix0("ClipMatrix", Vector) = (1,0,0,0)
[HideInInspector]_ClipMatrix1("", Vector) = (0,1,0,0)
[HideInInspector]_ClipMatrix2("", Vector) = (0,0,1,0)
[HideInInspector]_ClipMatrix3("", Vector) = (0,0,0,1)

f:id:Sokuhatiku:20190317232301j:plain
インスペクタではこのように表示される

CGPROGRAM内のコードでは以下のように記述します。

#include "./../MatrixTools/MatrixTools.cginc" // MatrixTools.cgincへの相対パス。ヘルパーマクロが入っている。
DECLARE_SERIALIZED_MATRIX(_ClipMatrix) // プロパティの値を代入するための変数宣言

v2f vert (appdata_full v)
{
    ~~~~省略~~~~
    USE_SERIALIZED_MATRIX(_ClipMatrix) // 変数からMatrixを復元する
    o.clipPos = mul(_ClipMatrix, v.vertex); // この場合、復元したMatrixは_ClipMatrixという名前で利用できる
    ~~~~省略~~~~
}

最終的に、こうして作った頂点座標とカメラ座標を適切に比較してカリング処理を行います。

カリング処理も、必要なコードはcgincファイルに抽出してあるので利用は簡単です。

struct v2f
{
    float4 vertex : SV_POSITION;
    float2 uv : TEXCOORD0;
    CONTAINER_NEAR_CLIP(1, 2) // クリップ用の変数を定義(引数は使用するTEXCOORDの番号選択)
};

v2f vert (appdata_full v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = TRANSFORM_TEX(v.texcoord1, _MainTex);

    USE_SERIALIZED_MATRIX(_ClipMatrix)
    V2F_NEAR_CLIP(_ClipMatrix, v.vertex, o) // V2F構造体にクリップ用の変数を格納する
    return o;
}

fixed4 frag (v2f i) : SV_Target
{
    DO_NEAR_CLIP(i) // 手前にあるピクセルを破棄する

    fixed4 col = tex2D(_MainTex, i.uv);
    return col;
}

詳しいシェーダーコードは上に貼ったBoothで配布しているunityPackage内に含まれていますので、ご参照ください。

ちなみに、Matrixによる手前カリングには大きな欠点があります。それは、Matrixによる計算式が表現可能な形状にしか切り抜きが出来ない事です。境界シェーダーと計算式の形状が乖離しすぎると、境界メッシュ付近で深度の不整合や過剰なカリングが起きてしまいます。例えば丸い境界を作りたい場合は、境界メッシュと数式の両方を大きく書き換える必要があります。これは手前のカリングを数式で、奥の表現をメッシュでやっている以上どうしてもついて回る問題であり、今回は解決は諦めました……。

なお、あかるい横丁のように1ヵ所の入り口だけををカリングするならば、もう少し単純な仕組みが使えると思います。(今回は勉強と汎用性を兼ねました)

他シェーダーとの競合の防止

今回このシェーダーはバーチャルマーケットの会場に設置されるため、他のブースや会場そのものと競合を起こす可能性がありました。 事前にどのようなブースが近くに来るのか、どのような会場の地形なのか等の状況を入稿前に知ることが出来なかったため、出来る限りの競合防止策を練りました。

RenderQueue

この機構の全てのシェーダーのレンダリング優先度(Queue)を通常の不透明オブジェクトより先に描画されるよう変更しています。Depthやステンシルを弄った事は他のシェーダーにも影響するため、他のオブジェクトが巻き込まれて描画破綻してしまうことを避けています。 今紹介しているサンプルにおいては、Geometry-20~Geometry-17の中でトンネルシェーダーの全ての描画が完了するようになっています。

ただし、代償として半透明オブジェクトの描画は難しくなっています。

ステンシル

ステンシルは使用可能な8ビット中、1ビットのみ操作します。ステンシル値の読み書き時にReadMask,WriteMaskを設定することで、特定のビット以外を無視して判定、上書きすることが出来ます。描画後にマスク値は0に戻し、他のシェーダーが利用できるように解放しています。

以下の例では、ステンシルマスクの読み書き両方に1の位の1ビットのみ利用するように指示しています。

Stencil 
{
    ReadMask 1
    WriteMask 1
    ~~~~各シェーダーのステンシル処理~~~~
}

Depth

深度情報をケアしなければ、路地の奥にブースの外にいるはずの人の姿や、ワールドの地形が見えてしまいます。その対策として、路地の奥をレンダリングした後に深度値を更新する為のCeilingシェーダーを作成し、Hollowシェーダーと同じ境界メッシュに適用しました。

f:id:Sokuhatiku:20190316013119p:plain
←Ceiling無し Ceiling有り→

その他

結局、やっていることは実質境界メッシュのレンダリングをひたすら盛っているだけで、プレイヤーに対する干渉は一切行っていません。(バーチャルマーケット2の入稿制約とVRChatに対する知識不足)その為、境界メッシュに顔や手を突っ込むと簡単に破綻してしまいます。配信を見ていると、カメラが突き抜けて向こう側が見えてしまう事故も多発していたようですが、こちらもClolliderを厚くするくらいしか現状対策は思いつきません。まあ、VRCSDKとお友達にならなくても、Unityのメッシュレンダリングを弄るだけでこれだけの演出が出来たので、落としどころとしては十分でしょう。

f:id:Sokuhatiku:20190316023348p:plain
虚空から生える手

また、バーチャルマーケット2に出展するためには、入稿ルールをクリアする為にもう少しシェーダーに手を加える必要がありました。

  • 入稿ブースのサイズ制限があるため、頂点とUV0を入れ替えたメッシュを作成し、頂点シェーダーで元に戻す対応をしました*4
  • ブースが回転移動して配置されるため、全ての計算がローカル空間で完結するように意識して作成しました*5
  • ポストプロセスの内容も確定していなかった為、電灯の位置にBloom風なんちゃってGPUパーティクルを配置しました。

普通に作る分にはこのような対応は不要かと思われます。

まとめ

以上、あかるい横丁のシェーダー解説でした。使っている技術自体はそれほど複雑なものではないので、シェーダーを触った事のある人、シェーダー芸をやったことのある人ならすぐに仕組みが理解できると思います。この仕組みを応用すればもっと面白そうな事ができそうな予感がしているので、是非BOOTHからダウンロードして触ってみてください。

最後まで読んでいただき、ありがとうございました。

※この記事の解説画像にはユニティちゃん(©UTC/UTL)に出演頂いております。


次回のVケット参戦は前向きではあるのですが、サークルメンバーに再び余裕が出来るか、僕がBlenderと和解して個人出展可能になったら、ですかね……。

*1:サークル名を求められて大量のボツの後5秒で作りました。VillainやRunとかけていますが、特に深い意味はないです

*2:日本一ソフトウェアが開発したコンシューマー向けホラーゲーム。お化けから隠れながら夜の街を探索する

*3:出展者間で独自に開催した企画(終了済み)。各会場に散らばった30のブースに隠されたパズルを解いて景品をゲットする。景品がやたら豪華。https://sites.google.com/view/vm2stamp/

*4:そのままでは視界外判定で描画時に消されてしまうので、Mesh.boundsを直接弄ってブースサイズいっぱいに拡大してあります。

*5:Colliderさえ諦めればアバターに仕込むことも出来る