18.2 《微光岛》第一个能走动的方块
18.1 装好的内核能空转了,但它还在「漂」——方块按一个恒定速度往一个方向滑,这不像一个游戏,像一段演示。游戏和演示的区别在于输入:玩家按键,方块响应,走位有始有终。这一节给内核喂一份预录的输入序列,让方块在《微光岛》的平面上真正走一段路,然后证明这段路是确定性的——同样的输入,跑一千遍,位置一模一样。
这不是炫技。确定性是《微光岛》这个项目的一条硬需求:岛屿是程序化生成的,存档要能在不同设备上读回同一座岛,将来做联机还要靠它做状态同步。第 10 章讲浮点确定性与定点数时埋下的伏笔,这一节收线。
确定性从哪来:三个「不许」
要让模拟可复现,最有效的办法不是「努力写对」,而是禁掉所有不确定的来源。这个 demo 里有三条铁律:
| 不许 | 为什么 | 本节的做法 |
|---|---|---|
不许用 Math.random | 随机数每次不同,结果自然不同 | 输入序列在编译期就定死,是纯函数 recordedInput(120) |
| 不许读真实时钟 | Date.now()/performance.now() 每帧都不同 | 每帧喂固定 delta = 1/60,不用墙钟 |
| 不许让物理步长浮动 | 步长一变,积分路径就变 | 每帧 delta 恰等于 stepSeconds,累加器恒定走一步 |
三条合起来,模拟就退化成一个纯函数:输入数组 → 最终位置。没有外部状态、没有时间依赖、没有随机源,它当然可复现。确定性不是算出来的,是「不给它机会不确定」换来的。
这里有个容易忽略的点:固定步长是三条里最难做到的一条。前两条只是「别调用某个函数」,第三条却要求主循环和物理系统达成一个约定——不管这一帧真实过了多少毫秒,物理永远以 1/60 秒为单位推进。这正是第 2.2 节 FixedStepLoop 的职责:累加真实时间、按固定步长扣减、把剩余的零头留给渲染插值。本 demo 更极端一点,直接让 delta 恒等于 stepSeconds,连累加器都不会积攒零头,每帧恰好一步。
输入序列本身也值得说一句。recordedInput(120) 每 40 帧换一次方向:先往 +x 走 40 帧,再往 +z 走 40 帧,最后往 -x+z 走 40 帧。这相当于一段「玩家按住方向键」的录像——真实游戏里它来自输入系统,这里我们把它写成一个数组,好让「同一份输入」这个前提在代码里看得见摸得着。
为什么输入要「预录」
真实游戏里,输入来自键盘、手柄或触摸屏,是实时的:这一帧玩家按了右键,下一帧可能松开。既然输入是实时的、每次都不一样,那还谈什么确定性?
关键在于把「输入」和「模拟」解耦。确定性的对象从来不是「玩家会做什么」,而是「给定一串输入,世界怎么演化」。只要模拟本身是确定性的,那么:
- 同一串输入 → 同一个结果。这是本节 demo 证明的。
- 不同串输入 → 确定地不同。玩家真的按了别的键,结果才不一样。
所以回放系统是这么做的:录下输入序列(几十字节),重放时把同一串输入喂回同一个模拟,就得到同一段画面——不需要录像,不需要存每一帧的状态。这正是 recordedInput 想演示的:把输入变成一个可序列化的数组,是「可回放」的第一步。
反过来说,如果模拟里混进了 Math.random 或墙钟时间,那么同一串输入也会跑出不同结果,回放立刻失效。这就是为什么三条铁律里,随机与时钟被列在最前面——它们是回放的头号杀手。
可运行产物:确定性 demo
下面是完整可编译的 src/apps/glimmer-isle-demo.ts。它复用 18.1 的 Kernel,只装配 Time 与 Move 两个子系统,把「输入 → 速度 → 积分」这条链路跑 120 帧,跑两遍并逐位比对。层级 L1,node 直跑。
// file: src/apps/glimmer-isle-demo.ts
// tier: L1
// verify: node src/apps/glimmer-isle-demo.ts
//
// 《微光岛》第一个能走动的方块:确定性 demo。
// 预录输入序列驱动一个方块在 16×16 的岛屿平面上按固定步长移动;
// 同一份输入连跑两遍,最终位置与轨迹校验和必须逐位相同。
import { Kernel, MoveSubsystem, TimeSubsystem, type KernelConfig } from '../core/kernel.ts';
export interface InputSample {
readonly dx: number;
readonly dz: number;
}
export interface DemoResult {
readonly frames: number;
readonly x: number;
readonly z: number;
readonly checksum: number;
}
/** 预录输入:每 40 帧换一次方向(+x / +z / -x+z),无随机、无真实时钟。 */
export function recordedInput(frames: number): InputSample[] {
const dirs: readonly (readonly [number, number])[] = [
[1, 0],
[0, 1],
[-1, 1],
];
const out: InputSample[] = [];
for (let i = 0; i < frames; i++) {
const d = dirs[Math.floor(i / 40) % dirs.length]!;
out.push({ dx: d[0], dz: d[1] });
}
return out;
}
const STEP = 1 / 60;
const SPEED = 2;
const ISLAND_HALF = 8; // 岛屿平面 16×16,中心在原点
/** 跑一次确定性模拟,返回最终位置与轨迹校验和。 */
export function runDemo(input: readonly InputSample[]): DemoResult {
const cfg: KernelConfig = {
frameArenaBytes: 32 * 1024,
stepSeconds: STEP,
maxStepsPerFrame: 4,
};
const kernel = new Kernel(cfg);
kernel.register(new TimeSubsystem());
kernel.register(new MoveSubsystem(kernel.world));
const cube = kernel.world.spawn('cube', 0, 0, 0);
kernel.boot();
let checksum = 0;
for (const s of input) {
cube.entity.vx = s.dx * SPEED;
cube.entity.vz = s.dz * SPEED;
kernel.tick(STEP); // delta == step,恒定一个物理步
// 位置钳制在岛屿平面内(确定性:纯比较 + 赋值,无随机)
const t = cube.entity.transform;
if (t.x > ISLAND_HALF) t.x = ISLAND_HALF;
if (t.x < -ISLAND_HALF) t.x = -ISLAND_HALF;
if (t.z > ISLAND_HALF) t.z = ISLAND_HALF;
if (t.z < -ISLAND_HALF) t.z = -ISLAND_HALF;
checksum += t.x * 31 + t.z * 17;
}
kernel.shutdown();
const t = cube.entity.transform;
return { frames: kernel.frame, x: t.x, z: t.z, checksum };
}
const input = recordedInput(120);
const a = runDemo(input);
const b = runDemo(input);
const sameX = Object.is(a.x, b.x);
const sameZ = Object.is(a.z, b.z);
const sameSum = Object.is(a.checksum, b.checksum);
console.log(`[glimmer-isle] frames=${a.frames} step=${STEP} speed=${SPEED}`);
console.log(`[glimmer-isle] run A: x=${a.x} z=${a.z} checksum=${a.checksum}`);
console.log(`[glimmer-isle] run B: x=${b.x} z=${b.z} checksum=${b.checksum}`);
console.log(`[glimmer-isle] deterministic: x=${sameX} z=${sameZ} checksum=${sameSum}`);
if (!(sameX && sameZ && sameSum)) {
throw new Error('non-deterministic: two runs diverged');
}
console.log('[glimmer-isle] PASS: 两次运行逐位相同');
两处设计值得单独点出。第一,位置每帧都钳制在 [-8, 8] 的岛屿平面内——这不是装饰,是「世界有边界」这条规则的最小实现;钳制用纯比较和赋值,本身不引入任何不确定性。第二,checksum 累加的是整条轨迹(每帧的 x*31 + z*17),而不是只看终点——只比终点会漏掉「中途走岔了又绕回来」的情况。校验和让「整条路径一致」这件事变成一个可以 Object.is 的数字。
一帧里发生了什么
把 for (const s of input) 里的循环体摊开,单帧的顺序是这样的,每一步都对应卷一的某一块:
- 读输入:从预录数组取出
{dx, dz},这是「本帧玩家意图」。 - 写速度:
cube.entity.vx = s.dx * SPEED,把意图翻译成速度。这一步是纯赋值,没有副作用。 - 推进一帧:
kernel.tick(STEP)。内部先arena.reset()回收帧内存,再让FixedStepLoop用累加器算出本帧跑几个物理步(这里恒为 1),最后按拓扑序调用Time.update、Move.update。 - 积分:
MoveSubsystem遍历world.all(),x += vx*dt、z += vz*dt。这是全 demo 唯一会累积浮点误差的地方。 - 钳制:把越界的位置拉回岛屿平面。纯比较,无误差。
- 记轨迹:把本帧位置折算进
checksum。
一帧六步,没有一步读时钟、没有一步掷骰子。这就是「确定性」在代码层面的全部秘密——它不是一个开关,而是每一帧都做同样的事这个事实的总和。
也正因为只有第 4 步会累积误差,我们要盯住的其实只有一处:MoveSubsystem 里那两行加法。其它五步都是「无状态」的——输入是常量、赋值是精确的、钳制是比较、校验和是确定的多项式。把不确定性的面积缩到最小,是写确定性系统的通用手法:不是消灭所有误差,而是让误差只出现在你能说清楚的那一处。
真跑结果:两次运行逐位相同
本机实跑(node src/apps/glimmer-isle-demo.ts):
[glimmer-isle] frames=120 step=0.016666666666666666 speed=2
[glimmer-isle] run A: x=9.71445146547012e-17 z=2.666666666666667 checksum=5142.666666666668
[glimmer-isle] run B: x=9.71445146547012e-17 z=2.666666666666667 checksum=5142.666666666668
[glimmer-isle] deterministic: x=true z=true checksum=true
[glimmer-isle] PASS: 两次运行逐位相同
断言的结果就是最后两行:x=true z=true checksum=true,并且 PASS 那行没有抛异常——两次运行在浮点位级别完全一致,不是「约等于」,是 Object.is 意义上的相等。这是《微光岛》「可回放」能力的第一个可运行证据。
确定性还有什么用:回放、存档与联机
确定性不是一个孤立的属性,它是好几项功能的共同地基:
- 回放(replay):录下输入序列就能重放整局游戏。竞速游戏、格斗游戏的「幽灵车」「对局回放」全靠它——存几十 KB 的输入,比存几 GB 的视频便宜得多。
- 存档(save):第 17 章的反射序列化负责把世界状态写成字节。如果模拟是确定性的,存档甚至可以不存全部状态,只存「初始状态 + 输入序列」,读档时重放一遍即可——这是「状态同步」存档的基础。
- 联机(netcode):确定性锁步(lockstep)网络模型里,各客户端只交换输入,各自本地模拟。只要模拟逐位一致,大家看到的画面就一致,服务器只需转发输入、不需要转发世界。这也是第 10.2 节定点数存在的原因——锁步要求跨机器逐位一致,浮点做不到。
- 测试(testing):确定性让「回归测试」变得可能。给一段输入、断言最终位置,就能守住一整块物理/逻辑代码不被改坏——本节的
PASS断言就是最小形态。
这四件事的代价也很一致:它们都要求你放弃「随手用 Math.random」的自由。粒子特效、程序化装饰、AI 的随机行为,全都得改用「可复现的伪随机」——给定种子,序列固定。第 10 章讲确定性时提过这件事,这里在《微光岛》的第一个 demo 上再确认一遍。
所以这个 demo 看起来只是「一个方块走了段路」,它实际上把《微光岛》未来要用的回放、存档、联机、测试四条路都打了个样。它们的共同前提,就是本节证明的这四个字:逐位可复现。
那个 9.7e-17 是什么
注意 x 的终点不是 0,而是 9.71445146547012e-17——一个约等于零、但又不是零的极小正数。这正好是第 10.1 节讲过的浮点现象:方块先往 +x 走 40 帧、再往 -x 走 40 帧,两段位移理论上互相抵消,但每次加法的舍入误差会累积,最后留下一个残渣。
关键有两点:
- 残渣是确定性的。两次运行的残渣都是
9.71445146547012e-17,一模一样的 17 位有效数字。误差本身不可怕,不可复现的误差才可怕。在同一台机器、同一份代码、同一份输入下,IEEE 754 的双精度浮点运算是逐位确定的,所以误差也逐位确定。 - 跨机器就不一定了。第 10.1 节说过,不同 CPU、不同 JIT 对某些运算(尤其是
fma、超越函数)的舍入可能不同。所以「同机确定」是这份 demo 能证明的;「跨端确定」要么靠定点数(10.2),要么靠严格的浮点环境约定。demo 里z的终点是干净的2.666666666666667,是因为它的两段位移方向一致、误差单调累积且不抵消,看起来更「整齐」而已。
换句话说,这个 demo 证明了**「同机可复现」**——这对单机游戏、回放、单元测试已经足够。要不要更进一步到「跨端可复现」,取决于你的游戏是否需要联机同步,那是 10.2 定点数要解决的问题。
顺带一提,z 的终点 2.666666666666667 也不是精确的 8/3。8/3 在二进制里是无限循环小数,双精度只能存它的近似。之所以它看起来「整齐」,是因为 40 次加法累积的误差恰好落在末位,而 x 因为两段方向相反、误差相消,才暴露出那个 9.7e-17 的残渣。两个坐标都带误差,只是误差的表现形式不同。
确定性 vs 可复现 vs 同步:三个容易混的词
这三个词经常被当成同义词,但它们要求的强度递增,代价也递增:
| 词 | 要求 | 手段 | 代价 |
|---|---|---|---|
| 确定性(deterministic) | 同样输入,同样输出 | 禁随机、禁墙钟、固定步长 | 低,本 demo 已达成 |
| 可复现(reproducible) | 换一次运行/换一台机器仍相同 | 上一条 + 控制浮点环境 | 中,需要固定 CPU 特性与编译选项 |
| 可同步(lockstep-safe) | 跨设备逐位一致,可做锁步联机 | 上一条 + 定点数/整数数学 | 高,浮点全部换成定点(10.2) |
本 demo 落在第一档:它保证「同一台机器、同一份代码、同一份输入,跑多少遍都一样」。这一档已经能支撑回放和回归测试。第二档要额外保证「换了机器也一样」,浮点在某些指令(如融合乘加)上会有平台差异,得靠约定或定点数抹平。第三档是最严的,锁步联机必须做到——否则两台机器的方块会越走越偏,几分钟后一个在岛上、一个掉进海里。
《微光岛》目前只需要第一档。等它真的要做联机,再把积分从 number 换成 10.2 的定点类型即可——接口不变,只换数值类型,这也是为什么本书把定点数放在数学卷而不是并发卷。
常见坑
写确定性 demo 时,下面几个坑反复出现,值得单独列一遍:
- 拿
Object.is去比两个不同来源的数。确定性比的是「同一份代码跑两遍」,不是「浮点结果等于手算的理论值」。x的理论值是 0,实测是9.7e-17,二者Object.is为false——但两次运行之间是true。别把这两个「相等」混为一谈。 - 用
delta等于真实帧时间。只要delta来自墙钟,每帧的步长就不同,累加器走的步数也不同,轨迹立刻发散。固定步长的意义就是把delta从「外部输入」变成「常量」。 - 校验和只看终点。路径可能中途偏出去又绕回来,终点相同不代表过程相同。累加整条轨迹(本例每帧
x*31 + z*17)才能真正守住「过程一致」。 - 钳制写在积分之前。本 demo 是「先积分、后钳制」,保证越界的那一步也被计入轨迹再被拉回。顺序反过来,越界帧的轨迹就被抹掉了。
- 忘了
kernel.shutdown()。两次runDemo各自建一个Kernel、各自boot、各自shutdown,状态互不串。如果偷懒复用同一个Kernel而不重置,第二次运行会带着第一次的残留,确定性无从谈起。 - 把
Set/Map的遍历顺序当稳定。本 demo 只操作单个实体,暂时碰不到;一旦世界里有多个实体、MoveSubsystem的遍历顺序又依赖插入顺序,就得像第 13.2 节那样显式固定迭代顺序,否则不同引擎版本可能给出不同轨迹。 - 用
console.log打印浮点。console.log(0.1 + 0.2)打出来是0.30000000000000004,看起来「乱」,但它是确定的。判断确定性要Object.is,不要靠肉眼看打印结果。 - 把「确定」当成「正确」。确定性只保证「每次都一样」,不保证「一样的结果是对的」。如果积分公式本身写错了,你会确定地得到错误结果——所以确定性测试要和数值正确性测试分开做。
这个方块证明了什么
回头看,这个只有几十行的 demo 一次性串起了全卷的一半:
- 主循环(第 2 章):
tick(STEP)里delta == step,累加器恒定走一步,steps=1。 - 内存与句柄(第 4、6 章):方块存在
World里,World用SlotMap存实体——cube.handle是世代句柄,不是裸指针。 - ECS 与数据导向(第 11–13 章):
MoveSubsystem遍历world.all()批量积分,是「系统操作组件」的最小形态。 - 数学与确定性(第 8–10 章):位移是向量积分,残渣是浮点确定性的活教材。
- 子系统编排(第 3 章):
Time → Move的拓扑序由依赖声明决定。
它没有一行渲染代码,画面要等卷二——但它已经是一个能接受输入、能推进世界、能存档复现的游戏循环了。这正是卷一《引擎内核》承诺过的那件事:让一个小方块在场景里走动。方块走起来了,卷一的使命就完成了。
从 demo 到游戏还差什么
这个方块是「能动」,还不是「好玩」。从它到真正的《微光岛》,还差几块拼图——它们全部属于内核之上的层:
| 缺什么 | 属于哪一卷 | 一句话说明 |
|---|---|---|
| 画面 | 卷二《渲染引擎》 | 现在方块只在内存里移动,没有一个像素被画出来 |
| 实时输入 | 卷二(PAL 输入层) | 预录输入换成键盘/手柄的实时事件流 |
| 物理与碰撞 | 子系统层 | 现在方块会穿过障碍物,只受平面边界钳制 |
| 相机与视口 | 卷二 | 没有相机,就没有「从哪看这个方块」 |
| 资源加载 | 卷二 | player.gltf 这类模型还没被真正读进来 |
| 场景与关卡 | 游戏层 | 岛屿、微光、昼夜循环都还是设计稿 |
注意这张表里没有一行属于内核——这恰恰是好消息。卷二要加的所有东西(渲染、相机、资源)都不需要改动内核一行代码,它们站在内核之上。这就是第 1 章那条「依赖只能向下」的铁律换来的回报:内核稳定了,上面的东西可以一直往上叠。
如果非要给这个 demo 补一块内核侧的拼图,那就是把 recordedInput 换成真正的输入系统——但那要等平台抽象层(3.2)接出真实的键鼠事件,而那需要窗口,窗口属于渲染卷。所以内核到此为止,是恰到好处的停手。
最后,回望这 18 章各自解决了什么、它们如何拼成今天这台引擎,以及从这里往渲染卷走该怎么走,是下一节的事。
阅读导航:上一节:18.1 内核装配:把前 17 章拼成引擎 · 下一节:18.3 回顾与路线:进入渲染卷 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。