1. 锁帧这件事,到底在锁什么
1.1 从一次手机发烫说起
去年夏天我拿一台骁龙870的机器跑一个Unity做的放置类小游戏,画面简单得不能再简单,几个2D精灵加一点粒子特效,结果玩了十分钟机身背面烫得能煎蛋。我当时第一反应是美术资源有问题,查了Draw Call、查了Overdraw、查了纹理压缩格式,折腾一圈发现渲染压力其实很小。后来用Profiler一挂,帧率稳定在120fps——问题就出在这里。这台机器屏幕刷新率是120Hz,Unity默认跟着屏幕跑,GPU和CPU每帧都在满负荷输出,明明一个静态界面根本不需要每秒画120次。
把Application.targetFrameRate设成60之后,同样的场景,机身温度从烫手降到温热,耗电曲线也平缓了一大截。这就是锁帧最朴素的价值:用你根本感知不到的帧率,去换实实在在的发热余量和续航。
很多人对锁帧有误解,觉得锁帧就是“降画质”或者“性能不够才做的事”。其实不是。锁帧是一个主动的功耗管理策略,跟画质没有直接关系。你画面再精美,如果帧率跑在一个不必要的高位,那多出来的每一帧都是在白白烧电、白白发热。尤其是移动端,SoC的功耗墙和温度墙卡得很死,帧率越高,SoC越容易撞到温度墙然后降频,降频之后帧率反而会剧烈波动,体验比稳定锁帧差得多。
1.2 锁帧不等于卡顿,关键在“稳”
这里要区分两个概念:帧率高低和帧率稳定性。玩家感知到的“卡”,绝大多数时候不是帧率低,而是帧率在波动。30fps稳定跑,比60fps忽高忽低要舒服得多。锁帧的核心目的之一,就是把帧率钉死在一个目标值上,让帧生成时间(Frame Time)尽可能均匀。
我做过一个对比测试,同一个场景,一组不锁帧让帧率在45到90之间飘,另一组锁60。用高速摄像机拍下来逐帧看,不锁帧那组的帧间隔方差是锁帧组的3倍多。玩家主观评价里,锁帧组被描述为“顺滑”,不锁帧组被描述为“有点飘”。这就是稳定性的价值。
所以锁帧的智慧不在于“锁到多低”,而在于“锁到一个你能稳定维持的值”。这个值选得好,发热和体验双赢;选得不好,要么还是烫,要么真的卡。
1.3 哪些场景最该考虑锁帧
不是所有项目都需要锁帧。我梳理了几类最典型的场景:
- 移动端休闲游戏、放置类、卡牌类:画面变化少,交互节奏慢,锁30或60完全够用,锁帧收益极大。
- VR/AR应用:这个比较特殊,VR对帧率要求极高,通常必须跑满头显刷新率(72/90/120),锁帧策略要非常谨慎,一般不建议低于刷新率,但可以在热节流时动态降。
- 桌面端窗口化小工具、数字孪生看板:常年挂着的界面,锁30甚至锁15都能接受,省电省风扇噪音。
- 微信小游戏:小游戏环境本身有帧率上限,但开发者仍需主动管理,否则在部分机型上会跑出不必要的功耗。
- 主机/PC大型游戏:通常有画质档位和帧率档位,锁帧是选项之一,配合垂直同步使用。
反过来,竞技类FPS、音游这类对输入延迟和帧率极度敏感的项目,锁帧要非常小心,通常不锁或者锁在很高的值。
2. Unity里锁帧的几种手段与选型逻辑
2.1 targetFrameRate:最直接的那把刀
Unity里最常用的锁帧接口就是Application.targetFrameRate。它的行为在不同平台上有差异,这点必须搞清楚,否则会踩坑。
在移动端(iOS/Android),设置targetFrameRate会直接影响引擎的主循环节流。设成60,引擎就会尽量把每帧控制在16.67ms左右。设成30,就是33.33ms。设成-1表示不限制,跟着平台默认走。
在桌面端,情况复杂一些。如果开启了垂直同步(VSync),targetFrameRate的效果会被VSync覆盖或者叠加。比如你设了60,但显示器是144Hz且开了VSync,实际可能跑在144或者48(144的1/3),取决于驱动和引擎的协商结果。所以桌面端锁帧,通常建议关掉VSync,用targetFrameRate来控制,或者用QualitySettings.vSyncCount配合。
// 移动端常见的锁帧写法 void Awake() { // 根据设备刷新率决定锁帧目标 int targetRate = 60; if (Application.targetFrameRate != targetRate) { Application.targetFrameRate = targetRate; } // 关闭VSync避免冲突 QualitySettings.vSyncCount = 0; }这段代码看起来简单,但有几个细节。第一,targetFrameRate设置后不是立即生效的,它会在下一帧开始起作用。第二,在某些Android机型上,如果系统本身有省电策略,可能会覆盖你的设置。第三,iOS上如果设了120但设备只支持60,实际会跑60,不会报错。
2.2 为什么我优先选targetFrameRate而不是自己写计时器
有人会想,我能不能自己写一个累加器,每帧判断时间够不够,不够就Thread.Sleep或者空转?理论上可以,但实践上不推荐。原因有几个:
- Unity的主循环、物理、动画、渲染都是耦合在引擎内部的,你自己在外面卡时间,会导致输入响应、物理步进和渲染不同步,出现抖动。
Thread.Sleep的精度在移动端很差,Windows上默认15ms左右,根本没法做16.67ms的精确控制。- 引擎内部的节流会配合平台特性,比如iOS的ProMotion、Android的Adaptive Refresh Rate,自己写很难兼顾。
所以除非你有非常特殊的需求,否则targetFrameRate就是首选。它简单、跨平台、和引擎配合好。
2.3 配合QualitySettings做分级锁帧
实际项目里,我很少只用一个固定值。更常见的做法是根据设备档位或者用户设置做分级。比如:
| 设备档位 | 目标帧率 | 说明 |
|---|---|---|
| 低端机 | 30 | 保稳定,保发热 |
| 中端机 | 45或60 | 平衡 |
| 高端机 | 60或120 | 体验优先 |
| 省电模式 | 30 | 用户主动选择 |
| 高性能模式 | 不锁或锁最高 | 插电场景 |
这个分级不是拍脑袋定的。低端机往往SoC能效比差,跑60可能直接撞温度墙,锁30反而能长时间稳定。高端机散热好,跑60甚至120问题不大。分级的关键是你要有办法识别设备档位,通常用SystemInfo.processorCount、SystemInfo.systemMemorySize、GPU型号字符串等做粗略判断,或者干脆让用户自己选。
int DecideTargetFrameRate() { // 简单的档位判断,实际项目会更细 int memory = SystemInfo.systemMemorySize; int cores = SystemInfo.processorCount; if (memory <= 2048 || cores <= 4) return 30; else if (memory <= 4096) return 45; else return 60; }这段判断很粗糙,但思路是对的。实际项目里我会结合更多维度,比如GPU的SystemInfo.graphicsDeviceName做关键词匹配,或者用一段时间内的平均帧时间做动态调整。
2.4 动态锁帧:根据温度或负载实时调整
固定锁帧有个问题:设备状态是变化的。刚启动时凉快,跑十分钟后热了,如果还锁60,可能就开始掉帧。这时候动态锁帧就有价值了。
动态锁帧的思路是:监测某个指标(帧时间、温度、CPU/GPU占用),当指标超过阈值时,逐步降低目标帧率。比如从60降到45,再降到30。降的时候要平滑,不能一帧从60跳到30,那玩家会明显感觉到卡顿。
Unity本身不提供温度接口,移动端要拿温度得走原生插件。Android可以用BatteryManager或者读取thermal zone,iOS用ProcessInfo.thermalState。桌面端可以用第三方库读CPU/GPU温度。如果没有温度数据,退而求其次用帧时间的移动平均做判断。
// 简化的动态锁帧逻辑 float frameTimeAvg = 0f; int currentTarget = 60; void Update() { frameTimeAvg = Mathf.Lerp(frameTimeAvg, Time.unscaledDeltaTime, 0.05f); // 如果平均帧时间超过目标帧时间的1.2倍,说明维持不住 float targetFrameTime = 1f / currentTarget; if (frameTimeAvg > targetFrameTime * 1.2f && currentTarget > 30) { currentTarget -= 15; Application.targetFrameRate = currentTarget; frameTimeAvg = 0f; // 重置,避免连续降 } }这个逻辑很简陋,但展示了核心思想。实际项目里我会加冷却时间、加回升逻辑(温度降下来后逐步升回去)、加滞回区间避免频繁抖动。
3. 锁帧实操:从参数计算到落地细节
3.1 帧率、帧时间与功耗的关系
要锁得好,得先理解帧率和功耗的关系。简单说,功耗大致和帧率成正比,但不是线性的。因为每帧有固定开销(比如引擎的Tick、UI重建),帧率翻倍,固定开销也翻倍,动态开销(渲染、物理)也翻倍,所以总功耗可能翻倍还多。
我实测过一组数据,同一个Unity场景,在骁龙865上:
| 目标帧率 | 平均功耗 | 机身温度(10分钟后) |
|---|---|---|
| 120 | 4.8W | 43度 |
| 90 | 3.9W | 40度 |
| 60 | 2.9W | 36度 |
| 45 | 2.4W | 34度 |
| 30 | 1.9W | 32度 |
可以看到,从120降到60,功耗降了约40%,温度降了7度。从60降到30,功耗再降35%,温度再降4度。这个收益在长时间游玩时非常明显。
所以选目标帧率的时候,可以算一笔账:每降低15fps,大约能省0.5到1W的功耗,温度降2到4度。具体数值因设备和场景而异,但量级是这样。
3.2 怎么确定“最低可接受帧率”
锁帧的底线是“玩家不觉得卡”。不同游戏类型底线不同:
- 回合制、卡牌、放置:30fps足够,甚至24fps也能接受。
- 横版动作、平台跳跃:45到60,30会明显感觉不跟手。
- 第一人称、竞速:60起步,30基本没法玩。
- VR:必须达到头显刷新率,不能低于72。
确定底线的方法,我一般用两个手段:一是自己反复试玩,从60往下调,调到感觉“开始不舒服”就停;二是找几个目标用户做A/B测试,让他们盲测不同帧率,记录主观评分。
有个经验值:大多数2D休闲游戏,30fps是安全线;大多数3D中度游戏,45fps是安全线;竞技类,60fps是安全线。低于这些值,就要非常谨慎。
3.3 锁帧和垂直同步的配合
垂直同步(VSync)和锁帧经常一起出现,但它们的机制不同。VSync是让GPU的帧输出和显示器刷新对齐,防止画面撕裂。锁帧是限制引擎的帧生成速率。
在桌面端,如果显示器是60Hz,你锁60且开VSync,效果最好,画面不撕裂也不浪费。如果显示器是144Hz,你锁60但开VSync,实际可能跑在144或者48,因为VSync会把帧率对齐到刷新率的整数分之一。这时候要么关VSync,要么把目标帧率设成刷新率的整数分之一(比如144/3=48,144/2=72)。
在移动端,VSync通常是强制的,你设targetFrameRate就是在和VSync协商。比如屏幕60Hz,你设45,实际可能跑在30(60/2)或者60,取决于驱动。所以移动端锁帧,最好锁在刷新率的整数分之一,比如60Hz屏锁60、30、20,120Hz屏锁120、60、40、30。这样能避免VSync带来的额外抖动。
// 根据屏幕刷新率选择整数分之一的锁帧值 int GetSafeTargetFrameRate(int desired) { int refreshRate = (int)Screen.currentResolution.refreshRateRatio.value; if (refreshRate <= 0) refreshRate = 60; // 找最接近desired的整数分之一 int best = refreshRate; for (int divisor = 1; divisor <= 4; divisor++) { int candidate = refreshRate / divisor; if (Mathf.Abs(candidate - desired) < Mathf.Abs(best - desired)) best = candidate; } return best; }这段代码在移动端和桌面端都能用,能有效避免VSync导致的帧率跳变。
3.4 锁帧后的性能余量怎么用
锁帧省下来的性能余量,不是让它闲着,而是可以拿去做别的事。比如:
- 提升画质:把省下来的GPU时间用来开更高分辨率的阴影、更好的后处理。
- 降低发热:什么都不做,让设备凉快,延长续航。
- 提升稳定性:把余量留给突发情况,比如场景切换、大量粒子特效时,帧率不会掉。
- 做后台任务:比如资源加载、AI计算,可以放在帧率有余量的时候做。
我个人的偏好是,移动端优先把余量用于降低发热,因为发热是移动端体验的头号杀手。桌面端可以用于提升画质,因为桌面散热通常不是问题。
4. 常见问题与排查技巧实录
4.1 设了targetFrameRate但没生效
这是最常见的问题。排查思路按顺序来:
- 检查VSync:
QualitySettings.vSyncCount如果大于0,会覆盖targetFrameRate。先设成0试试。 - 检查平台差异:某些Android机型上,系统省电模式会强制锁30或60,你的设置会被忽略。让用户关掉省电模式再测。
- 检查设置时机:
targetFrameRate要在Awake或更早设置,如果在Start里设,第一帧可能已经跑了。 - 检查是否有其他代码覆盖:项目里可能有多个地方设置帧率,后设置的会覆盖前面的。全局搜一下
targetFrameRate。 - 检查是否在编辑器里测:编辑器里的帧率行为和生产环境不同,编辑器可能跑得很快,要以真机为准。
我踩过最坑的一次,是项目里有个第三方插件在Update里每帧设置targetFrameRate,导致我的设置一直被覆盖。后来用grep搜出来才解决。
4.2 锁帧后画面反而更卡了
这种情况通常是锁帧值和VSync不匹配导致的。比如屏幕60Hz,你锁45,VSync会把45对齐到30,实际跑30,比预期的45卡。解决办法就是前面说的,锁在刷新率的整数分之一。
还有一种可能是锁帧后帧时间不均匀。比如你锁60,但某些帧因为GC或者资源加载耗时超过16.67ms,导致这一帧被拉长,下一帧又赶回来,形成抖动。这时候要排查GC和加载,用Profiler看GC.Alloc和Loading.UpdatePreloading。
4.3 不同机型表现差异大
Android碎片化严重,同样的锁帧设置,不同机型表现可能完全不同。我遇到过一台机器锁60很稳,另一台锁60但实际在50到60之间飘。原因是后者的CPU调度策略更激进,或者散热更差。
应对方法是做机型分级,不要指望一个值通吃。可以用SystemInfo做粗略分级,也可以做云端配置,根据机型下发不同的锁帧值。微信小游戏环境里,可以用wx.getSystemInfoSync()拿到机型信息做判断。
4.4 锁帧和帧率显示不一致
有时候你在游戏里显示60fps,但用第三方工具测出来是55或者65。这是因为帧率统计的口径不同。Unity的Time.deltaTime是引擎主循环的时间,第三方工具可能测的是GPU输出或者屏幕刷新。只要主观体验没问题,不用太纠结这个差异。
但如果差异很大,比如显示60实际40,那就要查是不是有掉帧。用Profiler看CPU和GPU的耗时,找出瓶颈。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 设了帧率没变化 | VSync覆盖 | 设vSyncCount=0 |
| 锁帧后更卡 | 帧率非刷新率整数分之一 | 调整为整数分之一 |
| 部分机型无效 | 系统省电策略 | 关闭省电模式测试 |
| 帧率忽高忽低 | GC或加载导致帧时间不均 | Profiler查GC和Loading |
| 编辑器正常真机异常 | 平台差异 | 以真机为准,做机型分级 |
| 锁帧后输入延迟增加 | 帧率过低 | 提高目标帧率或优化输入处理 |
4.6 几个我踩过的坑
第一个坑:在Update里每帧设置targetFrameRate。这会导致引擎每帧都重新协商帧率,反而增加开销。正确做法是在Awake里设一次,需要动态调整时再改。
第二个坑:忽略Application.targetFrameRate在iOS上的行为。iOS上如果设了120但设备不支持,不会报错,但实际跑60。如果你依赖帧率做逻辑(比如按帧计时),要小心。
第三个坑:锁帧后忘了调整物理步进。Unity的FixedUpdate默认是50Hz,和帧率无关。但如果你锁30,FixedUpdate可能一帧跑多次或者跳帧,导致物理表现异常。这时候要检查Time.fixedDeltaTime和maximumDeltaTime。
第四个坑:在微信小游戏里直接设targetFrameRate。小游戏环境有自己的帧率管理,直接设可能无效。要用小游戏提供的接口,或者通过requestAnimationFrame的节流来控制。
5. 锁帧策略的进阶玩法
5.1 分场景锁帧
不同场景对帧率的需求不同。比如主菜单可以锁30,战斗场景锁60,过场动画锁30。这样能在不需要高帧率的时候省电,需要的时候再拉满。
实现方式是在场景加载时根据场景类型设置targetFrameRate。要注意切换时的过渡,避免突然从60跳到30造成视觉不适。可以在切换时加一个短暂的渐变,或者用加载画面遮挡。
5.2 分交互状态锁帧
玩家操作时锁高帧率,无操作时锁低帧率。比如玩家滑动屏幕时锁60,松手后3秒降到30。这个策略在放置类和阅读类应用里特别有效。
实现方式是监听输入事件,有输入时设高帧率,同时启动一个计时器,超时后降回低帧率。要注意计时器要用unscaledTime,避免被Time.timeScale影响。
5.3 结合热节流的动态降帧
前面提过动态锁帧,这里再展开一下热节流的配合。移动端SoC有温度墙,撞到之后会降频。如果你能在撞墙之前主动降帧,就能避免降频带来的剧烈卡顿。
思路是:监测帧时间的移动平均,当它开始上升(说明SoC在降频或者负载增加),就主动降目标帧率。降的时候要平滑,比如每次降5fps,间隔几秒。温度降下来后再逐步升回去。
这个策略的关键是提前量。不能等帧时间已经崩了才降,要在它开始恶化时就降。我一般用帧时间的标准差或者斜率做判断,比单纯看平均值更灵敏。
5.4 锁帧与电池续航的实测
我做过一个续航测试,同一个放置类游戏,4000mAh电池:
| 锁帧策略 | 续航时间 | 机身最高温度 |
|---|---|---|
| 不锁(跑满120) | 3.2小时 | 44度 |
| 锁60 | 4.8小时 | 37度 |
| 锁30 | 6.5小时 | 33度 |
| 动态(30-60) | 5.6小时 | 35度 |
可以看到,锁30比不锁续航多了整整一倍。动态策略介于中间,但温度控制得不错。这个数据因设备和游戏而异,但趋势是一致的:锁帧是移动端续航优化性价比最高的手段之一。
5.5 锁帧不是万能的
最后泼点冷水。锁帧能解决发热和续航问题,但解决不了根本的性能问题。如果你的游戏本身渲染压力就很大,锁帧只是把问题推迟了。该优化的Draw Call、该压缩的纹理、该合并的材质,一个都不能少。
锁帧是“节流”,优化是“开源”。两者要配合使用。我见过一些项目,锁了30帧还是烫,一查发现是每帧都在实例化大量对象,GC压力巨大。这种情况下,锁帧救不了,得先优化代码。
另外,锁帧对输入延迟有影响。帧率越低,输入到显示的延迟越大。30fps下,理论延迟至少33ms,加上渲染管线,可能到50ms以上。对延迟敏感的游戏,锁帧要慎重。
6. 一些实操心得
我在多个项目里落地过锁帧策略,总结下来几条经验。
第一,默认锁60,特殊场景再调。60是大多数移动设备的甜点帧率,体验和功耗平衡得最好。除非有明确理由,否则不要一上来就锁30。
第二,给用户选择权。设置里放一个“省电模式”开关,让用户自己决定要不要锁30。有些用户对发热敏感,有些对流畅度敏感,让他们选比替他们选好。
第三,真机测试不可少。编辑器里跑得再好,真机上可能完全不是一回事。至少要在低、中、高三档真机上各测一遍。
第四,监控线上数据。上线后收集帧率、温度、耗电数据,看看实际表现和预期差多少。有条件的可以做A/B测试,对比不同锁帧策略的留存和时长。
第五,别忽略音频和网络。锁帧省的是CPU和GPU的功耗,但音频解码和网络请求也在耗电。如果这些部分开销大,锁帧的收益会被稀释。要整体看功耗,不能只盯帧率。
锁帧这件事,说简单也简单,一行代码的事。说复杂也复杂,涉及到平台差异、设备分级、动态调整、用户体验。但只要你理解了“用帧率换发热余量”这个核心逻辑,剩下的就是根据自己项目的情况去调参和验证。我个人的体会是,在移动端,锁帧带来的体验提升,往往比提升画质更明显。毕竟,一个不烫手、能玩得久的游戏,比一个画面精美但十分钟就烫得拿不住的游戏,要受欢迎得多。