UnityでCommandBufferを利用して流体のレンダリングを行う
はじめに
前回はパーティクルで見せかけの球体を大量に描画してみました。 今回は前回のプログラムをベースに流体のレンダリングを行ってみます。

Unityのバージョン:2018.3.0b1
今回実装するもの
今回実装するのは次のスライドで紹介されているものです。
次の記事でも紹介されています。
- PS4世代の表現を先取り? PhysXを駆使して超リッチなエフェクトを実現したPC版「Borderlands 2」 - 4Gamer.net
- [SQEXOC 2012]Agni’s Philosophyに見られるグラフィックス技術解説(後編)。ボリュームレンダリングやパーティクル処理の最新動向 - 4Gamer.net
球体のパーティクルのdepthに対してバイラテラルフィルタをかけることで 擬似的なメタボールを描画するものです。 バイラテラルフィルタについては過去に記事を書いたことがあります。
パーティクルの準備
シーンにパーティクルシステムを用意します。

パーティクルのLifetimeを10秒にして パーティクルの最大個数を1000個に設定しています。

パーティクルを毎秒200ほど出すようにします。

パーティクルに重力が働くようにします。

パーティクルにノイズを加えます。

物理の設定を行います。

レンダリングの設定は前回と同じです。 ビルボードでShadows Onlyにして影のマテリアルを設定しています。
プログラム
最初にソースコードの全文を最初に載せてしまいます。
スクリプトは次のとおり。
1 | using System.Collections; |
OnWillRenderObject
を使っているので、 画面に常に映る地面などのGameObjectへアタッチする必要があります。
シェーダは次のとおり。
1 | Shader "Fluid/FluidParticle" |
順番に解説していきます。
Meshの作成
Start
の最初で描画に使うMeshを作成しています。
1 | public class FluidRendering : MonoBehaviour { |
一辺の大きさが1の正方形を作っています。
次に画面全体を覆うメッシュも作成しています。
1 | public class FluidRendering : MonoBehaviour { |
こちらは2パス目以降で使います。 一辺が-1~1の正方形です。
マテリアルの準備
1 | private Material material; |
"Fluid/FluidParticle"
というシェーダからマテリアルを作成しています。 material.enableInstancing = true;
でGPUインスタンシングを有効にしています。
あとでシェーダに値を渡すときに利用するプロパティIDを取得しています。
パーティクル用の配列とパーティクルの行列用の配列を用意する部分は 前回と同じですね。
CommandBufferの初期化
1 |
|
カメラごとに呼ばれるOnWillRenderObject
でCommandBufferを作成しています。 作成したCommandBufferはCameraEvent.AfterGBuffer
のタイミングに差し込んでいます。 すでにカメラにCommandBufferが登録されている場合はそのCommandBufferを再利用します。 buf.Clear()
でCommandBufferをクリアします。 ここからコマンドを積んでいきます。
今回は全体で4つのパスを作っています。 1つ目でdepthを書き出し2つ目と3つ目で縦横のブラーをかけて4つ目でGBufferを書き出します。
1つ目から3つ目のパスはリニアのdepthを書き出すので、 チャンネル1つのRenderTextureを取得します。 3つ目では1つ目のRenderTextureを使い回すのでRenderTextureは2つ用意します。
1パス目
スクリプト
1 | private void OnWillRenderObject () { |
buf.GetTemporaryRT
でリニアなdepthを書き込むためのRenderTextureを取得します。 buf.GetTemporaryRT
の第一引数には前もって取得しておいたプロパティIDを設定します。 depth0RT = Shader.PropertyToID ("_Depth0RT");
としていたので、 このRenderTextureにはシェーダ内で_Depth0RT
をつかってアクセスできるようになります。 width
とheight
に-1
を指定することでカメラと同じサイズにできます。 デプスとステンシルにはカメラのものを使うためデプスバッファは必要ないので depthBuffer
に0を指定します。 format
をRenderTextureFormat.RFloat
にして floatの1チャンネルのRenderTextureを用意しています。
buf.SetRenderTarget
で描画先を作ったRenderTextureに設定します。 buf.ClearRenderTarget (false, true, new Color (1, 1, 1, 1));
で 描画先をクリアします。
material.SetFloat (radiusID, particleSize);
でパーティクルの半径を渡します。 今回はパーティクルの大きさは時間によって変化せず、すべてのインスタンスで同じなので、 インスタンスごとには値を設定しません。
パーティクルごとに行列を計算しCommandBuffer.DrawMeshInstanced
にわたすのは 前回と同じです。 particleSystem.GetParticles
でパーティクルを取得して行列の配列を作り、 buf.DrawMeshInstanced
で描画しています。
シェーダ
1パス目で使われているシェーダは次のとおりです。
1 | Pass |
ほとんど前回のと同じです。 今回はパーティクルの大きさを変化させるつもりはないので、 その部分のコードは省略しています。 また、法線は後で計算するのでここではデプスだけを書き出しています。
ステンシル
1 | Stencil { |
シェーディングを行うために必要な128の他に、このあとのパスで使う1を立てています。
頂点シェーダ
1 | void vert (in appdata v, out v2f o) |
前回と同じですね。
フラグメントシェーダ
1 | void frag (in v2f i, out flagout o) |
最後の書き出す値が0~1のリニアなdepthである点だけが前回と違います。
Linear01Depth(depth)
は環境の違いを吸収して 0から1までのリニアなdepthを作ってくれるものです。
2パス目
2パス目は画面全体を覆うメッシュを使って描画します。 1パス目の結果をテクスチャとして受け取ってもう一方のRenderTextureに 書き出しを行います。
2パス目ではx方向のバイラテラルブラーを行います。 2次元のバイラテラルブラーは本来xとyに分離できませんが、 ここでは大胆に分離して近似的に求めています。
スクリプト
1 | private void OnWillRenderObject () { |
先ほどと同じようにしてbuf.GetTemporaryRT
で 1チャンネルのRenderTextureを作成します。 このテクスチャにはシェーダ内から_Depth1RT
でアクセスできます。
buf.SetRenderTarget
でレンダーターゲットを指定した後に、 buf.ClearRenderTarget
でクリアしています。
その後、buf.DrawMesh
で画面全体を覆う四角形を描画しています。 shaderPass : 1
としてシェーダの2つ目のパスを使うようにしています。
シェーダ
2パス目のシェーダの全文は次のとおりです。
1 | Pass { |
ステンシル
1 | Stencil { |
前のパスで書き出されたところだけ処理するためにステンシルを使っています。
レンダーテクスチャーの取得
1 | uniform sampler2D _Depth0RT; |
これで前のパスのレンダーターゲットをテクスチャとして受け取ります。 これで前のパスの描画結果を受け取れます。
フラグメントシェーダの出力
1 | struct flagout |
今回の出力はfloatが1チャンネルだけです。
頂点シェーダ
1 | void vert (in appdata v, out v2f o) |
通常行うMVPをかけずに画面を覆うようにしています。
フラグメントシェーダ
フラグメントシェーダは次のとおりです。
1 | void frag (in v2f i, out flagout o) |
1パスで書き出したdepthを_Depth0RT
で受け取り、バイラテラルフィルターをかけて 書き出しています。 float2(1 / _ScreenParams.x, 0)
でx方向の1ピクセルのサイズを渡しています。 _ScreenParams.x
はUnityの組み込みの変数でレンダーターゲットのwidth方向の ピクセル数が入っています。
バイラテラルブラーを行うbilateralBlur
は3パス目でも使うので、CGINCLUDE
~ENDCG
に書いています。 CGINCLUDE
は次のとおり。
1 | CGINCLUDE |
スライドのとおりです。
ブラーの半径はデプスで適当に割った値と50との小さい方をとっています。 0.2
や5
というのはレンダリングの結果を見ながら適当に決めた値です。
3パス目
2パス目の方向を変えただけで同じです。
スクリプトは次のとおりです。
1 | private void OnWillRenderObject () { |
書き出すレンダーターゲットを_Depth0RT
にしています。 DrawMesh
でshaderPass
に2
を指定して3つ目のパスを使っています。
シェーダは次のとおりです。
1 | Pass { |
ブラーの方向だけが違っています。 float2(0, 1 / _ScreenParams.y)
でy方向の1ピクセルのベクトルを渡しています。
4パス目
depthから座標の復元
このパスではdepthからeyeスペースの座標を復元する必要があります。
視錐台の4つの頂点を与えることでdepthから座標を復元します。
まず最初に現在のピクセルがfar planeではどの位置にあたるかを計算します。
視錐台の左側と右側の縁へのベクトルを線形補間することで、 現在のピクセルをfar planeへ投影したときの点へのベクトルのx成分がわかります。

同様にして視錐台の上側と下側の縁へのベクトルを線形補間することで、 現在のピクセルをfarプレーンへ投影したときの点へのベクトルのy成分がわかります。

これで現在のピクセルがfarプレーン上ではどの位置に当たるのかというベクトルが手に入りました。 このfarプレーンへのベクトルに対して、カメラからfarまでを0から1にしたリニアなdepthを 掛け合わせることでeyeスペースでの座標が求まります。

スクリプト
1 | private void OnWillRenderObject () { |
buf.SetRenderTarget (gBufferTarget, BuiltinRenderTextureType.CameraTarget);
で書き出し先にGBufferを指定しています。
視錐台のfar planeでのleft、right、top、bottomの位置を計算してfloat4に詰め込んで渡します。
buf.DrawMesh
で画面いっぱいの長方形を描画します。 shaderPass
は3
を指定して4番目のパスを使います。
シェーダ
4パス目のシェーダは次のとおりです。
1 | Pass { |
ステンシル
1 | Stencil { |
これも最初のパスで書き出されたところだけ処理するためにステンシルを使っています。
プロパティの受け取り
1 | uniform float4 _GBuffer0Color; |
プロパティに設定した値をそれぞれ受け取っています。
フラグメントシェーダの書き出し先
1 | struct flagout |
スクリプトで設定したGBufferに書き出します。
頂点シェーダ
1 | void vert (in appdata v, out v2f o) |
画面いっぱいの長方形をそのまま渡しています。
フラグメントシェーダ
1 | void frag (in v2f i, out flagout o) |
前のパスで計算したdepthに対してx方向とy方向の偏微分を求めてそれの外積で法線を計算しています。
DirectX 11やDirectX 12などではZバッファが逆向きで1がnearで0がfarとして扱われるので normal.z = -normal.z;
としています。
実行結果
シーンには透明のMesh Colliderが配置されています。 パーティクルはこのColliderにぶつかっています。
シーンにReflection Probeを配置しました。
Post Processingも適当に設定してあります。 Post Processingのアンチエイリアスをかけるとバイラテラルフィルタをxとyに分離したことによる アーティファクトがほとんど気にならなくなりました。
流体のマテリアルは次のとおりです。

実行結果は次のようになりました。



fpsもリアルタイムで問題ないレベルです。


おわりに
今回はCommandBufferを利用して擬似的なメタボールによる流体のレンダリングを行いました。 今回は不透明な流体でしたが、裏面にも同様のことを行って屈折を考えることで 透明な水しぶきなどもレンダリングできるそうです。 興味のある方は挑戦してみるとよいのではないでしょうか。
今回のプロジェクトはGitHubのこちらのリポジトリにおいてあります。
なにか気づいた点などがあれば私のTwitterアカウントの方にお願いします。