解説: Just a Pool

少し前に Just a Pool という VRChat のワールドを公開しました。この記事では裏の色々な話や技術解説などを書いていこうと思います。

ワールド探索系の話でなくて恐縮なんですが、この記事は VRChatワールド探索部 Advent Calendar 2022 の20日目の記事になっています。

ワールドについて

名前の通りプールが置いてあるだけの小さなワールドなんですが、水がリアルに動きます。そして触れます

ただ「リアルな水とVRで触れ合いたい!」という欲望のために作られたようなワールドで、心ゆくまで水遊びを楽しむことができます。水鉄砲やポータルといった遊び道具も置いてあるので、多人数でわいわいするのにも向いているかと思います。よくみんなで遊んでいる様子が Twitter に流れてくるのでありがたく見させてもらっています。ありがとうございます。

VRChat における流体表現は一般的には2次元のものが多く、一部のヤバい人によってアバターに3次元の流体計算が埋め込まれていたりする例[1]はありましたが、公開されたワールドで人々が一般に遊べるものは恐らく初めてなのではないかと思います。

そういうこともあって、公開時には非常に大きな反響をいただきました。嬉しいです。

流体シミュレーション

このワールドの目玉である流体シミュレーションですが、アルゴリズム的には特段新しいものではなく、以前公開した WaterJelly にも使用した MLS-MPM をベースにしています。

MLS-MPM 自体の解説は以前書いたものがあるので詳細はそっちに譲るとして、Unity 上、ひいては VRChat 上で動かすためにしたことを書いていこうと思います。同じようなことをしたい人々(?)にとってはかなり参考になるかと思います。

前提

全ての計算を GPU でやります。いろんなシェーダを書きます。有難いことに WebGL と違って geometry shader や tessellation shader が使えます。

データの保持

RenderTexture を使います。浮動小数点数テクスチャが使えますが、一部整数になってほしいときに Texture2D<uint4> などを使って整数値としてサンプルし、asfloat で必要な場所を浮動小数点数に直す、みたいなことも可能なようです。ただ、以前 pack した値を float 値で保存したときにデータが消えたことがあったので、整数を扱いたいときは整数テクスチャにしておくのが無難かもしれません。目視によるデバッグは困難になりますが……

フィードバック

物理シミュレーションは状態の更新が必要なのでフィードバックシステムは必須です。よく知られているように Unity 標準の Camera を使います[2]

しかし、浮動小数点数テクスチャを利用する場合はここで double buffering が必要になります。つまり読み込み用と書き込み用のテクスチャを用意し、処理順を考えて source と destination を切り替えながらシェーダとカメラに割り当てていく必要が出てきます。

Camera にはプロパティから深度値を設定することで描画の優先度を変更できるので、1フレームの間にどの順番で描画が行われるかを制御することができるようになります。

運悪くテクスチャに書き込みが行われる回数が奇数回だった場合は、そのままだと次のフレームでの source と destination が反転してしまうので、テクスチャの転写が必要になります。つまり何もせず色をサンプリングしてそのまま出力するだけのシェーダが必要になります。そう、Unlit/Texture の出番です!

Unlit/Texture の罠

やめましょう。Alpha が死にます。これで一度ハマりました。

代わりに以下のようなシェーダを書いて転写に使いました。

Shader "Unlit/TrueUnlit" {
    Properties {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 100
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            struct appdata {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };
            struct v2f {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };
            sampler2D _MainTex;
            float4 _MainTex_ST;
            v2f vert(appdata v) {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }
            float4 frag(v2f i) : SV_Target {
                return tex2D(_MainTex, i.uv);
            }
            ENDCG
        }
    }
}

整数テクスチャを使う場合は整数用のシェーダが必要になります。

Shader "Unlit/TrueUnlitInt" {
    Properties {
        _MainTex("Texture", 2D) = "white" {}
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 100
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            struct appdata {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };
            struct v2f {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };
            Texture2D<uint4> _MainTex;
            float4 _MainTex_ST;
            v2f vert(appdata v) {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }
            uint4 frag(v2f i) : SV_Target {
                return _MainTex.Load(uint3(i.uv * _MainTex_ST.zw, 0));
            }
            ENDCG
        }
    }
}

頂点シェーダでデータの scatter を行う

MLS-MPM の Particle to Grid (P2G) フェイズでデータの scatter が必要になってきますが、この辺りは WebGL 版と変わりません。ただし、VRChat 向けには geometry instancing が事実上できない(エディタ上に同じ個数だけオブジェクトを生やす必要がある)ため、大量の頂点が含まれるメッシュを用意してカメラの前に配置する必要が出てきます。

自分は以下のように Unity Editor 上でのみ動く C# スクリプトで Mesh を生成して保存したものを使いました。便利です。

// 点群を作る
const int SIZE = 64 * 64 * 64;
Vector3[] vertices = new Vector3[SIZE];
int[] indices = new int[vertices.Length];

/* set vertices and indices here */

// メッシュを作る
Mesh m = new Mesh();
m.name = "points";
m.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
m.vertices = vertices;
m.SetIndices(indices, MeshTopology.Points, 0);
m.bounds = new Bounds(Vector3.zero, Vector3.one);

// 保存する
string path = "Assets/points.asset";
AssetDatabase.CreateAsset(m, path);
AssetDatabase.SaveAssets();

Unity では geometry shader が使えるので、必要に応じて分裂させて送り込んだりもできます。ただし geometry shader は重いので、特に書き出し頂点数は最低限に抑える必要があります。

P2G フェイズの最適化については、めちゃくちゃ参考になる tips を d4rkpl4y3r さんが投稿してくれています。

グリッド側の速度更新と Grid to Particle (G2P) フェイズは gather しかないので特に難しいことはありません。

というわけで、scatter が厄介ではありますが流体のシミュレーションそれ自体は実はそこまで大変でもありません。本当に大変なのは衝突処理とレンダリングです。

衝突処理

これがもう本当に大変でした。恐らく全ての苦労の7割くらいはこの辺りの調整に注ぎ込まれています。しかも苦労の原因は衝突処理自体の難しさからではなく、衝突を適用した際のシミュレーションの機嫌を取ることの難しさから来ています。ここで言わせてください。

適切な境界条件を設定するのマジで大変すぎ!!!!

同じようなことをやってる他の人に聞いても全員同じことを言っていた[3]ので界隈では共通の認識だと思います。

リアルタイム化のためにオリジナルの MLS-MPM にはない変更を加えている[4]のでそのせいではあるんですが、ここが MLS-MPM による流体計算の唯一かつ最大の難点と言っていいでしょう。実際どうしようもなくて、どうにかうまく動いているように見せるために大量の hack を仕込んでいる状態です。しんどい。

VR では CG によるムービー生成などと違ってユーザーがインタラクティブに流体をかき回すので、より一層の頑健性が求められます。なので CG 系の論文に載っている手法が改変無しにそのまま適用できることは非常に稀です。MLS-MPM もその例外ではありません。見た目よければ全てよしとは言いますがユーザーの操作で見た目が終わるようでは何もかも終わりです。

ここであまり MLS-MPM 流体の闇について掘り下げても仕方がないと思うので、衝突処理で利用した機構についてあまり闇に触れないように解説していこうと思います。

上下カメラによる物体検出

これが非常に優れた機構で、シミュレーション領域の上下にカメラを設置して領域を撮影し、depth buffer を見ることで描画可能なすべてのオブジェクトに対して極めて高精度な衝突検出ができるという仕組みです。手で水を掬えるのもこいつのおかげです。

深度が取れる必要があるので opaque なメッシュに限られはするんですが、逆に深度が取れるものなら何にでも反応します。VRChat のメニューの板なんかにも反応したりします(反応すると困るので culling mask で切ってありますが……)。

作り方は簡単で、適切な場所にカメラを設置したあと _CameraDepthTexture を参照して深度を色に書き出すだけです(例によって座標系に気を付ける必要があります)。どちらかというと得られた深度のペアの利用方法の方が重要で、こちらは簡単ではありません。

今回のワールドでは、アバターを流体に干渉させるために Signed Distance Field (SDF) を Level Set 法チックな方法で深度値のペアから構築しています。また、高速化のために大まかな判定のための SDF と手先なども表現可能な細かい SDF を2種類用意し、流体粒子の位置に応じて使い分けるなどしています。さらにはシミュレーションの挙動も SDF の値によって微妙に調整していて……闇が見えてきたのでこの辺りにしましょう。

ちなみに、この仕組みを地形との衝突検出のために使うべきではありません。上下から撮影している都合上、上下をオブジェクトで挟まれた空間は詰まっている判定になるので、床を含めてしまうとプレイヤーが手を伸ばしただけで床から手まで壁が生えてしまうためです。

地形との衝突判定

地形(プール)との衝突判定も細かく作られていて、この動画の冒頭にあるように水鉄砲で撃った水がきちんと跳ね返ります。

これはどうなっているかというと、プールの SDF を手作業で作ったものを埋め込んであります。SDF の解像度が 128×128×128 と形状の複雑さに対してかなりギリギリ(特に柱とか)なので、自動生成とかすると多分抜け落ちます。

SDF の作成には例によって iq さんのページを参考にしました。

コードはこんな感じで最悪になっています(一部抜粋)。前計算して画像に書き出しとかすればよかったかもしれない。

...
// pipes
float pipeRad = 0.015 * size.x;
float pipeL = streetL - wallW * 0.5;
float pipeR = streetR + wallW * 0.5;
float pipeZ1 = 0.4821 * size.z;
float pipeZ2 = 0.7486 * size.z;
float pipeY1 = 0.59 * size.y;
float pipeY2 = 0.75 * size.y;
float pipeY3 = 0.8 * size.y;
float pipeY4 = 0.827 * size.y;
float pipeW1 = 0.023 * size.x;
float pipeW2 = pipeW1 + 0.05 * size.x;
float pipeZs[3] = {pipeZ1, lerp(pipeZ1, pipeZ2, 0.5), pipeZ2};
for (int i = 0; i < 3; i++) {
    float z = pipeZs[i];
    res = min(res, capsule(float3(pipeL, pipeY1, z), float3(pipeL, pipeY2, z), pipeRad, p));
    res = min(res, capsule(float3(pipeL, pipeY2, z), float3(pipeL + pipeW1, pipeY3, z), pipeRad, p));
    res = min(res, capsule(float3(pipeL + pipeW1, pipeY3, z), float3(pipeL + pipeW2, pipeY4, z), pipeRad, p));
    res = min(res, capsule(float3(pipeR, pipeY1, z), float3(pipeR, pipeY2, z), pipeRad, p));
    res = min(res, capsule(float3(pipeR, pipeY2, z), float3(pipeR - pipeW1, pipeY3, z), pipeRad, p));
    res = min(res, capsule(float3(pipeR - pipeW1, pipeY3, z), float3(pipeR - pipeW2, pipeY4, z), pipeRad, p));
    res = min(res, capsule(float3(pipeL + pipeW2, pipeY4, z), float3(pipeR - pipeW2, pipeY4, z), pipeRad, p));
}
float pipeXDist = 0.303 * size.x;
res = min(res, capsule(float3(pipeL + pipeXDist, pipeY4, pipeZ1), float3(pipeL + pipeXDist, pipeY4, pipeZ2), pipeRad, p));
res = min(res, capsule(float3(lerp(pipeL, pipeR, 0.5), pipeY4, pipeZ1), float3(lerp(pipeL, pipeR, 0.5), pipeY4, pipeZ2), pipeRad, p));
res = min(res, capsule(float3(pipeR - pipeXDist, pipeY4, pipeZ1), float3(pipeR - pipeXDist, pipeY4, pipeZ2), pipeRad, p));
...

最終的に構築された SDF の Y 方向の断面がこれです。色は法線ベクトルを表しています。分かりにくいですがなんとなくプールの形が見えています。

3Dモデル

最初は適当なアセットを流用しようとか考えていたんですが、

  • どうせなら全部自分で作りたい
  • 用途がシビアなのでモデルを細かくカスタマイズしたい
  • そういえばモデリングできるようになりたかった

ということで、2週間ほどかけて Blender を1から勉強して自作しました。

Blender を勉強する

いろんなウェブサイトでコンセプトを学んだあと、操作方法付きで解説しているチュートリアル動画を見ながらひたすら基本操作と対応するショートカットを脳に叩き込んでいきます。20例くらいやったところで新しい操作に出会う確率が十分に低くなったので実戦に移りました。

操作方法がえげつないせいで最初は脳が拒否反応を起こしていたんですが、不思議とそのうち適応してきて快適にモデリングができるようになってきます。操作感は Vim に近いです。というかコンセプトはまんま Vim だと思います。

自分は Vim は性に合わなくて辞めてしまったんですが、Blender は Vim ほど強烈ではなかったのでどうにかなりました。次にしたい操作が脳直結でそのまま実現できるのがなんとも快感です。今では Z-up の座標系もどうにか受け入れてしまう有様となりました。

無事にプールが完成しました。

小物も作る

そんなこんなで Blender が扱えるようになったので、バケツや水鉄砲、ポータルなどの小物もモデリングしていきます。こういうちょっとしたものが自作できるのはかなり嬉しいですね。

エクスポート時の注意

Unity で使う場合は FBX でエクスポートするのが基本だと思いますが、このとき何もしないとエクスポート時の単位が cm になってしまい、Unity でインポートした際に Transform で各軸の Scale が 100 に設定されてしまいます。

この状態は色々な面で不便なだけでなく物理エンジンにとっても非常に良くないので、エクスポート時に Transform の Apply Scalings から FBX All を選択しておきます。

こうすることでモデルの単位が m になり、読み込んだ際に巨大スケールが設定されなくて済むようになります。

流体の描画方針

流体を綺麗に表示するシェーダを書きます。

さて、粒子ベースの流体シミュレーション結果は、主に2通りのレンダリング方法があります。

  1. Screen Space Fluid
  2. Marching Cubes

Screen Space Fluid

Water3D で採用した手法です。この辺りの資料が有名なのではないかと思います。

  1. まず流体粒子を球体と見なして深度をレンダリングする
  2. Bilateral Filter などを使って上手いこと描画した深度をぼかす。Water3D ではより洗練された Narrow-Range Filter を使いました
  3. ぼかした深度を利用して滑らかな法線ベクトルを screen-space で構築する
  4. 一方ぼかした粒子自体も加算合成でレンダリングしておき、ピクセルごとに流体の厚み情報を得る
  5. 法線と厚みを利用して好きなように色を塗る

screen-space でレンダリングした深度値をぼかすのが鍵です。メッシュを構築する必要がなく、かつ非常に解像度の高い綺麗な流体を描画できますが、Bilateral Filter(あるいはその他のフィルタ)によって発生する artifact を完全に消すことはできません。

また、画面の解像度に比例して結構な負荷がかかってしまうのも難点です。

ところで忘れがちですが、最近の VR の解像度はアホみたいに高く、Valve INDEX でも両目合わせると 2880×1600、Meta Quest 2 では 3664×1920 にもなります。いくら強靭な GPU を積んでいてもこの解像度で全面 Screen Space Fluid をやると大変なことになるのは目に見えています。

ということで Screen Space Fluid は避けた方がよさそうです。

Marching Cubes

先程の手法はメッシュ構築が不要でしたが、Marching Cubes (MC) を利用した方法では流体表面を表すメッシュの構築を行い、流体を直接レンダリングします。

Screen Space Fluid (SSF) と比較したときの利点は以下の通りです。

  • 高解像度でも負荷が上昇しにくい
  • メッシュを利用するため正確な法線と深度値が得られる

難点は以下の通りです。

  • メッシュ生成のための追加コストがかかる
  • 流体表現の解像度がメッシュ生成に用いたグリッドの解像度で頭打ちになる
  • メッシュの描画順序に気を配る必要がある

VR での利用を考えると、やはり SSF よりは MC に軍配が上がりそうです。3点目は後ほど詳しく取り上げます。

また、ここではメッシュの生成方法を MC に限定しましたが、Dual ContouringMarching Tetrahedra 等を利用しても構いません。

メッシュ生成に用いるグリッド

さて、MC は基本的に与えられたスカラー場に等高面となるメッシュを構築する手法なので、元となるスカラー場、すなわち流体密度を表したグリッドの用意が必要となります。

MLS-MPM は格子法と粒子法のハイブリッド手法なので、すでに物理計算用の密度情報はグリッドとして得られています。なのでこいつを流用することができます!

 

できませんでした。

というのも、MLS-MPM(というか一般に MPM)の計算に用いられるグリッドは粒子分布に対して十分粗い解像度に設定されているからです[5]。一般的には2次元だと1つのセルに対して平均して粒子が4つ、3次元だと8つくらい入っているイメージがあります。

なので、そのグリッドをそのまま描画に使ってしまうと細部が完全に潰れて粘土みたいになってしまいます。

そこで、各軸方向に解像度を2倍にしたグリッドを用意し、描画用の P2G ステップを追加で実行して解像度の高いグリッドを得てから MC を走らせています。P2G は geometry shader を含むのでかなり時間のかかるステップですが、描画のためなら仕方ありません。

後から分かったことですが、散々苦労してシミュレーションや描画の下準備の最適化を行ったものの、最終的な描画にかかる時間がその他の全ての処理の3倍以上みたいなことになっていました。もちろん描画自体も必死に最適化を行っています。MC でこれですから、SSF を採用していたらどうなっていたかと考えると恐ろしいですね……

流体描画シェーダの詳細

実際にワールドを体験した方は感じたかと思いますが、かなり綺麗で物理的に正確な方法で描画が行われています。以下のような特徴があります。

  • フレネルの式(の近似)に基づく反射率の設定
  • Screen Space Reflection & Refraction (SSR) による正確な屈折と反射
  • 全反射の考慮
  • Volumetric Absorption(流体の厚みを考慮した吸光)の計算
  • Bayer Matrix を用いた SSR の誤差分散

フレネル反射

正確なフレネル反射に用いられる式は結構複雑で、光を界面に対し垂直成分と水平成分に分解して考える必要があります。が、CG 用途ではそこまで厳密に考える必要がないことがほとんどなので、偏光されていない光に対する次の近似式 (Schlick’s approximation) がよく使われます。

R(\theta) = R_0 + (1-R_0)(1-\cos\theta)^5

値は反射率を表します。R_0 は垂直な入射光に対する反射率、\theta は入射角です。直感的には、R_0 が1に近いと水銀のように入射角によらず常に反射するようになり、\theta が90度に近いと R_0 の値によらず流体が鏡のように反射するようになります。

実はこのような反射は流体に限らず様々な物体に見て取ることができます。

スマホの画面でフレネル反射を感じる

スマホの表示を切って、何も表示していない画面を正面から覗き込んでみてください。光沢を防ぐ保護シートが貼ってある方は、残念でした。そうでない方は、鏡を1としたときにどの程度自分の顔が反射して見えましたか? その値が R_0 に相当します。まあ 0.1 とか 0.2 とか、その程度でしょうか。

次に、スマホを徐々に傾けて表面をかすめるような感じで画面を覗いてみてください。すると周囲のものがかなり綺麗に反射して見えませんか?

表面をかすめるような角度で見ると視線の入射角が90度に近付くので、先程の式の \cos\theta の値がほぼ0になり、反射率は

& R_0 + (1-R_0)(1-\cos\theta)^5\\
& \approx R_0 + (1-R_0)(1-0)^5\\
& = 1

と、1に近似できます。これはすなわち、ギリギリの角度から見たスマホの画面はほとんど鏡のように振る舞っているということになります。こうした入射角の変化による反射率の変化をうまく再現するのがフレネル反射、ということになります。流体の表面は波打っていることが多いので入射角の分散も大きく、フレネル反射の恩恵に与れる部分が大きいのではないかと思います。

Screen Space Reflection & Refraction

画面に表示されている範囲でできるだけ正確に屈折と反射の計算を行おう、というのが主旨になります。平たく言うとレイトレをします

Unity でよくある屈折表現としては、GrabPass で背景のテクスチャを得たあとで、法線ベクトルを見て UV 座標をずらしてサンプリングする、というものだと思います。ですが、これは物理的には正しくありません。なぜかというと、屈折によって曲がるものは光の進路であり、結果的にどの程度ズレが発生するかはその後の物体までの距離に比例するからです。

VRChat のワールドで、ほんの少し手を水に沈めただけなのにすごく大きな手のモヤモヤが見えたことはありませんか? それは恐らく物体までの距離を考慮していないことが原因です。

SSR では、深度バッファを利用することにより実際の物体までの距離を考慮した正確な反射・屈折表現が可能になります。

SSR の基本は凹みさんによる記事が非常に参考になります。というかこれさえ読めば十分かもしれません。追加でした工夫を書いておきます。

ディザリングで高速化

先にも述べた通り VR の解像度は非常に高いので、ピクセルごとのレンダリングの負荷はできる限り抑えておきたいものになります。本来であれば SSR で使うステップ幅は 1px 程度が理想となりますが、ここでは強気の 8px を選択しました

もちろん発生するアーティファクトも強烈になります。しかし、ここで発生するアーティファクトを空間方向に逃がすという手が使えます。所謂ディザリングというやつです。

ディザリングの手法は様々研究されていて、ここではお手頃な Bayer Matrix を用いたディザリングを採用しました。

...
uint2 pixel = uint2(floor(sp1 * SCREEN_SIZE));
static const float bayer[64] = {
    0.0,      0.5,      0.125,    0.625,    0.03125,  0.53125,  0.15625,  0.65625,
    0.75,     0.25,     0.875,    0.375,    0.78125,  0.28125,  0.90625,  0.40625,
    0.1875,   0.6875,   0.0625,   0.5625,   0.21875,  0.71875,  0.09375,  0.59375,
    0.9375,   0.4375,   0.8125,   0.3125,   0.96875,  0.46875,  0.84375,  0.34375,
    0.046875, 0.546875, 0.171875, 0.671875, 0.015625, 0.515625, 0.140625, 0.640625,
    0.796875, 0.296875, 0.921875, 0.421875, 0.765625, 0.265625, 0.890625, 0.390625,
    0.234375, 0.734375, 0.109375, 0.609375, 0.203125, 0.703125, 0.078125, 0.578125,
    0.984375, 0.484375, 0.859375, 0.359375, 0.953125, 0.453125, 0.828125, 0.328125,
};
float dither = bayer[(pixel.x & 7) << 3 | (pixel.y & 7)];
float t = delta * dither;
...

良くも悪くも HMD を装着した状態の VR では1ピクセルを綺麗に見ることができない(ブラウン管のようにぼやける)ので、こうしたディザリングによる方法が再び価値を増すんじゃないかなと思ったりしました。実際相当見た目が改善されます。ザラザラしては見えますがきちんとグラデーションとして認識されます。

もう何度目の登場か分かりませんが d4rkpl4y3r さんが Bayer Matrix と Blue Noise によるディザリング効果の比較を投稿してくれています。確かに Bayer Matrix の方が「より柔らかく見えるがパターンが認識しやすく」、Blue Noise の方が「よりザラザラして見えるがパターンは認識しにくく」なっているように感じます。この辺まで来ると好みの問題でもありますね。とりあえず何らかの誤差分散手法を利用することが重要です。

Reflection Probe をきちんと設定する

SSR を行う場合はほぼ必須になります。SSR はかなりの確率でレイの衝突検出に「失敗」するので、まともなフォールバック先が用意されていないと厳しい見た目になります。Reflection Probe を細かく設定しておけばそこまで気になりません。

Volumetric Absorption

いくら MC を利用するとはいえ、さすがに流体の厚みまではメッシュから取ることができません。そこで、SSF と同じように「ピクセルごとの流体の厚み」に対応するテクスチャを作成し、メッシュの描画時にサンプリングすることを試みます。

これは大変な結果に終わります。如何せん解像度が凄まじく高いので、ピクセルごとに流体の厚みを計算するシェーダですら許容不可能なレベルの負荷になります。

結局どうしたかというと、流体の厚みは滑らかに変化しがちなので多少誤魔化してもバレにくく、画面のごく一部を用いて低解像度・低負荷で厚みを計算するという方法に落ち着きました。

解像度を上下左右半分にするだけでも負荷は 1/4 になります。よって、画面の一部のみに厚みを描画し、流体を描画するときには低解像度の厚みを線形補間することで滑らかな厚みを取得するという方法で大幅に高速化することができました。

この画面の大きさに対して……

ここまで小さく描画しました(ぼかし適用前)。デスクトップでは微妙ですが、VR での解像度の高さを考えるとこの程度の大きさでも十分な情報を得ることができます。

ただし、本来描画される場所と異なる領域にパーティクルを描画しなければならないため、深度テストを自前で行う必要があります。したがって ShadowCaster を書いていない物体は貫通してしまうことになりますが、まあ仕方ありません。

厚みから色を計算する方法は、以前開発した Volumetric Rendering のモデルの式を用いました。この式を使うと吸光だけでなく拡散(濁り)も入れられるので、雰囲気を出すために多少の青を混ぜてあります。

内側も描画する

折角なので水中に潜ったときにも綺麗に表示されるようにしたいです。ですが、普通にやるとこれはうまくいきません。ポリゴンの裏面を認識して水中から見た場合の処理を書くことも必要なのですが、そもそも場所によっては裏面のポリゴンすら見えません

これを回避するには

  • 何らかの方法で視点が水中に沈んでいることを判定し、画面全体に post effect を掛ける

という方法が考えられますが、カメラが半分水に沈んでいる場合などはうまくいきません。試行錯誤の結果、

  • メッシュの多様体的性質を活かしてピクセル単位で正しい判定を行う

方法を思い付きました。これならどんな場合でもうまくいきます。

メッシュの多様体的性質とは「境界が空集合である」ことを指しています。平たく言うとメッシュに穴が開いていないということです。MC とメッシュの穴については以前書いた「穴の開かない Marching Cubes 法の lookup table 2種」で解説しています。

さて、メッシュに穴が開いていないということは、外側から見ている限りどうやっても裏面を見る方法が存在しないということになります。裏を返すと、メッシュの内側に存在する場合はメッシュの「外」を見ることが絶対にできません。つまり、必ずどこかで視線がメッシュの裏面のポリゴンに突き刺さるということになります。

裏面を見つけました。やるべきことは実は簡単で、depth を無視して裏面を発見すればよかっただけなのです。ポリゴンの裏面が描画されようとしているとき、次のように場合分けができます。

  • depth test に成功する
     → 内側から水面が見えているので、屈折率を反転して SSR
  • depth test に失敗する
     → 水中にある物体を見ているので、post effect を適用する

これで全ての場合で正しく処理ができます。

全反射を考慮する

屈折した光の向きが同じ媒質に戻されるようなとき、全反射が発生します。しかし、全反射はある角度で離散的に発生するのではなく、臨界角に向かって連続的に反射率が上がっていきます。近似前のフレネルの式ではこのような現象が正しく計算できるようなのですが、CG 向けに近似された式 (Schlick’s approximation) では滑らかな全反射を考慮することができません

そこで近似式をよく見てみると、これは \theta\to\pi/2 の場合に反射率が1に近付いていきます。つまり臨界角が90度に設定されていると言ってもいいような状況です。ということは、この近似式をいい感じに圧縮すれば任意の臨界角に向かって反射率が1に収束する近似式が得られるはずです。

得られました

\theta_\text{crit} &= \sin^{-1}\mu \\
R(\theta) &= \begin{dcases}
    1 & (\theta \geq \theta_\text{crit}) \\
    R_0 + (1-R_0)\left(1-\cos\frac{\pi\theta}{2\theta_\text{crit}}\right)^5 & (\theta\leq\theta_\text{crit})
\end{dcases}

\mu は相対屈折率です。水中から外を見た場合の反射率にはこの近似式を利用することで綺麗な全反射を再現することができます。

描画の順序に気を付ける

Unity では不透明な物体は手前から順に描画されます。これは深度テストが失敗することによって不要な色計算を事前に避けることができるためです。

しかし、MC で生成したメッシュは単一のオブジェクトに属すので、この逆 Z ソートの恩恵を受けることができません。結果として、シャワーエリアで上を向くとポリゴンの重複描画が多数発生し、とんでもない重さになります。

これを回避するため、MC の描画を多段階に分け、カメラに近いグリッドのセルから順に描画を行うようにしています。さらに、流体のメインの描画部分を遅延シェーディングすることでポリゴンが重なった際の負荷の増大を最低限に抑えています。

Tessellation Shader でカリングを行う

本来 tessellation shader はプリミティブを細分化するために存在しますが、分割数に0を指定することで逆にプリミティブを消し去ることもできます。しかも tessellator は geometry shader よりも前に走るので、明らかに三角形の存在しない領域に対して tessellation shader を用いて頂点を消滅させることで、処理の重い geometry shader で時間が消費されることを防いでいます。

その他いろいろ

粒子の出現と削除

全てテクスチャでデータを管理しているため、ランダムに粒子の削除を行った場合、該当するテクセルにデータの穴が開きます。しかし、次に粒子を出現させたい場合、どこに穴が開いているかを事前に知らないと正確な個数だけ粒子を出現させることができなくなってしまいます。

したがって、データを詰める必要が出てきますが、これが大変です。なぜなら、各テクセルがデータを詰めた後に参照するべき場所を知る必要があり、そのためには粒子の個数の累積和のようなデータを用意する必要があるためです。

これはなかなか大変だぞ……と思っていた矢先に、とんでもないツイートが流れてきます。

ミップマップの自動更新機能を利用して粒子の存在個数の累積数をカウントする四分木を全自動構築します。

もうね、控えめに言って大天才です。このアイデアを見たときは大変な衝撃を受けました。

この方法でシンプルかつ高速に完璧な四分木を構築できます。四分木なので累積和と同等の役割を果たします。何も言うことがありません。先に実装してなくてよかった。

数千リツイートくらいされてもいいと思うんですが、そもそもこんなマニアックなことをしようとしている人がほとんどいないため過小評価されています。悲しい。VRC Shader Dev 鯖の人たちは歓喜していた記憶があります。

これで粒子の個数カウント問題は完全に解決され、晴れてデータを一瞬で詰めることが可能になりました。水鉄砲が実現したのはこの人のおかげです。

シミュレーションの領域外の粒子

MLS-MPM は空間に固定されたグリッドを利用するので、シミュレーションの領域から外れてしまうと流体が計算できません。最初は強制的に粒子をシミュレーション領域に引き戻す処理を書いていたんですが、水鉄砲の登場により領域外でも粒子を飛ばせる仕組みの需要が高まります。

結局、粒子に「領域内に存在するかどうか」のフラグを持たせ、領域外で粒子が生み出された場合は一度領域に入るまで「ただの孤立した粒子」として振る舞うようにしました。

この処理によって、プールの外から中に向かって水を発射した場合に自然とシミュレーションに接続することができます。

ちなみに水鉄砲は Udon で処理しています。全部カメラで処理していたら @phi16_ に「そんな時代は終わった」みたいなことを言われたので現代的なシステムになりました。いやこっちの方が断然組みやすいです。いい時代になったなぁ……

ポータル

線形代数をすると転移先の位置が分かるので移動させるだけです。

抜ける栓

泡はフェイクですが、シェーダがよくできているため密度場をくり抜くだけでそれっぽく見えます。

泡が出るのはおかしい」という反応があったんですが、これは半分正しいです。

よく考えてみると、泡が出ているという事実から周囲の構造を推定することができます。その場合、プールの下に密閉された部屋があることになります。ちょっと怖いですね。

まあ多分水圧で栓が抜けないんですが……

この栓の形は「ユーザーに自然と「栓を抜く」という行動を想起させる」ことを目的としてデザインされています。ボタンがあったら押したくなるみたいなやつですね。

この栓についているチェーンですが、実は結構苦労しました。これについては別記事で詳しく解説してあります

デバッグ用テクスチャの展示

ワールドに入ると「壁に突っ込め」みたいな看板が目に入ります。その先では「計算機」がそのまま置いてあり、データを眺めることができます。

で、この計算機ですが、いわば「回路がむき出し」みたいな状態なので、異物がカメラに映ったりすると計算中のデータが影響を受けます

つまり「そういうアバター」を持っていればいたずらすることができます

あー!!困ります!

ぜひどうぞ。

おわり

まだ説明していない箇所はありますが、大体こんなところでしょうか。

実は遥か昔にも流体ワールドを作っており、そのときも Advent Calendar で解説を書いたので丁度4年前ということになります。あの頃は8ビットテクスチャしか使えなかったり Udon が存在しなかったりと大変でした。

ワールドの公開に至っては、何故かバグって一度下がったまま永遠に上がらなくなった Trust Level に長らく苦しめられましたが、本当にどうしようもないことが分かったのでそろそろ金で殴ろうかと考えていた矢先 @suzuki_ith さんが1ヶ月分の VRChat Plus を送ってくださいました。ありがとうございました。無事バグが直って(?)ワールドを公開することができました。

天才的な手法から細かい最適化のテクニックまでいろいろと教えてくれた @d4rkpl4y3r_vr にも超感謝です。彼がいなかったらこのワールドのクオリティは著しく下がっていたことでしょう。

A lot of thanks goes to @d4rkpl4y3r_vr whom I learned so many useful techniques from, from nothing but a genius idea to detailed GPU optimization tips! The world couldn’t have been this wonderful without you. Thank you so much!

何か質問があれば作者の Twitter までどうぞ。
VRChat で遊んでいるときはこっちのアカウントにいることが多いです。見つけたら仲良くしてやってください。

 

明日は rocksuch さんの「死んだ後に行きたいワールド10選」です。


  1. アルゴリズムは SPH だそうです ↩︎

  2. CustomRenderTexture (CRT) でもいいです。というか CRT で行けるなら CRT の方が望ましかったりします。CPU の負荷を下げられたり、アバターに搭載した場合に非 Friend 相手にもそのまま表示できたりするので。 ↩︎

  3. N = 3、というか3もあるのが驚きなんですが ↩︎

  4. mpm guide の part 3 を参照 ↩︎

  5. 周辺の粒子の情報をグリッドに十分量「集める」必要があるため ↩︎

このエントリーをはてなブックマークに追加