做 Unity 项目这些年,我听到最多的技术诉求大概就是“卡”。游戏卡、数字孪生卡、UI 卡、场景切换卡,甚至连编辑器里挪个窗口都有人问我为什么卡。但每次我追问一句“你说的卡,是帧率掉了,还是操作响应慢,还是加载时卡住不动?”,大部分人都会愣一下,然后说:就是……卡啊。
其实“卡”这个字背后包含了太多完全不同的技术问题。在做任何优化之前,先得把卡顿这件事度量清楚,否则你连敌人是谁都不知道,优化就成了一场玄学。这篇《Unity 卡顿·帧率保卫战》系列的第一篇,我不打算聊任何具体的优化技巧,而是先把最底层的工作做扎实:帧率、帧时间、卡顿数据采集与剖析思路。适合刚接手 Unity 项目性能优化的同学,也适合被“优化一下卡顿”这种需求反复轰炸,却没有一套度量方法的技术负责人。
1. 先把“卡”定义清楚:帧率、帧时间与卡顿的真实关系
1.1 平均帧率 60,为什么还是觉得卡
很多人汇报性能时只给一个平均帧率:“我项目稳定 60 帧。”但用户体验并不是由平均帧率决定的。举一个我很常见到的例子:在一次 10 秒测试里,大部分时间每帧消耗都不到 20ms,但场景切换或某个敌人出现的瞬间产生了 250ms 的帧尖刺,算下来平均帧率还是有 50 多。如果只看平均值,问题会被彻底掩盖,玩家却会在那瞬间真实地“卡”一下。
帧率(FPS)本质是每秒渲染多少帧,它是帧时间的倒数。问题在于,FPS 在玩家端是一个实时变化的瞬时值,而我们汇报性能时往往算的是平均值,平均值最容易抹平尖刺。A 项目帧时间恒定为 16.7ms,B 项目多数时候 16.7ms、但每 3 秒出现一次 100ms 尖刺,大概率 B 项目用户更明显地感觉到卡。也就是说,帧率平均值高并不等于流畅,帧率平均值低也不代表到处都卡。
这也是为什么我在做性能评审时,要求团队必须提供帧时间曲线截图,而不是只给一个平均 FPS。没有曲线,就没有讨论卡顿的基础。
1.2 帧时间与帧预算:16.67ms 这道坎
帧时间就是渲染一帧所花的时间。目标 60 帧每秒,单帧预算 1000 / 60 ≈ 16.67ms;目标 30 帧每秒,预算是 33.33ms。注意这是理论峰值,实际还要留给系统、驱动、垂直同步等余量,所以业界常说 60 帧的目标预算按 15ms 留才安全。
不同平台的帧预算很不一样。移动端很多休闲项目仍以 30 帧为目标,动作游戏和高帧率手游做到 60 甚至 90 帧,到了 VR 一体机设备上,PICO 和 Quest 这类平台常见 72Hz、90Hz、120Hz,单帧预算分别是 13.89ms、11.11ms、8.33ms。之前有个 PICO 项目,需求是 90Hz 不掉帧,也就意味着每个逻辑帧加渲染帧,超过 11.1ms 就算一次可见掉帧,优化空间比 60 帧项目紧张得多。
| 目标帧率 | 单帧预算 | 实际建议预留 | 常见场景 |
|---|---|---|---|
| 30 FPS | 33.33ms | 30ms | 普通移动端 UI 应用、低配手机游戏 |
| 60 FPS | 16.67ms | 15ms | 主流手游、桌面工具 |
| 90 FPS | 11.11ms | 10ms | VR 一体机(PICO/Quest 等) |
| 120 FPS | 8.33ms | 7.5ms | 高刷移动端、部分桌面应用 |
这里的预留意思是:当帧时间超过预算的那一瞬间,就是用户感知到的掉帧或卡顿。提高帧率的本质,不是让 FPS 数字变大,而是让帧时间稳定在预算线以下。
1.3 P50、P95、P99:用百分位数量化卡顿
FPS 平均值不靠谱,那用什么?业界通用做法是看帧时间百分位数。把所有采集到的帧时间从小到大排序,第 50 百分位叫 P50,代表普通水平;第 95、第 99 百分位代表最差的那 5% 和 1% 帧。
例如某项目测出 P50 为 16ms,P95 为 32ms,P99 为 250ms。含义是:一半帧稳定在 16ms 左右,表现不错;但有 1% 的帧耗时 250ms,用户感知就是“顿了一下”。如果不量 P99,光看 P50 会觉得项目很健康。另一个常用指标是 Jank(卡顿帧频率),业界常把单帧耗时超过特定阈值(如 50ms)的帧密度作为衡量标准,比如“每 10 分钟出现 2 次 100ms 的尖刺”,这比“平均 60 帧”更能反映主观流畅度。
所以,当我们说“把卡顿度量清楚”,第一步就是把项目里帧时间统计的 P50、P95、P99 打出来,最好画成曲线,而不是只填一个平均 FPS 到日报里。
2. 卡顿发生在哪条链路:CPU、GPU 与线程拆分
2.1 主线程、渲染线程和工作线程在忙什么
Unity 应用运行时不是一个线程从头跑到尾。主线程负责 Update、协程、物理(部分)、UI 逻辑、动画回调;渲染线程负责把主线程提交的渲染指令转换为 GPU 指令;另外还有工作线程处理 Job System、部分物理和资源加载等。卡顿度量时首先要问:是哪条线程爆了?
Profiler 的 CPU Usage 模块会把各线程的耗时列出来。主线程耗时高,多半是逻辑、物理、GC 分配、UI 重建等;渲染线程耗时高,则要看 DrawCall 提交、Canvas 重建、Shader 状态切换;GPU 耗时高,再看填充率、后处理、实时阴影等。
很多新手只看主线程的耗时,忽略了渲染线程,这样定位不到渲染侧的卡顿。正确做法是把每一帧里主线程、渲染线程、GPU 的耗时都记录下来,才能判断瓶颈。没有线程维度,你说“卡”别人只能猜。
2.2 CPU 瓶颈还是 GPU 瓶颈,先做一次粗判
有个很简单很粗暴的判断方式:在真机或编辑器中把分辨率调低一档,或者把后处理关闭,如果帧率大幅提升,很大概率是 GPU 压力过大;如果帧率几乎没变,说明瓶颈在 CPU 侧,因为 CPU 兜底的计算没有变。更低级的方案是直接看 Profiler 里的 GPU 模块,但像青涩项目里没接 Frame Timing 的情况,先做分辨率测试是最快的。
更准确的判断要看计时工具。Unity 编辑器里用 Profiler 的 CPU Usage 模块看主线程耗时,再配合 Frame Timing 或 GPU Usage 模块看 GPU Busy 时间。移动端还可以用厂商的工具,例如高通和联发科各自的性能分析器,或者直接在 Unity Profiler 里看 Rendering Profiler。
GPU 卡顿在帧时间曲线上通常表现为一整段高耗时,而不是单帧尖刺;CPU 侧的资源加载、GC 则更多表现为单帧尖刺。这个特征可以作为定位的第一印象。
2.3 热点方向:阴影、UI、后处理与网络同步
热词里有很多都指向具体场景。比如 Unity 阴影问题,实时阴影尤其是方向光阴影,每帧都要额外渲染一张深度图,再按阴影距离裁剪,阴影距离调太远或分辨率太高,GPU 负载直接上涨,帧时间曲线会呈现典型的持续偏高。
UI 界面卡顿更是 Unity 项目里的老大难。一个常见原因是 Canvas 频繁重建:只要 Canvas 里任何 UI 元素的尺寸、位置、文本内容发生变化,整个 Canvas 往往要重新生成网格。如果界面操作时帧时间突然从 16ms 跳到 50ms,第一反应就是看 Canvas 的 build 耗时。
网络帧同步卡顿则是另一种“卡”:帧同步逻辑里某帧在多玩家同步时等待了数据返回,帧时间被拉高,表现类似卡顿,但根源在网络延迟和同步策略,而不是渲染。所以度量的第一步不是拉开代码改,而是先判断这类卡顿是渲染、逻辑还是网络导致的。
3. 建立第一份性能基线:Profiler 实战操作
3.1 测什么场景才有参考价值
性能基线的核心是“在相同条件下反复测量”。我见过太多项目拿编辑器里的空场景说“跑得多快”,拿真机复杂场景说“太卡”,两边不是一个量级,结论自然没有意义。
建基线建议选三类场景:一是项目的主流程,比如从启动到主城;二是复杂度最高的场景,通常是战斗、多人同屏、复杂 UI;三是目标设备覆盖的中低配机。每个场景固定跑 2-3 分钟,覆盖常规操作,避开测试刚开始的 Shader 编译和资源加载窗口。记录下帧时间中位数、P95、P99,以及 GC 分配量、DrawCall 数、三角形面数等关键指标。
还有个小建议:基线场景里的人工操作路径最好录制成固定流程,例如每 10 秒开一次背包、每 30 秒发动一次技能。手点虽然有随机性,但只要大致固定,就已经比随机乱点强很多。
3.2 编辑器 Profiler 的正确打开方式
Unity 编辑器里 Window > Analysis > Profiler 是老牌入口。打开后默认是 CPU Usage 模块,用鼠标框选帧时间曲线里的尖刺区域,就能看到每一帧内部各模块的耗时。但编辑器 Profiler 有几个天然不准的地方:编辑器渲染走的是桌面显卡驱动,和真机 GPU 差别很大;Shader 编译方式不同;AssetBundle 加载路径可能不完整。所以编辑器数据只能做相对分析,不能直接代表真机。
另一个要注意的是 Deep Profile 选项。它会把每个函数的调用都插桩,信息很全,但性能开销极大,实测可能让帧时间翻几倍。用它定位调用栈可以,但绝对不要开着它去采集性能基线,否则你会拿一把放大镜当体温计用。
3.3 真机 Profiling:Android/iOS/PICO 的接法
真机采集的意义在于拿到真实硬件上的帧时间。以 Android 为例:
- 手机开启开发者选项和 USB 调试,连接电脑。
- Unity Build Settings 里勾选 Development Build、Autoconnect Profiler。
- 构建安装包并运行在真机上。
- 打开 Unity Profiler,等待自动连接设备。
- 跑测试流程 2-3 分钟,保存 Profiler 数据。
iOS 通过 Xcode 或 Unity 的 Player Connection 连接。PICO/Quest 这类一体机也支持无线连接,但我建议优先用 USB 或有线网络,无线 Profiling 本身会占用通信带宽,对帧时间数据有一定干扰。
要注意的是,Development Build + Profiler 跑出来的数据会略高于正式发布包,因为 Profiler 本身有开销。这套方式适合定位问题、对比相对变化;如果要验证最终线上性能,必须发布包再单独采集一次 FPS 和帧时间日志。
3.4 记录并保存“卡顿档案”
度量结果要存档,不然没法做前后对比。Profiler 窗口右上角可以把当前数据保存为 Profiler 文件,文件名建议带上项目名称、平台、场景、日期。同样建议把帧时间统计的 P50、P95、P99 写进自动化脚本或表格,后续每次优化前后都跑同一套流程,对比同一组指标。
我在实操里还有一个习惯:每一轮优化只改一个变量,录一次基线,严禁同时改 5 个点然后看整体效果,因为那样根本不知道是哪一步产生了收益。这也是很多团队优化做了半天,最后无法验证结论的原因。
4. 把卡顿变成可量化指标:帧时间采样与交叉验证
4.1 用 ProfilerRecorder 自己采集帧时间
从 Unity 2019.3 开始,官方提供了 ProfilerRecorder API,可以在运行时以低开销采集 Profiler 计数器的采样值。这比在 Update 里自己用 Time.deltaTime 求均值更规范,也不需要额外安装工具。
using UnityEngine; using Unity.Profiling; public sealed class FrameStats : MonoBehaviour { private ProfilerRecorder frameTimeRecorder; private ProfilerRecorder mainThreadRecorder; private void OnEnable() { frameTimeRecorder = ProfilerRecorder.StartNew(ProfilerCategory.Internal, "Frame Time"); mainThreadRecorder = ProfilerRecorder.StartNew(ProfilerCategory.Internal, "Main Thread"); } private void Update() { if (frameTimeRecorder.Valid && frameTimeRecorder.Count > 0) { float frameMs = frameTimeRecorder.LastValue * 1e-6f; Debug.Log($"[Stats] FrameTime={frameMs:F2}ms"); } } private void OnDisable() { frameTimeRecorder.Dispose(); mainThreadRecorder.Dispose(); } }这段代码里,ProfilerRecorder.LastValue 的单位是纳秒,所以乘 1e-6 转成毫秒。只要把它挂到一个长期存在的 GameObject 上,就能持续输出帧时间。生产环境里更推荐把采样值每 60 帧汇总一次,算平均、P95、P99,然后走日志或埋点上报,而不是每帧打一条 Debug.Log,否则 Debug.Log 本身就会成为性能瓶颈。
4.2 从帧时间尖刺反推可疑模块
拿到帧时间曲线后,优化思路就从“感觉卡”变成了“这一帧 80ms,去查这一帧里到底发生了什么”。查的方法是把帧时间曲线上的尖刺帧在 Profiler 里逐帧展开,看主线程、渲染线程、GPU 各占多少。
常见的尖刺来源包括:GC 分配触发的垃圾回收,Update 里 Instantiate 生成对象并同步触发资源加载,Shader 首个变体编译,AssetBundle 首次加载,Canvas 重建,后端低频逻辑集中执行等。逐个把这些模块的耗时跟帧时间尖刺对齐,就能确定优先优化的对象。
举个实际例子:在 UI 卡顿的场景里,看到 Profiler 中 Canvas.SendWillRenderCanvases 单帧占了 35ms,再结合 Frame Debugger 看 Canvas 包含几个界面,问题就很清楚了。这时候你知道卡顿是 UI 网格重建导致的,而不是去优化技能特效或阴影距离。
4.3 Frame Debugger + Memory Profiler 交叉验证
Profiler 告诉你时间花在哪儿,Frame Debugger 告诉你渲染状态长什么样。针对某一帧打开 Frame Debugger,可以看到完整的 DrawCall 列表,任意选中一个 DrawCall,还能查看画面网格、材质、贴图及各项渲染状态。如果怀疑是渲染卡顿,比起瞎猜,直接在这里排查 DrawCall 数和重复网格。
Memory Profiler 则是用来查资源层面的问题。很多卡顿不是计算量太大,而是内存抖动:一个三秒循环里反复 Instantiate 又重新加载图集或 Prefab,带来大量的 GC 和资源 IO。用 Memory Profiler 抓快照,对比两个相近瞬间托管堆的分配,能快速找出哪些对象在反复“出生-死亡”。
交叉验证的意思是说,一个帧时间尖刺背后往往是多个线索,帧时间定位用了多少 ms,Frame Debugger 看是不是 DrawCall 突然增多,Memory Profiler 看是不是 GC 或资源分配问题,三个工具互相对齐,定位才可靠,不会因为看见一个可疑点就冲上去改。
5. 性能度量防坑指南与排查实录
5.1 编辑器数据不等于真机数据
编辑器里跑得飞快不代表真机没问题。原因包括但不限于:编辑器使用主机显卡驱动,真机 GPU 的浮点性能、带宽差异极大;编辑器不会真实模拟移动端的内存带宽和功耗压力;某些优化,比如动态骨骼、LOD 切换,在编辑器里表现不明显,到了真机才看得出差别。
所以我的结论一直很明确:做性能决策,以真机为准,编辑器负责定位调用栈。用编辑器 Profiler 找逻辑调用栈是可以的,但要确认最终效果,必须回到目标设备上重新采集基线。
5.2 那些让数据“失真”的设置
一个是垂直同步(VSync)。开着垂直同步,帧时间会被强制对齐到屏幕刷新率的整数倍,如果本来就达不到 60 帧,实际表现可能直接掉到 30 帧,曲线会变得非常诡异。采集数据前先明确是否开 VSync,尽量所有测试保持一致。
另一个是手机发热降频。连续跑二十分钟,手机上电池温度上来后,CPU/GPU 会主动降频,帧率曲线持续下滑。这不是代码变差了,是散热变差了。所以做真机对比时,同一台设备、同一环境温度、每次跑相同时长,甚至每轮测试之间让设备静置降温,都是必要流程。
还有一个容易被忽略的点:开发版和发布版的逻辑条件可能不同,比如开发版里开着日志、热更新调试、Profiler 记录,都会额外增加耗时。度量基线时必须注明是什么版本测得,避免拿开发版数据和发布版数据直接比较。
5.3 常见卡顿现象、优先怀疑对象与排查动作速查表
最后分享一张我会贴在工位上的速查表。适合启动项目性能排查时按图索骥,但请记住,它是“优先怀疑”,不是“最终结论”。
| 常见现象 | 优先怀疑对象 | 最先看的度量点 |
|---|---|---|
| UI 操作时明显卡顿 | Canvas 重建、UI 网格更新、Overdraw | Profiler 中 Canvas.SendWillRenderCanvases、UI 模块耗时 |
| 同屏物体多时突然掉帧 | 阴影距离/实时阴影、粒子特效、动态光源 | GPU 耗时、Rendering 模块、Frame Debugger 的 DrawCall |
| 场景切换卡顿、加载卡顿 | Shader 编译、AssetBundle IO、GC 释放 | 尖刺帧的主线程耗时、异步加载进度、Memory Profiler |
| 网络同步引起“卡帧” | 帧同步等待、RTT 抖动、包体过大 | 网络延迟曲线、逻辑帧耗时,确认不是渲染层问题 |
| WebGL 持久化写入时卡顿 | 浏览器 IndexedDB 写入、同步刷新逻辑 | 主线程帧时间、异步写入回调,区分是否为 IO 阻塞 |
| 低端机发热后越来越卡 | 降频、功耗控制、没有帧率上限 | 连续运行帧率曲线、设备温度、是否开启自适应帧率策略 |
提示:所有“优先怀疑对象”都需要在实际项目中验证。性能问题的反直觉之处在于,有时候看起来是 UI 的卡顿,真正元凶是那一帧触发了 GC 分配的粒子系统;看起来是 GPU 压力大,实际可能是主线程把渲染指令提交得太慢。度量工具交叉使用,结论才经得起推敲。
写到这里,第一部分的“度量清楚”就说得差不多了。我个人在项目里坚持的流程是:先测基线,再改代码,改完再测同一套指标,只改一个变量。踩过太多次“感觉顺畅了”的坑,后来发现 P95、P99 一点没动,只是自己心理上觉得好了。所以现在只信帧时间曲线和 P99 数字。
《Unity 卡顿·帧率保卫战》第一篇先讲度量。下一篇我们开始进入真正的战斗:帧时间尖刺出现后,如何逐层缩小范围,定位到具体的函数、资源或渲染指令。在那之前,建议先把手头项目的帧时间基线建起来,把“卡”这个模糊的感受,变成一组可以讨论、可以对比、可以追踪的数字。