概要
今回はGraphics.DrawMeshInstancedIndirect
メソッドを使ってGPUパーティクルをレンダリングする方法をまとめます。
レンダリングに利用するパーティクルの位置計算はコンピュートシェーダで行います。
コンピュートシェーダについては過去に2つ記事を書いているので以下を参照ください。
ドキュメントはこちら。今回はこのドキュメントに沿って実装しつつ、できるだけミニマムに実装しました。
実際に動作するサンプルはGitHubにアップしてあります。
実際に動作している動画はこちら↓
Graphics.DrawMeshInstancedIndirect理解した。 #Unity #Shader pic.twitter.com/oGbKLJpMZ6
— edom18@AR / MESON (@edo_m18) November 24, 2019
ComputeShaderを準備する
まずはComputeShaderを用意します。
ComputeShaderを利用してGPUインスタンシングされたパーティクルの位置計算を行います。
今回実装するのはシンプルに、ランダムな位置から徐々に元の位置に戻るアニメーションです。
コード
ComputeShaderのコードを掲載します。
#pragma kernel ParticleMain struct Particle { float3 basePosition; float3 position; float4 color; float scale; }; RWStructuredBuffer<Particle> _ParticleBuffer; float _DeltaTime; [numthreads(8, 1, 1)] void ParticleMain(uint3 id : SV_DispatchThreadID) { const int index = id.x; Particle p = _ParticleBuffer[index]; p.position += (p.basePosition - p.position) * _DeltaTime; _ParticleBuffer[index] = p; }
コンピュートシェーダ自体の説明は割愛します。詳細については上で紹介した記事を参照ください。
ここではGraphics.DrawMeshInstancedIndirect
を利用する上で重要な点に絞って解説します。
システム全体で利用する構造体を定義する
今回はGPUパーティクルなので、パーティクルシステムで利用する構造体を定義します。
これはComputeShader内だけでなく、C#側のコードと、そしてGPUパーティクルをレンダリングするシェーダ側でも定義する必要があります。
Particle
構造体は以下。
struct Particle { float3 basePosition; float3 position; float4 color; float scale; };
注意点は構造体のレイアウトをすべて同じにすることです。
構造体は宣言された順番にメモリにレイアウトされます。
この順番が異なっていると各プログラム間で整合性が取れなくなってしまうので気をつけてください。
処理を少しだけ説明しておくとRWStructuredBuffer<Particle>
型のバッファから、カーネル(ParticleMain
)の引数に渡されるスレッドID(SV_DispatchThreadID
セマンティクス)を使って計算対象となるParticle
構造体を取り出します。
そして現在位置(Particle.position
)から元の位置(Particle.basePosition
)へ徐々に近づけていきます。
C#コード側でデータを準備し計算を実行する
次にC#側のコードを見てみましょう。
C#側で行うのは各データ(バッファ)の準備と計算の実行開始(Dispatch
)、そしてコンピュートシェーダで計算されるバッファをシェーダ(マテリアル)に対して紐付けることです。
順に見ていきましょう。
コンピュートシェーダで利用するものと同じレイアウトの構造体を定義する
C#側でもコンピュートシェーダで利用するのと同じレイアウトの構造体を定義します。
構造体の定義は以下。
private struct Particle { public Vector3 basePosition; public Vector3 position; public Vector4 color; public float scale; }
若干 型が違っていますが配置の順番が同じ点に注目してください。(floatN
型はそれぞれVectorN
型になります)
各データ用変数を定義
C#側のタスクとして、データの準備と毎フレームごとの更新処理リクエストがあります。
ということでまずはデータの宣言について見てみましょう。
[SerializeField] private int _count = 10000; [SerializeField] private ComputeShader _computeShader = null; private ComputeBuffer _particleBuffer = null; private ComputeBuffer _argBuffer = null; private uint[] _args = new uint[5] { 0, 0, 0, 0, 0, };
コンピュートシェーダで利用する変数についてのみ抜粋しました。
これをセットアップしていきます。
データのセットアップ
データのセットアップをしているところを抜粋します。
List<Vector3> vertices = new List<Vector3>(); _targetMeshFilter.mesh.GetVertices(vertices); Particle[] particles = new Particle[_count]; for (int i = 0; i < _count; i++) { particles[i] = new Particle { basePosition = vertices[i % vertices.Count], position = vertices[i % vertices.Count] + Random.insideUnitSphere * 10f, color = _color, scale = Random.Range(0.01f, 0.02f), }; } _particleBuffer = new ComputeBuffer(_count, Marshal.SizeOf(typeof(Particle))); _particleBuffer.SetData(particles); _computeShader.SetBuffer(_kernelId, "_ParticleBuffer", _particleBuffer);
今回のサンプルは指定したオブジェクト(メッシュ)の頂点位置からランダムに離れた位置にパーティクルを初期配置し、あとは元の頂点位置に徐々に戻っていくというものです。
そのためセットアップはターゲットとなるメッシュの各頂点位置を取得し、それを元に初期状態をバッファに設定しています。
バッファにセットする用の配列にデータを詰め込みそれを元にComputeBuffer
を生成、それをコンピュートシェーダにセットします。
Indirect(間接実行)用のバッファを用意する
ちょっとまだしっかりとは把握しきれていないのですが、このバッファはインスタンスを管理するために用いられるもののようです。
具体的にはインスタンス数や頂点数を渡し、適切な数頂点シェーダなどが起動するようにするもののようです。
ちなみに、凹みさんの記事から引用させてもらうと以下のように説明されていました。(Draw Procedural Indirectの箇所を参照)
ComputeBuffer.CopyCount() では追加した要素数を調べることが出来ます。この際、第 2 引数に渡している ComputeBufferType.IndirectArguments フォーマットのバッファは int 4 つ分のバッファです。GetData() をして一旦 CPU 側に値を持ってきてみると、何個の要素数が追加されたか確認できます。
そしてコード例として載っているのが以下。
var args = new int[4]; cbDrawArgs.GetData(args); Debug.LogFormat("vert: {0}, {1}, {2}, {3}", args[0], args[1], args[2], args[3]); // --> 939, 1, 0, 0
数値が格納されているのが第1要素と第2要素のみです。
さて、それを踏まえた上で今回のセットアップコードを見ると以下のようになっています。
int subMeshIndex = 0; // Indirect args. _args[0] = _targetMeshFilter.mesh.GetIndexCount(subMeshIndex); _args[1] = (uint)_count; _args[2] = _targetMeshFilter.mesh.GetIndexStart(subMeshIndex); _args[3] = _targetMeshFilter.mesh.GetBaseVertex(subMeshIndex); _argBuffer = new ComputeBuffer(1, sizeof(uint) * _args.Length, ComputeBufferType.IndirectArguments); _argBuffer.SetData(_args);
第1要素がターゲットとなるメッシュのインデックス数、そして第2要素がパーティクルの数です。
ドキュメントには第3、第4要素も値が設定されているのですが試しにこれを0
にしても正常に動きました。
なので3、4要素目はなにを意味しているのかちょっとまだ分かっていません。
分かっているのは、第1要素はインスタンスとしてレンダリングされる対象の頂点数、そして第2要素がインスタンスの数になることです。
このバッファを、DrawMeshInstancedIndirect
を実行する際に引数に渡してやることで無事、レンダリングが行われます。
レンダリング用シェーダを作成する
データの準備、計算、更新処理が完成したら最後はパーティクルをレンダリングするシェーダを準備します。
それほど長くないのでまずはコード全体を掲載します。
Shader "Unlit/Particle" { SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct Particle { float3 basePosition; float3 position; float4 color; float scale; }; struct appdata { float4 vertex : POSITION; }; struct v2f { float4 vertex : SV_POSITION; float4 color : COLOR; }; StructuredBuffer<Particle> _ParticleBuffer; v2f vert (appdata v, uint instanceId : SV_InstanceID) { Particle p = _ParticleBuffer[instanceId]; v2f o; float3 pos = (v.vertex.xyz * p.scale) + p.position; o.vertex = mul(UNITY_MATRIX_VP, float4(pos, 1.0)); o.color = p.color; return o; } fixed4 frag (v2f i) : SV_Target { return i.color; } ENDCG } } }
まず見てもらいたいのが、コンピュートシェーダとC#側で定義してきたParticle
型の構造体があることです。
こちらも同様にレイアウトが同じになっています。
そして普段シェーダを書いているとあまり見慣れないStructuredBuffer<>
という型が宣言されています。
これがまさにコンピュートシェーダで計算されたバッファが受け渡される変数です。
頂点位置を計算する
通常、シェーダはとあるオブジェクト(メッシュ)をレンダリングするために用いられます。
つまりレンダリングする対象はひとつのみです。
しかし今回は対象のメッシュはパーティクルひとつひとつを対象とし、それぞれがインスタンシングされるため複数オブジェクトを描くようにしないとなりません。
頂点シェーダのコードを抜粋してみましょう。
v2f vert (appdata v, uint instanceId : SV_InstanceID) { Particle p = _ParticleBuffer[instanceId]; v2f o; float3 pos = (v.vertex.xyz * p.scale) + p.position; o.vertex = mul(UNITY_MATRIX_VP, float4(pos, 1.0)); o.color = p.color; return o; }
普段見慣れないSV_InstanceID
というセマンティクスがついた引数があります。
これはGPUインスタンシングされた情報に対して何番目のオブジェクトかを表すIDとなります。
このIDを利用してコンピュートシェーダで計算された情報にアクセスします。
つまり以下の部分です。
Particle p = _ParticleBuffer[instanceId];
こうすることで起動された頂点シェーダが今、どのパーティクルを処理しているかが分かるわけです。
頂点の座標を変換する
次に対象パーティクルの頂点の座標変換処理を見てみましょう。
float3 pos = (v.vertex.xyz * p.scale) + p.position; o.vertex = mul(UNITY_MATRIX_VP, float4(pos, 1.0));
v.vertex.xyz
はパーティクルメッシュの頂点情報です。(今回はCube
を利用しています)
そしてその頂点をまずスケーリングし、そのあとで、コンピュートシェーダによって計算された位置を足し込んでパーティクルの位置としています。
Cube
の各頂点が一律同じように移動されるので、結果的にパーティクルとしてのCube
が移動するというわけですね。
そして実質、これがモデル行列を掛けた状態、つまりワールド座標空間での位置になります。
なのであとはビュー行列とプロジェクション行列を掛けてあげれば無事、クリッピング座標系に変換されるというわけです。
o.vertex = mul(UNITY_MATRIX_VP, float4(pos, 1.0));
として頂点シェーダの出力にしているわけですね。
これで無事、パーティクルが画面に表示されるようになります。
まとめ
シンプルな実装ならばコード量はさほど多くありません。
たくさんのパーティクルを出して絵を盛るもよし、複雑な処理をインスタンシングの力で打破するもよし。
使い方が分かれば色々と応用が効きそうです。
もしもっと複雑な処理をしたい場合は、以下のカールノイズの記事を参考に実装してみるといいかもしれません。