Vacuous Park : Alter 技術解説

こんにちはphi16です。この記事は [VRChat Advent Calendar 2018](https://qiita.com/advent-calendar/2018/vrchat) の12日目の記事です。

![20181208024110_1.jpg](https://qiita-image-store.s3.amazonaws.com/0/88634/6072eb8d-2b70-f396-04e4-3cb5f358d584.jpeg)

[Vacuous Park : Alter](https://www.vrchat.net/home/world/wrld_49df40fa-9363-4cae-a4d1-4bb790d34aad) というワールドを9月24日に制作、public化しました。
「見えるもの」に関しては予想できる通りShaderごりごり書いてる**だけ**なので、「見えないもの」の仕組みを紹介しようとおもいます。

具体的には、**2つの世界を別つ仕組み**です。

---

何が起きるのか、を説明しましょう。初期スポーン世界を **α**、観覧車世界を **β** とします。

ワールドに入ったときはα世界に居て、白いふわふわが浮いているだけです。このふわふわで出来た道があって、その先には金属のゲートがおいてあります。
<img src="https://qiita-image-store.s3.amazonaws.com/0/88634/24bbbf63-9559-1a85-2a32-1fa855878b24.jpeg" width="600"></img>
このゲートを通るとβ世界に移動して、周りにはパーティクルで出来た建物と、そして真後ろには壊れた観覧車があります。
<img src="https://qiita-image-store.s3.amazonaws.com/0/88634/1989d0eb-4f61-a46a-2347-3cf163c7f41d.jpeg" width="600"></img>
さらに前方にはもう一つのゲートがあり、その奥にはまたα世界が広がっています。終端には [Vacuous Park](https://www.vrchat.net/home/world/wrld_465649aa-d1da-49ff-b90c-456187a1c54e) へのポータルが置いてあります。
<img src="https://qiita-image-store.s3.amazonaws.com/0/88634/bfa2f003-91e0-650f-2ef4-374d83638a4f.jpeg" width="600"></img>
このゲート、実は**動く**んですが、つまりこの機構は視点位置の座標を直接拾ったりしているわけではありません。実際最初のゲートは通過するとすぐ閉じて、そこからではα世界に帰れなくなるので。真面目にこのゲートたちの挙動を計算して出来ているのです。

さて、この仕組みを使うことにしたきっかけは VoxelKei さんの [SpatialGate](https://twitter.com/VoxelKei/status/1037756177956790272) だったんですが、それとは実装は全く異なるものです。私は**全部Shaderで実現している**ので、Colliderのオンオフなどは出来ないのですね。ただ「全ての挙動を自分で書ける」という意味で**正確に動く**ので嬉しい人には嬉しい。あとこれくらいのプログラムなら書けるよという例にもなるかもしれません。

まずは内部構造の概略から紹介していきます。

## 概略

存在する最も重大な**状態**はもちろん「世界」です。α世界かβ世界のどちらに居るのかを保持しています。そして次に重要なのが「直前の視点」です。視点がゲートを超えるときに世界状態が書き換えられることになるわけですが、「超えた」ことを判定するために「1フレーム前の視点と現在の視点を結んだ線分がゲートと衝突するか」を調べています。なので1フレーム前の視点が状態として保持されています。世界を別つ為に在る**本質的**な状態はこの2つだけです。

そして世界の遷移を計算するために必要な外的**情報**があって、具体的には「ゲート」です。1枚のゲートを表現するためには「中心座標」「法線」「半径」があれば良いわけですが[^disc]、半径は或る世界状態に依存されて決定するため、必要なのは「座標」「法線」だけとなります。ゲートは今回2枚あるわけですが、プロトタイプでは8枚くらいありました[^proto]。

[^disc]: 厚み0の円盤なので。
[^proto]: https://twitter.com/phi16_/status/1040843748106690560

情報の流れを記すとこんな感じです。
<img src="https://qiita-image-store.s3.amazonaws.com/0/88634/0e60d78b-7d68-ae60-7ce1-205700c22581.png" width="400">
状態保持はカメラを利用したRenderTexture上のfeedbackで実現できます。ゲートの位置はその中心に仕込んだマーカーが法線と位置を出力することで得ることができ、また視点の位置[^eyep]はCanvasの視点追従を利用して[^canvas]マーカーを仕込むことで拾うことができます。実際の内部状態テクスチャはこんな感じ。
<img src="https://qiita-image-store.s3.amazonaws.com/0/88634/63ae2a33-49c8-7d6a-5a45-3fa7a9e5ae79.png" width="600">
というわけで機構が出来ました。あとは世界をひっくり返すだけです。

[^eyep]: `_WorldSpaceCameraPos` は使えないことに注意。視界を描画するときならば正しい。
[^canvas]: CanvasTrackingとか呼ばれている。ToyboxV2経由で使ってます。

ゲートの位置と現在状態から「各描画対象ピクセルがどっちの世界にいるのか」を計算することは可能なわけですが、毎回それをやるには既存のShaderに大きく手を加えなければいけないので面倒です。そこで**Stencil**を使います。推測していた人も多いと思いますが。
Stencil値を反転する透明なShaderをゲートに仕込み、そして現在居る世界に応じて反転するかどうかを決めるShaderを視界ジャックで画面全体に仕込みます。あとはStencil値に応じて消えるように既存のShader群にちょっと書き加えれば完成です。

## 詳細

### マーカー

私がマーカーと呼んでいるのは「ある指定されたカメラを**一部**視界ジャックする形で映る、座標情報などをエンコードした板ポリ」です。一般に複数個のfeedback loopは1つに集約できることが知られていて、カメラの半分で状態計算・残りで新規情報取得というような形式にすればカメラの負荷はほぼ最小限にできます。
複数のマーカーを正しく受信させるために描画領域をずらす必要があるため、それをMaterialのparameterなどで設定してあげることで2つのゲート位置をテクスチャに記録することができるようになっています。

マーカーの実装はこんな感じ。

```glsl
// 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`である」場合にのみ表示をするようにしています[^cam]。
あとはModel行列から位置と法線を抜き出し、描画対象外を`clip`、あとは[浮動小数点packing](http://vrcworld.wiki.fc2.com/wiki/Shader%E9%96%A2%E9%80%A3#b03c4e03)で表示です。

[^cam]: 最近は念を入れてカメラの座標で条件を掛けるようにしています。

カメラ制約を抜くとこんな感じに表示されます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/88634/06ae107b-1fb3-017f-5f14-812f3c39c947.png)

### 視点位置

Toyboxをそのまま使っているので細かいことはわかりません。Canvasを `Screen Space - Camera` 設定にし、`Plane Distance` を `0` にすることでCanvasは視界ジャックにあたる位置と向きに自動的に固定されます。正常動作させるためには幾らか処理をする必要があるらしいので[^toybox]ですが、細かい話は知らなくても良いでしょう。

[^toybox]: UnityがCanvasをスケールすることで内部的に大きさを合わせているのなら、`Plane Distance`が`0`というのはCanvasが無限の広がりを持たなければ「理想」通りの2次元Canvasを実現することはできないでしょう。だから根本的にこの設定は異常な気はするわけですが、それを無理やり動かすための奇妙なAnimatorと考えればわからなくもない話ですね。

視点位置も同様にしてマーカーを使って位置を取り込むことが出来ますが、今回は**視点に更新機構を埋め込む**ことで情報取得を行っています。理由としては、**カメラを介すと1フレーム掛かる**ことに拠ります。今回の「視点がゲートを通過した**瞬間**に世界を切り替えなければならない」という要件により、1フレーム前の視点 (及び2フレーム前の視点) を基準に計算していたのでは間に合いません。状態更新は最速でやる必要があったため、`UNITY_MATRIX_M` 経由で視点座標を拾うことにしています[^delay]。

[^delay]: ちなみにこの結果を用いて計算された状態は**尚1フレーム遅い**ことになる為、正確に表示するためにここと同様の計算を**描画にも**同タイミングで行っています。具体的には実視界ジャックによる世界反転を担う板ポリが同じところ(視点)に配置されています。

### 状態保持

状態はRenderTextureとして保存され、そのRenderTextureを読み込んで計算された新たな状態をカメラに読ませることでRenderTextureに出力される、というループによって1フレームに1度更新される状況を作り出すことができます。昔はカメラの位置に合わせて計算の出力を配置していましたが、視界ジャック式にすることでその必要はなく好きな場所にカメラを置くことができます。
とはいえBoundingBoxによるCullingが入ってしまうので、「板ポリのBBを超巨大にする」か「カメラを全体が映る位置にする」ようにする必要があります(特に今回は板ポリは視点位置にあるので)。前者のほうがローコストだとは思いますが今回は後者で実装しています。理由は特にありません。多分。
`(0,100,0)`の位置に下を向いた超巨大カメラが置いてあって、PickupLayerだけを読むようにしています[^pickup]。カメラの大きさと描画コストは無関係 (出力解像度には依存、今回は4x32) で、PickupLayerに所属する物体の数は定数なので問題ないでしょう。

[^pickup]: ゲートがPickupなのでついでに。

### 世界計算

**Just do the math.**

今持っている情報は「視点」「1フレーム前の視点」「ゲートの位置・姿勢・大きさ」「今居る世界」です。ゲートを通った回数だけ世界を反転すれば良いわけです。

<img src="https://qiita-image-store.s3.amazonaws.com/0/88634/4575060d-4083-8144-c665-a86b49a0c501.png" width="300">

線分内の点は $\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$ がわかることがわかります[^me]。

[^me]: 自由度1、制約1なので。特に今回は線形であることが見てわかるので解き方の具体的存在もわかりますね。

あとは計算して出てきた衝突点がゲートの中心から半径以内にあるかを見れば大丈夫です。実際のコードはこんな感じ。

```glsl
// 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` の計算で割り算が入っているので**怖かった**からです[^div]。代わりに「片方が表、片方が裏にいる」ことを `pt * ct < 0.0` によって判定しています。
そして世界の情報は `0` か `1` で保存しているので、`state = 1 - state` によって世界を反転したことにできます。これでゲート1枚分の処理ができたので、後は回すだけです。

[^div]: `ct = pt` の状況は結構発生しうる (視点が動かなかった場合) ので良くないのです。

### 反転描画

あとはそこまで複雑ではない…はずだったんですが、実は結構大変だったのがここです。**VRでは視点が2つある**のですね。Toyboxで拾える視点は1つ (やろうと思えば増やせるけど) で、今までの処理も1視点ベースで記述しているのでこのままでは片目だけ世界の向こう側に行ったときにうまく表示できません。そこで、各ゲートの反転Shader側で「**本当の視点**がゲートの向こう側にある場合、もう一度反転する」ようにしました。一応これで問題ないようには見えたんですが、ついこの前に遊びに行ったときには絶妙にうまくいかないケースを視認してしまいました。さすがに面倒なので放置ですが。

というわけでShaderです。

```glsl
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);
  }
}
```

ほぼそのままです[^clip]。あとは先述の視界ジャックによる世界全体反転をいれてあげればStencilにどちらを描画すべきかが書き込まれたことになりますが、これは実際簡単ですね。

```glsl
// for α world
Stencil {
  ReadMask 64
  Comp Equal
}
// for β world
Stencil {
  ReadMask 64
  Comp NotEqual
}
```

過去の私が適当にやった所為で何故か `Ref` が無いんですが、どうせ片方で通るならもう片方が通らないはずなので動くだろうという気持ちっぽいですね[^just]。実際動いてるけど。

[^clip]: そういえば `clip(-1.0)` は `discard` で良いんでしたっけね。昔のソースだから許して。
[^just]: もしも初期の世界が反転してても視界ジャック側の処理を逆転してあげればうまくいくわけです。

### ポータル

仕上げになんとなく配置した Vacuous Park へのポータルですが、これはStencilで消すことができません。仕方が無いので強いDepthを吐いて消すことにしました。
<img src="https://qiita-image-store.s3.amazonaws.com/0/88634/8ae3e032-a4b0-d404-f897-a82c00c1c8ac.png" width="400">
箱で囲み、先程のStencilの処理と同様にしてα世界にだけ存在するようにします。背景の透明化のために `Blend SrcAlpha OneMinusSrcAlpha` を入れつつ、敢えて **`ZWrite Off` を入れません**。この箱のRenderQueueは `Geometry-100`[^depth] なのでその後に描画されるポータルはこのDepthに隠れて見えなくなります。
[^depth]: ちなみに反転機構は `Geometry-200`、遠景は `Geometry-101` とかで全体的にめっちゃ低いです。ちなみにふわふわ浮いてるパーティクルは人に被さる可能性があるためRenderQueueは通常通り `Transparent` なので、たまにこの箱に潰されます。じっくり見るとわかるはず。
![20181209155643_1.jpg](https://qiita-image-store.s3.amazonaws.com/0/88634/06f41ba4-497d-fc1e-08bb-71fd134163ab.jpeg)
細かな条件でうまくいかないケースはもちろんあるわけですが (ゲートをポータル直前まで持っていくとか) **そんなことしないだろう**という願いと期待と妥協の下で成り立たせています。

## 終わりに

このワールドは「表現」の方向性で作ったもので、ここに使われている機構は完全に「表現のための技術」です。なので技術的観点の話はしなくて良いし**しないで欲しい**わけですが、自分で面白いと思ったので解説を書いてしまいました。これを見て検証しに行くとかよりは、自分のやりたい表現に応用できないかとかを模索してくれるといいかなと思います。

お疲れ様でした。明日は [shr_em](https://qiita.com/shr_em) さんの「流体計算の話」です。