こんにちはphi16です。この記事は VRChat Advent Calendar 2018 の12日目の記事です。
Vacuous Park : Alter というワールドを9月24日に制作、public化しました。
「見えるもの」に関しては予想できる通りShaderごりごり書いてるだけなので、「見えないもの」の仕組みを紹介しようとおもいます。
具体的には、2つの世界を別つ仕組みです。
何が起きるのか、を説明しましょう。初期スポーン世界を α、観覧車世界を β とします。
ワールドに入ったときはα世界に居て、白いふわふわが浮いているだけです。このふわふわで出来た道があって、その先には金属のゲートがおいてあります。
このゲートを通るとβ世界に移動して、周りにはパーティクルで出来た建物と、そして真後ろには壊れた観覧車があります。
さらに前方にはもう一つのゲートがあり、その奥にはまたα世界が広がっています。終端には Vacuous Park へのポータルが置いてあります。
このゲート、実は動くんですが、つまりこの機構は視点位置の座標を直接拾ったりしているわけではありません。実際最初のゲートは通過するとすぐ閉じて、そこからではα世界に帰れなくなるので。真面目にこのゲートたちの挙動を計算して出来ているのです。
さて、この仕組みを使うことにしたきっかけは VoxelKei さんの SpatialGate だったんですが、それとは実装は全く異なるものです。私は全部Shaderで実現しているので、Colliderのオンオフなどは出来ないのですね。ただ「全ての挙動を自分で書ける」という意味で正確に動くので嬉しい人には嬉しい。あとこれくらいのプログラムなら書けるよという例にもなるかもしれません。
まずは内部構造の概略から紹介していきます。
概略
存在する最も重大な状態はもちろん「世界」です。α世界かβ世界のどちらに居るのかを保持しています。そして次に重要なのが「直前の視点」です。視点がゲートを超えるときに世界状態が書き換えられることになるわけですが、「超えた」ことを判定するために「1フレーム前の視点と現在の視点を結んだ線分がゲートと衝突するか」を調べています。なので1フレーム前の視点が状態として保持されています。世界を別つ為に在る本質的な状態はこの2つだけです。
そして世界の遷移を計算するために必要な外的情報があって、具体的には「ゲート」です。1枚のゲートを表現するためには「中心座標」「法線」「半径」があれば良いわけですが1、半径は或る世界状態に依存されて決定するため、必要なのは「座標」「法線」だけとなります。ゲートは今回2枚あるわけですが、プロトタイプでは8枚くらいありました2。
情報の流れを記すとこんな感じです。
状態保持はカメラを利用したRenderTexture上のfeedbackで実現できます。ゲートの位置はその中心に仕込んだマーカーが法線と位置を出力することで得ることができ、また視点の位置3はCanvasの視点追従を利用して4マーカーを仕込むことで拾うことができます。実際の内部状態テクスチャはこんな感じ。
というわけで機構が出来ました。あとは世界をひっくり返すだけです。
ゲートの位置と現在状態から「各描画対象ピクセルがどっちの世界にいるのか」を計算することは可能なわけですが、毎回それをやるには既存のShaderに大きく手を加えなければいけないので面倒です。そこでStencilを使います。推測していた人も多いと思いますが。
Stencil値を反転する透明なShaderをゲートに仕込み、そして現在居る世界に応じて反転するかどうかを決めるShaderを視界ジャックで画面全体に仕込みます。あとはStencil値に応じて消えるように既存のShader群にちょっと書き加えれば完成です。
詳細
マーカー
私がマーカーと呼んでいるのは「ある指定されたカメラを一部視界ジャックする形で映る、座標情報などをエンコードした板ポリ」です。一般に複数個のfeedback loopは1つに集約できることが知られていて、カメラの半分で状態計算・残りで新規情報取得というような形式にすればカメラの負荷はほぼ最小限にできます。
複数のマーカーを正しく受信させるために描画領域をずらす必要があるため、それをMaterialのparameterなどで設定してあげることで2つのゲート位置をテクスチャに記録することができるようになっています。
マーカーの実装はこんな感じ。
// vert
o.uv = v.uv;
o.vertex = float4(v.uv.x*2-1,1-v.uv.y*2,0,1);
float4x4 P = UNITY_MATRIX_P;
if(abs(P[3][3]) < 0.01 || abs(P[1][1] / P[0][0] * 8.0 + 1) > 0.01) o.vertex = 0;
return o;
// frag
float3 pos = mul(UNITY_MATRIX_M, float4(0,0,0,1)).xyz;
float3 nrm = normalize(mul(UNITY_MATRIX_M, float4(0,0,1,0)).xyz);
if(int(i.uv.y*16.0) != _Index) clip(-1.0);
float3 target = fmod(i.uv.y*32.0,2.0) < 1.0 ? pos : nrm;
return float4(pack(target, int(i.uv.x*4.0)),1);
o.vertex
には視界ジャックにあたる座標をUVから直接計算、Projection行列を使って「Orthogonalで」「出力先テクスチャのアスペクト比が1:8
である」場合にのみ表示をするようにしています5。
あとはModel行列から位置と法線を抜き出し、描画対象外をclip
、あとは浮動小数点packingで表示です。
視点位置
Toyboxをそのまま使っているので細かいことはわかりません。Canvasを Screen Space - Camera
設定にし、Plane Distance
を 0
にすることでCanvasは視界ジャックにあたる位置と向きに自動的に固定されます。正常動作させるためには幾らか処理をする必要があるらしいので6ですが、細かい話は知らなくても良いでしょう。
視点位置も同様にしてマーカーを使って位置を取り込むことが出来ますが、今回は視点に更新機構を埋め込むことで情報取得を行っています。理由としては、カメラを介すと1フレーム掛かることに拠ります。今回の「視点がゲートを通過した瞬間に世界を切り替えなければならない」という要件により、1フレーム前の視点 (及び2フレーム前の視点) を基準に計算していたのでは間に合いません。状態更新は最速でやる必要があったため、UNITY_MATRIX_M
経由で視点座標を拾うことにしています7。
状態保持
状態はRenderTextureとして保存され、そのRenderTextureを読み込んで計算された新たな状態をカメラに読ませることでRenderTextureに出力される、というループによって1フレームに1度更新される状況を作り出すことができます。昔はカメラの位置に合わせて計算の出力を配置していましたが、視界ジャック式にすることでその必要はなく好きな場所にカメラを置くことができます。
とはいえBoundingBoxによるCullingが入ってしまうので、「板ポリのBBを超巨大にする」か「カメラを全体が映る位置にする」ようにする必要があります(特に今回は板ポリは視点位置にあるので)。前者のほうがローコストだとは思いますが今回は後者で実装しています。理由は特にありません。多分。
(0,100,0)
の位置に下を向いた超巨大カメラが置いてあって、PickupLayerだけを読むようにしています8。カメラの大きさと描画コストは無関係 (出力解像度には依存、今回は4x32) で、PickupLayerに所属する物体の数は定数なので問題ないでしょう。
世界計算
Just do the math.
今持っている情報は「視点」「1フレーム前の視点」「ゲートの位置・姿勢・大きさ」「今居る世界」です。ゲートを通った回数だけ世界を反転すれば良いわけです。
線分内の点は $\alpha\times\mathrm{prev} + (1 - \alpha)\times\mathrm{cur}\ (\alpha\in[0,1])$ で表現できます。点 $A$ がゲートの存在する平面に居るという制約はゲート中心座標を $p$、法線を $n$ として $\langle A,n\rangle = \langle p,n\rangle$ と表現できるので、これを解くと $\alpha$ がわかることがわかります9。
あとは計算して出てきた衝突点がゲートの中心から半径以内にあるかを見れば大丈夫です。実際のコードはこんな感じ。
// Gate information
float3 p = ...; // position
float3 n = ...; // normal
float r = ...; // radius
// dot(a*prev + (1-a)*cur - p, n) = 0
float pt = dot(prev - p, n);
float ct = dot(cur - p, n);
// a*pt + (1-a)*ct = 0
float a = ct / (ct - pt);
float3 op = lerp(cur, prev, a);
if(pt * ct < 0.0 && distance(op,p) < r) {
state = 1 - state;
}
線分内に居るかどうかを $\alpha$ (即ちa
) を使わずに判定していますが、これは a
の計算で割り算が入っているので怖かったからです10。代わりに「片方が表、片方が裏にいる」ことを pt * ct < 0.0
によって判定しています。
そして世界の情報は 0
か 1
で保存しているので、state = 1 - state
によって世界を反転したことにできます。これでゲート1枚分の処理ができたので、後は回すだけです。
反転描画
あとはそこまで複雑ではない…はずだったんですが、実は結構大変だったのがここです。VRでは視点が2つあるのですね。Toyboxで拾える視点は1つ (やろうと思えば増やせるけど) で、今までの処理も1視点ベースで記述しているのでこのままでは片目だけ世界の向こう側に行ったときにうまく表示できません。そこで、各ゲートの反転Shader側で「本当の視点がゲートの向こう側にある場合、もう一度反転する」ようにしました。一応これで問題ないようには見えたんですが、ついこの前に遊びに行ったときには絶妙にうまくいかないケースを視認してしまいました。さすがに面倒なので放置ですが。
というわけでShaderです。
Stencil {
WriteMask 64
Pass Invert
}
Pass {
// ...
fixed4 frag(v2f i) : SV_Target {
return float4(0,0,0,0);
}
}
Pass {
// ...
fixed4 frag(v2f i) : SV_Target {
// ...
float cu = dot(lastPos - center, i.normal);
float eu = dot(_WorldSpaceCameraPos - center, i.normal);
if(cu * eu > 0.0) clip(-1.0);
// ...
return float4(0,0,0,0);
}
}
ほぼそのままです11。あとは先述の視界ジャックによる世界全体反転をいれてあげればStencilにどちらを描画すべきかが書き込まれたことになりますが、これは実際簡単ですね。
// for α world
Stencil {
ReadMask 64
Comp Equal
}
// for β world
Stencil {
ReadMask 64
Comp NotEqual
}
過去の私が適当にやった所為で何故か Ref
が無いんですが、どうせ片方で通るならもう片方が通らないはずなので動くだろうという気持ちっぽいですね12。実際動いてるけど。
ポータル
仕上げになんとなく配置した Vacuous Park へのポータルですが、これはStencilで消すことができません。仕方が無いので強いDepthを吐いて消すことにしました。
箱で囲み、先程のStencilの処理と同様にしてα世界にだけ存在するようにします。背景の透明化のために Blend SrcAlpha OneMinusSrcAlpha
を入れつつ、敢えて ZWrite Off
を入れません。この箱のRenderQueueは Geometry-100
13 なのでその後に描画されるポータルはこのDepthに隠れて見えなくなります。
終わりに
このワールドは「表現」の方向性で作ったもので、ここに使われている機構は完全に「表現のための技術」です。なので技術的観点の話はしなくて良いししないで欲しいわけですが、自分で面白いと思ったので解説を書いてしまいました。これを見て検証しに行くとかよりは、自分のやりたい表現に応用できないかとかを模索してくれるといいかなと思います。
お疲れ様でした。明日は shr_em さんの「流体計算の話」です。
-
厚み0の円盤なので。 ↩
-
_WorldSpaceCameraPos
は使えないことに注意。視界を描画するときならば正しい。 ↩ -
CanvasTrackingとか呼ばれている。ToyboxV2経由で使ってます。 ↩
-
最近は念を入れてカメラの座標で条件を掛けるようにしています。 ↩
-
UnityがCanvasをスケールすることで内部的に大きさを合わせているのなら、
Plane Distance
が0
というのはCanvasが無限の広がりを持たなければ「理想」通りの2次元Canvasを実現することはできないでしょう。だから根本的にこの設定は異常な気はするわけですが、それを無理やり動かすための奇妙なAnimatorと考えればわからなくもない話ですね。 ↩ -
ちなみにこの結果を用いて計算された状態は尚1フレーム遅いことになる為、正確に表示するためにここと同様の計算を描画にも同タイミングで行っています。具体的には実視界ジャックによる世界反転を担う板ポリが同じところ(視点)に配置されています。 ↩
-
ゲートがPickupなのでついでに。 ↩
-
自由度1、制約1なので。特に今回は線形であることが見てわかるので解き方の具体的存在もわかりますね。 ↩
-
ct = pt
の状況は結構発生しうる (視点が動かなかった場合) ので良くないのです。 ↩ -
そういえば
clip(-1.0)
はdiscard
で良いんでしたっけね。昔のソースだから許して。 ↩ -
もしも初期の世界が反転してても視界ジャック側の処理を逆転してあげればうまくいくわけです。 ↩
-
ちなみに反転機構は
Geometry-200
、遠景はGeometry-101
とかで全体的にめっちゃ低いです。ちなみにふわふわ浮いてるパーティクルは人に被さる可能性があるためRenderQueueは通常通りTransparent
なので、たまにこの箱に潰されます。じっくり見るとわかるはず。
細かな条件でうまくいかないケースはもちろんあるわけですが (ゲートをポータル直前まで持っていくとか) そんなことしないだろうという願いと期待と妥協の下で成り立たせています。 ↩