站内已有一整套引擎内核 方向的专题,但那是 helio 引擎内核的架构与实现(TS + WebGPU,180 篇),讲的是渲染管线、资源系统、ECS 这些引擎内部怎么搭。本章讲的是小游戏语境下的确定性与生命周期:可变帧率下如何保证同一局能复现、切后台回来如何把状态对齐。换句话说,那套讲「引擎怎么写」,本卷这一章讲「一款小游戏怎么让自己的逻辑在千奇百怪的设备帧率下依然说一不二」。
上一章我们做完了资源与素材管线,回答了「包体怎么塞进限制里」。从这一节开始进入运行时。而运行时的第一件事,不是渲染、不是输入,而是时间——一帧到底代表多少游戏时间。这件事看起来最基础,却是后面所有可复现性、回放、反作弊的地基。
可变帧率为什么会毁掉可复现
先把问题摆出来。最直觉的写法是「每帧跑一步」:读一次帧间隔 dt,把物理推进 dt 秒。问题是,dt 由设备决定——30fps 的设备一帧 33ms,120fps 的设备一帧 8ms,还有各种掉帧造成的 47ms、61ms。同一个「推右两秒」的操作,在两种设备上会走出不同的轨迹:因为物理积分是非线性的,步长不同,累积误差不同。
对小游戏来说,这直接踩三个坑。回放对不上:录一段操作回放,换台设备就重演不出同一局。云存档不可信:客户端算出的金币数在服务端复算对不上,反作弊无从谈起。Bug 不可复现:玩家报「卡在某个角落」,你在开发机上永远重现不了,因为你的帧率和他不一样。
固定步长主循环就是治这个的。它的主张很简单:游戏逻辑永远按一个固定的时间片推进,帧率只决定「这一帧补几个时间片」,不决定「时间片有多长」。帧率再飘,逻辑看到的永远是整齐的 16ms 一步。
累加器:把「墙钟」翻译成「固定步」
固定步长的经典实现是累加器(accumulator),只有三行核心:
accumulator += dt; // 把这一帧的真实时长攒起来
while (accumulator >= STEP) { // 攒够一个固定步长
step(); // 就推进一步
accumulator -= STEP; // 并从累加器里扣掉
}
dt 是这一帧的真实毫秒数(可变),STEP 是固定步长(不变)。30fps 时 dt=33,一帧补两步还剩 1ms;120fps 时 dt=8,两帧才攒够一步。无论帧率怎么变,step() 看到的永远是同一个 STEP。累加器里剩下的零头(accumulator)会留到下一帧,所以时间不会被吞掉,也不会被凭空放大。
这里有一个必须讲清的细节:光有固定步长还不够,输入和随机数也必须挂在「步」上,而不是挂在「帧」上。如果输入按帧采样,那么一帧补两步时,两步会共用同一帧的输入,而 120fps 下两步会用到两帧的输入——结果又不一样了。所以正确的做法是把输入定义成仿真时间的函数:第 tick 步该是什么输入,就是什么输入,与它是第几帧触发的无关。同理,随机数每步推进一次,绝不在帧里抽。
确定性的三个前提
把上面的经验收成三条硬规则,它们是本节的骨架,也是本套全部逻辑内核的共同约定。
| 前提 | 错误写法 | 正确写法 |
|---|---|---|
| 输入挂步 | 每帧读一次按键,一步或多步共用 | inputs[tick],按步索引取样 |
| 随机挂步 | 每帧 Math.random() 抽一次 | 每步推进一次可播种 LCG |
| 全整数 | dt 用浮点秒、位置用浮点 | 毫秒整数、位置整数、整数比 |
第三条尤其容易被忽略。只要有一条浮点路径,两次运行的末位就可能差一个 ULP,=== 就不成立,「逐位一致」就是空话。本套所有仿真与内核都走整数:时间用毫秒,速度用整数加速度,摩擦用整数比,幂运算用 BigInt。
P 层内核:可播种、帧率无关的主循环
下面这段是 P 层内核 core/src/e05/loop.ts。它实现了累加器主循环,并把上面三条前提全部落进代码:stepOnce 按 tick 取样输入、按步推进 LCG、全程整数运算;frame 是累加器;advanceSteps 是给离线补偿用的直接推步(下一节会用到)。注意 maxStepsPerFrame——它是防死亡螺旋的刹车,后面单说。
// file: core/src/e05/loop.ts
// tier: P
// verify: node --test 'core/test/e05_*.test.ts'
//
// 固定步长主循环:累加器(accumulator)+ 固定步长(stepMs)。
// 帧率只决定「一帧补几步」,不改变「补了哪几步」——同一 seed + 同一输入序列,
// 在 16ms / 32ms / 不规则帧率下终局逐位一致。
// 全整数、可播种、无 Math.random()、无浮点;只用可擦除语法(禁 enum / namespace)。
export interface LoopConfig {
readonly stepMs: number;
readonly seed: number;
readonly accel: number;
readonly frictionNum: number;
readonly frictionDen: number;
readonly maxStepsPerFrame: number;
readonly rngMultiplier: number;
readonly rngIncrement: number;
}
export interface World {
tick: number;
posX: number;
posY: number;
velX: number;
velY: number;
score: number;
rng: number;
droppedMs: number;
}
export interface Loop {
readonly world: World;
readonly inputs: readonly number[];
accumulator: number;
stepsTotal: number;
framesTotal: number;
}
export function createWorld(cfg: LoopConfig): World {
return { tick: 0, posX: 0, posY: 0, velX: 0, velY: 0, score: 0, rng: cfg.seed >>> 0, droppedMs: 0 };
}
export function createLoop(cfg: LoopConfig, inputs: readonly number[]): Loop {
return { world: createWorld(cfg), inputs, accumulator: 0, stepsTotal: 0, framesTotal: 0 };
}
/** 一步固定步长:输入按 tick 取样、随机按步推进,都与帧率无关。 */
export function stepOnce(w: World, input: number, cfg: LoopConfig): void {
w.velX += input * cfg.accel;
w.velX = Math.floor((w.velX * cfg.frictionNum) / cfg.frictionDen);
w.posX += w.velX;
w.velY += 1;
w.posY += w.velY;
w.rng = (Math.imul(w.rng, cfg.rngMultiplier) + cfg.rngIncrement) >>> 0;
w.score += w.rng % 7;
w.tick += 1;
}
/** 直接推进 n 个固定步(离线补偿用):输入仍按 tick 取样。 */
export function advanceSteps(loop: Loop, n: number, cfg: LoopConfig): number {
let done = 0;
for (let k = 0; k < n; k += 1) {
stepOnce(loop.world, loop.inputs[loop.world.tick] ?? 0, cfg);
loop.stepsTotal += 1;
done += 1;
}
return done;
}
/** 喂一帧:累加帧时长,按固定步长补步;单帧补步数封顶,超出部分丢弃(防死亡螺旋)。 */
export function frame(loop: Loop, frameMs: number, cfg: LoopConfig): number {
loop.framesTotal += 1;
loop.accumulator += frameMs;
let steps = 0;
while (loop.accumulator >= cfg.stepMs && steps < cfg.maxStepsPerFrame) {
stepOnce(loop.world, loop.inputs[loop.world.tick] ?? 0, cfg);
loop.accumulator -= cfg.stepMs;
loop.stepsTotal += 1;
steps += 1;
}
if (loop.accumulator >= cfg.stepMs) {
loop.world.droppedMs += loop.accumulator;
loop.accumulator = 0;
}
return steps;
}
/** 依次喂一串帧时长,返回总步数。 */
export function runSchedule(loop: Loop, dts: readonly number[], cfg: LoopConfig): number {
let steps = 0;
for (const dt of dts) steps += frame(loop, dt, cfg);
return steps;
}
/** 世界状态摘要:稳定字符串,全整数,无浮点。 */
export function digest(w: World): string {
return [w.tick, w.posX, w.posY, w.velX, w.velY, w.score, w.rng, w.droppedMs].join("|");
}
配置来自 config/e05_loop.json,全整数、时间用毫秒,没有一处浮点。它同时也是 Python 侧 S 层仿真与浏览器原型读的那一份——「一份 config,三种消费」在时间参数上同样成立。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"stepMs": 16,
"accel": 3,
"frictionNum": 15,
"frictionDen": 16,
"maxStepsPerFrame": 8,
"rngMultiplier": 1103515245,
"rngIncrement": 12345,
"note": "固定步长主循环参数。全整数:时间用毫秒整数 stepMs,速度用整数 accel 与整数摩擦比 frictionNum/frictionDen。core/ 内禁用 Math.random(),随机一律由 seed + 整数 LCG 播种。"
}
断言:三种帧率,同一个终局
光有实现不算数,得证明它真的帧率无关。下面这段是 P 层测试 core/test/e05_loop.test.ts,核心断言是:把同一段 1600ms 的输入序列,切成 16ms 帧、32ms 帧、以及一串不规则的 3/7/13/29/48ms 帧,三者的世界摘要必须逐位相同。它同时验证了「换 seed 结果变」「换输入结果变」,确保摘要真的对状态敏感。
// file: core/test/e05_loop.test.ts
// tier: P
// verify: node --test 'core/test/e05_*.test.ts'
//
// 硬断言:同一 seed + 同一输入序列,在 16ms / 32ms / 不规则帧率下终局摘要逐位一致。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { createLoop, runSchedule, advanceSteps, digest, frame } from "../src/e05/loop.ts";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "e05_loop.json"), "utf8"));
// 同一输入序列:前 20 步推右、随后 20 步推左,如此交替;索引即 tick。
const inputs = Array.from({ length: 100 }, (_v, i) => (i % 40 < 20 ? 1 : -1));
function schedule(frameMs: number, totalMs: number): number[] {
const out: number[] = [];
let t = 0;
while (t + frameMs <= totalMs) { out.push(frameMs); t += frameMs; }
if (t < totalMs) out.push(totalMs - t);
return out;
}
test("不同帧率同终局:16ms / 32ms / 不规则切片摘要逐位一致", () => {
const TOTAL = 1600; // 1600 / 16 = 100 步,整除
const a = createLoop(cfg, inputs);
const b = createLoop(cfg, inputs);
const c = createLoop(cfg, inputs);
const sa = runSchedule(a, schedule(16, TOTAL), cfg); // 100 帧,每帧 1 步
const sb = runSchedule(b, schedule(32, TOTAL), cfg); // 50 帧,每帧 2 步
const irr: number[] = [];
for (let g = 0; g < 16; g += 1) irr.push(3, 7, 13, 29, 48); // 每 5 帧和恒为 100ms
const sc = runSchedule(c, irr, cfg); // 80 帧,每帧 0~3 步
assert.equal(sa, 100, "16ms 切片应补 100 步");
assert.equal(sb, 100, "32ms 切片应补 100 步");
assert.equal(sc, 100, "不规则切片应补 100 步");
assert.equal(a.world.tick, 100);
assert.equal(b.world.tick, 100);
assert.equal(c.world.tick, 100);
const da = digest(a.world);
assert.equal(da, digest(b.world), "16ms 与 32ms 终局应逐位一致");
assert.equal(da, digest(c.world), "16ms 与不规则终局应逐位一致");
assert.equal(da, "100|-131|5050|18|100|323|3492529440|0", "金标准摘要(跨语言比对用)");
});
test("同 seed 同输入:两个独立实例结果相同(可复现)", () => {
const x = createLoop(cfg, inputs);
const y = createLoop(cfg, inputs);
runSchedule(x, schedule(16, 1600), cfg);
runSchedule(y, schedule(16, 1600), cfg);
assert.equal(digest(x.world), digest(y.world));
});
test("换输入序列 → 终局不同(输入确实进了状态)", () => {
const flipped = inputs.map((v) => -v);
const x = createLoop(cfg, inputs);
const y = createLoop(cfg, flipped);
runSchedule(x, schedule(16, 1600), cfg);
runSchedule(y, schedule(16, 1600), cfg);
assert.notEqual(digest(x.world), digest(y.world));
});
test("换 seed → 终局不同(随机确实可播种)", () => {
const other = { ...cfg, seed: cfg.seed + 1 };
const x = createLoop(cfg, inputs);
const y = createLoop(other, inputs);
runSchedule(x, schedule(16, 1600), cfg);
runSchedule(y, schedule(16, 1600), other);
assert.notEqual(digest(x.world), digest(y.world));
});
test("累加器:15ms 不补步,再喂 1ms 才补 1 步", () => {
const loop = createLoop(cfg, inputs);
assert.equal(frame(loop, 15, cfg), 0);
assert.equal(loop.world.tick, 0);
assert.equal(frame(loop, 1, cfg), 1);
assert.equal(loop.world.tick, 1);
assert.equal(loop.accumulator, 0);
});
test("死亡螺旋封顶:单帧超大 dt 只补 maxStepsPerFrame 步并记 droppedMs", () => {
const loop = createLoop(cfg, inputs);
const steps = frame(loop, 100000, cfg);
assert.equal(steps, cfg.maxStepsPerFrame);
assert.equal(loop.world.tick, cfg.maxStepsPerFrame);
assert.ok(loop.world.droppedMs > 0, "应记录被丢弃的时间");
assert.equal(loop.accumulator, 0);
});
test("advanceSteps(n) 与 n 帧 16ms 等价", () => {
const x = createLoop(cfg, inputs);
const y = createLoop(cfg, inputs);
advanceSteps(x, 100, cfg);
runSchedule(y, schedule(16, 1600), cfg);
assert.equal(digest(x.world), digest(y.world));
});
在本机跑 node --test 'core/test/e05_*.test.ts',这一组 7 个用例全部通过,其中最关键的确定性断言实测输出如下(同机相对量级,绝对数值不可移植):
sa = sb = sc = 100 步
digest(16ms) = digest(32ms) = digest(不规则) = 100|-131|5050|18|100|323|3492529440|0
三种截然不同的帧率切片,跑出的是同一串数字。这不是「差不多」,是逐位相等——posX=-131、score=323、rng=3492529440 全都一样。这就是「确定性」在工程上的样子。
跨语言对拍:Python 复算得到同一摘要
TS 侧自证还不够,容易自己骗自己。本套额外用一份 S 层 Python 对偶实现 sim/e05_loop.py 复算同一条时间线:它读同一份 config/e05_loop.json,用同样的整数地板除、同样的 LCG 取低 32 位,跑同样的三种帧率切片,并断言结果等于 TS 侧落盘的金标准摘要。本机实跑输出:
{"allMatch": true, "sameStepCount": true, "stepCount": 100, "digest": "100|-131|5050|18|100|323|3492529440|0"}
Python 与 TypeScript 这两套完全独立的实现,在同一份配置、同一段输入下给出了同一个 40 位摘要。跨语言一致比单语言自证强得多:只要有一边的整数语义、LCG 参数、地板除方向写错,摘要立刻分叉。这也是本套「一份 config,三种消费」真正想守住的东西——Python 仿真、TS 内核、浏览器原型,看到的必须是同一组数字。
死亡螺旋:累加器必须带刹车
累加器有一个著名的副作用:死亡螺旋(spiral of death)。如果某一步的计算比一个固定步长还慢,那么每帧攒下的时间就补不完,accumulator 越积越多,下一帧要补的步数更多,于是更慢……几帧之内,设备就被拖死。
maxStepsPerFrame 就是刹车:单帧最多补 8 步(128ms),多出来的时间直接丢弃并计入 droppedMs。丢时间听起来很浪费,但这是对的取舍——宁可让游戏「跳过」一段最卡的时间,也不能让它彻底卡死。测试里那条 frame(loop, 100000, cfg) 断言的就是这件事:一帧喂 10 万毫秒,也只补 8 步,其余进 droppedMs。
注意刹车和「可复现」是有边界的:只要真实帧率正常(每帧补步数不超过上限),终局就逐位一致;一旦真的触发了封顶,说明设备已经卡到丢失了时间,此时不同设备丢多少不一样,可复现性自然让位于「先别卡死」。所以工程上的口径是:封顶是异常兜底,正常情况下它永远不该被触发——你可以把它当成一个「设备卡顿报警器」,droppedMs 持续大于 0,就说明该优化性能了。
边界:逻辑走固定步,表现走可变步
固定步长不是万能药:它牺牲了「视觉天然跟手」,换来了可复现。所以分界不是「全用固定步」,而是逻辑、物理、结算走固定步,纯视觉动画走可变步(或对固定步做插值渲染)。《合成小岛》的合成判定、金币结算、岛屿进度全部走固定步;飘字、光效这类「不影响状态」的表现,允许直接按帧走。分界线只有一条:这一步的结果会不会进入存档、会不会影响下一局——会,就必须是固定步。
自检表
改主循环之前,逐条对照这张表,任何一条不满足,可复现性就有漏洞。
| 检查项 | 合格标准 |
|---|---|
| 步长 | 固定、整数毫秒,写在 config 里 |
| 输入采样 | 按 tick 索引,不按帧 |
| 随机 | 可播种 LCG,每步推进一次,core/ 无 Math.random() |
| 数值 | 全程整数,除法地板除,幂用 BigInt |
| 兜底 | 单帧补步封顶,droppedMs 可观测 |
| 验证 | 至少两种帧率切片 + 跨语言对拍同一摘要 |
这套约定的回报,是后面几章的全部前提:第 8 章的存档迁移要靠确定性回放旧版本,第 15 章的端到端回放测试要靠它,卷三的反作弊校验也要靠它。主循环是地基,地基一歪,上面盖什么都是歪的。下一节给这套主循环装上场景与状态:菜单、对局、结算各自是一个场景,它们之间怎么切换、切换时谁该暂停谁该恢复——把「时间怎么走」扩展到「谁的时间在走」。
阅读导航:上一节:资源热更新与版本管理 · 下一节:场景管理与状态机 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。