尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity卡顿度量指南:从帧率到帧时间,先把“卡”量化清楚

Unity卡顿度量指南:从帧率到帧时间,先把“卡”量化清楚 项目标题: 帧率与卡顿先把卡这件事度量清楚——《Unity 卡顿·帧率保卫战》1项目正文: 关键词: Unity, 帧率, 卡顿摘要描述: 有天下午测试同事扔过来一句话“主城打开背包的时候卡了一下。”我问她卡多久掉到几帧是打开瞬间卡还是滚动列表卡UI半透明还是完全卡成PPT她想了几秒留下一句“反正就是卡了一下”就回去继续测了。这种对话我经历过太多次——所有人都说游戏卡但几乎没人能说清卡成什么样、卡在哪里、卡了多久。也正是从那次起我决定把Unity项目的性能摸底从“感觉”这个最不靠谱的维度搬到“数字”这个可以争吵、可以验收的维度。这一篇聊的就是这个过程帧率与卡顿先度量清楚再谈优化。1. 为什么感觉有点卡是最难用的反馈做Unity性能优化这么久我最大的感慨是优化本身往往不难难的是让所有人对“卡”有一个统一的认识。主程觉得30帧能玩美术觉得掉到40就是灾难测试写“界面卡顿”四个字就算提了bug策划拿着体验报告说“反正不太顺”。这些反馈全都没错但没有任何一条可以直接指导你动手改代码。体感反馈有三个致命问题。第一是主观性太强不同人对帧率的敏感程度差异极大。有人天生对掉帧不敏感30FPS和60FPS在他眼里差不多有的人连一次16ms的跳变都受不了尤其是在转动视角的时候。放到VR一体机这类项目里更夸张帧率抖动已经不是“体验不好”的问题而是直接让人头晕恶心、当场摘设备的问题。同一个帧率曲线在不同受众那里结论可以完全相反。第二是不可复现。测试在某个设备上“卡了一下”等你自己拿同型号手机跑可能跑了二十分钟都没复现。因为卡顿往往跟设备温度、后台进程、关卡里动态加载的对象、甚至错开的那一次GC都有关系。没有量化数据“卡了一下”就成了薛定谔的卡你说卡了我没复现这个bug单就悬在那里最后不了了之。可玩家实际玩到的体验是不会不了了之的。第三是无法定位。“卡”和“卡在哪”是两回事。同样的卡顿体感可能是某个Instantiate在同步加载资源可能是DrawCall短时间冲高可能是Shader首次编译也可能是UI的某个Layout重建。你拿到一句“卡了一下”等于拿到一个谜面谜底藏在十几万个函数调用里全靠猜和试。咱们做性能专项第一课就是别在体感阶段停留先把“卡”翻译成可度量、可对比、可验收的指标。这一篇先解决“度量”下一篇才能谈“定位”。2. 把尺子换对FPS告诉你有多少帧帧时间才告诉你卡在哪帧率或者说FPSFrames Per Second是Unity项目里最常被挂在嘴边的数字。它定义很简单设备每秒钟可以渲染并呈现多少帧画面。60FPS就是每秒60帧30FPS就是每秒30帧。可如果只盯FPS做性能分析很容易踩坑因为FPS是一个统计产物它把一秒钟内所有帧的耗时揉成了一个平均值帧之间的起伏被抹平了。跟FPS强相关的另一个概念是帧时间Frame Time渲染一帧画面实际花了多少毫秒。两者互为倒数大致换算关系是FPS ≈ 1000 / 帧时间(ms)一帧耗时16msFPS约等于60一帧耗时33msFPS约等于30一帧耗时100msFPS只有10。注意这个公式里的“约等于”因为Unity的主循环里还涉及VSync、FixedUpdate步长和渲染管线同步实际帧率不会严格等于1000除帧时间但用来估算量级已经足够。我强烈建议在项目里把核心度量单位从FPS换成帧时间。原因是FPS是非线性的帧时间的变化在FPS上会被扭曲。举个例子一帧从16ms涨到32msFPS从60掉到30你已经觉得挺卡可如果从10ms涨到20msFPS从100掉到50很多人反而觉得“还行”。但在帧时间坐标上两者的恶化幅度完全一样都是涨了16ms。卡顿的长尾问题如果只看FPS很容易被漂亮平均数掩盖掉。帧时间还可以再拆。我习惯把一帧的耗时想象成三段接力主线程逻辑Update、FixedUpdate、物理、动画、UI布局、渲染线程提交相机裁剪、合批、DrawCall提交、GPU执行顶点处理、片元着色、显存读写。一帧最终呈现在屏幕上的时间是这三段中最慢的那段决定的而不是三段之和。这有点像做菜备菜、烹饪、装盘是流水线你看到的“上菜时间”取决于最慢的环节。所以你拿到帧时间后第一件事是判断瓶颈在主线程、渲染线程还是GPU——这在Profiler里一目了然也是后续优化的入口。在Unity代码里拿帧时间最简单的方式是float frameTimeMs Time.unscaledDeltaTime * 1000f;注意要用unscaledDeltaTime而不是deltaTime否则游戏里一旦有Time.timeScale缩放比如暂停、子弹时间效果帧时间记录也会被缩放性能数据就失真了。很多人会写1f / Time.unscaledDeltaTime去算FPS也能用但建议顺手把毫秒帧时间也存一份后面做统计会舒服得多。3. 度量工具三件套Profiler窗口、FrameTiming API、自定义采集器把“卡”量化不是盯着一个数字看而是要搭一套能持续收集数据的体系。我建议所有Unity项目至少准备三样东西Editor里的Profiler窗口、脚本里的FrameTiming API、自己写的自定义帧时间采集器。三者的分工完全不同下面挨个说。3.1 Unity Profiler窗口Unity自带的Profiler是定位卡顿最常用的入口入口在菜单栏Window Analysis Profiler。打开后默认停在CPU Usage模块你能看到主线程每一帧的调用栈什么函数占了多少毫秒GC Alloc是多少。对于“找卡点”来说Timeline视图比Hierarchy视图更直观它可以按线程展示每帧的时间切片你能一眼看出是主线程跑了16ms、渲染线程等了10ms还是GPU那边顶了一堵墙。但我必须提醒一句Editor里的Profiler数据只能作为结构性参考不能当作真机性能的最终结论。编辑器里跑的是开发模式脚本有额外的Mono开销渲染走的是编辑器管线Shader也未必是目标平台上的最终版本。你可以在编辑器里定位“哪个函数耗时大”“哪段逻辑GC高”但要回答“真机上到底多少帧”还是得打到设备上看。另外Deep Profile模式会极大拖慢运行速度不适合用来量真实帧率只适合看具体函数的调用次数和耗时分布。3.2 FrameTiming APIUnity从2017.2左右开始提供FrameTimingManager和FrameTiming专门用来取一帧里CPU/GPU/渲染线程各自的时间。它比Time.unscaledDeltaTime的信息更细可以在一行代码里拿到三段耗时。我通常在项目里写一个这样的探针脚本using UnityEngine; public class FrameTimingProbe : MonoBehaviour { private FrameTiming[] _timings new FrameTiming[8]; void Update() { if (!FrameTimingManager.IsFeatureEnabled()) return; FrameTimingManager.CaptureFrameTimings(); uint count FrameTimingManager.GetLatestTimings((uint)_timings.Length, _timings); for (int i 0; i count; i) { float cpuMs _timings[i].cpuFrameTime; float gpuMs _timings[i].gpuFrameTime; // 记录到日志或内存缓存稍后统一上报 } } }这里有个坑FrameTimingManager在部分平台或部分图形API下某些字段会返回0。比如gpuFrameTime在移动端经常拿不到准确值cpuRenderThreadTime也不是所有设备都支持。我一般拿cpuFrameTime作为主参考其他的能取到就记录取不到就留0不要因为字段是0就直接放弃整个工具链。真机GPU耗时如果取不到可以用后面说的自定义采集补一部分。3.3 自定义帧时间采集器工具再好也得有人把数据落下来。我习惯从项目一开始就内置一个轻量的帧时间采集器它不依赖Profiler在真机上也能跑。核心逻辑特别简单每帧把Time.unscaledDeltaTime的毫秒数存进一个数组按设定的窗口长度比如一帧存一个值存满1024个就滚动覆盖最后在退出关卡或者点击按钮时导出统计。更实用的是在采集器里直接算分位数。你们项目可以定义“卡顿标准”为“某帧耗时超过100ms”那么指标就变成“每千帧有多少帧超过100ms”。这样你就不需要看原始曲线直接看一个数字就能知道这版本是变好还是变坏。下面是一个简易的分位数统计思路using System.Collections.Generic; using UnityEngine; public class FrameMetricRecorder : MonoBehaviour { private Listfloat _frameMs new Listfloat(4096); void Update() { _frameMs.Add(Time.unscaledDeltaTime * 1000f); } public float GetPercentile(float percent) { if (_frameMs.Count 0) return 0f; var sorted new Listfloat(_frameMs); sorted.Sort(); int idx Mathf.Clamp(Mathf.CeilToInt(percent / 100f * sorted.Count) - 1, 0, sorted.Count - 1); return sorted[idx]; } public void DumpReport() { Debug.Log($p50{GetPercentile(50f):F1}ms p95{GetPercentile(95f):F1}ms p99{GetPercentile(99f):F1}ms max{GetPercentile(100f):F1}ms); } }这个采集器的好处是完全可控、跨平台、不依赖第三方SDK。它在打版本时的Build里可以保留一个“安静模式”只记录不上报等QA反馈卡顿时让测试把设备日志导出你就能拿到当时的帧时间分布而不是一句“反正卡了一下”。3.4 编辑器、真机和云测的区别三件套之外我还要强调真机和云测的差异。编辑器Profiler不是真机性能真机连着自己的电脑跑Profiler同样不是真实玩家环境——连Profiler本身就有额外开销尤其在移动设备上USB调试和Profiler通信会影响帧率。我自己习惯的做法分三阶段白天用Editor Profiler做日常开发和逻辑定位打包阶段用FrameTiming探针打到几台固定测试机上让QA正常玩一段时间记录原始帧时间每周挑一次用UWA或类似性能分析服务做深度扫描看GC、渲染、资源加载的综合报告找出测试机上看不到的长尾问题。这套组合下来性能数据才算比较完整。4. 平均60帧还卡平均值把卡顿“稀释”了很多人一开始会把性能目标定成“平均60FPS”然后看统计工具显示平均55FPS就觉得还有救。实际上这种平均数字在卡顿治理里基本没有意义。我说个极端的例子60秒内如果你的游戏大部分帧都稳定在16ms但中间穿插了30帧耗时250ms的“巨帧”合计3280帧左右平均帧时间约18ms换算成平均FPS约55。单看平均值这游戏似乎离60FPS只差一点。可实际上玩家每两秒就会经历一次250ms的冻结这已经不是“流畅”的反面而是“能玩与否”的问题。问题就出在平均值把长尾帧“稀释”了。你感受卡顿的时候感知的不是一秒钟的平均帧率而是最难受的那一帧或连续几帧的停顿。游戏引擎里绝大多数卡顿的诱因比如GC触发、资源同步加载、Shader编译、UI第一次打开的布局计算都是突发型事件它们可能只占全部帧的1%不到但对体验的破坏力远超那1%。如果用均值或平均值加标准差的统计口径这些突发帧很容易被掩盖。这也是为什么很多项目会出现“测试报告显示平均50FPS但玩家评论骂成一片”的诡异局面。帧时间的抖动幅度比均值本身更能说明问题。两段游戏时长相同的视频一段帧时间一直稳定在20ms另一段一半时间10ms、一半时间30ms交替后者的平均帧时间也是20ms但体感明显更差。卡顿研究里有个观念人眼对稳态低帧率的容忍度其实不低但对帧与帧之间的“突然变慢”极其敏感。换句话说你要防的从来不是“平均掉帧”而是“一帧突然飞走了”。所以我把卡顿定义为一种“帧时间的异常尖峰”而不是“平均帧率的下降”。这是后续所有指标设计的基石。与其盯着平均FPS从56调到58不如盯着帧时间超过卡顿阈值的次数从每分钟20次降到每分钟2次。前者是自我安慰后者才是玩家真正感知到的改善。5. 卡顿的量化标准阈值、分位数与帧时间直方图要度量卡顿先得给“卡”一个可执行的算术定义。不同项目目标帧率不一样我一般把阈值分成几档再根据项目属性选定“卡顿线”。我常用的经验阈值表如下以60FPS目标项目为例帧时间范围主观感受常见诱因举例0 ~ 16ms流畅常规逻辑、渲染正常16 ~ 33ms轻微波动个别脚本耗时、瞬时加载33 ~ 50ms能感到迟滞DrawCall冲高、UI局部重建50 ~ 100ms明显掉帧资源同步加载、Shader编译 100ms卡顿HitchGC峰值、阻塞式IO、极端行为对普通手游我建议把“单帧耗时大于100ms”定义为一次卡顿事件。这个数字不是拍脑袋100ms意味着瞬间帧率掉到10以下人对这个量级的停顿感知非常明确。如果项目偏竞技或者偏VR阈值要更激进比如50ms就算事故因为在VR一体机上头部跟踪一旦超过20ms的延迟就会引起明显不适50ms以上的停顿基本属于不可接受。光有阈值还不够数据分析上要引入分位数。平均帧时间可以用p50表示但它看不出长尾。我会在每轮性能报告里同时看p95和p99。p95的意思是“这次采集里95%的帧低于这个耗时”p99则是“99%的帧低于这个耗时”。举例来说指标数值解读平均帧时间18ms整体水平尚可p95帧时间29ms大部分时间流畅偶尔轻微波动p99帧时间63ms长尾里存在明显掉帧最大帧时间245ms至少发生过一次严重卡顿从这个表格能看出平均帧时间和p95都“还行”但p99和最大值已经暴露了问题。我们定性能标准时不会只卡平均帧时间而是同时卡p95和p99的上限。比如“目标60FPS的项目p95必须低于20msp99低于50ms最大帧不超过150ms”。这样每个版本的数据出来好坏一目了然。帧时间直方图是另一个好用的可视化手段。你把帧时间按区间分桶比如0-5ms、5-10ms、10-15ms……一直到100ms以上然后统计每个桶里的帧数占比。直方图一眼就能看出帧时间分布是集中在低值区还是拖着一条长长的尾巴。优化卡顿的过程本质上就是把直方图右边的长尾往左边压缩。这条尾巴越长玩家的卡顿抱怨就越多。这里还要提醒一个细节卡顿率的统计口径要统一。你可以在日志里同时记录“每千帧卡顿次数”“每分钟卡顿次数”和“卡顿帧时长占比”三个指标。呃别问我为什么强调这个因为不同平台用的口径不一样口径不统一会导致你拿着两份报告在会议上辩论半小时最后发现一个数算的是“超过50ms帧占比”另一个算的是“超过100ms帧次数”。度量之前先定义清楚这是血泪教训。6. 度量前的准备工作场景、设备、记录规范一个都不能少工具和指标都定了最后一步是把度量流程固化下来。卡顿数据最怕的就是“条件失控”今天这版在测试机A上跑明天那版在测试机B上跑场景还不一样最后数据波动到底是优化带来的还是环境差异带来的根本说不清。所以每次开测之前我会确认三件事。第一测试场景必须固定。性能专项不能只跑“随便玩一会儿”。我会让QA按固定的操作序列执行进游戏—进主城—打开背包—连续翻页—打开一次商店—放一个技能—跑图30秒—退出关卡。每一步之间的停留时间、操作节奏尽量保持一致。这样前后两个Build的对比才有意义。如果你的项目有自动化测试框架把这套路径写成脚本更好手动测试总会有偏差。第二设备分级管理。我把测试机分成三档旗舰档当前最新SoC、主流档两年前的中端机、入门档三年前的低端机。每次性能报告必须标注用的哪一档不能拿旗舰机跑出好看数据就说卡顿解决了。像Pico4这类一体机开发项目设备型号相对固定反而好办但也要区分不同固件版本。设备温度也要留意手机玩久了降频会让帧率出现明显拐点分析数据时看到后期帧时间整体上涨先查是不是降频。第三数据记录规范要统一。我建议帧时间探针的日志至少包含这些字段Build编号、Unity版本、设备型号、测试场景、操作序列标识、采样开始时间、平均帧时间、p95/p99、最大帧时间、卡顿次数。输出格式可以用CSV方便归档和做趋势图。字段一旦定下来就不要轻易改否则历史数据对不上。你可以在脚本里把这些信息组合成一个字符串写到本地文件也可以直接上报到自建日志服务具体看项目阶段。还有一个常被忽略的点单次采样时长不能太短。卡顿是低概率事件如果只跑30秒可能一个突刺都碰不到数据自然好看但毫无代表性。我建议每次专项测试至少持续3到5分钟并且同一版本在不同时段多跑几轮。这样p99和最大值才有统计意义。前期我踩过的坑就是采样太短优化前和优化后都显示“无卡顿”做得跟没做一样白折腾一版。处理数据时也可以顺手建一个简单的趋势看板每周把p95/p99、卡顿次数挂上去。这样版本迭代过程中性能是变好还是变差一眼就能看到不需要等到玩家评分下跌才后知后觉。这套“事前固定场景—事中记录帧时间—事后看分位数和卡顿次数”的流程跑通之后团队里再有人说“这里卡了一下”你至少能拿出一串数字去对齐哪个场景什么设备p99多少卡了几次当“卡”从形容词变成数字性能优化这场仗才算真正开始。先把度量做扎实后面谈定位瓶颈、Shader优化、资源加载优化、GC治理才站得住脚。下一篇我会聊“代码和资源到底卡在了哪里”也就是从帧时间尖峰倒推现场的方法。
返回列表