返回全部文章

Unity 绘制调用从 20,001 次降到 153 次,为什么帧时间几乎没变

在一个包含 20,000 个运动立方体的 Unity 场景中,我们把绘制调用从 20,001 次降到了 153 次。帧时间中位数却只从 8.948 ms 降到 8.709 ms,改善了 0.24 ms。

第三种模式不再为每个立方体创建 GameObject,而是用 Graphics.RenderMeshInstanced 直接绘制,帧时间降到了 1.678 ms。比较各线程的耗时,可以解释为什么仅开启 GPU 实例化效果不大。不过,直接绘制模式的改善也不能只归因于绘制调用减少。

比较的三种绘制模式

三种模式使用同一个场景:20,000 个立方体排成圆盘,随行进的正弦波上下起伏,相机在周围缓慢旋转。每帧都会更新所有立方体的位置。同一构建类型内,三种模式使用相同的可执行文件,只通过启动参数切换。

  • NAIVE:每个立方体有一个 GameObject 和一个 MeshRenderer,材质关闭 GPU 实例化
  • INSTANCED_RENDERER:保留相同的 GameObject 结构,开启材质的 GPU 实例化
  • INSTANCED_DIRECT:不为每个立方体单独创建 GameObject,由一个脚本通过 Graphics.RenderMeshInstanced 绘制

测量配置如下:

  • Unity 6000.4.11f1、Built-in Render Pipeline 前向渲染、Mono、D3D12
  • RTX 4070 Ti、Core i5-13500、1920x1080 分辨率
  • 所有模式均关闭 vSync 和阴影
  • 关闭动态批处理和静态批处理,并确认各自的批次计数器均为 0

每种模式先预热 15 秒,再测量 120 秒。耗时取自 Release 构建,绘制计数器取自 Development 构建,以免 Development 构建的性能分析开销影响耗时比较。因此,下面两张表来自不同的运行,并不是对同一批帧的同步测量。

除非另有标注,数值均为中位数 p50。帧时间的 p95 表示 95% 的被测帧耗时不超过该值;p95 越高,分布中较慢的帧耗时越长。

在 Release 构建中使用 FrameTimingManager 测量时,请开启 Player Settings 中的 Frame Timing Stats,详见 Unity 的设置说明。数值缺失或为 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

仅开启 GPU 实例化,帧时间中位数几乎没有变化。其他指标也没有一致改善:平均 FPS 从 111.2 降到 109.0,帧时间 p95 从 10.644 ms 升到 12.919 ms,内存从 169.0 MB 增加到 176.6 MB。不能仅凭 p50 的小幅改善,就认定性能稳定提升了。

模式 标准绘制调用 实例化绘制调用 实例批次 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:每个立方体一个 GameObject,截图中的绘制调用为 20,001 次。
同一个立方体圆盘。左上角的叠加信息显示 GPU INSTANCING ONLY、FPS 118、FRAME TIME 8.44 ms、DRAW CALLS 153、OBJECTS 20,000
INSTANCED_RENDERER:GPU 实例化将绘制调用降到 153 次,仍保留每个立方体的 GameObject。
同一个立方体圆盘。左上角的叠加信息显示 RenderMeshInstanced、FPS 654、FRAME TIME 1.53 ms、DRAW CALLS 60、OBJECTS 20,000,下方的横幅上是 No GameObjects: 60 draw calls - 5x FPS
INSTANCED_DIRECT:去掉每个立方体的 GameObject 时也改变了渲染路径。横幅中的“FPS 五倍”描述的是截图比较,并非单独去掉 GameObject 的收益。

如何解读各线程的耗时

NAIVE 的游戏线程耗时为 8.920 ms,几乎等于 8.948 ms 的帧时间;渲染线程为 0.480 ms,GPU 为 0.909 ms。这里的“游戏线程”指 Unity 主线程。这些时间会重叠,不能相加来计算帧时间;各指标的 p50 也不代表某一个具体帧的耗时明细。

这组比较表明,应该先检查主线程。INSTANCED_RENDERER 的结果也支持这个方向:绘制调用大幅减少后,游戏线程仍需 8.680 ms。渲染线程从 0.480 ms 变成 0.519 ms,都在 0.5 ms 左右。GPU 时间从 0.909 ms 降到 0.703 ms,却没有带来相应幅度的帧时间改善。

在自己的场景中,可以用这些时间来选择下一步检查的位置:

  • 主线程时间接近帧时间:检查脚本、Transform 更新,以及主线程上的渲染准备工作
  • 渲染线程时间接近帧时间:检查绘制命令提交和渲染状态切换
  • GPU 时间接近帧时间:检查着色器、过度绘制和渲染通道等 GPU 工作

这些是排查方向,不是按最大值就能下的诊断。帧率限制、呈现等待和线程同步都会影响判断。时间字段的定义见 Unity 的 FrameTiming 文档,具体开销还要在 Profiler 的 Timeline 中确认。主线程耗时并不全是游戏逻辑,减少绘制调用也可能影响多个线程。

不再为每个立方体创建 GameObject 后,哪些工作变了

INSTANCED_DIRECT 不再为每个立方体创建 GameObject、Transform 和 MeshRenderer,而是由一个脚本每帧更新矩阵数组并提交绘制。这个基准测试把 20,000 个矩阵按每组 1,023 个拆分,因此每帧调用 C# API 20 次。API 调用次数与表中的绘制计数器并不一一对应。

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);
    }
}

1,023 是本测试使用的分组大小,不是适用于所有配置的批处理规则。Unity 给出的上限为 1,023 个实例,但实际限制取决于实例数据和着色器设置;默认的双矩阵布局允许 511 个实例。改用这段代码前,应查阅 RenderMeshInstanced API 文档。

游戏线程耗时降到了 1.668 ms,而 NAIVE 为 8.920 ms,INSTANCED_RENDERER 为 8.680 ms。这说明值得进一步检查每个立方体对应对象的维护和更新成本。组件处理、Transform 同步和剔除都是候选,但这些汇总耗时无法测出各项分别贡献了多少。

直接绘制模式同时改变了渲染路径,因此本实验不能单独算出“去掉 GameObject 节省的时间”。SetPass Calls 从 153 次降到 2 次,GPU 时间从 NAIVE 的 0.909 ms 降到 0.232 ms。结果反映的是对象表示方式与绘制方式共同变化的效果。

内存从 169.0 MB 降到 56.6 MB,减少约 112 MB。将差值除以 20,000,约为每个立方体 5.6 KB,但这不是一个 GameObject 的通用内存成本。SDK 使用的 Profiler.GetTotalAllocatedMemoryLong 返回 Unity 内部分配器正在使用的内存,不是整个进程的内存占用。

直接绘制模式仍为 20,000 个 Matrix4x4 分配了约 1.28 MB。三种模式都绘制 240,002 个三角形,因此内存下降并不需要减少画面中的几何体。不过,仅凭三角形数量相同,也无法确定究竟减少了哪些内存分配。

用 Framedash 比较运行结果

在本次实验中,Framedash 负责采集耗时并比较运行结果。Unity SDK 在 perf_heartbeat 事件中发送 frame_time_ms、game_thread_ms、render_thread_ms 和 gpu_time_ms。我们为每种模式设置不同的 BeginAutomatedSession build_id,再用 CLI 比较 NAIVE 与 INSTANCED_DIRECT。

运行前,将两个变量设置为对应的构建 ID:

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%”

这些数值与表格不同,因为聚合的样本不同。预热后的 120 秒自动会话中,服务端合并了每秒发送的事件和每 10 秒发送的 perf_heartbeat,每种模式共 133 个样本。表格使用的则是逐帧样本。上面的数值精度保留了当时的服务端输出。

发布前也可以从 CI 执行这个比较,但抽样数据的 p50 不能代替逐帧的尾部延迟分析。判定只针对所比较的指标和阈值,不会确定瓶颈,也不代表每一帧都变快了。

这个实验能说明什么

这是在一种软硬件配置上运行的一种工作负载,而且每个立方体每帧都在移动。它说明:在本测试中,仅开启 GPU 实例化对帧时间中位数帮助很小,而直接绘制模式显著更快。它不能预测大部分对象静止,或渲染成本不同的场景能改善多少。

移动设备、其他图形 API、使用 SRP Batcher 的 URP 或 HDRP、IL2CPP,以及阴影,都可能改变耗时比例。绘制计数器的逐帧波动也很大,因此这里报告中位数,不依赖最小值或最大值。现有结果没有给出重复运行之间的波动范围;在把 0.24 ms 这样的微小差异视为稳定收益前,应先重复测量基线。

减少绘制调用之前,先做三项检查

  1. 在有代表性的构建中采集有效的主线程、渲染线程和 GPU 耗时,并检查帧率限制与等待时间
  2. 在 Profiler 中定位耗时较高的工作。主线程耗时高,意味着该检查对象更新,并不意味着应该直接删除 GameObject
  3. 尽量一次只改变一个可能的原因,重复相同工作负载,比较帧时间中位数、较慢的帧和内存