正文
Unity 出现掉帧,应该先改 Update,还是先降 DrawCall?Profiler 中 BehaviourUpdate、Camera.Render、Canvas.BuildBatch、Physics.Simulate 很高,又分别意味着什么?
这篇文章把 CPU 优化串成一条排查路线:先判断帧预算和关键线程,再找到具体工作,最后通过减少工作量、降低频率、错峰与分帧控制峰值。示例用于解释实现方法,不包含未经测量的性能收益。
本文根据学习笔记整理,API 和工具说明参考 Unity 6.0 官方文档。不同 Unity 版本、渲染管线和平台的 Profiler 标记可能不同;UI 部分特指 UGUI,物理示例使用 3D Physics。代码为教学示例,需要放入自己的 Unity 项目验证。
一、从帧预算开始,而不是从优化口诀开始
帧率目标决定了每帧可用时间:
| 目标帧率 | 一帧预算 |
|---|---|
| 30 FPS | 约 33.33 ms |
| 60 FPS | 约 16.67 ms |
| 120 FPS | 约 8.33 ms |
计算方式是 1000 / FPS。这个预算要覆盖一帧的关键路径,不能全部留给脚本。CPU 与 GPU 可以流水执行,多线程也有重叠,不能把各线程耗时简单相加来推算帧率。
首先区分两类问题:
- 持续慢:每帧都有大量脚本、渲染准备或物理计算,优先减少稳定开销。
- 偶发尖峰:某一帧集中生成对象、刷新 UI、同步加载或计算路径,优先定位触发事件和工作聚集。
建议固定设备、分辨率、画质、场景和操作路线,先在目标设备的 Development Build 上采集。Editor 适合快速定位,但它自身的工作会影响测量,不应直接把编辑器数据当作发布版本表现。[1]
二、Main Thread 很长,先分清计算与等待
1. Timeline 看关系,Hierarchy 看调用
我的排查顺序是:
- 选中一段异常帧,同时保留相邻正常帧作对照。
- 在 Timeline 看 Main Thread、Render Thread 和 Worker 的时间关系。
- 找到关键路径上的长区段,再切到 Hierarchy / Raw Hierarchy 展开具体调用。
- 结合调用次数、单次成本和 GC Alloc,决定需要减少哪部分工作。
Hierarchy 会合并相同调用;Raw Hierarchy 便于查看各次独立样本。几个常用列的含义如下:[1]
| 列 | 如何使用 |
|---|---|
| Time ms / Total | 包含子样本,适合找开销大的系统入口 |
| Self ms / Self | 排除已记录的子样本,继续判断本层在做什么 |
| Calls | 区分一次昂贵调用和大量便宜调用的累积 |
| GC Alloc | 找本帧的托管分配来源,不等于本帧发生了 GC |
Self 高只表示未被子样本覆盖的时间长,不自动证明它全在执行计算。 等待、同步或尚未插入标记的工作,也可能落在这里。
2. 不要把 Wait 当作需要删除的业务逻辑
| 常见标记 | 下一步检查 |
|---|---|
WaitForTargetFPS |
帧率上限、VSync、父标记与目标平台的帧节奏 |
Gfx.WaitForPresentOnGfxThread |
同一时段渲染线程在准备命令,还是等待呈现;再看 GPU 时间 |
Gfx.WaitForCommands |
渲染线程是否在等主线程供给命令 |
JobHandle.Complete 等同步区段 |
主线程是否过早等待任务;Worker 上任务什么时候结束 |
前两类等待可能与 GPU、VSync 或目标帧率有关。仅凭一个节点的名字,不能判断“CPU 脚本太慢”或“GPU 一定太慢”;应把线程时间线和 GPU/平台工具的证据放在一起看。[2]
例如,60 FPS 场景中业务工作只用了 6 ms,之后等待下一次呈现,这是完全不同于脚本计算用了 22 ms 的情况。这里的数字只是解释用的假设。
三、BehaviourUpdate 高:找到业务热点
BehaviourUpdate 一类节点通常是脚本更新的汇总入口,不能直接拿它当成最终原因。继续展开后,才可能看到敌人感知、背包刷新、技能搜索等具体函数。协程恢复的位置取决于其执行阶段和 yield 类型,不应把所有协程都归到同一个 Update 节点。[2][10]
按下面的顺序找问题:
- 调用太多:几千个对象每帧轮询同一个条件,或每个对象都搜索整张场景。
- 单次太重:一次完整 A*、全量排序、复杂遍历或同步资源加载。
- 分配太多:热路径创建列表、字符串、闭包,或使用会产生临时结果的查询。
- 联动太多:一个数值赋值触发事件、文本更新、布局和批次重建。
GetComponent、LINQ、事件都不是脱离上下文的禁用项。先证明它们在热点上、执行足够频繁,再选择缓存、复用或更直接的循环。
用 ProfilerMarker 标记真正关心的工作
下面的脚本演示标记感知扫描,并把原本可能每帧执行的工作降到约每 0.2 游戏秒一次:[3]
1 | using Unity.Profiling; |
Auto() 的作用域保证退出时结束标记。标记应围住可解释的业务阶段,不必给每一行都加一层。这个示例仍有每对象 Update,解决的是昂贵扫描的执行频率;当回调数量本身成为问题时,再考虑统一调度。
短时间启用 Deep Profile 可以辅助找到调用细节,但其插桩会增加开销。找到热点后,回到普通采样和自定义标记确认,不用 Deep Profile 的耗时直接作为最终收益。[1][3]
四、大量 Update:先减少工作,再统一调度
可以按这个顺序处理:[4]
| 方法 | 适合的问题 | 代价或边界 |
|---|---|---|
| 删除空 Update | 没有实际工作的回调 | 确认没有继承或生成逻辑依赖 |
| 缓存引用和结果 | 重复查找相同对象或重复计算 | 对象失效、场景切换时更新缓存 |
| 事件驱动 | 血量、任务、背包等变化频率低的数据 | 明确订阅与退订生命周期 |
| 降频与错峰 | 感知、距离评估、低频状态判断 | 接受更长响应时间,避免同相触发 |
| 距离与重要度分组 | 远处、不可见或非关键单位 | 关键规则不能因分组而失效 |
| Update Manager | 大量分散回调与统一预算需求 | 业务总量不会因为换入口自动减少 |
把 1000 个 Update() 挪进一个 Manager,然后每帧照样调用 1000 次业务函数,并不意味着计算量消失。Manager 的价值还在于统一决定“谁需要更新、多久更新一次、这一帧做多少”。
协程适合把可拆分流程跨帧推进,但同步计算仍发生在主线程。一个循环只在末尾 yield return null,前面的重计算仍会阻塞这一帧。[10]
五、Camera.Render 高:检查 CPU 的渲染准备
渲染也会消耗 CPU:剔除、排序、准备状态、提交绘制,以及处理多个相机和额外渲染流程。
Camera.Render 常见于 Built-in 管线相关分析;URP、HDRP 和自定义 SRP 中要沿实际 Render Pipeline 节点展开,不强行寻找同名入口。[2]
建议逐项做对照实验:
- 暂停非必要相机、反射相机或小地图相机,观察对应 CPU 区段是否下降。
- 缩小相机剔除范围,检查重复可见对象和额外阴影绘制。
- 用 Frame Debugger 查绘制事件、材质状态与合批中断位置。
- 结合 CPU Profiler 与 GPU 时间,判断成本发生在提交侧还是 GPU 执行侧。
Frame Debugger 用于理解一帧怎样画出来、状态在哪里变化;精确耗时仍需要 Profiler 或平台计时工具。[5]
SRP Batcher 与 DrawCall 的关系
SRP Batcher 主要减少绘制之间的状态准备成本,不保证 DrawCall 数下降。 它依赖兼容的 Shader 等条件;不能只看 Batches 数字没变,就认定没有效果。[6]
GPU Instancing 等方案也有材质、网格、Shader 和管线限制,应以当前项目兼容性和实测为准。降低分辨率后帧率明显改善可以作为 GPU 压力的线索,但不能替代对关键线程的测量。
六、Canvas.BuildBatch 高:减少 UGUI 的重建范围
UGUI 中,UI 数据变化可能引起布局、顶点或批次相关工作。Canvas.BuildBatch 高时,优先找“是什么变化让哪一块 Canvas 重新参与批次构建”,并结合其他布局、重建标记判断。[7]
典型触发场景是:每帧改文本、动态元素与大量静态元素放在同一个 Canvas、频繁改父子关系,以及大列表不断创建和刷新条目。
按变化频率拆 Canvas
可以把 HUD 组织为:
1 | HUD |
子 Canvas 可以隔离几何与批次工作。划分依据是变化频率和关联性,不能简单地“每个控件一个 Canvas”;拆得过碎可能增加绘制批次。[7]
只在显示值变化时写入文本
下面示例使用 UGUI Text,应在血量初始化、变化或面板重新显示时调用 Refresh:
1 | using UnityEngine; |
如果数值带小数,应比较最终显示精度,而不是让肉眼看不到的变化也刷新文本。事件订阅在启用时建立、禁用时解除;重新打开面板时主动同步一次当前值。
另外三个检查点
- 长列表:采用可见条目复用与虚拟列表,避免为全部数据创建 GameObject。
- 布局:减少热路径上的多层 Layout Group、反复改尺寸和强制立即重建。
- 输入:非交互 Canvas 移除不需要的 GraphicRaycaster,装饰图片和文字关闭 Raycast Target。
输入检测与 BuildBatch 是两条排查线,不能把关闭 Raycast Target 当成批次重建的直接修复。图集也只是合批条件之一,材质、裁剪和绘制顺序仍可能打断批次。[7]
七、Physics.Simulate 高:区分步数与每步成本
1. 先看 Calls,判断是否在补步
Time.fixedDeltaTime = 0.02f 对应每游戏秒约 50 个固定步。它不意味着每个渲染帧只模拟一次;慢帧之后可能集中执行多个固定步。[8]
1 | 某帧脚本或渲染变慢 |
因此,Physics.Simulate 突然升高时,要同时看本帧调用次数和前几帧发生了什么。原始触发原因可能是同步加载,而不是物理配置。[8]
降低物理频率会减少步数,也会改变碰撞与运动的时间分辨率;Maximum Allowed Timestep 限制追赶时间也有模拟语义代价。不能只为了让图表变低就统一修改整个项目。
2. 再降低每一步的不必要工作
| 方向 | 检查内容 |
|---|---|
| 碰撞层 | 用 Layer Collision Matrix 排除不需要的相互碰撞 |
| 碰撞体 | 在满足玩法精度下使用更简单的形状 |
| 刚体 | 检查长期活跃对象、休眠以及无必要的刚体交互 |
| 连续碰撞检测 | 只给确实存在高速穿透风险的对象配置合适模式 |
| 求解与同步 | 检查迭代设置、Transform 与物理状态同步的实际需求 |
| 回调 | 展开碰撞回调,确认业务代码没有反过来成为热点 |
这些方向应根据物理诊断结果逐项验证。[9] Rigidbody Interpolation 改善显示的平滑度,不会增加实际模拟步数或碰撞精度。
3. 查询成本和分配成本分别处理
脚本中的 Raycast、Overlap 等查询不一定都记录在 Physics.Simulate 下,应沿它们实际发生的调用栈排查。
优先减少查询次数、缩短距离、收紧 LayerMask,并做距离等便宜的前置过滤。NonAlloc 类接口用于避免结果数组等分配,不会自动减少同一查询的物理求交工作。
使用复用缓冲区时,只处理返回数量以内的元素。以 RaycastNonAlloc 为例,结果顺序未定义;返回数量等于容量时可能截断,而且不保证保留的是最近命中。需要检测满容量情况,按业务选择扩容重试或安全的降级策略,不能把不完整结果当成完整集合。[11]
修正“目标后方的墙也算遮挡”的写法
若分别向目标层和障碍层发射两条无限长射线,再用 hitTarget && !hitObstacle 判断视线,目标后方的墙也可能被错误算作遮挡。
已知目标点时,只检查眼睛到该点之间的障碍即可:
1 | using UnityEngine; |
这里的 obstacleMask 应仅包含遮挡物,排除观察者与目标自身。方法只回答这两个点之间有无遮挡,距离、视角、目标是否存活等判断应在外层处理;射线起点位于障碍物内部等情况也要根据玩法专门定义。
八、AI 分帧:用响应延迟换峰值下降
假设有 100 个敌人,每帧只处理 20 个,那么一轮需要 5 帧。总工作没有凭空消失,但被摊到了多帧。
| 适合分帧或降频 | 通常需要更及时 |
|---|---|
| 感知刷新、选目标、远处单位决策 | 玩家输入、关键命中与受击反馈 |
| 寻路请求、掩体评分、环境扫描 | 必须遵守固定步语义的物理控制 |
对于稳定队列,可用下面的估算评估延迟:
1 | 一轮帧数 ≈ ceil(对象数 / 每帧处理数) |
这是理想情况下的估算,不是实测。对象增减、帧率波动和各任务成本差异都会改变实际等待时间。要给感知刷新设置可接受的延迟上限,而不是只盯每帧 CPU 图表。
一个处理注册、注销的轮询示例
下面将 Manager 与被调度对象显式关联,避免依赖单例初始化顺序。将两个类分别放入同名 .cs 文件;子类实现 Think,并在 Inspector 中指定场景里的调度器。
AiTickClient.cs:
1 | using UnityEngine; |
AiTickScheduler.cs:
1 | using System.Collections.Generic; |
这里有几个刻意保留的约束:
- 只在主线程调用。 注册只写入待处理列表,注销立即使对象失去资格,业务遍历期间不修改
slots,避免跳项和越界。 - 对象类型明确。 使用
MonoBehaviour子类引用判断 Unity 对象是否已销毁,避免接口引用的判空语义混淆。覆写OnEnable/OnDisable时应调用基类实现。 - elapsed 属于对象自身。 它记录距该对象上次更新的游戏时间;不能直接传本次渲染帧的
Time.deltaTime。重新启用会重置起点,暂停游戏时的行为也要按需求定义。 - 这是数量预算。 一个
Think本身若耗时很长,20 个任务的上限仍不能保证时间预算。可增加时间预算,但单个任务仍需能拆分,才能控制最坏情况。 - 生命周期由场景管理。 调度器应覆盖客户端使用期;更换调度器时需要重新注册。它禁用期间停止调度,恢复后 elapsed 会包含暂停调度的游戏时间。
这个教学实现每帧会清理注册列表,增删使用 List,新增去重也有线性成本;大规模频繁增删应改成索引映射、空槽或其他适合项目的数据结构。是否采用 Manager,最终比较调度开销、业务耗时和响应延迟。[4]
九、寻路:分散请求,并区分同步与异步
Unity NavMesh 的两个常见入口
| API | 调度含义 |
|---|---|
NavMesh.CalculatePath |
同步计算;长路径或同帧大量调用可能拉长当前帧 |
NavMeshAgent.SetDestination |
请求计算路径,结果可能几帧后才可用;期间查看 pathPending |
SetDestination 返回成功表示请求被接受,不保证已经找到完整可达路径。还要在合适时机检查 hasPath、pathStatus 等状态。[12][13]
可以为敌人寻路建立请求队列:目标移动超过阈值才提交;同一单位只保留最新请求;按重要度处理;每帧限制提交量。不要每个 Update 都对相同目的地反复请求。
自己写 A* 时怎么拆
可以保存 Open/Closed 集合与搜索状态,每次只展开限定数量节点,下一帧继续;但“只处理一个寻路请求”不等于控制住耗时,一个特别复杂的请求也可能超预算。
需要后台计算时,把可供线程安全访问的导航数据快照交给工作线程,主线程负责 Unity 对象读取与结果应用。结果返回时检查单位是否仍存在、请求编号是否仍有效,避免旧路径覆盖新目标。
async/await 也不会自动把 CPU 密集计算搬到后台。Task.Run、Job System 或其他并行方案是否适用,取决于数据访问、线程限制和目标平台。不要把包含任意 Unity API 调用的函数直接放到后台。[10]
十、优化完成后,怎么确认有效
复测应使用同一设备、构建配置和场景操作,每次优先只改变一个主要因素。下面是一张记录模板,空白项需要项目实测填写:
| 指标 | 优化前 | 优化后 | 关注点 |
|---|---|---|---|
| 帧时间中位数 / P95 / P99 | 待测 | 待测 | 典型表现和尾部卡顿 |
| 目标业务 marker 的时间与 Calls | 待测 | 待测 | 总量、单次成本、执行频率 |
| GC Alloc 与回收事件 | 待测 | 待测 | 是否引入新的临时分配 |
| 物理模拟 Calls | 待测 | 待测 | 是否仍有补步聚集 |
| AI / 路径队列最长等待 | 待测 | 待测 | 分帧是否突破体验延迟上限 |
| GPU 时间与显示效果 | 待测 | 待测 | 是否把压力转移到了另一侧 |
P95/P99 表示采样帧时间的第 95/99 百分位,样本窗口也应一致。平均 FPS 上升,仍可能同时存在明显尖峰。
实际排查时可以沿用这条路线:
1 | 固定目标与采样条件 |
相关阅读
参考资料
以下为 Unity 官方资料;文中的调度器和业务示例是为说明方法编写的教学实现,并非官方性能基准。
- CPU Usage Profiler module
- Profiler markers reference
- ProfilerMarker API
- Advanced programming and code architecture
- Frame Debugger
- Scriptable Render Pipeline Batcher
- Unity UI optimization tips
- Fixed updates;Physics performance issues
- Optimize the physics system for CPU usage
- Coroutines
- Physics.RaycastNonAlloc
- NavMesh.CalculatePath
- NavMeshAgent.SetDestination