返回全部文章

在 Unity 里把绘制调用从 20,001 降到 153,却没有变快的原因

“减少绘制调用就能变快”,是 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 次是立方体以外的绘制。

三种模式的画面并排放在下面。

排成圆盘状、上下起伏的 20,000 个立方体。左上角的叠加信息显示 NAIVE、FPS 125、FRAME TIME 8.01 ms、DRAW CALLS 20,001、OBJECTS 20,000
NAIVE。每一个立方体各带一个游戏对象,绘制调用是 20,001 次。
同一个立方体圆盘。左上角的叠加信息显示 GPU INSTANCING ONLY、FPS 118、FRAME TIME 8.44 ms、DRAW CALLS 153、OBJECTS 20,000
INSTANCED_RENDERER。GPU 实例化把绘制调用减到了 153 次,但 FPS 和 NAIVE 几乎一样。
同一个立方体圆盘。左上角的叠加信息显示 RenderMeshInstanced、FPS 654、FRAME TIME 1.53 ms、DRAW CALLS 60、OBJECTS 20,000,下方的横幅上是 No GameObjects: 60 draw calls - 5x FPS
INSTANCED_DIRECT。下方的横幅可以读作“不用游戏对象、60 次绘制调用、FPS 五倍”,但这个模式在去掉游戏对象的同时也换了绘制路径。FPS 的差距,并不只是去掉游戏对象带来的结果。

叠加信息里的数字,是截图那一瞬间某一帧的值,和表格里的 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_msgame_thread_msrender_thread_msgpu_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。再把帧时间和三项明细排在一起,看哪一个最接近。削减是这之后的事。