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 时间缩放、暂停与慢动作 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。