《Cocos 游戏开发入门》4.2 固定步长物理与渲染插值

可变 dt 会让物理抖、会穿墙。本节用累加器把真实时间切成固定步长:讲清 maxSubSteps 如何防死亡螺旋、渲染插值 alpha 怎么把画面抹平,并把 mini2d 的 Clock 类完整写出来,包括「对增量取整而非绝对时间戳取整」这个关键点,最后给出 Cocos 3.8 对照。

4.2 固定步长物理与渲染插值

4.1 节我们让速度乘上了 dt,运动学不再随帧率变化。但那只解决了「量」的问题,没解决「稳」的问题。真实帧率是抖的:这一帧 16ms,下一帧可能 40ms,再下一帧 12ms。把这种忽大忽小的 dt 直接喂给物理,会让碰撞检测、摩擦、堆叠这类依赖步长精度的计算变得不可预测——同一个跳跃,在不同的帧率下可能落到平台边缘内,也可能穿过去。

这一节给出游戏工业里用了二十多年的标准答案:固定步长模拟 + 可变频率渲染。逻辑永远以恒定的 1/60 秒推进,渲染则按屏幕真实刷新率来画,中间用插值补平滑。

本节是第 4 章的核心,也是全书唯一一个「把时间管理写成类」的地方。我们会从零写出 mini2d 的 Clock,并解释它为什么用整数微秒累加、为什么对增量取整而不是对绝对时间戳取整。这些细节正是引擎源码里不会写进文档、但错了就会「60 帧只跑出 58 步」的地方。

4.2.1 可变 dt 为什么会抖

先看问题本身。物理里有大量形如「先判断接触、再修正位置」的逻辑。以落地为例:

// 可变 dt 下,一步可能跨过地面
p.y += p.vy * dt;
if (p.y <= GROUND) {
  p.y = GROUND;
  p.vy = 0;
}

当 p.vy 是 -800 px/s、dt 是 0.04s 时,这一步下落 32 像素。如果地面在 5 像素之下,方块这一帧直接「跳过」了地面——碰撞判定发生在越界之后,于是要么修正得不自然,要么(在有厚度的平台上)直接穿过去。

帧率越高,单步位移越小,判定越准;帧率越低,越容易漏。这就是为什么同一套物理代码,在高刷手机上稳、在老设备上抖。固定步长的意义,就是让单步位移永远是同一个数,物理的精度不再取决于运气。

4.2.2 累加器:把可变时间切成固定步

思路很直接:不直接用真实 dt 跑物理,而是把它攒进一个「累加器」,每攒够一个固定步长就跑一步。

const FIXED_STEP = 1 / 60;   // 固定步长(秒)
let accumulator = 0;

function frame(now: number): void {
  const dt = (now - last) / 1000;
  last = now;

  accumulator += dt;                       // 把真实时间攒起来
  while (accumulator >= FIXED_STEP) {      // 攒够一步就跑一步
    step(FIXED_STEP);                      // 注意:传的是固定步长,不是 dt
    accumulator -= FIXED_STEP;
  }

  render(accumulator / FIXED_STEP);        // 剩下的零头交给渲染插值
}

三件事被拆开了:

变量含义单位
dt这一帧真实经过的时间秒(可变)
FIXED_STEP逻辑模拟的恒定步长秒(常量)
accumulator还没被消耗的剩余时间秒([0, FIXED_STEP))

关键点在于 step(FIXED_STEP) 里传的是固定步长,不是真实 dt。无论真实帧率是 30 还是 144,逻辑每次看到的 dt 都是 1/60。物理从此运行在一个均匀的时间轴上。

4.2.3 maxSubSteps 与死亡螺旋

上面的 while 循环有一个致命隐患。假设某一帧卡了 5 秒(切标签页回来、或者一次大 GC),累加器里会攒下 5 秒。while 就会一次性补 5 / (1/60) = 300 步。而这 300 步本身要花时间,可能又超过了一帧的预算,导致下一帧累加得更多——补得越多、越卡、越要补。这就是著名的死亡螺旋(death spiral)。

修法是给单帧能补的步数封顶:

const MAX_SUB_STEPS = 5;

let steps = 0;
while (accumulator >= FIXED_STEP && steps < MAX_SUB_STEPS) {
  step(FIXED_STEP);
  accumulator -= FIXED_STEP;
  steps += 1;
}
// 超出的时间直接丢弃,游戏表现为「慢动作」而不是「卡死」
if (steps === MAX_SUB_STEPS) accumulator = 0;

MAX_SUB_STEPS = 5 意味着单帧最多按 5/60 ≈ 83ms 计算。卡顿超过这个量,多出来的时间被丢弃——玩家看到的是游戏「跳了一下然后继续」,而不是彻底卡死。这是「宁可丢时间,不可卡死」的经典取舍。

mini2d 里这个参数叫 maxSubSteps,默认 5。我们用一个假时钟测试验证过:一帧卡 5 秒,frame() 也只返回 5。

4.2.4 渲染插值 alpha

固定步长带来一个新问题:逻辑一步 16.7ms,但屏幕可能 8.3ms 就刷新一次。某次渲染发生时,逻辑可能「刚好停在两步之间」。如果直接把逻辑的当前位置画出来,画面会在两个固定位置之间跳,看起来一顿一顿的。

解法是渲染插值:用累加器里剩下的零头算出 alpha = accumulator / FIXED_STEP,把上一个逻辑位置和当前逻辑位置按 alpha 线性混合:

// 逻辑每步记录「上一步」和「当前」两个位置
prevX = currX;
currX = currX + vx * FIXED_STEP;

// 渲染时按 alpha 混合,alpha ∈ [0, 1)
const drawX = prevX + (currX - prevX) * alpha;

alpha 是 [0, 1) 之间的小数:0 表示刚好跑完一步,接近 1 表示马上就要跑下一步。视觉上,方块在两个离散逻辑位置之间被平滑地摆到中间,60Hz 的物理也能在 144Hz 屏幕上顺滑移动。

插值是纯表现层的补偿,绝不能写回逻辑状态。drawX 只用来画,物理里的 currX 不受影响。把插值结果存回物理,下一帧就会基于被污染的位置继续算,累积出可见的漂移。

4.2.5 把 Clock 类写出来

把上面几节拼起来,就是 mini2d 的 Clock。它要同时管好三件事:累加、封顶、给出 alpha。先看接口:

export interface TimeSource {
  now(): number;   // 单调递增的毫秒时间戳(可以是小数)
}

export interface StepStats {
  steps: number;   // 本帧实际执行的固定步数
  alpha: number;   // 本帧的插值系数 [0, 1)
}

TimeSource 是时间源的抽象:真实环境用 performance.now(),测试里换成手动推进的 FakeTime。这样「假时钟」和全书「假模型 / 假渲染器」是同一个思路——把不确定的输入换成确定的可注入对象。

export class FakeTime implements TimeSource {
  private t: number;

  constructor(startMs = 0) {
    this.t = startMs;
  }

  now(): number {
    return this.t;
  }

  advance(ms: number): void {
    this.t += ms;
  }
}

4.2.6 对增量取整,而不是对绝对时间戳取整

Clock 里有一个非常反直觉、但决定了正确性的细节。先看常量:

const US_PER_SECOND = 1_000_000;

export class Clock {
  readonly fixedStep: number;
  readonly maxSubSteps: number;

  private readonly fixedStepUs: number;
  private readonly time: TimeSource;
  private accumulatorUs = 0;   // 用整数微秒累加
  private lastMs: number | null = null;

  constructor(time: TimeSource = realTime, options: ClockOptions = {}) {
    this.time = time;
    this.fixedStep = options.fixedStep ?? 1 / 60;
    this.maxSubSteps = options.maxSubSteps ?? 5;
    this.fixedStepUs = Math.round(this.fixedStep * US_PER_SECOND);
  }

注意 accumulatorUs 是整数微秒,不是秒。为什么不直接用秒做浮点累加?因为浮点误差会累积。

假设用秒累加:t += 16.666666666666668 / 1000。16.666…ms 除以 1000 得到的 elapsed 有时会比 fixedStep 小一个 ulp(最后一位的误差),于是 while (accumulator >= fixedStep) 判 false,这一帧就少跑一步。60 帧跑下来可能只跑了 58 步——一个几乎无法定位的「时间凭空少了一点」的 bug。

用整数微秒累加就绕开了它:Math.round(elapsedMs * 1000) 得到一个精确整数,比较和相减都是整数运算,不会有 ulp 误差。

但还有一个更隐蔽的点:要对增量取整,而不是对绝对时间戳取整。

tick(step: () => void): StepStats {
  const nowMs = this.time.now();
  if (this.lastMs === null) {
    this.lastMs = nowMs;                 // 第一帧只对齐基准
    return { steps: 0, alpha: 0 };
  }

  // 关键:对增量取整,而不是对绝对时间戳取整
  const elapsedMs = Math.max(0, nowMs - this.lastMs);
  this.lastMs = nowMs;
  const elapsedUs = Math.round(elapsedMs * 1000);   // 增量 → 整数微秒

  const maxElapsedUs = this.fixedStepUs * this.maxSubSteps;
  this.accumulatorUs += Math.min(elapsedUs, maxElapsedUs);

  let steps = 0;
  while (this.accumulatorUs >= this.fixedStepUs) {
    step();
    this.accumulatorUs -= this.fixedStepUs;
    steps += 1;
  }

  return { steps, alpha: this.accumulatorUs / this.fixedStepUs };
}

如果改成对绝对时间戳取整(Math.round(nowMs * 1000)),会把两次相邻取整的误差做差,差值可能少 1 微秒。一帧只有 16667 微秒,少 1 微秒就足够让这一帧的累加器差一点点够不上 fixedStepUs,于是少跑一步。对增量取整,误差不会跨帧累积——每一帧算的都是「这一帧真实过了多久」,互不影响。

4.2.7 用假时钟测试

有了可注入的 TimeSource,测试完全确定,不需要真的等 16 毫秒:

import { describe, expect, it } from 'vitest';
import { FakeTime } from '../core/clock';
import { Game } from '../game';

const STEP_MS = (1 / 60) * 1000;

describe('Clock 固定步长', () => {
  it('1 秒切成 60 步', () => {
    const time = new FakeTime();
    const game = new Game({ time });
    let total = 0;
    for (let i = 0; i < 60; i += 1) {
      time.advance(STEP_MS);
      total += game.frame();
    }
    // 第一帧只对齐基准;剩下 59 帧各推进 1 步。
    // 因为用整数微秒累加,这个数字精确,不需要容差。
    expect(total).toBe(59);
  });

  it('单帧卡顿不会触发死亡螺旋:最多补 5 步', () => {
    const time = new FakeTime();
    const game = new Game({ time });
    time.advance(STEP_MS);
    game.frame();
    time.advance(5000);        // 卡了 5 秒
    expect(game.frame()).toBe(5);
  });
});

第二个测试正是 4.2.3 的死亡螺旋防护:卡 5 秒本应补 300 步,但 maxSubSteps 把它截成 5 步。断言写的是精确的 toBe(5),不是「小于 300」——整数微秒累加让这个数字是确定的。

4.2.8 Cocos Creator 3.8 对照

Cocos 3.8 把「固定步长 + 渲染」这套东西内建在引擎里,你几乎不用自己写累加器。

  • 逻辑回调:Component.update(dt) 每帧调用,dt 是可变秒数;Component.lateUpdate(dt) 在所有 update 之后调用,适合放相机跟随、插值表现——对应我们 render(alpha) 的位置。
  • 引擎主循环:director 驱动 update → lateUpdate → 渲染 这条流水线;想在这条流水线里插钩子,可以监听 Director.EVENT_BEFORE_UPDATE / EVENT_AFTER_UPDATE 等事件。
  • 物理固定步长:Cocos 的物理系统(PhysicsSystem / 内置 2D 物理)自己维护固定步长和子步,你只需要在物理回调里拿结果,不必手写累加器。

对照表:

概念mini2dCocos Creator 3.8
固定步长常量Clock.fixedStep物理系统内部配置
单帧补步上限Clock.maxSubSteps物理 maxSubSteps 配置
每帧逻辑Game.step(dt)Component.update(dt)
帧末表现render(alpha)Component.lateUpdate(dt)
主循环钩子自己写 frame()director 事件

本节的 Cocos 部分只做概念对照,代码不落 L2 片段(因为核心逻辑在引擎内部,没有对等的可复制接口)。Cocos 侧真正的可跑代码会在卷二的工程实践里给出。

下一节处理时间轴上的另一类需求:当你要「暂停游戏但让 UI 继续动」,或者做「慢动作」「命中停顿」时,固定步长和 dt 该怎么配合。核心是引入一个 timeScale,并想清楚它作用在哪一层。

阅读导航:上一节:4.1 帧率无关的移动与 deltaTime · 下一节:4.3 暂停、时间缩放与慢动作 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画