3. 帧率控制原理:固定时间步长、可变时间步长、Delta Time 计算、帧率限制器
帧率控制,说白了就是让动画在用户屏幕上跑得「稳」。不管用户的显示器是 60Hz 还是 144Hz,不管 CPU 偶尔卡一下,动画都不能忽快忽慢。我早年做游戏时就被这个坑过——明明逻辑没问题,动画却像在坐过山车。后来才明白,核心就在时间步长和 Delta Time 上。
核心目标:让动画的物理表现与帧率解耦。无论 FPS 怎么变,物体移动的距离、旋转的角度、碰撞的判定,都只依赖真实流逝的时间,而不是帧数。
3.1 固定时间步长(Fixed Time Step)
固定步长,就是每次更新逻辑时,都假设过去了相同的时间。比如 1/60 秒。不管实际渲染帧率是多少,物理和逻辑更新都按这个节奏跑。
我习惯在需要精确物理模拟的场景用固定步长。比如刚体碰撞、粒子系统、网络同步。为什么?因为物理引擎的积分器对时间步长很敏感,步长一变,结果可能就飘了。
// 固定时间步长示例(伪代码)
const float FIXED_DT = 1.0f / 60.0f; // 每步 16.67ms
float accumulator = 0.0f;
void Update(float deltaTime) {
accumulator += deltaTime;
while (accumulator >= FIXED_DT) {
FixedUpdate(FIXED_DT); // 物理/逻辑更新
accumulator -= FIXED_DT;
}
// 可选:插值渲染
}
我的经验:固定步长最大的坑是「螺旋式死亡」。如果一次实际帧耗时超过步长,accumulator 会越积越多,导致一帧内执行多次 FixedUpdate。我曾经在低端机上遇到过物理直接崩掉。解决方案是加一个最大迭代次数限制,比如最多执行 5 次,剩下的丢弃。
3.2 可变时间步长(Variable Time Step)
可变步长,就是每次更新都传入真实的帧间隔时间。Delta Time 是多少,逻辑就跑多少。简单直接,适合 UI 动画、摄像机跟随、非物理类缓动。
// 可变时间步长示例
void Update(float deltaTime) {
// 直接使用 deltaTime 驱动
transform.position += velocity * deltaTime;
// 或者做插值
alpha = Mathf.Lerp(alpha, targetAlpha, 0.1f * deltaTime);
}
但要注意,可变步长有个致命问题:不确定性。如果某帧卡了 500ms,Delta Time 就会很大,物体可能直接穿模。我曾在项目中用可变步长做角色移动,结果玩家从高处跳下时帧率骤降,角色直接掉到地图外面去了。
避坑指南:可变步长绝对不能用于物理碰撞、网络状态同步、关键帧判定。只适合视觉上「慢一点快一点无所谓」的场景。如果你不确定,优先用固定步长。
3.3 Delta Time 计算
Delta Time 就是两帧之间的真实时间差。计算本身很简单,但精度和边界情况容易翻车。
| 方法 | 精度 | 适用场景 | 注意事项 |
|---|---|---|---|
| 系统时钟差 | 毫秒级 | 通用 | 注意时钟回拨(少见) |
| 高精度计时器 | 微秒级 | 物理引擎、音频 | QueryPerformanceCounter 等 |
| 帧计数器 + 固定刷新率 | 近似 | 老旧平台 | 不准确,不推荐 |
// 标准 Delta Time 计算(C++ 风格)
static auto lastTime = std::chrono::high_resolution_clock::now();
float GetDeltaTime() {
auto currentTime = std::chrono::high_resolution_clock::now();
float dt = std::chrono::duration<float>(currentTime - lastTime).count();
lastTime = currentTime;
// 防抖:如果 dt 太大(比如切窗口、断点调试),直接截断
if (dt > 0.1f) dt = 0.016f; // 或者直接返回 0
return dt;
}
我踩过的坑:有一次在移动端,系统休眠唤醒后返回的时钟差巨大,Delta Time 直接变成 30 秒。动画瞬间飞到了天边。从那以后,我每次计算 Delta Time 都会加一个上限钳制(clamp),一般设为 100ms 以内。超过就按上一帧的步长处理。
3.4 帧率限制器(Frame Rate Limiter)
帧率限制器,就是主动控制渲染循环的节奏。为什么要限制?三个原因:
- 省电:移动端跑 120fps 和 60fps,功耗差一倍。
- 稳定:帧率波动比低帧率更影响体验。稳定 30fps 比 30~60 跳变舒服得多。
- 同步:配合垂直同步(V-Sync)防止画面撕裂。
实现方式主要有两种:
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 忙等待(Busy Wait) | 循环检测时间,未到就空转 | 精度高,延迟低 | CPU 满载,费电 |
| 休眠等待(Sleep Wait) | 调用系统 Sleep 函数 | 省电,CPU 空闲 | 精度低(最小约 1ms) |
| 混合模式 | 先 Sleep 大部分时间,再忙等待微调 | 兼顾精度和省电 | 实现稍复杂 |
// 混合帧率限制器示例(目标 60fps)
const float TARGET_FRAME_TIME = 1.0f / 60.0f; // 16.67ms
void FrameRateLimiter() {
float frameTime = GetDeltaTime(); // 上一帧实际耗时
float sleepTime = TARGET_FRAME_TIME - frameTime;
if (sleepTime > 0.002f) { // 剩余超过 2ms,先休眠
std::this_thread::sleep_for(std::chrono::milliseconds(
static_cast<int>(sleepTime * 1000 - 1)));
}
// 忙等待微调,直到精确时间点
while (GetDeltaTime() < TARGET_FRAME_TIME) {
// 空转或 yield
}
}
注意:帧率限制器不能替代垂直同步。V-Sync 是硬件级别的同步,限制器是软件层面的节流。两者可以配合使用:限制器设定上限,V-Sync 保证不撕裂。我一般建议在 PC 端用 V-Sync + 限制器,移动端只用限制器。
3.5 核心逻辑关系图
下面这张图梳理了帧率控制的整体脉络。你看一眼就能明白各个概念怎么配合。
从图里能看出来,Delta Time 是入口,步长策略是分水岭,帧率限制器是最后的阀门。我个人建议:物理和核心逻辑用固定步长,视觉和 UI 用可变步长,帧率限制器始终开启。这样既保证了确定性,又兼顾了流畅度和功耗。
最后一个小技巧:如果你在做编辑器工具或调试面板,可以加一个「帧率控制模式」的切换开关。固定步长和可变步长随时切换,方便对比效果。我自己的工具链里一直保留这个功能,排查动画抖动问题时特别好用。