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/非物理动画 限制器:省电 + 稳定帧率 配合 V-Sync 使用更佳

从图里能看出来,Delta Time 是入口,步长策略是分水岭,帧率限制器是最后的阀门。我个人建议:物理和核心逻辑用固定步长,视觉和 UI 用可变步长,帧率限制器始终开启。这样既保证了确定性,又兼顾了流畅度和功耗。

最后一个小技巧:如果你在做编辑器工具或调试面板,可以加一个「帧率控制模式」的切换开关。固定步长和可变步长随时切换,方便对比效果。我自己的工具链里一直保留这个功能,排查动画抖动问题时特别好用。