GPU Instancing
目录+
GPU Instancing 做的事:一次 Draw Call + 一份 Mesh/Shader 状态,通过 InstanceID 在 GPU 端索引每个实例的差异数据(transform、color 等),复用顶点数据,省掉 CPU → GPU 的重复提交。
问题:CPU 循环发 Draw Call 的开销
传统流程每画一个物体,CPU 都要调用图形API 发 Draw Call ,给到图形驱动,由驱动翻译成 GPU 原生指令写进 Command Buffer,GPU 再异步取出执行(这条 CPU→驱动→GPU 的提交链路见 性能优化大纲性能优化大纲diagram (1).png 前提:先测量,再优化 没有数据支撑的优化都是猜。动手前先用 Profiler 定位瓶颈,CPU/GPU Profiler、Memory Profiler、Frame Debugger 分别覆盖不同维度。 下面列出的所有手段,都是针对已定位的具体问题的备选方案,而不是一份要逐条执行的清单。 判定性能瓶颈 一帧之内发生了什么 一帧由 CPU 和 GPU 分工完成。要判断瓶颈在哪一端,得先知道这一帧里两边各自在做什么、按什么顺序做。 image.png CPU 侧 CPU 侧在主线程上按固定顺序推进: - 物理 >> 脚本逻辑 >> 动画 >> UI 重建 >> 渲染数据准备 渲染准备就是 Profiler 里的 Camera.Render,它做三件事: 剔除**(视锥剔除、遮挡剔除,筛掉看不见的物体); 排序**(不透明物体从前往后、半透明从后往前); 合批** 最后为每个要画的物体生成渲染命令。 Unity 默认开启多线程渲染,主线程生成渲染指令,渲染线程逐条取出指令,调用对应的图形 API(DX/GL),由驱动将命令写入 Comma)。驱动每翻译一条 Draw Call 都有一笔固定的 CPU 耗时,场景里几千个物体,这笔翻译成本就付几千遍。

问题在于: 如果这几千个物体用的是同一份网格和同一个材质,CPU 其实在为同一件事反复发命令,每次唯一的差别只是变换矩阵。GPU Instancing 从这里切入:它不让每次提交更便宜(那是 SRP Batcher性能优化-SRP合批SetPass Call 为什么贵 CPU 提交一次绘制,真正贵的不是 Draw Call 本身,而是它前面的 SetPass Call——切换渲染状态的那一步。每次 SetPass,CPU 端要: 1. 选择要使用的 Shader 变体(Variant),绑定对应的管线状态; 2. 把这个材质的所有属性从 CPU 内存写入 GPU 的常量缓冲区(CBuffer)。 这一步之所以慢,是因为它走的是图形驱动的通用路径:驱动要校验状态合法性、重新组织资源绑定表,材质数据每次都要重新走一遍 CPU→GPU 的上传。场景里材质一多,即使共用同一个 Shader,传统管线也要为每个材质完整做一遍 SetPass。 SRP Batcher 要解决的就是这个:让使用相同 Shader 变体的多个材质共享一次 SetPass。注意它减少的是 SetPass Call,Draw Call 的次数并不变——只是每次 Draw 变得极其廉价。 核心思路:材质数据常驻显存 传统批处理(Static/Dynamic Batching,见 [[性能优化-静态合批]])的思路是合并网格,把多个物体拼成一 的活),而是直接把提交次数从 N 压到 1。
底层工作流程
思路是把渲染循环从 CPU 挪到 GPU 硬件:传统方式是 CPU 循环、每次循环发一个 Draw Call;Instancing 是 CPU 只发一个 Draw Call,由 GPU 在硬件层面循环。CPU 的工作量从 O(N) 降为 O(1),这就是性能提升的全部来源。
CPU 端先备好两类数据:
- 共享的网格(顶点/索引缓冲)上传显存一次;
- 每个实例独有的数据——世界矩阵、颜色、自定义属性——打包成实例数据缓冲区(Instance Data Buffer),也是一次性上传。
然后发绘制命令。不再用 DrawPrimitive 这类一次画一个的接口,改用 Direct3D 的 DrawIndexedInstanced() 或 OpenGL 的 glDrawElementsInstanced(),比常规绘制只多传一个参数:实例数量。命令缓冲于是变得极短——一组绑定指令加一个 DrawInstanced(1000) 就完事。
GPU 端的"循环"其实是硬件展开
"由 GPU 在硬件层面循环"是个简化说法。GPU 并不是像 CPU 那样跑串行 for 循环,而是把这条带计数的命令交给固定功能硬件展开:
- 命令处理器读到一条带计数的绘制命令。 命令本身携带
indexCount(网格多少索引)和instanceCount(画多少份)。命令处理器读到的不是 1000 条命令,而是一条"这个网格 × 1000"。 - 固定功能前端负责"展开循环"。 GPU 前端有一个固定功能单元(图元分发器 / Input Assembler),按计数器批量产出
(vertexID, instanceID)二元组:实例 0 的所有顶点、实例 1 的所有顶点……直到实例 999。这一步纯硬件,没有驱动或软件参与,生成一个 ID 对的成本几乎为零。真正的"循环"发生在这里——它不是执行代码的循环,而是硬件按计数器批量生成工作项。 - 着色器核心大规模并行消费这些 ID。 ID 对被打包成 wave(一组 32/64 个线程)扔给着色器阵列。实例之间没有依赖,实例 0 和实例 500 可以同时在不同核心上算——不是"循环 1000 次",而是"1000 份工作平铺开来并行吃掉"。
- 两种 ID 各取各的数据。 顶点位置、法线、UV 按
vertexID从共享顶点缓冲读;逐实例数据按instanceID取——要么实例化顶点流声明成 per-instance 步进(D3D 的PER_INSTANCE_DATA),硬件读取指针按 instanceID 步进;要么把矩阵数组放进缓冲,着色器里用SV_InstanceID(HLSL)/gl_InstanceID(GLSL)自己下标取(instanceDataBuffer[SV_InstanceID])。对着色器来说,SV_InstanceID只是多一个输入寄存器。
变换算完之后进光栅化、片元着色,和普通绘制没有区别。整条链路没有任何一步是串行迭代:CPU 发一条命令 → 固定功能硬件按计数器生成 ID 流 → 着色器阵列并行处理。这正是前面 O(N) 降为 O(1) 的落地——CPU 省下的 API 调用,换成的是 GPU 前端近乎零成本的硬件 ID 生成。
一个实践推论:正因为前端按"每实例一份完整网格"分发工作,网格太小时 Instancing 效率会打折——每个实例只有几个三角形(草叶)时,wave 填不满、顶点复用缓存跨实例失效,硬件利用率低。所以草地渲染常见做法是先把若干草叶合并成一个 patch 网格,再对 patch 做 Instancing。
Unity 中的使用
Unity 里用 GPU Instancing 有三条路径。分辨它们的关键就一句话:逐实例数据谁来管、这帧画多少个谁说了算——从"引擎全包"到"你自己管 buffer、GPU 定数量",灵活度和上手成本递增。
一、材质自动合批(Enable GPU Instancing)
最省事的一条:材质用 URP Lit/Unlit 这类标准 Shader,Inspector 勾上 Enable GPU Instancing 即可。Unity 自动把相同网格、相同材质的 Renderer 合成实例化绘制,逐实例的变换矩阵引擎全程替你管,一行代码不用写。
要注意的是"能不能合批"和"要不要声明实例 buffer"是两回事,别搞混:
- 只想批量画一堆位置/旋转/缩放不同的物体 —— 什么都不用声明。逐实例的
unity_ObjectToWorld是引擎内置 instancing buffer 的一部分,UNITY_SETUP_INSTANCE_ID会自动按 instanceID 索引到。手写 Shader 时也只需要开变体 + 拿 ID 两步:
#pragma multi_compile_instancing // 开 instancing 变体
struct Attributes
{
float4 positionOS : POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID // 展开为 uint instanceID : SV_InstanceID
};
Varyings vert(Attributes input)
{
UNITY_SETUP_INSTANCE_ID(input); // 选中本实例的矩阵;之后 TransformObjectToWorld 自动用它
...
}
- 每颗还要不同的颜色/自定义属性 —— 这时才需要
UNITY_INSTANCING_BUFFER_START/END声明一个"逐实例属性"的 CBUFFER:
UNITY_INSTANCING_BUFFER_START(PerInstance)
UNITY_DEFINE_INSTANCED_PROP(half4, _BaseColor) // 声明一个逐实例独立的属性
UNITY_INSTANCING_BUFFER_END(PerInstance)
读的时候用 UNITY_ACCESS_INSTANCED_PROP(PerInstance, _BaseColor)——这组宏在背后做的就是拿 SV_InstanceID 索引数组。CPU 端则通过 MaterialPropertyBlock(SetVectorArray 等)给不同 Renderer 灌不同值,属性名要和 Shader 里 UNITY_DEFINE_INSTANCED_PROP 声明的变量对上。
一句话:声明那三行的唯一理由是"每实例不同的自定义属性";只差变换矩阵的话不用写。
二、脚本绘制:DrawMeshInstanced + MaterialPropertyBlock
不想依赖场景里的 GameObject、由脚本直接批量画,用 Graphics.DrawMeshInstanced。C# 端准备一个 Matrix4x4[],有自定义属性就再准备对应数组(如 Vector4[])塞进 MaterialPropertyBlock,每帧调用 DrawMeshInstanced 传入网格、材质、矩阵数组和这个 MPB。
这里的 MPB 是给一整批实例设属性数组,和 Renderer.SetPropertyBlock(给单个 Renderer 设属性)不是一回事,别混淆。
三个坑:单次调用受常量缓冲区大小限制,上限 1023 个实例,超了要自己拆批;它是"全有或全无"的调用,不做视锥剔除,屏幕外的实例照样走顶点着色,剔除得自己做、只把可见实例塞进矩阵数组;画出来的只是视觉表现,没有 GameObject,也就没有碰撞体和脚本。
三、GPU 驱动:DrawMeshInstancedIndirect / Procedural
前两条路径的实例数量都由 CPU 准备。上百万个实例要做视锥/遮挡剔除时,CPU 遍历这个大数组本身就成了新瓶颈。Indirect 把剔除也搬到 GPU(GPU 驱动渲染,GPU-Driven Rendering),完整实现见 GPU Instancing - DrawMeshInstancedIndirectGPU Instancing - DrawMeshInstancedIndirectDrawMeshInstancedIndirect 合批流程 DrawMeshInstancedIndirect 把"这帧画多少个实例"的决策权从 CPU 移到 GPU:CPU 不再遍历、剔除、统计数量,而是把全部候选实例、一段做剔除的 Compute、一个绘制参数缓冲交给 GPU,由 GPU 自己算出可见数量并据此绘制。实例化的基础原理见 [[GPU Instancing]],这里只讲 Indirect 多出来的部分。整条链路分三段。 一、CPU 准备数据 - 实例数据缓冲 instanceDataBuffer:一个 ComputeBuffer,按实例数量上限存下所有候选实例的属性(变换矩阵、颜色等),一次性上传显存。 - 绘制参数缓冲 argsBuffer:类型为 ComputeBufferType.IndirectArguments 的 ComputeBuffer,含 5 个 uint,布局由图形 API(DirectX/Vulkan)固定: | 下标 | 含义 | 取值 | |---|---|---| | args[0] | indexCountPerInstance:
- CPU 把所有潜在实例的数据(比如整片草地每根草的位置)一次性传到 GPU 的
ComputeBuffer。 - 每帧派发一个 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 更高,在 GPU 上并行遍历所有实例做视锥/遮挡/距离剔除,通过测试的写入另一个
ComputeBuffer(可见实例列表),同时原子地累加出可见总数。 ComputeBuffer.CopyCount把可见总数写进一个结构固定的小 Args Buffer:{indexCountPerInstance, instanceCount, startIndexLocation, baseVertexLocation, startInstanceLocation},和DrawIndexedInstanced的参数一一对应。- CPU 调用
Graphics.DrawMeshInstancedIndirect(mesh, material, argsBuffer)。CPU 全程不知道这帧画多少个,只是告诉 GPU:绘制参数去 argsBuffer 里自己读,数据用可见实例列表里的。
实例数据走 ComputeBuffer(StructuredBuffer),不受 MPB 那个 1023 的限制,百万级也没问题。
Indirect 和 Procedural(DrawMeshInstancedProcedural)的区别只有一个:逐实例数据都放 StructuredBuffer、都用 SV_InstanceID 取,区别在"实例数量从哪来"——Procedural 由 CPU 传一个 int count,Indirect 从 GPU 的 args buffer 里读。正因为数量能由 GPU 算出来,Indirect 才能做到"剔除→定数量→绘制"全在 GPU 闭环、CPU 不回读;Procedural 可以看成去掉这一能力的阉割版。做 GPU 剔除时直接上 Indirect。
三种方式对比
| 材质自动合批 | DrawMeshInstanced | DrawMeshInstancedIndirect | |
|---|---|---|---|
| 逐实例数据谁管 | 引擎(内置 buffer + MPB) | CPU 传 Matrix4x4[] + MPB | 你自己建 ComputeBuffer |
| 数量上限 | 无硬上限 | 1023 / 批 | 百万级 |
| 数量谁决定 | 引擎 | CPU | GPU(args buffer) |
| 视锥剔除 | 引擎按 Renderer 做 | 无,自己做 | GPU 里做(Compute) |
| 需要 GameObject | 是 | 否 | 否 |
| 上手成本 | 勾选框 | 写脚本 | 脚本 + Compute + Shader |
| 典型场景 | 场景里重复摆放的物件 | 中等数量动态实例 | 海量草地/植被 + GPU 剔除 |
适用边界
Instancing 的前提是大量实例共享同一份网格和材质——树、草、石头、重复的建筑构件。两条边界:走 Instancing 的绘制不经过 SRP Batcher 路径,对每种物体只能二选一——同一个网格大量重复用 Instancing,网格各异但共享 Shader 的交给 SRP Batcher性能优化-SRP合批SetPass Call 为什么贵 CPU 提交一次绘制,真正贵的不是 Draw Call 本身,而是它前面的 SetPass Call——切换渲染状态的那一步。每次 SetPass,CPU 端要: 1. 选择要使用的 Shader 变体(Variant),绑定对应的管线状态; 2. 把这个材质的所有属性从 CPU 内存写入 GPU 的常量缓冲区(CBuffer)。 这一步之所以慢,是因为它走的是图形驱动的通用路径:驱动要校验状态合法性、重新组织资源绑定表,材质数据每次都要重新走一遍 CPU→GPU 的上传。场景里材质一多,即使共用同一个 Shader,传统管线也要为每个材质完整做一遍 SetPass。 SRP Batcher 要解决的就是这个:让使用相同 Shader 变体的多个材质共享一次 SetPass。注意它减少的是 SetPass Call,Draw Call 的次数并不变——只是每次 Draw 变得极其廉价。 核心思路:材质数据常驻显存 传统批处理(Static/Dynamic Batching,见 [[性能优化-静态合批]])的思路是合并网格,把多个物体拼成一;另外组织实例数据有固定开销,个位数的实例直接画反而更快。