Imaginantia

思ったことを書きます

Shaderで計算機を作る

シェーダはいろんなことができます。Geometry Shaderで好きな場所にポリゴンを出したり、Fragment Shaderでポリゴンではない方法で物体を描画したり。 その中でも特に「メモリにデータを保存して計算を回す」話について書きます。これは実質好きなプログラムを自由に書けるという話です。変数の保存とか。

概ねVRChatにおける話を書きますが、まぁ普遍的な状況と大して違いはないと思います。

読み飛ばしながら必要なところだけを読むのを推奨します。あと修正/意見などあればtwitter (@phi16_) まで。

全体構造

プログラムには入力と出力があります。VRChatに於いては或る計算の最終的な出力としては当然「視界に映るモノ」ということになりますが、それを生み出すための入力、そしてその入力を作り出す計算機など、様々な機構が存在できます。 特に私が計算機と呼んでいるモノは、「何か」を入力として情報が格納されたテクスチャを生成するものです。そのテクスチャを使って視界に映るモノを制御することで計算結果を表示することができます。 一般的なシェーダは時間や場所などのみが入力として与えられているので、その挙動はある程度固定化されるものです。ですが「自由に生成できるテクスチャ」を入力とすればその自由度は跳ね上がります。

f:id:phi16_ind:20190901181647p:plain

  • 入力系
    • 時間 (_Time)
    • 物体・カメラの位置・姿勢 (UNITY_MATRIX_MUNITY_MATRIX_V)
    • テクスチャ
    • Material Parameter (Animatorで制御可能)
    • Render Texture
  • 計算系
    • CustomRenderTexture (毎フレーム実行、外部パラメータは変更不可、ARGBFloatが気軽に使える)
    • カメラ (毎フレーム実行、外界をそのまま入力として拾える(depthも取れる)、基本ARGB32だと思います)
    • Compute Shader (スクリプトから実行、VRChatでは無理)
  • 出力系
    • Fragment Shaderで色を計算
    • Vertex/Geometry Shaderでポリゴン移動/生成
    • Render Texture

Render Textureの自己参照を利用して1フレーム前の出力を現在の計算に使うことが出来ます。計算用のメモリとして使うには十分です。

計算機を建てる

  • CustomRenderTexture式
    • サンプルは世の中に転がっているのでそれを参照すると良いです。
    • CustomRenderTextureのUpdate ModeをRealTimeに設定、Double Bufferedを有効化。
    • _SelfTexture2D で前フレームが取れます。
    • 私はInitialization ModeはOnLoadで、SourceをColorの(0,0,0,0)にすることが多いです。
      • シェーダ分ける程でもないし。最初のフレームかどうかくらい簡単に調べられるし。
  • カメラ式
    • 計算用の板 (Quad) を用意して、(基本的には)それだけが映るようなカメラを設置します。
      • Culling Mask/Near/Far/Sizeを適切に指定すれば映したい対象は明確に指定できるはずです。
    • TargetTextureに書き込み先メモリを指定します。
    • 計算用の板では計算実行用カメラを識別して視界ジャックをします。
      • 昔はいい感じの場所に配置するようにしてましたが面倒ですし不安定です。
      • o.vertex = float4(o.uv.x*2-1,1-o.uv.y*2,0,1); で全画面に貼れます。
      • カメラの識別にはカメラの座標や、UNITY_MATRIX_P を利用したOrthogonal判別/Near-Far検出が使えます。
        • 完全に「或るカメラ」を指定することはできないと思います。実用上の問題は無いはず。
        • 対象のカメラ以外に対しては o.vertex = 0; でポリゴンを消せばそれ以降の負荷はありません。
      • ちなみに私はだいたい ZWrite Off ZTest Always Cull Off"Queue"="Overlay" を指定しています。念の為に。
    • 前フレームは計算用シェーダのMaterial ParameterとしてRender Textureを渡せば良いだけです。
  • Compute Shader
    • 自明ですね。

作りたい計算機によってメモリの大きさ・フォーマット・諸々変わってくると思います。私は毎度0から作っていますがある程度固定化してもいいかもしれません。

入力と出力

テクセル座標

扱うメモリはテクスチャで、これはUVを使って参照するものです。しかし書き込み先としては「値の列」を想定しているわけで、その乖離を吸収する必要があります。

テクスチャサイズ (w,h) に対し、(i,j) テクセルを丁度ぴったり参照するUV座標は (float2(i,j)+0.5)/float2(w,h) です。読み出すときはこれが使えます。 書き込むときは、シェーダの場合「今何処に書き込んでいるのか」を表すUV uv を知ることができるわけで、そこからテクセル位置は floor(uv*float2(w,h)) で計算できます。 書き込み先UVは元々テクセルの中心を指している (0.5オフセットが入ってる) ので floor でぴったりの値を得ることができます。

内部フォーマット

また、入力・出力する値は基本 float4 ではありますが、CustomRenderTextureではなくカメラの場合はこれが「レンダリングのプロセス」として扱われる為に余計な処理が入る可能性があります。 私はARGBFloatはカメラ式では使わず、もしもどうしてもという必要性があれば浮動小数点パックを使って float3 を4ピクセル(RGB)に押し込めるようにしています。

昔はCustomRenderTextureがなかったのでこれを使うしかなかったんですが、今ではそうでもないのでありがたいことです。

出力の分岐

あと1つのシェーダで複数の異なる計算を回したいという場合は多く在ると思いますが、これは単純に書き込み先UVを使って計算内容を分岐させてあげれば良いです。 複数の値を出力したいと思ったら (冗長に見えても) 複数テクセル間で同じ計算を回して出力したい値を選ぶだけで良いです。

シェーダで分岐は良くないと言っている方が数多くいらっしゃいますが、(推測ではありますが)1度に並列で計算されるのは高々3000テクセルで、異なる計算内容の領域が同一並列区画に存在しなければ分岐ロスは発生しないはずです。(大きく計算内容を変えるときは私は丁度8~16の倍数の位置で切るようにしていますが、それがどれくらい価値があるのかは知りません) それにまぁ、GPUは優秀なので全プログラムを全テクセルで動かしちゃってもなんとかなるケースは多いと思います (forとか使ったら知りませんけど)。とりあえずやってみるのは大事だと思います。

並列的入力

前回の出力として参照できるのは基本的にメモリ全域ではありません。実際の所「必要なメモリは局所領域だけ」であることによってGPUは高速化が出来ています。 だからテクスチャサンプルする場所を適切に選択するのは大事だと思います。

参照する領域はもちろん自由に選べて、毎フレーム別の場所を拾いに行くとかもできるとは思いますが、よくあるケースとしては「一個隣を参照する (トレイルなど)」とか「U=0のテクセルを拾いに行く (サブエミッタなど)」とかが多いかと思います。

あといくらかの領域で同じテクセルを参照することは多いと思うので、そのようなアクセスは一本化してあげたほうが多分GPUに優しいような気がします。つまるところ tex2D で検索を掛けたときにヒットする数が少なければ少ないほど良いのです。

GPUは複数分岐をどっちも実行する可能性が結構あるので、1つの分岐で使われるサンプル回数がそう多くないとしても全分岐を実行した結果サンプル数がめちゃ多くなってしまうことが有り得ます。

VRChatにおける入力

折角のVR環境なのでいろんな入力を取りたいわけです。が、案外これは簡単ではありません。VRChatはスクリプトが使えないので。

物体の位置・姿勢

基本的な方針としては UNITY_MATRIX_M から得た情報を計算機に流し込む感じになります。 カメラによる計算機の実装では複数の板を表示するのが簡単なので、下半分で計算を回して上半分は別の板からの入力情報にしたりできます。

私は位置を知りたい物体の子としてTransformがIdentityのQuadを配置し、そのレンダリング時に計算機用カメラを部分的に視界ジャックすることでこれを行っています。私はマーカーと呼んでいます。

f:id:phi16_ind:20190901191106p:plain

カメラ判定を外すとこうなります。視界の一部にQuadが複数配置されている状況。

f:id:phi16_ind:20190901191534p:plain

カメラ経由なのでfloatを気軽には扱えなくて浮動小数点パックしなきゃいけないケースだと思います。多分。(私が怖がってるだけなので一度試してみてもいいとは思います、はい)

私のマーカーは大体こんな実装をしています。

fixed4 frag (v2f i) : SV_Target {
    float4x4 M = UNITY_MATRIX_M;
    float3 val = 0;
    if(i.uv.x < 0.25) val = mul(M, float4(0,0,0,1)).xyz;
    else {
        int ix = floor((i.uv.x - 0.25) * 3 / 0.75);
        val = normalize(M[ix].xyz);
    }
    return float4(pack(val,frac(i.uv.x*4)*4),1);
}

ちなみにこのモデル行列さえわかれば「その物体からある平面へ伸びるビームの衝突位置」や「Y軸に対する回転角度」などは簡単にわかります。これは Just do the math です。

尚物体の位置は (元々コントローラが微振動しているので) 基本的に振動します。x = 0.9x + 0.1v みたいに平滑化 (LPF) すると劇的に綺麗になるので、ちょっとメモリを使いはしますが導入することを推奨します。

マーカーの注意点としては、計算機カメラの視界に元々入っていないとFrustum Cullingされて映らない為、マーカーのBoundingBoxを大きくする (Skinned Mesh Renderer、またはスクリプト) かカメラの視界を大きくする (Width, Near-Far) 必要があります。

頭の位置・姿勢

頭は視界上のカメラなわけではありますが計算機にとってのカメラは其処ではありません。よって簡単にできるわけではありません。

Vacuous Park : Alter 技術解説に書きましたが、Canvasを用いた頭の位置検出を行います。

これによって頭の位置に物体が置けるようになるので、そこに先程のマーカーを仕込めば完璧です。

触れた領域

手を入力として取りたいとか思うことがあります。残念ながら人の体はポリゴンで出来ているので内部がありません。 よって「ある程度の近さにあるかどうか」程度の判定が限界です。が、大きな問題はありません。

簡単にやるならカメラの初期化をSolid Color (0,0,0,0) にして映るか (alphaが0でないか) を見ればNear-Far領域内に「何かがある」ことがわかります。 ちゃんとdepthを取るなら書き込み先Render TextureのColor FormatをDepthにすれば良いです。実際のdepthへの変換式は検索すると見つかりますが、あまり綺麗にうまくいった覚えはないので毎度試行錯誤をしています。

ちなみにわかるのは「ある場所に触れているかどうか」だけで、「触れた中心がどこか」などは知ることができません。もしもそういうことがしたいなら最大depthを取っている場所を後述のParallel Reductionで落とし込むことになると思います。あまりおすすめはしません。

トリガー

トリガーを引いたことを拾いたいときは単純にAnimation経由でMaterialのパラメータを直接いじれば良いです。また外部のAnimation移動する物体との同期がしたいときにもMaterial Parameterでどうにかなると思います。

尚これはCustomRenderTextureには使えないことに注意する必要があります。純粋な計算機としてはCustomRenderTextureは優秀なんですがI/Oには向かないですね。

計算のテクニック

総和

局所領域での計算に特化するGPUが単純には最も苦手な計算の1つが総和です。256テクセルの和を取ろうとしたら256回のループを回してサンプリングしまくるしかないのです。なので出来ればまずこういう計算は避けるべきです。 それでもやらざるを得ない場合があれば、総和演算の性質を利用した軽量化が存在します。a+b+c+d(a+b)+(c+d) であるように、和を取る領域を分割して分割して、だんだん足し合わせていくのです (Parallel Reduction)。 1度の計算では2個程度の値しか参照しないのでそれ自体は優しいのですが、逆に言えば1度の計算では総和自体は求まりません。なのでリアルタイム性を求められる場合には不向きです。

でももしもリアルタイムで無くても良いのであれば、総和が計算される時間が例えば8F遅れても良いのであれば、これは劇的な軽量化に成り得ます。実際の所案外そういう瞬間的リアルタイムが求められるケースはそこまで多くないのでどうにかなります。(例えばEye Adaptionの実装でこの遅延を入れることで人間の視覚を模す話があった気がします)

f:id:phi16_ind:20190901185343p:plain

こういう図 (MipMapっぽい) を組んであげればいい感じに計算が出来ます。各領域は毎フレームその2倍サイズの領域の4テクセルを参照しにいくわけです。

フレーム遅延は空間計算量を時間計算量にダイレクトに運べるので色々と使えるとは思います。

ちなみに総和の場合は4チャンネル同時に使うのも効果的ですね。 dot(v,1) でベクトルの成分の和が取れたりもします。

パーティクルの実現

パーティクルは位置・速度・色・形状その他諸々様々な情報が必要になることがあります。1テクセルでは大体収まらないので複数参照して組むのがベースだと思います。 参照領域のとり方は人に依りそうです。私はパーティクル毎に置いていますがプロパティ毎のほうが計算負荷は低かったりするかもしれません (計算過程がほぼ同等であれば前者のほうが良い気はします、メモリアクセスが局所的なので)。

f:id:phi16_ind:20190901193521p:plain

特に生成と消失は多少問題で、生存期間が全てのパーティクルで一定であれば「初期化する領域をだんだんずらしていく」とか「パーティクル自身を1区画右に移動する (右端で消える)」とかができます。 が、任意のタイミングでそれらができるようにするにはまぁちゃんと仕様を考えなければなりません。

あるパーティクルを出したい!と思ってもそのための領域が余っているとは限らないのです。全体量に限界があるので。 循環キューのようにして「次に書き込む先を保存しておいて」「もしも書き込み先が空いてなければ棄却または上書き」みたいにする必要があると思います。 単純にやると1フレームに複数出せないので、キューもどきを複数用意してうまいことやることも出来ると思います。

計算機を増やす

あとになってパーティクルが足りないとか、全体の計算機自体を2つにしたいとか思うことがあるかもしれません。 単純に計算機構を2つ置いてもいいんですが、描画回数が2回に増えます。DrawCallは少ない方が良いです。

ちゃんとテクスチャサンプルや保存するための情報を受け取る関数を分離 (抽象化) しておけば、Render Textureの大きさを2倍にするだけで済むケースは少なくないと思います。 参照するUVを適切に振り分ければ各々の領域では今までと何ら変わらないプログラムが動きます。あんまり気負わずにいきましょう。

1フレーム内の計算回数を増やす

http://el-ement.com/blog/2018/12/13/unity-fluid-with-shader/

こちらの記事で紹介されている方法として、GrabPassを用いて無理やり1フレーム内でデータを横流しするものがあります。先程の総和演算などもこれを使えば1フレームで無理やり計算できると思います。

また、CustomRenderTextureはある程度依存解決を自動的に行なってくれるので、チェーンを組んであげることで思い通りに複数Pass実行させることもできます。循環すると不定になる気がするのでカメラを挟まなきゃいけなかったりするかもしれません。カメラのDepthでも同様の制御ができる気がします。私はどれもやったことはありません。

まぁ当然DrawCallの上昇が入るので負荷的には悩ましいところです。やりたい表現内容に応じて計算手法も変えていくと良いと思います。

離散値の扱い

浮動小数点をベースに扱う環境なので離散値の取り扱いはあまり向いていません。別にintを使うのは何も問題ありませんが保存周りがちょっと悩ましいところです。 私は基本的に整数 x に対して浮動小数点数 x+0.5 を保存するようにして、floor またはintへの直接代入 (同じ効果) で離散値に落とすようにしています。離散値であってもfloatで全部処理したりもします、GPUなので。 また浮動小数点上で 01 を区別する際には x < 0.5 にするとか。怖いと思ったら x = floor(x+0.5) するとか。いろんなきっかけで意図しない動作が起きることがあるので離散値を扱うときは慎重に行うことをおすすめします。

ちなみに離散値というのは別にintとかそういう話ではなく、扱っている値の世界が分断的 (区切りがある) ときの話です。なのでfloatを使っていても自分が離散値だと思っている値ならそれは気をつけるほうが良いと思います。 というかこの話はシェーダに限らないですけどね。離散空間を扱うのはなかなか難しいものです。

作例

なんやかんや書くよりも実事例を見せたほうがまぁ理解が早いかもしれません。

傾く水

水の傾きは回転行列から取得できるので「現在有り得る最大の水位」は do the math で出ます。しかし単純には傾きを戻したときに水位が上昇してしまうことになります。

今までの最大回転角をメモリに記録することで「これまでの最大回転角をベースに水位を計算」「現在の回転角が最大回転角を上回ったら更新」すればうまくいきます。 ちなみに水位の計算は自明ではないです。疑似的にそれっぽい関数をつくるのが一番簡単そう。

f:id:phi16_ind:20190901203220p:plain

ろくろ回し

粘土の形状を各角度・高さに対して外側からの深度でモデル化します。これはつまり1チャンネルの2次元テクスチャです。 これに対して「衝突球の位置 (マーカー経由)」「ろくろの速さ (Animation経由)」を与え、当たってそうな領域の深度を変化させることで処理をしています。

ろくろの速さは本質的で、高速回転するろくろは球の当たっている領域ではなく1フレーム以内に当たっていたはずの領域を処理するべきなので、判定領域を速度に合わせて伸ばしてあげることで所謂ろくろの動きを実現できています。

f:id:phi16_ind:20190901204002p:plain

プログラムっぽいの

唐突に複雑度が増しますが、内部的な計算はわりと単純です。ちなみにこれも「現在居る場所のプログラムを実行する」ので、メモリアクセスが局所的な例ですね。

f:id:phi16_ind:20190901205948p:plain

配置されたキューブ群から、実際に通ることになる道を構築。実行のトリガーが引かれたらそれに合わせてシミュレーションを開始。

GPU Particle

ここまで理解が伴っていれば最早複雑な部分は一つもありません。

f:id:phi16_ind:20190901210958p:plain

各パーティクル (位置と速度を持つ) に対して、杖の位置に向かう加速度を掛けてあげるだけです。

ちなみに花火大会のパーティクルでは「私」に向かって飛んでくるものがありましたが、これには計算機構は使っていません。 パーティクルを出現する位置を自分自身、速度を適当に加えることで経過時間から「出現したときの私の位置」を算出可能にして、花の場所から私の場所へ来るようにGeometry Shaderで組んだものです。 VRChatではアバター仕込みのカメラは他人 (特にフレンド外) からは映らないので、アバター仕込みでこういう演出をする際には計算機を使わない代替を考える必要がありますね。

Vacuous Park : Alter

こっそり使われていた計算機です。解説は昔書きました。

f:id:phi16_ind:20190901211229p:plain

Just a canvas

f:id:phi16_ind:20190901215014p:plain

基本的にあのUIは「トリガーを押した時点の姿勢(座標系)を保存」「移動した結果の(保存された座標系上の)相対座標から新たなプロパティを計算」という流れで出来ています。 ちゃんと考えずに作り始めたから座標系周りの計算がめちゃくちゃでコードも最悪みたいな感じだったりします。かなしい。

UI系計算機側から筆の位置姿勢や大きさ、色がキャンバス計算機側に送られてくるのでそれを拾ってキャンバスに色を塗ります。 ちなみに1フレーム前の筆の位置を覚えておいて、その間を各点ランダムに補間することで引き摺った跡みたいなのを出ないようにして、高速に振っても大丈夫にしています。

ミニマルゲーム

f:id:phi16_ind:20190901222118p:plain

計算機の構造としては明らかですが、メモリの使い方は多少面白いことになっています。

f:id:phi16_ind:20190901222840p:plain

上の段でスコアや経過フレームなどが記録されており、下の段では積んであるキューブたちがパーティクルに近い形で格納されています。 これは下から順番に並んでいて、1つドロップさせる度に全体を左に1つシフトする即ち読み出す位置を1つ右にするようにしています。 積む処理も単純で、一個下のキューブに乗る位置になるまで物理的自由落下を行うようなコードになっています。すこし適当な弾性も入ってますが。

それに対して流れてくるキューブには記録領域がありません。これは(開始時間と)位置を種とする乱数で決まっていて、わざわざ保存する必要がなかったのです。 落ちてくるキューブも結局乱数で決まっているわけですが、この落下の動きが作りたかったのでパーティクル的な実装をしています。

BeyondReality

実はあの3つの展示はすべて同一の計算機で賄われています。

f:id:phi16_ind:20190901224010p:plain

f:id:phi16_ind:20190901225426p:plain

「暗号という恐怖」のタイル移動の基本は、ある乱数を元にした1~512のランダムシャッフルです。 ランダムシャッフルの実装としては「ランダムに1つ選んで確定」「それを候補から外して最選択」を繰り返すものを使っており、これを512F掛けて行っています。確定したものから移動を開始するのと、移動時間は位置関係によって変化するためにあんな感じの見た目になっています。

「非観測的観察」で使われている状態は「各可能世界がどれくらいの割合であり得るか」です。5つの球があるので32通りあるわけですが、これを総和が1になるように保ちながら確率変動を計算しています。 ちなみに11ピクセル3チャンネルで保存しています。

「一貫性の境界」ではCanvas Trackingを利用した「ある点の周りを回った角度」を記録しています。一周すると2π増えて最初とは異なる状態になって、異なる見た目になるわけです。 この角度はあの柱に加えて展示室内部の無の領域でも測っていて、展示室を何周したかどうかがわかるようにもなっています。

ド Controller

これも機構としては単純です。

f:id:phi16_ind:20190901230822p:plain

f:id:phi16_ind:20190901231031p:plain

内部はもはや覚えていませんが、パーティクルを自由に制御するためにいろいろとやったことは覚えています。

マーカーの実装がちょっと変わっていて、「自分が所属するボードの姿勢行列で逆変換した座標」を保存するようになっています。 なので最初から座標系がボード上にあって計算が多少簡単になっています。まぁ大きな話ではないけれど。

Just a canvas with your friend

もう折角なのでネタバレをしちゃいます。

f:id:phi16_ind:20190901235042p:plain

あれは畳み込みニューラルネット逆運動学、あと探索アルゴリズムの組み合わせです。人間の模倣手法スターターキットみたいな感じです。

f:id:phi16_ind:20190901235325p:plain

プレイヤーの筆がキャンバスから離れた瞬間、その場所を起点に探索アルゴリズムが起動します。

  • 周囲に未だ塗っていない領域がそこそこあれば、ランダムに振動しながらその周囲を漂います。
    • もしも未だ塗っていない領域に丁度居れば、そこからさらに「まだ塗っていない領域がありそうな方向」に移動します。
  • 周囲よりももっと未だ塗っていない領域がある領域を見つけたら、確率でターゲットをそちらに変更します。

大域的に塗っていなそうな領域は前述の領域総和 (正確には重心を計算しています)、局所的に塗ってそうな方向に移動するのは場の勾配を取っています。

その結果得られた場所の近傍128x128領域を出力し、CNN側はそれを入力として受け取ります。

f:id:phi16_ind:20190902000617p:plain

モデルはU-Netのめちゃくちゃ小さい版 (各層でフィルターが16個) になっていて、Contracting Pathが下半分です。 あるニューロンの出力は3x3x16個の値を参照しなきゃいけないわけですが、つらそうなので4Fに分散 (3x3x4channel) させています。 結果として計算機構は6F (初期化+4F+活性化) 毎のクロックで動作していき (なので入出力も6F毎です)、入力を与えてからその出力が出るまで123F掛かります。

それに合わせてIKへの入力も123F遅延させます。プレイヤーの筆が離れた瞬間には黒い筆が動かないのはこれが理由です。

f:id:phi16_ind:20190902000204p:plain

探索の結果得られた座標をターゲットとして、2関節のIKを解いてもらいます。クォータニオンで適当に実装しました。 それっぽく見せるための調整は結構沢山入っています、始点自体がなめらかに動くとか。

そんなこんなでアレが出来ています。CNNもIKも初めて動かしたので能力不足であったのはそこですね。難しい。

終わり

わざわざ言う必要もないと思いますができることが異常に増えるので是非状態保持を使ったギミックを作ってほしいなと思います。まる。

おまけ: そういうことをしているタイプのワールド(たぶん)

シンプルにカメラで状態保存するタイプのワールドはそこそこありそう?Just graffitiとか胡蝶庵とか。