“减少绘制调用就能变快”,是 Unity 优化里最先被提起的一句话。我在一个让 20,000 个立方体上下起伏的场景里,把它真的做了一遍。绘制调用从 20,001 次减到了 153 次。可帧时间只从 8.948 ms 变成 8.709 ms,p50 上差 0.24 ms。差到这个程度,跑起来也看不出来。
把同样的画面改成不为每个立方体创建游戏对象的画法,帧时间变成了 1.678 ms。换成 FPS 就是从 111.8 到 596.0。这时的绘制调用是 60 次。
153 次和 60 次,从 20,001 次的角度看都一样小。可一边停在 8.709 ms,另一边变成了 1.678 ms。造成这个落差的,不是绘制调用的次数。
测量的三种模式
场景是同一个。20,000 个立方体排成圆盘状,叠加一道行进的正弦波。每帧改写全部 20,000 个的位置,相机绕着它慢慢转。不同的只有两点:立方体的画法,以及是否为每个立方体各带一个游戏对象。三种模式的可执行文件相同,只改启动参数。
- NAIVE:每个立方体各带一个游戏对象和一个 MeshRenderer,材质的 GPU 实例化关闭
- INSTANCED_RENDERER:游戏对象的构成和 NAIVE 相同,只有材质的 GPU 实例化是打开的
- INSTANCED_DIRECT:不为每个立方体创建游戏对象,从一个脚本用
Graphics.RenderMeshInstanced绘制的方式
测量环境是 Unity 6000.4.11f1、Built-in Render Pipeline(前向渲染),脚本后端是 Mono,图形 API 是 D3D12。GPU 是 RTX 4070 Ti,CPU 是 Core i5-13500,分辨率 1920x1080,vSync 关闭,阴影在所有模式下都关掉。动态批处理和静态批处理都显式关闭,并确认批次的计数器为 0。每种模式都先预热 15 秒再测量 120 秒。下面的数值如无特别说明都是 p50。
在 Unity 里,如果不在 Player Settings 中打开 Frame Timing Stats,渲染线程和 GPU 时间就一直是 0。这次的区分,全靠这一个复选框。
结果
| 模式 | FPS | 帧时间 | 帧时间 p95 | 游戏线程 | 渲染线程 | GPU 时间 | 内存 |
|---|---|---|---|---|---|---|---|
| NAIVE | 111.8 | 8.948 ms | 10.644 ms | 8.920 ms | 0.480 ms | 0.909 ms | 169.0 MB |
| INSTANCED_RENDERER | 114.8 | 8.709 ms | 12.919 ms | 8.680 ms | 0.519 ms | 0.703 ms | 176.6 MB |
| INSTANCED_DIRECT | 596.0 | 1.678 ms | 2.774 ms | 1.668 ms | 0.091 ms | 0.232 ms | 56.6 MB |
绘制计数器的明细是这样的。
| 模式 | 标准绘制调用 | 实例化绘制调用 | 实例批次 | SetPass Calls | 三角形 |
|---|---|---|---|---|---|
| NAIVE | 20,001 | 0 | 0 | 153 | 240,002 |
| INSTANCED_RENDERER | 1 | 152 | 149 | 153 | 240,002 |
| INSTANCED_DIRECT | 20 | 40 | 59 | 2 | 240,002 |
本文里写的“绘制调用”,指的是标准和实例化两栏之和。NAIVE 是 20,001 次,INSTANCED_RENDERER 是 1 + 152 共 153 次,INSTANCED_DIRECT 是 20 + 40 共 60 次。SetPass Calls 那一栏里也排着 153 这个数字,但它是另一个指标。NAIVE 的 20,001 次里有 20,000 次是立方体的份,剩下的 1 次是立方体以外的绘制。
三种模式的画面并排放在下面。
叠加信息里的数字,是截图那一瞬间某一帧的值,和表格里的 p50 对不上。
关于 INSTANCED_RENDERER,不好看的数字也一并列出来。FPS 在 p50 上从 111.8 涨到了 114.8,看平均值却是从 111.2 降到 109.0。帧时间的 p95 从 10.644 ms 恶化到 12.919 ms。内存也从 169.0 MB 增加到 176.6 MB。把绘制调用从 20,001 次减到 153 次换来的,是 p50 上的 0.24 ms。
瓶颈不是渲染线程,而是游戏线程
答案从一开始就写在 NAIVE 的明细里。帧时间是 8.948 ms。与之相对,并行运行的三项明细是:游戏线程 8.920 ms,渲染线程 0.480 ms,GPU 时间 0.909 ms。这里说的游戏线程,就是在 Unity 的 Profiler 里显示为主线程的那一条。堆绘制调用是渲染线程的工作。就算能把渲染线程压到 0 ms,帧时间也还是由游戏线程的 8.920 ms 决定。
这份明细的读法很简单。最接近帧时间的那个值,就是决定这一帧的地方。
- 游戏线程和帧时间几乎相等:瓶颈在 CPU 的逻辑一侧
- 渲染线程逼近帧时间:瓶颈在 CPU 的绘制一侧
- GPU 时间逼近帧时间:瓶颈在 GPU
削减绘制调用最容易见效的是第二种。这个场景属于第一种,游戏线程的 8.920 ms 和帧时间的 8.948 ms 几乎一致。
INSTANCED_RENDERER 就是为了验证这种读法才测的模式。标准绘制调用从 20,001 次掉到 1 次,取而代之的是 152 次实例化绘制调用和 149 个实例批次。渲染线程则从 0.480 ms 变成 0.519 ms,反而略有增加。保留游戏对象的两种模式里,渲染线程都在 0.5 ms 上下没有动过。在 D3D12 的这个配置下,发起绘制调用还不是能影响到帧时间的负担。GPU 时间那边则从 0.909 ms 降到了 0.703 ms。它确实起了作用,但 GPU 本来就不是瓶颈,所以体现不到帧时间上。
真正起作用的,是不再创建 20,000 个游戏对象
INSTANCED_DIRECT 改的不只是绘制调用的发出方式,还有场景的构成本身。它不为每个立方体创建游戏对象、Transform 和 MeshRenderer,而是每帧改写一个矩阵数组,从一个脚本里画出来。Graphics.RenderMeshInstanced 一次调用最多只能画 1,023 个实例,所以要把 20,000 个切成每份 1,023 个再传进去。调用会变成 20 次,但计数器上的明细和调用次数并不是一一对应的。
private void RenderInstanced()
{
for (int start = 0; start < _objectCount; start += MaxInstancesPerCall)
{
int chunk = Mathf.Min(MaxInstancesPerCall, _objectCount - start);
Graphics.RenderMeshInstanced(_renderParams, _cubeMesh, 0, _matrices, chunk, start);
}
}
游戏线程在 NAIVE 里是 8.920 ms,在 INSTANCED_RENDERER 里也有 8.680 ms。它变成了 1.668 ms。消失的是那 20,000 个游戏对象每帧都要索取的 CPU 时间。组件的更新、按游戏对象进行的剔除、Transform 的同步,就是它的内容。
不过,这个模式同时改了两件事。游戏线程从 8.680 ms 掉到 1.668 ms 的原因,我们主要读作游戏对象那一侧的贡献,因为保留游戏对象、只换绘制路径的 INSTANCED_RENDERER 并没有让游戏线程动起来。话虽如此,仅凭这三种模式还不能把两个因素完全分开。SetPass Calls 从 153 次降到 2 次、GPU 时间从 NAIVE 的 0.909 ms 降到 0.232 ms,是绘制路径那一侧的效果,和有没有游戏对象是两回事。
内存的减少,同样是不再创建游戏对象的结果。从 169.0 MB 到 56.6 MB,少了 112 MB。折算下来一个游戏对象约 5.6 KB,但这个值只是把差值除以个数得到的。SDK 送出的是整个进程的已分配内存(Profiler.GetTotalAllocatedMemoryLong)。INSTANCED_DIRECT 这一侧还为 20,000 个 Matrix4x4 新分配了约 1.28 MB。即便如此,这 112 MB 也不是网格、顶点这类绘制数据减少的部分。三角形在三种模式下都还是 240,002 个。
分辨自己的场景属于哪一种
要看的只有帧时间和三项明细。确认游戏线程、渲染线程、GPU 时间里哪一个最接近帧时间。Framedash 的 Unity SDK 会把这四个值作为 perf_heartbeat 自动送出(frame_time_ms、game_thread_ms、render_thread_ms、gpu_time_ms)。
这次测量里,Framedash 负责的就是这份收集和比较。按模式改变 BeginAutomatedSession 的 build_id,把三次运行区分开,再用 CLI 取差分。
framedash perf-diff --baseline <NAIVE 的 build_id> --candidate <INSTANCED_DIRECT 的 build_id> \
--threshold 5 --fail-on-regression
返回的差分是这样的。
- 帧时间 p50:从 9.011 ms 到 1.701 ms(-81.12%)
- GPU 时间:从 0.9257 ms 到 0.2406 ms(-74.00%)
- 内存:从 175.7 MB 到 59.4 MB(-66.20%)
- 样本数各 133,判定是“No performance regression beyond 5%”
和上面表格里的 p50 稍有出入,是因为聚合的样本不同。服务端聚合的是预热之后的 120 秒自动会话里每秒送出的事件,加上 SDK 每 10 秒送出的 perf_heartbeat。这样每种模式就是 133 个样本。表格里的 p50 则是逐帧取到的值。上面照搬了服务端的输出,所以小数位数也和表格不同。从 CI 里敲同一条命令,就能在发布前以这个粒度确认构建之间的差异。
这次的结果适用到什么范围
到这里为止的数值,都来自一个配置上测到的一种工作负载。D3D12、Built-in Render Pipeline、Mono、桌面 GPU 这些前提一变,明细的比例也会跟着变。在驱动开销更大的移动端图形 API、SRP Batcher 生效的 URP 或 HDRP、IL2CPP 构建上,渲染线程的占比不保证和这次一样。打开阴影会增加渲染通道,一次绘制调用的分量也会变。
测量的做法本身也有几点要注意。绘制计数器的值取自 Development 构建,时间的值取自 Release 构建,为的是不把性能分析器的开销混进时间那一侧。计数器逐帧的波动很大,最小值和最大值不可靠。计数器只放了 p50,就是这个原因。这还是一个每帧改写全部 20,000 个位置的工作负载,往自己的场景上套之前也请先确认这一点。
先测量,再削减
削减绘制调用最容易见效的时候,是渲染线程逼近帧时间的时候。先测过,就能在动手之前判断:削减绘制调用这件事,是不是像这次一样只值 0.24 ms。如果游戏线程和帧时间几乎相同,下一个该怀疑的就是游戏对象的数量。
顺序是这样的。先打开 Frame Timing Stats。再把帧时间和三项明细排在一起,看哪一个最接近。削减是这之后的事。
- 测量中用到的指标定义整理在数据模型里。
- SDK 的接入步骤在 Unity SDK 指南里。