《游戏引擎实现原理》2.2 固定步长、累加器与渲染插值

上一节量出的帧时间抖得厉害,直接喂给物理会跑出不确定的结果。本节实现 FixedStepLoop:用累加器把不均匀的 dt 变成均匀的物理步,再用 alpha 做渲染插值;并真实复现死亡螺旋,用 maxStepsPerFrame 把它钳制住。

2.2 固定步长、累加器与渲染插值

上一节我们量出了一件事:真实帧时间抖得厉害——17ms、33ms、8ms 来回跳,还时不时冒出 34ms 以上的尖峰。如果把这种抖动的 dt 直接喂给物理积分,同一套参数在快机器和慢机器上会跑出不同结果:弹跳高度不同、碰撞穿透不同、录像回放对不上。

固定步长(fixed timestep) 就是为解决这件事:物理永远以固定的 Δt 推进,与渲染帧率解耦。本节实现 FixedStepLoop——累加真实经过的时间,每攒够一个固定步长就跑一步物理,剩下的零头作为渲染插值系数 alpha。最后我们会亲手复现「死亡螺旋」,并用一个上限把它按住。

为什么物理必须固定步长

变步长积分的问题在误差。最简单的欧拉积分 x += v · dt,截断误差是 O(dt²);dt 一变,误差跟着变。弹簧、布料、刚体约束这类系统对 dt 极其敏感,dt 一大就发散——物体被弹飞、关节炸开。固定步长让积分器始终面对恒定的 dt,误差可控,而且结果可复现:同样的初始状态、同样的步数,一定得到同样的结果,这对录像回放和联机同步是刚需。

代价有两个:一是需要同时维护「上一物理态」和「当前物理态」两份状态,渲染时在两者之间插值;二是输入延迟最多增加一个固定步长(60Hz 下约 16.67ms),这在大多数游戏里可以接受。

累加器:把不均匀的 dt 变成均匀的步

核心是一个浮点累加器,逻辑只有三行:

accumulator += 本帧真实 dt
while accumulator >= step:
    accumulator -= step
    跑一步物理(固定 step)
alpha = accumulator / step        // 恒落在 [0, 1)

渲染时用 alpha 在「上一物理态」与「当前物理态」之间插值,画面就不会因为物理步率低于渲染率而一顿一顿。这里有一条硬约束:alpha 必须严格落在 [0, 1)。一旦 alpha 到了 1,插值就变成了外推,物体会跑到它还没到达的位置,表现为抖动或穿模。

可运行产物:FixedStepLoop

FixedStepLoop 不自己积分,只负责「攒时间、数步数、算 alpha」;真正的物理积分通过 onStep 回调注入。这样循环本身与具体物理无关,可单独测试。

// file: src/core/fixed-step-loop.ts
// tier: L1
// verify: npx vitest run src/core/fixed-step-loop.test.ts
//
// helio 内核:固定步长主循环 + 渲染插值。
// 物理以固定 stepSeconds 推进,渲染帧只负责「补间」到当前时刻。
// maxStepsPerFrame 是死亡螺旋(spiral of death)的唯一刹车。

export interface FixedStepConfig {
  /** 固定步长(秒),如 1/60。 */
  stepSeconds: number;
  /** 单帧最多执行的物理步数,超过就丢弃剩余时间。 */
  maxStepsPerFrame: number;
}

export interface StepOutcome {
  /** 本帧执行的物理步数。 */
  steps: number;
  /** 渲染插值系数,恒落在 [0, 1)。 */
  alpha: number;
  /** 被 maxStepsPerFrame 钳制丢弃的时间(秒)。 */
  droppedSeconds: number;
  /** 本帧结束后累加器里的剩余时间(秒)。 */
  accumulatorSeconds: number;
}

export class FixedStepLoop {
  readonly #stepSeconds: number;
  readonly #maxSteps: number;
  #accumulator = 0;

  constructor(config: FixedStepConfig) {
    if (!(config.stepSeconds > 0)) {
      throw new RangeError(`stepSeconds 必须为正,收到 ${config.stepSeconds}`);
    }
    if (!Number.isInteger(config.maxStepsPerFrame) || config.maxStepsPerFrame <= 0) {
      throw new RangeError(`maxStepsPerFrame 必须是正整数,收到 ${config.maxStepsPerFrame}`);
    }
    this.#stepSeconds = config.stepSeconds;
    this.#maxSteps = config.maxStepsPerFrame;
  }

  get stepSeconds(): number {
    return this.#stepSeconds;
  }

  get maxStepsPerFrame(): number {
    return this.#maxSteps;
  }

  get accumulatorSeconds(): number {
    return this.#accumulator;
  }

  reset(): void {
    this.#accumulator = 0;
  }

  /**
   * 喂入本帧真实经过的秒数,返回本帧应跑的物理步数与插值系数。
   * `onStep(stepSeconds)` 每执行一步被调用一次,真正的积分写在这里。
   */
  advance(deltaSeconds: number, onStep?: (stepSeconds: number) => void): StepOutcome {
    if (!(deltaSeconds >= 0)) {
      throw new RangeError(`deltaSeconds 必须非负,收到 ${deltaSeconds}`);
    }
    this.#accumulator += deltaSeconds;
    let steps = 0;
    while (this.#accumulator >= this.#stepSeconds) {
      if (steps === this.#maxSteps) {
        // 钳制:这一帧已经追不上了,丢弃剩余时间,防止下一帧更糟。
        const dropped = this.#accumulator;
        this.#accumulator = 0;
        return { steps, alpha: 0, droppedSeconds: dropped, accumulatorSeconds: 0 };
      }
      this.#accumulator -= this.#stepSeconds;
      steps++;
      onStep?.(this.#stepSeconds);
    }
    return {
      steps,
      alpha: this.#accumulator / this.#stepSeconds,
      droppedSeconds: 0,
      accumulatorSeconds: this.#accumulator,
    };
  }
}

/** 用插值系数在上一物理态与当前物理态之间补间,供渲染使用。 */
export function interpolate(prev: number, curr: number, alpha: number): number {
  return prev + (curr - prev) * alpha;
}

验证命令:npx vitest run src/core/fixed-step-loop.test.ts。

第一步先用「不均匀 dt」把两条核心断言钉死:喂 16.6ms / 33ms / 8ms 交替的 90 帧,断言①每帧 alpha ∈ [0, 1),②90 帧累计步数恰好等于 floor(总时间 / 步长)(因为没有任何一步被丢弃,累加器只是搬运时间,不产生也不消灭时间)。这两条一旦成立,说明「步数正确」和「插值系数合法」都没问题。

完整测试文件如下。它同时覆盖了「不足一步只累积」「onStep 按步长触发」「钳制丢弃」「死亡螺旋」四组场景,是本节所有数字的来源。

// file: src/core/fixed-step-loop.test.ts
// tier: L1
// verify: npx vitest run src/core/fixed-step-loop.test.ts
//
// 断言:不均匀 dt 下步数正确、alpha ∈ [0,1)、钳制生效、死亡螺旋可复现。

import { describe, expect, it } from 'vitest';
import { FixedStepLoop, interpolate } from './fixed-step-loop.ts';

const STEP = 1 / 60;

describe('FixedStepLoop', () => {
  it('不均匀 dt 下物理步数正确、alpha ∈ [0,1)', () => {
    const loop = new FixedStepLoop({ stepSeconds: STEP, maxStepsPerFrame: 8 });
    const dts = [16.6, 33, 8]; // 交替:偏慢 / 慢 / 快
    let totalSteps = 0;
    let totalMs = 0;
    for (let i = 0; i < 90; i++) {
      const ms = dts[i % dts.length];
      const out = loop.advance(ms / 1000);
      expect(out.alpha).toBeGreaterThanOrEqual(0);
      expect(out.alpha).toBeLessThan(1);
      expect(out.droppedSeconds).toBe(0); // 没有触发钳制
      totalSteps += out.steps;
      totalMs += ms;
    }
    // 无钳制时,总步数 == floor(总时间 / 步长)
    const expected = Math.floor(totalMs / 1000 / STEP);
    expect(totalSteps).toBe(expected);
  });

  it('单帧喂 16.6ms 不足一步,只累积不推进', () => {
    const loop = new FixedStepLoop({ stepSeconds: STEP, maxStepsPerFrame: 8 });
    const out = loop.advance(16.6 / 1000);
    expect(out.steps).toBe(0);
    expect(out.alpha).toBeCloseTo(16.6 / 1000 / STEP, 6);
  });

  it('onStep 回调按步长触发', () => {
    const loop = new FixedStepLoop({ stepSeconds: STEP, maxStepsPerFrame: 8 });
    const seen: number[] = [];
    const out = loop.advance(50 / 1000, (s) => seen.push(s));
    expect(out.steps).toBe(3);
    expect(seen).toEqual([STEP, STEP, STEP]);
  });

  it('插值在两端之间取值', () => {
    expect(interpolate(10, 20, 0)).toBe(10);
    expect(interpolate(10, 20, 0.5)).toBe(15);
    expect(interpolate(10, 20, 0.99)).toBeCloseTo(19.9, 6);
  });

  it('maxStepsPerFrame 钳制并丢弃剩余时间', () => {
    const loop = new FixedStepLoop({ stepSeconds: STEP, maxStepsPerFrame: 5 });
    const out = loop.advance(1); // 喂 1 秒 = 60 步
    expect(out.steps).toBe(5);
    expect(out.droppedSeconds).toBeGreaterThan(0);
    expect(out.alpha).toBe(0);
  });

  it('复现死亡螺旋:单帧步数指数增长(无钳制)', () => {
    // 反馈模型:本帧跑 N 步,每步真实成本 = 2×步长(机器带不动物理),
    // 于是「下一帧真实经过的时间」= N × 2 × step → N 每帧翻倍。
    const cost = STEP * 2;
    const loop = new FixedStepLoop({ stepSeconds: STEP, maxStepsPerFrame: 1_000_000 });
    let elapsed = STEP; // 首帧 16.67ms
    const trace: number[] = [];
    let maxSteps = 0;
    for (let i = 0; i < 40; i++) {
      const out = loop.advance(elapsed);
      if (i < 12) trace.push(out.steps);
      maxSteps = Math.max(maxSteps, out.steps);
      elapsed = out.steps * cost; // 工作量反过来决定下一帧的 dt
    }
    console.log('spiral trace (前 12 帧步数):', trace.join(' → '));
    console.log('spiral 40 帧内单帧最大步数:', maxSteps);
    expect(trace.slice(0, 5)).toEqual([1, 2, 4, 8, 16]);
    expect(maxSteps).toBeGreaterThan(1000);
  });

  it('钳制后同一反馈模型保持有界', () => {
    const cost = STEP * 2;
    const loop = new FixedStepLoop({ stepSeconds: STEP, maxStepsPerFrame: 5 });
    let elapsed = STEP;
    let maxSteps = 0;
    let dropped = 0;
    for (let i = 0; i < 1000; i++) {
      const out = loop.advance(elapsed);
      maxSteps = Math.max(maxSteps, out.steps);
      dropped += out.droppedSeconds;
      elapsed = out.steps * cost;
    }
    console.log('钳制后 1000 帧单帧最大步数:', maxSteps, '累计丢弃秒数:', dropped.toFixed(2));
    expect(maxSteps).toBeLessThanOrEqual(5);
    expect(dropped).toBeGreaterThan(0);
  });
});

7 个用例全部通过(本机 Test Files 1 passed、Tests 7 passed)。下面引用到的每一个数字,都直接来自这段输出。

死亡螺旋与 maxSteps 钳制

现在看最危险的情况。如果某一帧特别慢——比如加载贴图卡了 1 秒——累加器里会攒下 1 秒,也就是 60 个物理步。这一帧要跑 60 步物理,而跑 60 步本身又要花时间;于是下一帧的真实 dt 更大,攒的步更多……步数指数增长,帧率雪崩,再也追不上。这就是死亡螺旋(spiral of death)。

用一个反馈模型把它复现出来:设每个物理步的真实成本是步长的 2 倍(代表一台带不动这套物理的机器),并让「下一帧真实经过的时间」等于本帧步数乘以每步成本。本机跑出来的前 12 帧步数是:

spiral trace (前 12 帧步数): 1 → 2 → 4 → 8 → 16 → 32 → 63 → 127 → 254 → 508 → 1015 → 2031

每帧翻倍。第 12 帧已经要跑 2031 步,40 帧之内就直接顶到 maxStepsPerFrame 设的 1e6 上限——这还只是模拟,真实机器上帧率早就掉到个位数了。

唯一的刹车是给单帧步数设上限 maxStepsPerFrame:超了就丢弃剩余时间。宁可让物理时间走慢一点(玩家看到慢动作),也不能让帧率崩掉(玩家看到彻底卡死)。同一个反馈模型加上 maxStepsPerFrame = 5:

钳制后 1000 帧单帧最大步数: 5 累计丢弃秒数: 83.05

单帧步数被死死钉在 5,1000 帧累计丢弃了 83.05 秒逻辑时间。丢弃听起来很浪费,但它换来的是帧率有界——这正是死亡螺旋唯一的解药。

上限不能随便设。设太大(比如 1000),死亡螺旋只是被推迟而不是被阻止,一帧跑 1000 步照样能卡到几秒;设太小(比如 1),稍微抖一下的帧就会丢时间,物理看起来像在「打嗝」。一个实用的取值原则是:maxStepsPerFrame ≈ 一帧预算能容纳的物理步数。60Hz、物理每步约 2ms 的话,16.67ms / 2ms ≈ 8,取 8 左右比较稳妥。它同时是「追帧的野心」和「帧率的底线」之间的一条线。

与渲染插值的配合

钳制只在极端情况下触发;正常帧里循环几乎总是走 alpha ∈ [0,1) 的那条返回路径。渲染时用它插值:

render(prevState, currState, alpha):
    for each entity:
        pos = interpolate(prevState.pos, currState.pos, alpha)

prevState 是上一步物理结束时的快照,currState 是当前物理态。因为物理步率(如 60Hz)常常低于渲染率(如 144Hz),同一个 currState 会被连续渲染若干帧——没有插值的话,画面就会以 60Hz 的节奏「跳」,看起来比实际帧率还低。

阅读导航:上一节:2.1 帧、时间与帧预算 · 下一节:2.3 时间缩放、暂停与慢动作 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径