始めに
UnityではGraphics機能も更新され Graphics.DrawMeshInstancedIndirect
がObsoleteになり、代わりに Graphics.RenderMeshIndirect
が新たに追加されました。
また、Built-inよりもURPの方が使われるようになり、Unity Standard Shaderが過去のものとなりました。
昔まとめた以下の Graphics.DrawMeshInstancedIndirect
記事が古くなったので、新しくURPで行う Graphics.RenderMeshIndirect
についてサンプルを作ってここにまとめます。
環境
- Unity 2022.3.30f1
- URP 14.0.11
リポジトリ
Graphics.RenderMeshIndirectについて
最初に、Unityの描画について簡単に紹介します。
Unityでオブジェクトを描画するとき、CPUからGPUへMaterial情報を送るSetPass Callや描画命令をするDraw Callが実行されます。
大量のオブジェクトを描画する場合、SetPass CallやDraw Callが増えて重くなる原因になります。
そこで、Graphics.RenderMeshIndirect
を使うことで同一のメッシュとマテリアルのインスタンスを一度に描画することで、少ないSetPass CallとDraw Callで大量に描画できます。
サンプルコード
指定されたメッシュを範囲内に個数分だけ描画するサンプルです。シェーダーコードが長いので注意してください。
サンプルコード
RenderMeshIndirect対応のシェーダー作成
RenderMeshIndirect
は特殊でシェーダー側でインスタンス毎の描画するデータを設定する必要があります。
RenderMeshIndirect
用のシェーダーを作るうえで、以下の下準備が必要となります。( Graphics.DrawMeshInstancedIndirect
と異なる点)
- プリプロセッサに以下2つを追加
#define UNITY_INDIRECT_DRAW_ARGS IndirectDrawIndexedArgs
#include "UnityIndirect.cginc"
- 頂点シェーダーの引数から
SV_InstanceID
を受け取る InitIndirectDrawArgs(0);
の呼び出しGetIndirectInstanceID(svInstanceId)
で instanceId を求める
プリプロセッサに #define UNITY_INDIRECT_DRAW_ARGS IndirectDrawIndexedArgs
, #include "UnityIndirect.cginc"
を追加します。
#define UNITY_INDIRECT_DRAW_ARGS IndirectDrawIndexedArgs #include "UnityIndirect.cginc"
次に頂点シェーダーの引数から SV_InstanceID
を受け取り、InitIndirectDrawArgs(0)
を実行し GetIndirectInstanceID(svInstanceId)
で instanceId を求めます。
Varyings ShadowPassVertexForRenderMeshIndirect(Attributes input, uint svInstanceId : SV_InstanceID) { InitIndirectDrawArgs(0); ...... ...... uint instanceID = GetIndirectInstanceID(svInstanceId);
下準備をして InstanceId を求めたら、StructuredBufferから描画に必要なデータを取得し頂点や法線などを計算します。
頂点や法線を計算するメソッドはPassの外に書くことで、すべてのPassから実行できるようにしています。
注意点として RenderMeshIndirect
では設定された Bounds.center
分だけ描画がずれるので、計算した座標がワールド空間であれば修正する必要があります。
HLSLINCLUDE StructuredBuffer<float4x4> _TransformMatrixArray; half3 _BoundsOffset; void transform_vertex(inout float3 position, uint instanceId) { float4x4 mat = _TransformMatrixArray[instanceId]; position = mul(mat, float4(position, 1.0)).xyz - _BoundsOffset; // Bounds.center分だけ描画がずれるので修正 } void rotate_vector(inout float3 vec, uint instanceId) { float4x4 mat = _TransformMatrixArray[instanceId]; vec = normalize(mul((float3x3)mat, vec)); } ENDHLSL .... .... Varyings LitPassVertexForRenderMeshIndirect(Attributes input, uint svInstanceID : SV_InstanceID) { InitIndirectDrawArgs(0); ...... ...... uint instanceID = GetIndirectInstanceID(svInstanceID); transform_vertex(input.positionOS.xyz, instanceID); rotate_vector(input.normalOS.xyz, instanceID); rotate_vector(input.tangentOS.xyz, instanceID); ...... ......
今回作成したLitシェーダーでは以下の3つのPassを使用しています。
ForwardLit
: オブジェクトの描画するPassShadowCaster
: 影を描画するPassDepthOnly
: 深度を描画するPass
反対にUnity URPのLitシェーダーから以下のPassは不必要そうだったので除きました。もし必要であれば適宜対応が必要になります。
GBuffer
: Deffered Renderingで使用されるPassDepthNormals
:_CameraNormalsTexture
を描画するときに使用されるPassMeta
: Light Mapのベイクで使用される PassUniversal2D
: 2D描画用 Pass
InitIndirectDrawArgs と GetIndirectInstanceID の補足
この ちょっと長いので折り畳み
InitIndirectDrawArgs(0)
や GetIndirectInstanceID(svInstanceId)
は UnityIndirect.cginc
内で定義されており、実装をみることができます。InitIndirectDrawArgs(0)
では RenderMehsIndirect で指定された引数の設定、GetIndirectInstanceID(svInstanceId)
は instanceId の計算が行われています。グラフィックスAPIによる処理の差を吸収している模様なので、これらを使用するほうが無難そうです。void GetIndirectDrawArgs(out IndirectDrawIndexedArgs args, ByteAddressBuffer argsBuffer, uint commandId)
{
uint offset = commandId * 20;
args.indexCountPerInstance = argsBuffer.Load(offset + 0);
args.instanceCount = argsBuffer.Load(offset + 4);
args.startIndex = argsBuffer.Load(offset + 8);
args.baseVertexIndex = argsBuffer.Load(offset + 12);
args.startInstance = argsBuffer.Load(offset + 16);
}
void InitIndirectDrawArgs(uint svDrawID) { GetIndirectDrawArgs(globalIndirectDrawArgs, unity_IndirectDrawArgs, GetCommandID(svDrawID)); }
#if defined(SHADER_API_VULKAN)
uint GetIndirectInstanceID(IndirectDrawIndexedArgs args, uint svInstanceID) { return svInstanceID - args.startInstance; }
uint GetIndirectInstanceID_Base(IndirectDrawIndexedArgs args, uint svInstanceID) { return svInstanceID; }
#else
uint GetIndirectInstanceID(IndirectDrawIndexedArgs args, uint svInstanceID) { return svInstanceID; }
uint GetIndirectInstanceID_Base(IndirectDrawIndexedArgs args, uint svInstanceID) { return svInstanceID + args.startInstance; }
#endif
uint GetIndirectInstanceID(uint svInstanceID) { return GetIndirectInstanceID(globalIndirectDrawArgs, svInstanceID); }
ArgsBufferの初期化
RenderMeshIndirect
では GraphicsBuffer.IndirectDrawIndexedArgs
を使ってArgsBufferの初期化を行います。
旧API DrawMeshInstancedIndirect
では単純な uint[]
の各要素に mesh.GetIndexCount
instanceCount
などを設定する形でしたが、IndirectDrawIndexedArgs
の登場により明示的な変数に設定するようになりました。
private GraphicsBuffer _drawArgsBuffer; ...... ...... private void Start() { ....... ....... _drawArgsBuffer = CreateDrawArgsBufferForRenderMeshIndirect(_mesh, _count); ....... ....... } private static GraphicsBuffer CreateDrawArgsBufferForRenderMeshIndirect(Mesh mesh, int instanceCount) { var commandData = new GraphicsBuffer.IndirectDrawIndexedArgs[1]; commandData[0] = new GraphicsBuffer.IndirectDrawIndexedArgs { indexCountPerInstance = mesh.GetIndexCount(0), instanceCount = (uint)instanceCount, startIndex = mesh.GetIndexStart(0), baseVertexIndex = mesh.GetBaseVertex(0), }; var drawArgsBuffer = new GraphicsBuffer( GraphicsBuffer.Target.IndirectArguments, 1, GraphicsBuffer.IndirectDrawIndexedArgs.size ); drawArgsBuffer.SetData(commandData); return drawArgsBuffer; }
今回は試しておりませんが、複数の IndirectDrawIndexedArgs
をArgsBufferに設定することも可能なようです。
描画データの初期化
インスタンスの描画では Matrix4x4
を使用し、ランダムな座標と回転で描画するインスタンス分だけ初期化しています。
後は GraphicsBuffer
にデータを格納して、マテリアルに設定します。
private void Start() { ...... ...... _dataBuffer = CreateDataBuffer<Matrix4x4>(_count); var transformMatrixArray = TransformMatrixArrayFactory.Create(_count, maxPosition, minPosition); _dataBuffer.SetData(transformMatrixArray); _material.SetBuffer("_TransformMatrixArray", _dataBuffer); _material.SetVector("_BoundsOffset", transform.position); transformMatrixArray.Dispose(); } private static GraphicsBuffer CreateDataBuffer<T>(int instanceCount) where T : struct { return new GraphicsBuffer( GraphicsBuffer.Target.Structured, instanceCount, Marshal.SizeOf(typeof(T)) ); }
RenderMeshIndirectを呼び出して描画
RenderMeshIndirect
では RenderParams
に描画設定をします。あとは必要な引数を RenderMeshIndirect
に渡して描画を行います。
private void Update() { var renderParams = new RenderParams(_material) { receiveShadows = _receiveShadows, shadowCastingMode = _shadowCastingMode, worldBounds = new Bounds(transform.position, transform.localScale) }; Graphics.RenderMeshIndirect( renderParams, _mesh, _drawArgsBuffer ); }
動作結果
動作環境
- Windows11 Home
- Core i7-14700KF
- メモリ 32GB
- Geforce RTX 4070 Ti Super
RenderMeshIndirectで10万個のCubeを描画
10万個のCubeを描画してみました。カメラを遠くに配置しているので少しだけ軽くなっています。
GameObjectで10万個のCubeを描画
比較用として、GameObjectとして10万個のCubeを描画したものが次の通りです。同じマテリアルで描画しているのでSetPass Callsは低いですが、DrawCallが20万とかなり大きくなっています。
RenderMeshIndirectで100万個のCubeを描画
最後に100万個のCubeを描画してみます。遠くから描画する場合はギリギリ60fpsになりました。
カメラを寄せると30fpsを下回りました。(内側は影で真っ黒なので、側面にカメラを設置)
ただし、Shadow Casting Mode
と RecieveShadow
を切ることでカメラを寄せても100fpsを上回りました。影の描画は結構重たいようです。
JobSystem, ComputeShaderと組み合わせる
RenderMeshIndirectを使用して大量のインスタンスを描画できますが、何かしら動かすためには描画データを処理する必要があります。
単純に実装すると数万単位の処理が走るため、無視できないレベルの負荷になります。 そこで、JobSystemやComputeShaderを使うことで並列で高速に描画データを処理できます。(他の方法としてVertex Animation Textureなどもあります)
ここでは、そのサンプルを簡単に作って紹介します。
JobSystem
JobSystemはCPU上で並列に処理を実行する機能です。こちらを使って10万個のCubeを動かすサンプル作りました。 Burst compileも使うことでかなり高速になります。
録画処理でCPUリソースが使われているため動画上では120fps程度となりますが、実機ではもっと250fps程度でました。
実行しているコードは以下のような形で、毎フレームJobを実行し計算結果を _dataBuffer.SetData(_transformMatrixArray)
でGPUに渡しています。
private void Update() { var job = new CubeAnimationJob( (transform.position + transform.localScale / 2).y, (transform.position - transform.localScale / 2).y, _settings.MoveVelocity, _settings.RotationVelocity, Time.deltaTime, _transformMatrixArray ); var jobHandle = job.Schedule(_count, 64); jobHandle.Complete(); _dataBuffer.SetData(_transformMatrixArray); var renderParams = new RenderParams(_material) { receiveShadows = _receiveShadows, shadowCastingMode = _shadowCastingMode, worldBounds = new Bounds(transform.position, transform.localScale) }; Graphics.RenderMeshIndirect( renderParams, _mesh, _drawArgsBuffer ); }
ComputeShader
ComputeShaderはGPU上で並列に処理を実行する機能で、JobSystemより高速になります。 半面、C#が使えず汎用的な機能が使えないため扱いづらいデメリットもあります。
ComputeShaderで実装した結果250fpsとなり、JobSystemとあまり結果は変わらずとなりました。おそらくデータの処理よりも描画の方がボトルネックになっているようです。(インスタンスの数が増えるとComputeShaderの方が若干高速)
以下が実行しているコードの一部で、JobSystemと異なりGPU上で計算をしているためCPUからGPUへデータを送信する処理が不要になります。この点をみれば RenderMeshIndirect
とComputeShaderは相性が良いです。
private void Update() { _computeShader.SetFloat(DeltaTime, Time.deltaTime); _computeShader.GetKernelThreadGroupSizes(_kernelIndex, out var threadGroupSizeX, out _, out _); var threadGroups = Mathf.CeilToInt(_count / (float)threadGroupSizeX); _computeShader.Dispatch(_kernelIndex, threadGroups, 1, 1); var renderParams = new RenderParams(_material) { receiveShadows = _receiveShadows, shadowCastingMode = _shadowCastingMode, worldBounds = new Bounds(transform.position, transform.localScale) }; Graphics.RenderMeshIndirect( renderParams, _mesh, _drawArgsBuffer ); }
まとめ
ここ最近のUnity更新に追いつけていませんでしたが、一通り触ってみますと Graphics.DrawMeshInstancedIndirect
とそこまで大きく変わっていません。
良く遊びで並列処理を使ったシミュレーションを作っているので、このサンプルを元に開発を続けていく予定です。
参考
Unityの描画周りついては以下の記事をみて振り返ったりしてました。
Graphics.RenderMeshIndirect
の実装は以下の記事を参考にしました。