GPU Instancing - DrawMeshInstancedIndirect
目录+
DrawMeshInstancedIndirect 合批流程
DrawMeshInstancedIndirect 把"这帧画多少个实例"的决策权从 CPU 移到 GPU:CPU 不再遍历、剔除、统计数量,而是把全部候选实例、一段做剔除的 Compute、一个绘制参数缓冲交给 GPU,由 GPU 自己算出可见数量并据此绘制。实例化的基础原理见 GPU InstancingGPU InstancingGPU Instancing 做的事:一次 Draw Call + 一份 Mesh/Shader 状态,通过 InstanceID 在 GPU 端索引每个实例的差异数据(transform、color 等),复用顶点数据,省掉 CPU → GPU 的重复提交。
问题:CPU 循环发 Draw Call 的开销
传统流程每画一个物体,CPU 都要调用图形API 发 Draw Call ,给到图形驱动,由驱动翻译成 GPU 原生指令写进 Command Buffer,GPU 再异步取出执行(这条 CPU→驱动→GPU 的提交链路见 [[性能优化大纲]])。驱动每翻译一条 Draw Call 都有一笔固定的 CPU 耗时,场景里几千个物体,这笔翻译成本就付几千遍。
image.png
问题在于: 如果这几千个物体用的是同一份网格和同一个材质,CPU 其实在为同一件事反复发命令,每次唯一的差别只是变换矩阵。GPU Instancing 从这里切入:它不让每次提交更便宜(那是 [[性能优化-SRP合批|SRP Batcher]] 的活),而是直接把提交次数从 N 压到 1。
底,这里只讲 Indirect 多出来的部分。整条链路分三段。
一、CPU 准备数据
- 实例数据缓冲
instanceDataBuffer:一个ComputeBuffer,按实例数量上限存下所有候选实例的属性(变换矩阵、颜色等),一次性上传显存。 - 绘制参数缓冲
argsBuffer:类型为ComputeBufferType.IndirectArguments的ComputeBuffer,含 5 个uint,布局由图形 API(DirectX/Vulkan)固定:
| 下标 | 含义 | 取值 |
|---|---|---|
args[0] | indexCountPerInstance,单个实例的索引数 | mesh.GetIndexCount(0) |
args[1] | instanceCount,要画多少个 | 初始填 0,运行时由 GPU 写入 |
args[2] | startIndexLocation,起始索引 | 通常 0 |
args[3] | baseVertexLocation,起始顶点 | 通常 0 |
args[4] | startInstanceLocation,起始实例 | 通常 0 |
其中 args[1] 是整套机制的关键——它不由 CPU 填,而是每帧由 GPU 剔除完再写进去。
二、GPU 剔除(Compute Shader)
派发一个 Compute Shader(线程组织见 Compute ShaderCompute ShaderCompute Shader 让我们能直接把通用计算任务交给 GPU 并行处理,而不局限于传统的顶点/片元着色。要写好它,先得理解 GPU 是如何把成千上万个线程分配到硬件上执行的。
GPU 的并行执行层级
GPU 的并行能力来自一套分级的线程调度体系。从最底层的执行单元到开发者组织的线程集合,NVIDIA 和 AMD 有各自的术语,但结构基本对应。
执行单元:SP / CUDA Core
SP(Streaming Processor)是 GPU 最基本的执行单元,在 NVIDIA 中也叫 CUDA Core,相当于 CPU 里的算术/逻辑单元。所有算术、逻辑、数据搬运指令最终都在 SP 上执行,每个 SP 通常一个时钟周期处理一个线程的一条指令。
GPU 之所以能高并发,正是因为它拥有成百上千个 SP 同时工作——市场上宣传的"几千核心"指的就是 SP(CUDA Core)的数量。AMD 架构中对应的执行单元通常称为 SIMD 单元,实现细节略有不同。
调度单元:SM / Compute Unit
SM(Streaming Multiprocessor)是比 SP 更高)并行遍历所有实例,逐个做视锥 / 距离 / 遮挡判断。通过测试的实例 Append 进一个可见实例缓冲(AppendStructuredBuffer),这个缓冲自带内部计数器,自动记录追加了多少个。
三、CPU 发起绘制
Dispatch 剔除 Compute 后,用 ComputeBuffer.CopyCount 把可见缓冲的计数器值拷进 argsBuffer 的 args[1],再调用 Graphics.DrawMeshInstancedIndirect。CPU 到此为止,全程不知道这帧画了几个;GPU 读 argsBuffer 拿到实例数,从可见缓冲里取数据绘制。
要点是:可见数量从产生(Append 计数器)到消费(argsBuffer)全程在显存里流转,没有一次 GPU→CPU 回读(read-back)。这正是它相比 DrawMeshInstanced 的价值——后者的实例数量和矩阵数组仍由 CPU 准备,几十万实例时光是 CPU 遍历剔除、组织数组本身就成了瓶颈;Indirect 把这步搬进 GPU 并行做,CPU 只剩几次缓冲绑定和一次绘制调用。本质上是拿现代 GPU 的通用并行计算(GPGPU)换掉了 CPU 的串行准备。
代码示例
C# 逻辑
为方便编辑模式下实时调参,在 OnValidate() 里参数变化时重新初始化:
void OnValidate()
{
if (!Application.isPlaying)
{
Initialize();
}
}
Initialize 先释放旧 Buffer 防泄漏,再做可用性检查(缺资源或平台不支持 Compute 就直接退出):
void Initialize()
{
// 确保在重新初始化前释放旧资源
ReleaseBuffers();
if (mainCamera == null)
mainCamera = Camera.main;
if (instanceMesh == null || instanceMaterial == null || cullingComputeShader == null || mainCamera == null ||
!SystemInfo.supportsComputeShaders)
{
isInitialized = false;
return;
}
// ...
}
private void ReleaseBuffers()
{
allInstancesDataBuffer?.Release();
allInstancesDataBuffer = null;
argsBuffer?.Release();
argsBuffer = null;
visibleInstancesDataBuffer?.Release();
visibleInstancesDataBuffer = null;
// isInitialized 在这里设为 false,确保下次 Update 时能重新初始化
isInitialized = false;
}
再生成实例数据:在世界空间的圆形区域内随机撒点、随机上色,instanceRadius 是单个实例的包围球半径(供 Compute 剔除用),最后填进 InstanceData 列表:
void Initialize()
{
// ...
this.instanceRadius = instanceMesh.bounds.extents.magnitude * Radius;
// 初始化所有实例数据
List<InstanceData> allInstances = new List<InstanceData>(instanceCount);
for (int i = 0; i < instanceCount; i++)
{
float angle = Random.Range(0f, Mathf.PI * 2f);
float distance = Random.Range(0f, areaSize / 2f);
float x = Mathf.Cos(angle) * distance;
float z = Mathf.Sin(angle) * distance;
Vector4 pos = new Vector4(x, 0, z, 1);
Color randomColor = new Color(Random.value, Random.value, Random.value, 1);
allInstances.Add(new InstanceData { position = pos, color = randomColor });
}
// ...
}
接着创建三个 ComputeBuffer——allInstancesDataBuffer(全部实例)、visibleInstancesDataBuffer(剔除后可见)、argsBuffer(绘制参数)。三个细节:
stride(步幅)是每个元素的字节数,GPU 按它切分缓冲。InstanceData是两个Vector4(各 16 字节),所以stride = 32。visibleInstancesDataBuffer用ComputeBufferType.Append:Compute 里能Append追加元素,缓冲自带计数器记录数量,后续靠它拿到可见总数。argsBuffer用ComputeBufferType.IndirectArguments,只含 1 个元素(一组绘制指令),大小5 * sizeof(uint) = 20字节。args[1]故意初始化为 0,交给 GPU 填。
void Initialize()
{
// ...
int stride = Marshal.SizeOf(typeof(InstanceData));
allInstancesDataBuffer = new ComputeBuffer(instanceCount, stride);
allInstancesDataBuffer.SetData(allInstances);
visibleInstancesDataBuffer = new ComputeBuffer(instanceCount, stride, ComputeBufferType.Append);
argsBuffer = new ComputeBuffer(1, args.Length * sizeof(uint), ComputeBufferType.IndirectArguments);
args[0] = (uint)instanceMesh.GetIndexCount(0);
args[1] = 0;
args[2] = (uint)instanceMesh.GetIndexStart(0);
args[3] = (uint)instanceMesh.GetBaseVertex(0);
args[4] = 0;
argsBuffer.SetData(args);
// ...
}
private struct InstanceData
{
public Vector4 position;
public Vector4 color;
}
然后把缓冲绑定到 Compute 和材质。注意最后一行——要把可见实例缓冲绑给渲染材质,渲染时才能按 SV_InstanceID 取到剔除后的数据:
void Initialize()
{
// ...
cullingKernelID = cullingComputeShader.FindKernel("CullInstances");
// 绑定缓冲区
cullingComputeShader.SetBuffer(cullingKernelID, "_AllInstancesData", allInstancesDataBuffer);
cullingComputeShader.SetBuffer(cullingKernelID, "_VisibleInstancesData", visibleInstancesDataBuffer);
// 关键:将*可见*实例的缓冲区绑定到渲染材质
instanceMaterial.SetBuffer("_VisibleInstancesData", visibleInstancesDataBuffer);
isInitialized = true;
// ...
}
Update 每帧驱动剔除和绘制。先把剔除所需的相机信息发给 Compute:CalculateFrustumPlanes 取相机视锥的六个平面,连同远裁切距离、相机变换、包围球半径一起 Set 进显存:
void Update()
{
if (!isInitialized)
{
Initialize();
// 如果初始化失败,则不执行后续逻辑
if (!isInitialized)
return;
}
// --- GPU Culling ---
Plane[] frustumPlanes = GeometryUtility.CalculateFrustumPlanes(mainCamera);
cullingComputeShader.SetVectorArray("_FrustumPlanes", PlanesToVector4(frustumPlanes));
cullingComputeShader.SetFloat("_MaxDistance", mainCamera.farClipPlane);
cullingComputeShader.SetMatrix("_CameraLocalToWorld", mainCamera.transform.localToWorldMatrix);
cullingComputeShader.SetFloat("_InstanceRadius", instanceRadius);
// ...
}
接着是每帧的三步核心:
SetCounterValue(0):清零可见缓冲的计数器,否则会跨帧累加。Dispatch:按 64 一组派发线程,每个实例一个线程并行剔除。CopyCount:纯 GPU 内部操作,把可见缓冲的计数器拷进argsBuffer的args[1](偏移sizeof(uint)= 4 字节),避免回读。
最后 DrawMeshInstancedIndirect 是一次很轻的 CPU 调用,绘制数量和参数都在 argsBuffer 里。传入的 renderBounds 是能罩住所有实例的大包围盒,供引擎做整批的粗剔除——连这个盒子都不可见时可整批跳过。
void Update()
{
// ...
visibleInstancesDataBuffer.SetCounterValue(0);
cullingComputeShader.Dispatch(cullingKernelID, Mathf.CeilToInt(instanceCount / 64f), 1, 1);
ComputeBuffer.CopyCount(visibleInstancesDataBuffer, argsBuffer, sizeof(uint));
// --- Rendering ---
Bounds renderBounds = new Bounds(Vector3.zero, new Vector3(areaSize * 2, 20, areaSize * 2));
Graphics.DrawMeshInstancedIndirect(
instanceMesh,
0,
instanceMaterial,
renderBounds,
argsBuffer
);
}
Compute Shader
剔除逻辑跑在 Compute 上,只做一件事:读入全部实例,输出一份只含可见实例的紧凑列表。
数据结构和缓冲声明——InstanceData 的字段布局必须和 C# 端完全一致,否则 GPU 解析错位。输入是只读的 StructuredBuffer,输出是可追加的 AppendStructuredBuffer:
#pragma kernel CullInstances
// 与 C# 脚本匹配的数据结构
struct InstanceData
{
float4 position;
float4 color;
};
// 输入:所有实例的数据
StructuredBuffer<InstanceData> _AllInstancesData;
// 输出:一个可追加的缓冲区,用于存储可见实例
AppendStructuredBuffer<InstanceData> _VisibleInstancesData;
// 从 C# 传递过来的摄像机视锥平面
float4 _FrustumPlanes[6];
float _MaxDistance;
float4x4 _CameraLocalToWorld;
float _InstanceRadius;
// ...
主函数 CullInstances 每线程处理一个实例,IsInFrustum 分两步判可见:
- 距离初筛:
distance(worldPos, cameraPos) > _MaxDistance直接剔掉远处的,省下后面的平面测试。 - 视锥测试:遍历六个平面,
dot(plane.xyz, pos) + plane.w + radius < 0表示整个包围球落在某平面外侧;只要在任一平面外就判不可见,过了全部六个才可见。
// ...
// 判断一个点是否在所有视锥平面内部
bool IsInFrustum(float3 worldPos, float radius)
{
float3 cameraPos = _CameraLocalToWorld[3].xyz;
if (distance(worldPos, cameraPos) > _MaxDistance)
{
return false;
}
for (int i = 0; i < 6; i++)
{
// dot(plane.xyz, pos) + plane.w 计算点到平面的垂直距离
if (dot(_FrustumPlanes[i].xyz, worldPos) + _FrustumPlanes[i].w + radius < 0)
{
return false; // 在视锥外
}
}
return true; // 在视锥内
}
[numthreads(64, 1, 1)]
void CullInstances(uint3 id : SV_DispatchThreadID)
{
// 获取当前线程处理的实例数据
InstanceData data = _AllInstancesData[id.x];
// 执行剔除检查
if (IsInFrustum(data.position.xyz, _InstanceRadius))
{
// 如果可见,则将其添加到可见实例的缓冲区中
_VisibleInstancesData.Append(data);
}
}
渲染 Shader
渲染 Shader 很简单:用 SV_InstanceID 从可见缓冲取当前实例的数据,把实例位置加到模型顶点上,转到裁剪空间。
// ...
// 与 C# 脚本中匹配的数据结构
struct InstanceData
{
float4 position;
float4 color;
};
StructuredBuffer<InstanceData> _VisibleInstancesData;
Varyings vert(Attributes input, uint instanceID : SV_InstanceID)
{
Varyings o = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(input);
// 使用实例 ID 作为索引,从 Buffer 中读取当前实例的数据
InstanceData data = _VisibleInstancesData[instanceID];
// 将实例的位置应用到模型的顶点上
float3 worldPos = input.positionOS.xyz + data.position.xyz;
// 转换到裁剪空间
o.positionHCS = TransformWorldToHClip(worldPos);
// ...
}