《游戏引擎实现原理》18.2 《微光岛》第一个能走动的方块

用装好的 helio 内核跑出《微光岛》第一个能走动的方块:一份 120 帧预录输入序列驱动方块在 16×16 岛屿平面上按固定步长移动,连跑两遍,最终位置与轨迹校验和逐位相同;正文给出真实的最终坐标与断言结果,并解释那个 9.7e-17 的浮点残渣从何而来。

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) 里的循环体摊开,单帧的顺序是这样的,每一步都对应卷一的某一块:

  1. 读输入:从预录数组取出 {dx, dz},这是「本帧玩家意图」。
  2. 写速度:cube.entity.vx = s.dx * SPEED,把意图翻译成速度。这一步是纯赋值,没有副作用。
  3. 推进一帧:kernel.tick(STEP)。内部先 arena.reset() 回收帧内存,再让 FixedStepLoop 用累加器算出本帧跑几个物理步(这里恒为 1),最后按拓扑序调用 Time.update、Move.update。
  4. 积分:MoveSubsystem 遍历 world.all(),x += vx*dt、z += vz*dt。这是全 demo 唯一会累积浮点误差的地方。
  5. 钳制:把越界的位置拉回岛屿平面。纯比较,无误差。
  6. 记轨迹:把本帧位置折算进 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 回顾与路线:进入渲染卷 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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