Unityでスクリプトを使わずに流体を計算する

VRChatで流体計算をするワールドを作ったので、経緯や技術解説などを書いていきます。

この記事は VRChat Advent Calendar 2018 の13日目の記事です

 

事の発端

phi16 がVRChatで面白そうなものを作っていて楽しそうだったので、ぼくもやってみたいなぁと思いました

先行研究

VRChatにはUnityで作ったシーンを「ワールド」としてアップロードし、みんなで訪問できるようにする機能があるのですが、残念なことにC#で書いたプログラムをスクリプトとして動かすことができません。この辺はセキュリティの問題があるのでまあ仕方ないかなと思います。では代わりに何ができるかというと、シェーダを動かすことができます。シェーダとして書いたプログラムであれば実行させることができるわけです。

シェーダを使って色々動かしてやろうという目論みは先駆者たちによって様々な研究がされており、情報がまとめられています
この辺りの情報を参考にしつつ、シェーダを使って何か作ってみようということになりました。

状態を保存する

シェーダというのは元々何かを(綺麗に)表示するためのものであり、単体では情報を保存・更新する能力を持ちません。
そこでUnityに用意されているRenderTextureを使います。Unityのカメラで見た色情報をこのテクスチャに保存することができます。

RenderTextureの設定は画像のようにしました。
サイズは2冪であればなんでもいいのですが、

  • Anti-Aliasing をNone に
  • Color Format を ARGB32 に
  • Filter Mode を Point に

しておきます。大事な情報なのでぼやけたりすると困るからです。また、浮動小数点テクスチャは不安定なので使えないようです。

作ったテクスチャは板ポリに貼り付け、Orthographicにしたカメラにちょうど映し出されるように配置します。もちろんカメラのレンダリング先は作ったテクスチャにしておきます。この辺りの詳しい設定は Shader関連 – VRChat 技術メモ帳 を見てください。

こうすることで、カメラによって映し出された情報がテクスチャに保存され、テクスチャの情報は板ポリに出力された後、再びカメラによって取り込まれます。板ポリにMaterialを使ってシェーダを設定すれば、テクスチャの情報はシェーダを通して出力されます。すなわち、シェーダによって情報を読み書きできる機構の完成です。

動いた

ライフゲームが動きました。

色々試してみると、テクスチャのアルファ値も正しくカメラによって取り込まれることが分かった※1ただし、カメラの Clear Flags を Solid Color にし、Background の色の透明度を0にしておく必要があります。また、カメラのファークリップ面を十分に近づけておき、他のオブジェクトが板ポリの背後に写り込まないようにしなければなりません。ので、1テクセルにつき32ビット分の情報を保存できることが分かりました。これはfloat型1つの情報量に該当します。

浮動小数点数を保存する

テクセル1つにつき1つの32ビット浮動小数点数が保存できることが分かったので、RGBA値とfloat値の変換を行う処理を書きます。
丸め誤差などで微妙に数値が変わることがあり、苦労の末できたのが以下のコードです。

これらの関数を使うと、tex2Dlod などで取り出した float4 型を float 型に変換したり、float 型を保存するときにフラグメントシェーダで返すべき float4 型に変換することができます。

これで実質1チャネルの浮動小数点テクスチャを扱うことができるようになりました。

複数の状態を保存する

さて、浮動小数点数を保存することには成功しましたが、1テクセルにつき1変数なので使い勝手が悪いです。また、残念なことにスクリプトを使わないと複数のテクスチャに同時書き込みを行う MRT (Multiple Render Targets) ができないので、2つ以上の変数を保存することができません。

ではどうするかというと、1枚のテクスチャに全ての変数を押し込みます

1枚のテクスチャは小さなテクスチャが集まってできたものと考え、小さなテクスチャにおけるUV座標と、小さなテクスチャの「座標」(1つの座標が1つの変数に対応します)から元のテクスチャのUV座標を計算する仕組みを作ります※2もちろん逆も必要になります。。これで複数の変数を擬似的に扱うことができ、実質的にnチャネルの浮動小数点テクスチャが扱えるようになりました。

ただし、書き込む座標が固定されているため保存する際には注意が必要で、「書き込もうとしているテクセルがどの変数(=小さいテクスチャ)に該当するか」を見て書き込みデータを変える必要があります。

元のテクスチャのUV座標から小さなテクスチャのUV座標とインデックスを計算するコードは次のようになります。

1フレームに複数回の更新を行う

道具は揃いました。後は好きに流体を計算するだけです。
……と言いたいところですが、まだこの状態では「1フレームに1度、同じ方法で変数を一斉に更新する」ことしかできないため、複雑な計算を行うことはできません。

そこで、Unityのマルチパスシェーダを作るために用意されている機能 GrabPass を使います。複数のフラグメントシェーダの間に GrabPass { } を挟むことで、直後のフラグメントシェーダに直前のフラグメントシェーダによる描画結果をテクスチャとして渡すことができます※3ここで渡されるのはカメラから見た描画結果なのですが、今はカメラの範囲をテクスチャが貼られた板ポリとぴったり同じ大きさにしているので、テクスチャがそのまま渡されると考えて問題ありません。。この処理は1フレームの間に行われるため、1フレームの間に複数のシェーダによって複数回変数を更新する処理が書けるようになります。

こうして設定した板ポリをエディタで見るとヤバい見た目になるのですが、テクスチャにはきちんとデータが保存されているので問題ありません※4見た目がおかしくなるのは GrabPass で渡されるテクスチャがメインカメラから見た映像になるためです。しかし、メインカメラには RenderTexture を設定していないのでテクスチャが上書きされることはありません。

流体を計算する

ようやく自由にプログラムが書けるようになったので、シェーダで好きなように流体を計算します。今回も例によって Stable Fluids です。ここでは詳しく説明しませんが、興味がある方は調べてみてください。

できました。ここから色々拡張していきます。

数値拡散によって画像がぼやけるのを軽減する

水面の色を移流させると、解像度が有限であるため少しずつ周囲の色と混ざっていき、最終的には一色になってしまいます。これを数値拡散といいます。VRで見ることを考えると解像度はできるだけ高くした方がいいのですが、複数の変数を詰め込んでいる都合上あまり解像度を上げることができませんでした※5解像度256×256で計算しても全体のテクスチャサイズは1024×1024になります。

そこで方針を変え、なるべく色が混ざらないように移流計算ができないか考えていたところ、色ではなくテクセルの位置を移流させる※6データの再分配を行わなければ数値拡散が起こらない、というのは粒子法によるシミュレーションで数値拡散が起こらないのと同じ原理です。というアイデアが降ってきたので実装しました。これが思いの外うまくいき、色を全く混ぜることなく模様だけ移流させることができました。

↑後で気づいたんですが、diffusion と distribution を間違えました。恥ずかしい…><

やっていることは次の通りです。まず各テクセルにパーティクルを1つ割り当てます。
パーティクルのx座標とy座標はテクセルのU座標とV座標で初期化します。
次に、各フレームの移流計算時にパーティクルをその位置の速度ベクトルに対して逆向きに移動させます。

あれ?なぜ逆向きなの?と思った方は移流計算で使ったセミラグランジュ法を思い出してみてください。

これと同じことを位置情報に対して行います。すなわち、粒子を逆方向に進めておいて、粒子がもともと存在していたテクセルの色を、粒子が今いる位置の色から拾ってきます。逆像の逆像みたいな考え方です。

この手法は一見すると永遠にうまく動いてくれそうですが、残念ながら時間がたつと破綻していきます。理由は粒子が速度とは逆向きに進んでいるためで、結果として本来(順方向に進んだときに)現れるべき模様からは少しずつ離れていくことになります※7綺麗なカルマン渦が見えなくなるのはこれが原因です。

そのため、定期的に水面の模様を元の状態に戻してやる処理を加えてあります。

高さベースの波紋シミュレーションを組み合わせる

Stable Fluids は水面に平行な成分のシミュレーションですが、これと高さベースの波紋シミュレーションを組み合わせられないか試してみました。折角水面が水平方向に速度を持っているので、高さの情報も色情報と合わせて移流させてやることでそれっぽい動きになりました。

何も考えずに移流させると高さ成分が発散してしまったりして大変だったのですが、うまいことパラメータを調整して回避してあります。完全にCGの世界です。また、波紋のシミュレーションはテクスチャサイズが大きくなるにつれ見た目がゆっくりになるので、Stable Fluids のポアソン方程式を解いている間に平行して複数回計算を行って加速させています。

高さ方向の情報はテクスチャに保存してあり、これは頂点シェーダから参照できる (Vertex Texture Fetch) ため、実際に頂点を動かして表示することができます。

インタラクティブにする

このままでは水面がただ動いているだけなので、水面がオブジェクトに反応するようにします。具体的には、触った場所に波が立つ、動いた方向に模様が動く、などです。
触った場所に波が立つのはデプスを読めるのでまだいいのですが、模様を混ぜられるようにするためにはオブジェクトの速度が分からないといけません。そして、スクリプトなしにピクセルごとにオブジェクトの正確な速度を取得する方法は恐らく存在しません※8スクリプトが使えるならば、Motion Vectors が役に立ちそうです。

以下は完全にゴリ押しでオブジェクトの速度のようなものを取得する方法の説明です。

Projectorを使ってオブジェクトの中心座標を書き込む

速度は位置の微分なので、まずは位置が分からないことには始まりません。ここではピクセルごとの正確な速度を出すことは諦めて、オブジェクトの中心座標の差分から速度を出すことを考えます。オブジェクトの中心座標は、大体モデル変換行列の平行移動成分に近いと考えることができるので、フラグメントシェーダで行列の平行移動成分を取得して※9ComputeScreenPos(UnityObjectToClipPos(float4(0, 0, 0, 1))); でローカル座標における原点のスクリーン座標が分かります。色に変換し、テクスチャに記録することにします。

しかし困ったことに、プレイヤーのアバターは(当然ですが)事前にアバターの作者によって登録されているシェーダを使って描画されます。つまり、ワールド側で好き勝手に色を変えることができません

どうにかならないか @phi16_ に相談したところ、UnityのProjectorという機能を使うと実現できるらしいという話を聞いたので、早速試してみることにします。

Projectorは一言で言うと「特定領域に入ったモデルの色を好きなシェーダで上塗りできる機能」です。よって、「モデル変換行列の平行移動成分を表す色でベタ塗りするシェーダ」を書いておき、適当な場所にProjectorを仕込んでおけば中心座標を書き込むことができます。

しかし、残念なことにProjectorは問答無用で全てのカメラに対して有効になるため、メインカメラでもベタ塗りされたアバターが表示されることになります。

これではさすがにまずいので、平衡投影設定になっているカメラに対してのみ色を塗り替えるようにシェーダを書き換えます。

これでテクスチャに書き込むときだけ色塗りが行われるようになります。

中心座標の差分を計算して速度を求める

テクスチャに座標の書き込みができたら、直前のフレームでの情報と比較し、差分から速度を計算します。ただし、オブジェクトが移動したことにより座標が書き込まれているピクセルも動くことになるので注意が必要です。

画像のような場合、「今のフレーム」と「直前のフレーム」で重なっていない部分が多いので、1点だけでなく周辺n点のピクセルを見て速度を決める必要があります。シェーダでは今のピクセルに近い方から渦巻き状に最大で1089点のサンプリングを行うようにしました※10かなり多いですが、そもそもオブジェクトが存在している領域が少ないのと、オブジェクトの速度がゆっくりであれば少ないサンプル数で済むのでそこまで高負荷にはなりませんでした。

ついにオブジェクトから流体を触れるようになりました。

変形させる

今は真四角の領域上で計算が行われていますが、圧力のポアソン方程式の境界条件を適切に設定してやることで、任意の領域上での流体計算が可能になります。あまりシェーダとは本質的に関係がないのでここでは詳しくは触れませんが、気になる方は「ノイマン境界条件」や「ディリクレ境界条件」などのワードで調べてみてください。

さて、折角カメラがあるので、壁を検出して動的に境界条件を設定するようにしてみます。まず、Animatorを使ってフラグで上下する壁のアニメーションを作ります。

次に、Blenderでモデリングした壁をUnity上に読み込み、壁が上がっているときだけ写し出されるようにカメラを配置してテクスチャに書き出します。

画像は3種類の壁が全て上がっているときのテクスチャの様子です。
1つの壁が下がるとこうなります。

このテクスチャを読んで境界条件を動的に計算するようにすると、ダイナミックなプールが出来上がります。

そろそろ疲れてきたので適当に完成させることにします。

ボールを置いて水面を綺麗にする

何かオブジェクトがあると楽しいので、その辺にボールを撒いて投げられるようにしておきます。
ついでに描画用のシェーダを書き直して水面を綺麗にして完成です。

最終的には全体像は以下のようになりました。

public化する

誰でもワールドに入れるようにVRChatにpublic化申請を行います。
基本的に激重でなければ大丈夫なようで、1日くらいで申請が通りました

完成

……という訳で、長かったですが初めてのpublicワールドの完成です!
多分ここまで真面目に流体を計算しているワールドは今のところ他にないと思います。走り回ったり、ボールを投げたりして水面が変化する様子を楽しんでみてください。Twitterなどに感想を投げてもらえるととても喜びます。

裏話

実はVRデバイスを持っていないので、波打つ水面を3Dで見ることができませんでした。HTC VIVEほしい……

 

明日は @willow710kut さんの記事です。

注釈   [ + ]

1. ただし、カメラの Clear Flags を Solid Color にし、Background の色の透明度を0にしておく必要があります。また、カメラのファークリップ面を十分に近づけておき、他のオブジェクトが板ポリの背後に写り込まないようにしなければなりません。
2. もちろん逆も必要になります。
3. ここで渡されるのはカメラから見た描画結果なのですが、今はカメラの範囲をテクスチャが貼られた板ポリとぴったり同じ大きさにしているので、テクスチャがそのまま渡されると考えて問題ありません。
4. 見た目がおかしくなるのは GrabPass で渡されるテクスチャがメインカメラから見た映像になるためです。しかし、メインカメラには RenderTexture を設定していないのでテクスチャが上書きされることはありません。
5. 解像度256×256で計算しても全体のテクスチャサイズは1024×1024になります。
6. データの再分配を行わなければ数値拡散が起こらない、というのは粒子法によるシミュレーションで数値拡散が起こらないのと同じ原理です。
7. 綺麗なカルマン渦が見えなくなるのはこれが原因です。
8. スクリプトが使えるならば、Motion Vectors が役に立ちそうです。
9. ComputeScreenPos(UnityObjectToClipPos(float4(0, 0, 0, 1))); でローカル座標における原点のスクリーン座標が分かります。
10. かなり多いですが、そもそもオブジェクトが存在している領域が少ないのと、オブジェクトの速度がゆっくりであれば少ないサンプル数で済むのでそこまで高負荷にはなりませんでした。

Leave a Reply