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

资讯详情

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

Unity限帧实战:targetFrameRate、垂直同步与FixedUpdate的协同使用

Unity限帧实战:targetFrameRate、垂直同步与FixedUpdate的协同使用 做了这么多年Unity项目我一直在跟一个听起来很反直觉的问题打交道——怎么把帧率压下去。很多新同事一开始都会愣一下帧率不是越高越好吗真不是。一个纯UI界面或者低多边形场景在高端手机上能轻松跑出200fps以上此时GPU满载、手机发烫、电量哗哗掉而画面中根本没有需要这么高帧率的动态内容。这种情况下主动限制帧率反而是优化性能最有效的手段。Unity里限制帧率的方式比大多数人想象的复杂一些不是简单调一个API就能完事。它牵扯到渲染循环的调度机制、垂直同步的优先级、操作系统的计时精度、不同平台的屏幕刷新率差异以及Fixed Timestep对物理模拟的影响。如果只知其然而不知其所以然很容易出现“明明设了60帧真机一跑还是90帧”或者“帧率确实锁住了但游戏里的动画和物理变得很别扭”这类情况。这篇内容我把自己在多个项目里实际验证过的限帧方案、踩过的坑、以及排查思路完整写出来给正在被帧率问题困扰的兄弟一个能直接参考的实操笔记。1. 先把帧率这件事拆开帧循环、Update和时钟的关系1.1 一帧到底有多长在动手限制帧率之前得先搞清楚“帧”在Unity里到底是什么。很多初学者把帧理解成“画面刷新一次”这个说法不够准确。对Unity来说一帧是主循环PlayerLoop里一次完整的迭代处理输入事件、执行各个系统的Update回调MonoBehaviour的Update、LateUpdate、物理系统的FixedUpdate、提交渲染命令、等待GPU完成一帧画面的绘制然后进入下一轮。帧率就是每秒完成多少次这样的迭代。所以Unity官方文档里经常用的一个说法是“每一帧的调度间隔”frame period。当你说“限制到60帧”本质是让主循环尽量每1000 / 60 ≈ 16.67毫秒跑完一轮。这里的“尽量”很关键因为它暗示了Unity的限制帧率机制是一种“尽力而为”的调度而不是硬实时系统。1.2 Update与FixedUpdate两套完全不同的时钟理解帧率限制必须先分清Unity里的两套循环渲染帧驱动的Update和固定时间间隔驱动的FixedUpdate。Update每帧执行一次帧率越高执行次数越多。FixedUpdate则完全不同它的执行频率由项目设置里的Fixed Timestep决定默认是0.02秒也就是每秒50次。这个频率和你当前跑的是30帧还是300帧没关系哪怕游戏掉到20帧FixedUpdate也会尽量按固定的物理间隔补足调用次数。打个生活化的比方Update有点像外卖平台每来一个新订单就响一次的提示音订单来得多响得就多FixedUpdate则是店里的自动搅拌机不管门口排队多少人它都严格按自己设定好的转速工作。限帧限制的是“订单提示音”的频率但搅拌机还在按自己的节奏转。很多人限制帧率后发现物理、动画表现不对问题往往就出在这两套时钟的错位上。1.3 限帧限制的是渲染节奏不是逻辑速度还有一个容易混淆的点限帧之后游戏逻辑并不会变慢。因为Time.deltaTime会随着帧率降低而变大Update里写的transform.position velocity * Time.deltaTime依然能保持正确的移动速度。这一点理解透了才不至于在限帧时产生“我把游戏调慢了”的错觉。真正会有感知差异的是那些“按帧驱动”的东西某个动画的位移步长、相机的平滑跟随、UI的缓动曲线。帧率被压低后单帧步长变大即使总速度不变视觉上也可能出现一顿一顿的感觉。这是后面所有限帧方案都要面对的核心矛盾——如何在降低渲染调用频率的同时保证视觉节奏的平滑。2. Application.targetFrameRate最正统的限帧入口和隐藏行为2.1 基本用法与预期效果Unity官方最推荐的限帧方式就是设置Application.targetFrameRate。用法简单到一句话Application.targetFrameRate 60;把这行代码放在游戏启动时执行一次Unity的主循环就会在每帧结束时检测距离开帧是否已经过去了目标间隔。如果还没到16.67毫秒主线程会主动休息Sleep把剩余的CPU时间让给操作系统和其他进程直到时间达标才进入下一帧。实测下来这个API在多数情况下表现不错。在PC端把帧率限制到60后Unity Profiler里能明显看到帧耗时的柱状图被压得很平GPU占用率降下来风扇声音都小很多。移动端同样有效特别是对纯UI或2D游戏限制后发热量会有立竿见影的改善。2.2 版本差异与默认行为这里有个容易踩坑的版本差异。Unity 2020.3之前移动端在不主动设置targetFrameRate时部分设备会默认按30帧运行这是出厂的省电策略。但从2020.3开始Unity调整了默认行为如果你完全不管这个字段很多移动设备会直接跑满刷新率甚至冲到120fps。我接手过一个老项目和同事接手的新项目同样是没设置targetFrameRate一个锁30一个冲满了120排查了半天才发现是Unity版本的默认策略变了。所以一个非常实际的建议是不要依赖默认值在项目启动阶段显式设置你想要的帧率。即使你后面想改成“不限制”也明确写一句Application.targetFrameRate -1这样别人读代码时不会被默认行为误导。2.3 精度问题和操作系统调度再来说说那个让人头疼的精度问题。设成60帧是不是就严格稳定在60实测不是的在PC上经常看到58、59、61的波动偶尔掉到55。原因藏在操作系统的定时器机制里。Unity内部限制帧率时最终要调用操作系统的Sleep一类接口来让主线程休息。而Windows系统默认的定时器精度大约是15.6毫秒这个值比16.67毫秒的目标间隔要粗。结果是Unity想睡16.67毫秒实际一睡就是15.6或31.2毫秒于是帧间隔忽长忽短帧率就在目标值附近来回抖。这不是Unity的bug而是操作系统层面的调度精度限制。如果项目对帧率稳定性有硬性要求比如做帧同步的格斗游戏、音游或者需要精确回放的录像系统可以考虑在启动时调用Windows的timeBeginPeriod(1)接口把定时器精度提到1毫秒。不过这属于系统级操作要在合适时机调用并记得timeEndPeriod恢复不推荐在生产环境里随便用开发期验证问题时倒是很好使。3. vSyncCount和targetFrameRate互相压制的垂直同步开关3.1 垂直同步的数理逻辑QualitySettings.vSyncCount是另一个和帧率有关的开关它控制的是垂直同步VSync行为。所谓垂直同步是指GPU的帧画面提交与显示器的刷新信号对齐避免画面撕裂。vSyncCount的取值含义是“每几个刷新周期提交一帧画面”vSyncCount60Hz显示器下的实际帧率120Hz显示器下的实际帧率0关闭垂直同步帧率由其他因素决定同左160 fps120 fps230 fps60 fps从表格就能看出来vSyncCount带来的帧率完全取决于显示器刷新率。在60Hz屏幕上设vSyncCount 1能锁到60帧但同一份代码放到144Hz的显示器上就会变成144帧你“限制帧率”的目标就此失效。这是vSync和targetFrameRate一个很大的区别targetFrameRate不关心屏幕刷新率它只按照你给定的时间间隔调度vSync则完全从属于屏幕刷新周期。3.2 两者互相压制时的优先级这里是最容易出问题的地方。Unity文档里明确写了当vSyncCount大于0时targetFrameRate的设置会被忽略。也就是说如果你同时设置了QualitySettings.vSyncCount 1和Application.targetFrameRate 60最终起作用的是垂直同步targetFrameRate那一行代码等于白写。在144Hz显示器上你可能设了60却依然跑在144帧怎么查都查不出代码问题最后才发现是vSync在“劫持”帧率控制权。正确做法是用targetFrameRate限帧时务必把vSyncCount设回0。QualitySettings.vSyncCount 0; Application.targetFrameRate 60;反过来如果你想用垂直同步来配合避免画面撕裂就不要再设置targetFrameRate否则也不生效。两者选择一种作为项目的帧率控制策略即可不要混用。3.3 编辑器里的VSync选项还有一个特别隐蔽的坑Unity编辑器Game视图右上角有一个“VSync”开关它对应的是编辑器预览时的垂直同步选项。这个开关和运行时代码里的QualitySettings.vSyncCount是相互独立的但它会直接影响你在编辑器里观察到的帧率表现。我遇到过这样的情况运行时脚本已经设置了vSyncCount 0和targetFrameRate 60在真机上一切正常但编辑器里帧率纹丝不动的锁在显示器刷新率上怎么调都不变。排查半天发现是Game视图工具栏的VSync被误打开了。编辑器里做帧率相关测试前一定先确认这个开关处于关闭状态否则你看到的“限帧效果”完全不可信。4. 脚本级手动限帧一个可复用的高精度工具4.1 思路帧耗时预算与Sleep在某些场景下targetFrameRate满足不了需求。比如编辑器运行时不方便改Player Settings、游戏需要根据运行状态动态调整帧率、或者你想精确控制每帧的最大耗时来做性能测试。这时候可以绕过Unity的内置机制自己写一个限帧工具。核心原理很简单记录每一帧开始的时间计算这一帧实际花了多少毫秒再用目标帧耗时减去实际耗时得到“剩余时间预算”。如果预算大于0就让主线程睡掉这段时间。4.2 一个可直接复用的限帧Manager脚本下面是我在几个项目里验证过的一套写法直接挂到场景里的空物体上就能用using System.Diagnostics; using UnityEngine; public class FrameRateLimiter : MonoBehaviour { [Tooltip(目标帧率0 表示不限帧)] public int targetFPS 60; private Stopwatch stopwatch; private double targetFrameMs; private void Awake() { stopwatch Stopwatch.StartNew(); QualitySettings.vSyncCount 0; UpdateTarget(); } private void Update() { if (targetFPS 0) return; UpdateTarget(); double elapsedMs stopwatch.Elapsed.TotalMilliseconds; double remainingMs targetFrameMs - elapsedMs; if (remainingMs 0) { // 粗略睡眠利用操作系统Sleep释放CPU int sleepMs (int)remainingMs; if (sleepMs 1) { System.Threading.Thread.Sleep(sleepMs - 1); } // 精确补偿睡眠之后再自旋等待尽量逼近目标帧时间 while (stopwatch.Elapsed.TotalMilliseconds targetFrameMs) { // 空转等待 } } stopwatch.Restart(); } private void UpdateTarget() { targetFrameMs 1000.0 / targetFPS; } }这段代码的关键在于Sleep之后再用一个自旋循环做精确补偿。前面提到操作系统Sleep精度不足单独靠Sleep往往睡过头或者睡不够加上自旋补偿后能显著提高帧间隔的稳定性。我测试过的结果纯靠targetFrameRate时帧间隔波动在±2ms左右加上这套脚本能做到±0.5ms以内对帧同步类项目来说这个精度差别很大。4.3 什么时候需要脚本限帧脚本限帧的好处还不止精度。因为帧率控制逻辑完全在你手里可以很方便地做成运行时动态调节比如打开设置面板时降到30帧省电切换到战斗场景时提到60帧保证流畅或者开发时做一个带滑动条的调试面板拖动一下就能实时改变帧率观察不同帧率下的表现。这些都是内置API不方便做到的。不过脚本限帧有个需要注意的地方它损耗的CPU比targetFrameRate略多因为每帧结束后的自旋等待阶段CPU并没有真正闲置。如果项目对功耗敏感还是优先用内置方案只有在需要精确节奏控制时才上脚本限帧。5. Fixed Timestep与模拟稳定性限帧后最容易翻车的物理和动画5.1 FixedUpdate的批次数变化限制帧率会直接改变每个渲染帧内FixedUpdate的执行次数。默认Fixed Timestep 0.02秒时60帧时一个渲染帧间隔约16.67ms大部分帧内FixedUpdate执行0~1次30帧时一个渲染帧间隔约33.3ms每帧内FixedUpdate基本1次到2次120帧时一个渲染帧间隔约8.3ms经常出现连续好几帧都不执行物理更新然后某一帧集中补多次这带来的后果是物理模拟的时间步长本身是稳定的但分发的节奏变了。在30帧下做连续碰撞检测时单次物理更新的位移量更大高速物体更容易出现穿透而120帧下则可能出现物理表现“一阵一阵”更新的现象。简单说限帧不只是渲染层的事还会波及物理层。如果你做的项目里有很多高速运动的物体限制帧率后建议重点回归测试碰撞和刚体行为。必要时可以适当调低Fixed Timestep比如从0.02改为0.015但要注意这会增加物理计算的次数CPU开销会涨。5.2 动画和Timeline的步长感游戏对象身上的Animator、粒子系统、Timeline播放默认都是逐渲染帧更新的。帧率从60降到30这些系统的单帧步长翻倍最直观的感受是动画变得“肉”特别是那些对节奏敏感的动作游戏、镜头动画或者UI动效。我做过的某个项目里为了降低发热把帧率限制到30结果发现UI面板的弹出动画明显变钝像加了慢动作特效。排查后确认不是时间缩放的问题而是动画每一步的位移跨度变大缓动曲线的中间过程被跳过了。解决方案有这么几类一是动画系统改用Animate Physics模式让动画跟着FixedUpdate跑这样动画的更新频率和物理一致不再受渲染帧率控制二是对关键UI动画使用DOTween等补间库时要确保它们的更新基于真实时间而不是渲染帧数三是如果一定要保持视觉平滑考虑在30帧下用相机Motion Blur或动态模糊来掩盖单帧步长但这是下策能不加就别加。5.3 帧率限制和Time.timeScale是两回事还有一个经常被放在一起问的问题限制帧率会不会影响Time.timeScale 0的暂停效果答案是不会。timeScale控制的是逻辑时间快慢targetFrameRate控制的是帧调度节奏两者正交。就算你把帧率限制到10帧timeScale设为0时游戏依然暂停只是暂停状态下渲染帧还是以10帧的间隔刷新。所以用限帧来做“节能暂停画面”是可行的画面会以低帧率继续渲染动态效果只是逻辑不动。6. 平台差异实测PC、移动端和WebGL的表现6.1 移动端发热降频会击穿你的帧率目标移动端是限帧需求最强烈的平台也是情况最复杂的平台。最典型的例子是你在设置里把targetFrameRate设为60真机跑起来确实能到60但玩上十分钟后手机发热系统开始降频帧率自己掉到45、30。这时候限制帧率的目标反而变成了“维持一个稳定的低帧率”而不是“追求高帧率”。我的做法是在移动端项目里做一个帧率档位自适应逻辑。检测到持续掉帧时不直接解除限制而是把targetFrameRate从60降到45再降到30让玩家感受到的是“画质档位下降”而不是“卡顿”。帧率稳定在一个档位的体验远好于在60和30之间反复横跳。另外要注意iOS上的动态刷新率屏幕ProMotion120Hz。Unity在iOS上设置targetFrameRate后会通过DisplayLink的frameInterval来实现帧率控制一般来说设60就能稳定在60。但在Android上不同厂商的刷新率策略差异很大有些机型默认开启了自适应刷新率即使targetFrameRate设60屏幕也可能以90Hz或120Hz刷新这时候画面的流畅度虽然看起来还行但功耗可能会偏高。需要严格控制功耗时可以查询Screen.currentResolution.refreshRate来动态调整策略。6.2 WebGL后台标签页的自动暂停行为WebGL平台有一个和原生平台截然不同的行为当浏览器标签页切到后台或者最小化时浏览器的requestAnimationFrame会自动停止导致Unity的帧循环暂停。这个不是Unity能控制的是浏览器为了节省资源做的一致性策略。这意味着WebGL游戏在后台时帧率是0切回来时恢复。如果你的WebGL项目有后台挂机模拟之类的功能不能依赖主循环计时需要记录真实流逝的时间来做离线收益计算。另外WebGL的targetFrameRate受限于浏览器的帧调度通常最高只能到显示器的刷新率设置超出显示器刷新率的目标值没有实际意义。6.3 编辑器和特定硬件下的测试陷阱在Unity编辑器里做限帧测试得到的数据只能拿来参考不能代表真机表现。原因一是编辑器本身有大量的Editor渲染开销二是Game视图的帧率和编辑器整体UI的刷新策略绑定三是我们前面提到的Game视图VSync开关可能干扰结果。经验做法是自己写一个统计脚本在编辑器运行模式和真机上都打点对比帧率分布曲线而不是单看帧率均值。另外我自己在测试VR项目时遇到过VR设备对帧率的特殊要求。VR设备的帧率通常必须与设备刷新率匹配比如72、90、120Hz否则会产生严重晕动感。在这些设备上主动“锁帧”是不推荐的反而要做的是尽量保持达到设备刷新率并在掉帧时使用异步空间扭曲等设备级补偿机制。7. 验证限帧效果帧率统计和异常排查完整套路7.1 正确统计帧率的方式限制帧率做得对不对前提是帧率统计方法是对的。常见错误做法是每秒统计一次帧数然后把这60个fps数值做平均。这样算出来的平均帧率在帧率波动时会失真比如一秒钟内前半秒30fps、后半秒60fps平均是45但这个数值并没有说明真实体验。正确做法是用帧时间的平均值再取倒数也就是先对每帧耗时做平均再换算成fps。实现起来很简单using System.Collections.Generic; using UnityEngine; public class FPSCounter : MonoBehaviour { public float smoothFPS { get; private set; } private Queuefloat frameTimeQueue new Queuefloat(); private const int sampleCount 60; private float timeAccumulator 0f; private void Update() { float dt Time.unscaledDeltaTime; timeAccumulator dt; frameTimeQueue.Enqueue(dt); if (frameTimeQueue.Count sampleCount) { timeAccumulator - frameTimeQueue.Dequeue(); } if (frameTimeQueue.Count sampleCount timeAccumulator 0f) { smoothFPS 1f / (timeAccumulator / sampleCount); } } }滑动窗口最后计算的是“最近60帧的平均帧耗时”的倒数能比较平稳地反映真实性能趋势又不会像瞬时帧率那样抖动得没法看。开发时把这个数值显示在屏幕上同时记录最大值、最小值、P1最差1%的帧耗时比只看平均帧率可靠得多。7.2 限帧失效的排查清单最后整理一份我自己排查限帧问题时的清单按出现频率排序现象优先检查项设置了targetFrameRate但帧率没变vSyncCount是否大于0编辑器Game视图VSync是否开启帧率锁在显示器刷新率而不是目标值检查是否有其他代码或插件重设了vSyncCount数字在60和30之间跳检查系统定时器精度把targetFrameRate设为30/60是否整除屏幕刷新率真机生效但编辑器不生效编辑器自身开销Frame Debugger在运行时是否开启整个游戏变慢而不是帧率变化检查Time.timeScale是否被意外改变Animator是否用了Normal模式限帧后UI动画不丝滑固定步长变大导致缓动中间帧被跳换用基于真实时间的补间方案这里解释一下“整除”问题在144Hz显示器上限制到60帧帧间隔16.67ms和屏幕刷新间隔6.94ms不是整数倍关系画面刷新会呈现不规则的节奏人眼会觉得有不自然的微抖动。遇到这种感觉可以考虑限制到72帧144的一半或者48帧144的三分之一节奏会稳很多。这也算是限帧方案里被讨论得比较少但实际影响很大的细节。最后一个我自己的习惯做帧率控制时把设置项做成一个集中的配置类里面同时管理targetFrameRate、vSyncCount、Fixed Timestep和平台策略不要在代码里散落多处直接赋值。项目后期做性能优化时拿着一页配置表比全局搜赋值语句省力得多。有一次我排查另一个同事的项目发现targetFrameRate被某个广告SDK在初始化时重设为30找了整整一天最后用全局搜索把所有赋值点列出来才定位到这种苦头尝过一次就再也不想尝第二次。
返回列表