
[Unity]Graphics.DrawMeshInstancedIndirect を使って大量描画する[URP]
今回は、Graphics.DrawMeshInstancedIndirectを使ってシーン内にオブジェクトを大量に描画する方法についてまとめます。
今回作ったもの
弊社で現在開発中のスマホ用フィットネスゲームJoggleで樹木を描画する効率を改善するために、Graphics.DrawMeshInstancedIndirectを導入しました。

また、こちらはシーンを上部からキャプチャした動画です。地面のカリングに合わせて大量の樹木が瞬時に更新されているのが分かるかと思います。

現段階では、余裕を持ってカメラから描画されていない遠くの範囲まで木を描画していますが、それでも60FPS近くは出せています。
ここから更なる効率化を図って最適化していこうと思います。
DrawMeshInstancedIndirectに渡すパラメーターを分類する
2種類のComputeBuffer
Graphics.DrawMeshInstancedIndirectを扱う際は、必ず2種類のComputeBufferが登場します。混乱しないように、まずは役割を整理しておきましょう。
なお、Transform BufferとArgs Bufferという名称は正式なものではなく、以下の説明がしやすいように便宜的に付けました。
Transform Buffer
マテリアルに渡す
位置、回転、スケールを保持したバッファ
要素数は描画するインスタンス数と一致する
Args Buffer
DrawMeshInstancedIndirectに直接渡す
メッシュに関する基本情報を保持したデータ
5項目の整数のみ
2番目の値以外は基本的に変化しない
DrawMeshInstancedIndirectのパラメーターの変化しない値
続いて、他のパラメーターについても見ていきましょう。
同じメッシュを描画し続ける場合、下記の4項目は変化しません。
メッシュ
メッシュのインデックス
マテリアル
Args Bufferの第2引数以外
サンプルコードでは、これらの値は宣言時にまとめて渡しています。また、実際に渡しているのは最初の3項目のみで、Args Bufferの第2引数以外の値は、メッシュ自体から取得できます。
毎フレーム値が変化する可能性があるもの
一方で、下記の3項目は毎フレーム異なる値である可能性があります。
インスタンス数
Transform Buffer(位置、回転、スケール)
描画範囲
動く物体であれば、そのインスタンス数とそれぞれの位置、回転、スケールが毎フレーム変化するのは当然です。
静止している物体(例えば樹木)の場合、毎フレーム必ず値が変化するわけではありませんが、例えばプレイヤーの周辺にだけ樹木を描画する場合は、プレイヤーの位置が変化して描画対象となる樹木の構成に変更があった時だけ、これらの値は変化します。
また、描画範囲については、カメラの位置に合わせて毎フレーム変更するのが適切と思われます。
そのためサンプルコードでは、Transform Bufferと描画範囲は、必要に応じて描画前に変更できるように作ってあります。
また、Transform Bufferに変更があった際は、Args Bufferのうち、第2引数(インスタンス数)だけ変更しています。
サンプルコード
IndirectInstancedMeshDrawer
こちらがメインのコードです。
宣言時に、メッシュ、メッシュのインデックス、マテリアル、およびマテリアルのシェーダーが持つStructuredBuffer<float4x4>の変数名を渡しておきます。
これらは、毎フレーム共通で扱われる情報であり基本的に変化しません。
Graphics.DrawMeshInstancedIndirectに渡す情報の多くは、メッシュから取得できます。
また、それぞれの情報の渡し方が、正直大変ゴチャついています。
先にマテリアルに渡しておいたり、パラメーターに直接入れたり、Args Bufferに入れたりと結構難解です。せめて第2引数のメッシュのインデックスをArgs Bufferに含める仕様に変更するだけでも大分スッキリするのでは…兄弟分のDrawMeshInstancedの引数に合わせてこのような仕様になっているのでしょうか。
それを毎回記述するのは面倒なので、このクラスのようにラッパー的なものを作って必要な記述だけで済むようにすると便利かと思います。
namespace Project.Utilities
{
[Serializable]
public class RenderableData
{
public Mesh mesh;
public int meshIndex;
public Material material;
}
public class IndirectInstancedMeshDrawer : IDisposable
{
private ComputeBuffer transformBuffer;
private ComputeBuffer argsBuffer;
private uint[] args;
private RenderableData data;
private Bounds bounds;
public IndirectInstancedMeshDrawer(RenderableData data)
{
this.data = data;
this.bounds = new Bounds(Vector3.zero, Vector3.one * 1000);
args = new uint[5];
args[0] = (uint)data.mesh.GetIndexCount(data.meshIndex);
args[2] = (uint)data.mesh.GetIndexStart(data.meshIndex);
args[3] = (uint)data.mesh.GetBaseVertex(data.meshIndex);
argsBuffer = new ComputeBuffer(1, args.Length * sizeof(uint), ComputeBufferType.IndirectArguments);
}
public void SetBounds(Bounds bounds)
{
this.bounds = bounds;
}
public void SetMatrices(Matrix4x4[] matrices)
{
if (transformBuffer != null) transformBuffer.Release();
transformBuffer = new ComputeBuffer(matrices.Length, 16 * sizeof(float));
transformBuffer.SetData(matrices);
SetBuffer(transformBuffer);
}
public void SetBuffer(ComputeBuffer transformBuffer)
{
this.transformBuffer = transformBuffer;
data.material.SetBuffer("transformBuffer", transformBuffer);
args[1] = (uint)transformBuffer.count;
argsBuffer.SetData(args);
}
public void Draw()
{
Graphics.DrawMeshInstancedIndirect(data.mesh, data.meshIndex, data.material, bounds, argsBuffer);
}
public void Dispose()
{
if (argsBuffer != null)
{
argsBuffer.Release();
argsBuffer = null;
}
}
}
}
IndirectCollectionDrawer
こちらはおまけです。
上述のIndirectInstancedMeshDrawerを複数まとめて扱うことができます。
オブジェクトが複数のマテリアルに分かれている場合、それぞれのマテリアルごとにDrawMeshInstancedIndirectを実行する必要があります。
そのような時はインスタンス数とMatrix4x4[]の情報は共通になるはずですので、このようにまとめて渡した方が効率的です。
今回の樹木の描画もこの方法で行いました。
using System;
using UnityEngine;
namespace Project.Utilities
{
public class IndirectInstancedCollectionDrawer : IDisposable
{
private ComputeBuffer transformBuffer;
private IndirectInstancedMeshDrawer[] drawers;
public IndirectInstancedCollectionDrawer(RenderableData[] dataList, string transformBufferName)
{
drawers = new IndirectInstancedMeshDrawer[dataList.Length];
for (int i = 0; i < dataList.Length; i++)
{
drawers[i] = new IndirectInstancedMeshDrawer(dataList[i], transformBufferName);
}
}
public void SetBounds(Bounds bounds)
{
for (int i = 0; i < drawers.Length; i++)
{
drawers[i].SetBounds(bounds);
}
}
public void SetMatrices(Matrix4x4[] matrices)
{
if (transformBuffer != null) transformBuffer.Release();
transformBuffer = new ComputeBuffer(matrices.Length, 16 * sizeof(float));
transformBuffer.SetData(matrices);
foreach (var drawer in drawers)
{
drawer.SetBuffer(transformBuffer);
}
}
public void Draw()
{
foreach (var drawer in drawers)
{
drawer.Draw();
}
}
public void Dispose()
{
if (transformBuffer != null)
{
transformBuffer.Release();
transformBuffer = null;
}
foreach (var drawer in drawers)
{
drawer.Dispose();
}
}
}
}
URP対応Litシェーダー
DrawMeshInstancedIndirectについて解説した記事はすでに多くありますが、Universal Render Pipelineに対応したシェーダーについての解説があまり見つからなかったので、サンプルを掲載しておきます。
Litシェーダーとしての最低限の機能だけを記述しました。
通常のvert関数と異なり、SV_InstanceIDというuintの引数を持っていることが特徴です。
やっていることは、StructuredBuffer<float4x4>からinstanceIDで各インスタンス固有の位置回転スケールを取り出し、それを通常のvert関数の計算に掛け合わせているだけです。
frag関数については、通常のシェーダーと特に変わりはありません。
Shader "Instanced/Lit"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Color("Color", Color) = (1,1,1,1)
[ToggleUI]_AlphaClip("Alpha Clipping", Int) = 0
_Cutoff("Alpha Cutoff", Range(0, 1)) = 0.5
}
SubShader
{
Tags {
"RenderType"="TransparentCutout"
"Queue"="AlphaTest"
"RenderPipeline"="UniversalPipeline"
}
LOD 100
Pass
{
Name "ForwardLit"
Tags { "LightMode"="UniversalForward" }
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fog
#pragma multi_compile_instancing
#pragma multi_compile _ _ADDITIONAL_LIGHTS
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
int _AlphaClip;
half _Cutoff;
float4 _Color;
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
CBUFFER_END
StructuredBuffer<float4x4> transformBuffer;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};
struct v2f
{
float2 uv : TEXCOORD0;
float fogFactor: TEXCOORD1;
float4 vertex : SV_POSITION;
float3 normal : NORMAL;
float3 position : TEXCOORD2;
};
v2f vert (appdata v, uint instanceID : SV_InstanceID)
{
v2f o;
float4x4 t = transformBuffer[instanceID];
float4 worldPosition = mul(t, v.vertex);
o.vertex = mul(UNITY_MATRIX_VP, worldPosition);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.fogFactor = ComputeFogFactor(o.vertex.z);
o.normal = normalize(mul((float3x3)t, v.normal));
o.position = mul(unity_ObjectToWorld, v.vertex).xyz;
return o;
}
half3 GetAmbientLight(half3 normal)
{
half3 ambientLight = dot(normal, unity_SHAr) + dot(normal, unity_SHAg) + dot(normal, unity_SHAb);
return ambientLight;
}
float4 frag (v2f i) : SV_Target
{
float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
col *= _Color;
if(_AlphaClip == 1){
clip(col.a - _Cutoff);
}
Light light = GetMainLight();
float t = dot(i.normal, light.direction);
t = max(0, t);
t = min(t, 1);
float3 diffuseLight = light.color * t;
float3 additionalLights = float3(0, 0, 0);
#if defined(_ADDITIONAL_LIGHTS)
uint additionalLightCount = GetAdditionalLightsCount();
for (uint lightIndex = 0; lightIndex < additionalLightCount; ++lightIndex)
{
Light additionalLight = GetAdditionalLight(lightIndex, i.position);
float t = dot(i.normal, additionalLight.direction);
t = max(0, t);
additionalLights += additionalLight.color * t;
}
diffuseLight += additionalLights;
#endif
half3 ambient = half3(unity_SHAr.w, unity_SHAg.w, unity_SHAb.w);
col.rgb *= (diffuseLight + ambient);
col.rgb = MixFog(col.rgb, i.fogFactor);
return col;
}
ENDHLSL
}
}
}