《游戏引擎实现原理》18.1 内核装配:把前 17 章拼成引擎

把前面 17 章的零件——arena 帧内存、slot map 句柄、ECS 世界、固定步长主循环、子系统注册表——装配成一个可运行的 helio 内核 Kernel 类,真跑 4 帧,打印每帧子系统的执行顺序与真实耗时,并说清装配顺序为什么必须是拓扑序。

18.1 内核装配:把前 17 章拼成引擎

前面 17 章,我们一章一章地造零件:内存分配器、世代句柄、自研容器、游戏数学、数据导向布局、ECS、Job System、反射与序列化。每一章都能单独跑通、单独测试,但它们加起来还不叫「引擎」——它们是一箱散装的齿轮。这一节做最后一道工序:把它们装到一根主轴上,让它转起来。

装配的核心问题是「谁先动、谁依赖谁」。一台引擎每帧要做的事有确定的先后:时间先走,逻辑才更新;逻辑更新完,渲染才提交。这个顺序不是拍脑袋排的,而是第 3 章的子系统依赖图算出来的拓扑序。这一节把这个顺序变成代码里一个能打印出来的东西。

本章与站内既有内容的分工

「把引擎拼起来」这个题目,站内已经有一篇很完整的文章。为避免重复,先把分工写清楚:

既有文章它负责什么本节只做什么
/game-engine-ecs-architecture-deep-dive/用一篇文章讲清 ECS 引擎的架构哲学与骨架概览,末尾给出一段「从 0 到渲染一个精灵」的启动示例把前 17 章真跑过的零件装配成一个 Kernel 类,跑出每帧子系统的执行顺序与真实耗时
/defold-ecs-architecture/从现成引擎的子系统的视角讲 ECS 怎么用从「内核实现者」视角讲子系统怎么注册、怎么排序
/posts/game/engine/引擎选型与架构对照不选型,只装配 helio 自己的内核

一句话:别人讲「引擎架构长什么样」,本节交付的是一个可运行、可回放输入、可截图的确定性 demo 的装配代码——真正能 node 跑起来、能打印每帧顺序的东西。

装配清单:内核由哪几个零件拼成

Kernel 不发明新东西,它只做「接线」。下面这张表是它的物料清单,每一行都指向前面已经写好并测过的模块:

零件来自在 Kernel 里的角色
帧内存 Arena第 4.2 节每帧开头 reset(),帧内临时内存只前进不回收
实体句柄 SlotMap第 6.2 节World 用它存实体,删除后旧句柄立即失效
固定步长循环 FixedStepLoop第 2.2 节把真实帧时间换算成「本帧跑几个物理步」
子系统注册表 SubsystemRegistry第 3.1 节算拓扑序、按序 onInit/onStart、逆序关闭
世界 World本节新增句柄 → 实体,是 ECS 存储的最小外壳
每帧执行序本节新增从注册表的启动序里挑出带 update 的子系统

装配的成品是 src/core/kernel.ts。注意它是「薄」的:Kernel 类里几乎没有算法,只有把上面这些对象创建出来、在正确的时机调用它们的正确方法。引擎的复杂度在零件里,不在总装里——这是从第 1 章就定下的分层原则。

Kernel 对外只暴露五个动作,正好对应引擎一生的五个阶段:

  • register(sub):登记一个子系统,可以链式调用。
  • boot():算拓扑序、按序初始化与启动,并把每帧执行序钉下来。
  • tick(dt):推进一帧,返回这一帧的执行报告(步数、顺序、耗时)。
  • shutdown():按启动序的逆序关闭,保证依赖被依赖者先关。
  • order / frame:只读地观察当前状态,方便测试与日志。

这五个动作之外,Kernel 不再多做一件事。真正干活的是它 readonly 持有的那四个零件对象。

可运行产物:Kernel 类

下面是完整可编译的 src/core/kernel.ts。它 import 了 4.2 的 Arena、6.2 的 SlotMap、2.2 的 FixedStepLoop、3.1 的 SubsystemRegistry——这些文件都在本书前面出现过。层级 L1,node 直跑。

// file: src/core/kernel.ts
// tier: L1
// verify: node src/apps/kernel-boot.ts
//
// helio 内核:把前面各章的零件装配成一个可运行的引擎内核。
// 装配件:帧内存(4.2 arena)、实体句柄(6.2 slot map)、世界、主循环(2.2)、
// 子系统注册表(3.1)。Kernel 把它们接起来,按拓扑序推进每一帧并回报耗时。

import { Arena } from './arena.ts';
import { FixedStepLoop } from './fixed-step-loop.ts';
import { SlotMap, type Handle } from './slotmap.ts';
import { SubsystemRegistry, type ISubsystem, type SubsystemContext } from './subsystem.ts';

export type { ISubsystem, SubsystemContext } from './subsystem.ts';

export interface Entity {
  readonly name: string;
  readonly transform: { x: number; y: number; z: number };
  vx: number;
  vz: number;
}

/** 世界:实体句柄 → 实体。句柄安全来自 6.2 的 SlotMap。 */
export class World {
  readonly #entities = new SlotMap<Entity>();
  get count(): number { return this.#entities.size; }
  spawn(name: string, x = 0, y = 0, z = 0): { handle: Handle; entity: Entity } {
    const entity: Entity = { name, transform: { x, y, z }, vx: 0, vz: 0 };
    return { handle: this.#entities.insert(entity), entity };
  }
  get(h: Handle): Entity | undefined { return this.#entities.get(h); }
  despawn(h: Handle): boolean { return this.#entities.remove(h); }
  *all(): Generator<Entity> {
    for (const [, e] of this.#entities.entries()) yield e;
  }
}

/** 参与每帧更新的子系统:在生命周期钩子之外多一个 update。 */
export interface FrameSubsystem extends ISubsystem { update(dt: number): void; }

export interface KernelConfig {
  readonly frameArenaBytes: number;
  readonly stepSeconds: number;
  readonly maxStepsPerFrame: number;
}
export interface SystemTiming { readonly name: string; readonly ms: number; }
export interface FrameReport {
  readonly frame: number;
  readonly steps: number;
  readonly order: readonly string[];
  readonly timings: readonly SystemTiming[];
}

export class Kernel {
  readonly arena: Arena;
  readonly world: World;
  readonly registry: SubsystemRegistry;
  readonly loop: FixedStepLoop;
  #frame = 0;
  #order: FrameSubsystem[] = [];
  #ctx: SubsystemContext;

  constructor(cfg: KernelConfig) {
    this.arena = new Arena(cfg.frameArenaBytes);
    this.world = new World();
    this.registry = new SubsystemRegistry();
    this.loop = new FixedStepLoop({
      stepSeconds: cfg.stepSeconds,
      maxStepsPerFrame: cfg.maxStepsPerFrame,
    });
    const kernel = this;
    this.#ctx = {
      get frameCount(): number { return kernel.#frame; },
      log(msg: string): void { console.log(`[helio] ${msg}`); },
    };
  }

  get frame(): number { return this.#frame; }
  get order(): readonly string[] { return this.#order.map((s) => s.name); }
  register(sub: FrameSubsystem): this { this.registry.register(sub); return this; }

  /** 按拓扑序 onInit / onStart,并把带 update 的子系统钉进每帧执行序。 */
  boot(): void {
    this.registry.boot(this.#ctx);
    this.#order = this.registry.startOrder.filter((s): s is FrameSubsystem => 'update' in s);
  }

  /** 推进一帧:喂入真实经过的秒数,主循环决定跑几个物理步。 */
  tick(deltaSeconds: number): FrameReport {
    this.#frame += 1;
    this.arena.reset();
    const timings: SystemTiming[] = [];
    const outcome = this.loop.advance(deltaSeconds, (step) => {
      for (const sub of this.#order) {
        const t0 = performance.now();
        sub.update(step);
        timings.push({ name: sub.name, ms: performance.now() - t0 });
      }
    });
    return { frame: this.#frame, steps: outcome.steps, order: this.order, timings };
  }

  shutdown(): void { this.registry.shutdown(); }
}

/** 三个内置子系统共用空的生命周期钩子。 */
abstract class BaseSubsystem implements FrameSubsystem {
  abstract readonly name: string;
  readonly dependencies: readonly string[] = [];
  onInit(_ctx: SubsystemContext): void {}
  onStart(): void {}
  onStop(): void {}
  onShutdown(): void {}
  abstract update(dt: number): void;
}

/** 子系统一:时间。累积缩放后的游戏时间。 */
export class TimeSubsystem extends BaseSubsystem {
  readonly name = 'Time';
  elapsed = 0;
  update(dt: number): void { this.elapsed += dt; }
}

/** 子系统二:移动。把速度积分进变换,依赖 Time 先更新。 */
export class MoveSubsystem extends BaseSubsystem {
  readonly name = 'Move';
  readonly dependencies: readonly string[] = ['Time'];
  readonly #world: World;
  constructor(world: World) { super(); this.#world = world; }
  update(dt: number): void {
    for (const e of this.#world.all()) {
      e.transform.x += e.vx * dt;
      e.transform.z += e.vz * dt;
    }
  }
}

/** 子系统三:渲染提交(内核侧占位)。统计本帧要画的实体数。 */
export class RenderSubsystem extends BaseSubsystem {
  readonly name = 'Render';
  readonly dependencies: readonly string[] = ['Move'];
  readonly #world: World;
  lastDrawCount = 0;
  constructor(world: World) { super(); this.#world = world; }
  update(_dt: number): void {
    let n = 0;
    for (const _e of this.#world.all()) n += 1;
    this.lastDrawCount = n;
  }
}

三个内置子系统构成了《微光岛》每帧最小的执行链:Time 累积游戏时间,Move 把速度积分进变换,Render 统计要画的实体数。它们通过 dependencies 声明依赖,而不是靠注册顺序碰运气——MoveSubsystem 声明依赖 Time,RenderSubsystem 声明依赖 Move,注册表会算出唯一正确的顺序。

真跑结果:每帧的子系统顺序与耗时

装配只是搭好线,能不能跑、跑出来什么顺序,得真跑。下面是完整可编译的 src/apps/kernel-boot.ts:它把三个子系统注册进去、生成一个方块、跑 4 帧,打印每帧的执行顺序与每个子系统各自的真实耗时。

// file: src/apps/kernel-boot.ts
// tier: L1
// verify: node src/apps/kernel-boot.ts
//
// helio 内核装配的启动示例:注册三个子系统,生成一个方块,跑 4 帧,
// 打印每一帧的子系统执行顺序与各自的真实耗时。

import {
  Kernel,
  MoveSubsystem,
  RenderSubsystem,
  TimeSubsystem,
  type KernelConfig,
} from '../core/kernel.ts';

const cfg: KernelConfig = {
  frameArenaBytes: 64 * 1024,
  stepSeconds: 1 / 60,
  maxStepsPerFrame: 8,
};

const kernel = new Kernel(cfg);
kernel.register(new TimeSubsystem());
kernel.register(new MoveSubsystem(kernel.world));
kernel.register(new RenderSubsystem(kernel.world));

const cube = kernel.world.spawn('cube', 0, 0, 0);
cube.entity.vx = 1.5;
cube.entity.vz = -0.75;

kernel.boot();
console.log(`[helio] boot order: ${kernel.order.join(' -> ')}`);

const dt = 1 / 60;
for (let i = 0; i < 4; i++) {
  const r = kernel.tick(dt);
  const parts = r.timings.map((t) => `${t.name} ${t.ms.toFixed(3)}ms`);
  console.log(`[helio] frame ${r.frame}: steps=${r.steps} | ${parts.join('  ')}`);
}

const e = kernel.world.get(cube.handle);
if (e === undefined) throw new Error('cube vanished');
console.log(
  `[helio] cube after ${kernel.frame} frames: x=${e.transform.x} z=${e.transform.z}`,
);
kernel.shutdown();

本机实跑(node src/apps/kernel-boot.ts)的输出如下:

[helio] boot order: Time -> Move -> Render
[helio] frame 1: steps=1 | Time 0.011ms  Move 0.055ms  Render 0.016ms
[helio] frame 2: steps=1 | Time 0.001ms  Move 0.004ms  Render 0.002ms
[helio] frame 3: steps=1 | Time 0.001ms  Move 0.005ms  Render 0.018ms
[helio] frame 4: steps=1 | Time 0.003ms  Move 0.008ms  Render 0.003ms
[helio] cube after 4 frames: x=0.1 z=-0.05

三件事被这段输出钉死了:

  • 顺序对了:Time -> Move -> Render,不是注册顺序,而是拓扑序。注册顺序恰好也是这个,但你故意颠倒注册(先注册 Render)结果仍是这个——依赖声明说了算。
  • 步数对了:steps=1。每帧喂进去的 delta 恰好等于 stepSeconds,累加器刚好够走一步。这是第 2.2 节固定步长的直接体现。
  • 位置对了:vx=1.5、4 步、每步 1/60 秒,位移 1.5 × 4 / 60 = 0.1;vz=-0.75 位移 -0.05。方块从原点走到了 x=0.1, z=-0.05。

关于耗时那几列:它们是 performance.now() 包住每个 update 的真实采样,量级在微秒,且随机器和当次运行波动(第 1 帧普遍偏大,是首次调用未预热)。请把重点放在「我们能逐子系统量出耗时」这件事上,而不是具体那几位小数——这正是第 2.1 节帧预算统计的落点:帧预算的账,最终要能拆到子系统粒度。

装配顺序为什么必须是拓扑序

如果把 Render 排在 Move 前面会怎样?渲染会读到上一帧的变换——画面上所有东西都慢一帧。把 Time 排在最后,Move 就用不到本帧的时间缩放。这些 bug 不会崩溃,只会让画面「不太对」,极难排查。

这就是为什么第 3 章要把依赖做成声明式的:每个子系统只声明「我先要谁」,顺序由注册表算。Kernel.boot() 里那句 filter((s): s is FrameSubsystem => 'update' in s) 是把「生命周期子系统」(只关心启停)和「每帧子系统」(还要 update)分开——同一个拓扑序同时决定了启动顺序和每帧执行顺序,二者天然一致。

还有一条铁律在这一节被再次守住:Kernel 只依赖 arena、slotmap、fixed-step-loop、subsystem 这些内核层模块,一行渲染 API 都没有。RenderSubsystem 是「渲染提交的占位」,它只数实体、不碰 GPU——真正的绘制要等卷二《渲染引擎》。内核先跑通,画面后接上,这个次序不能反。

零件装好了,也跑起来了,但它现在只是「空转」:一个方块按固定速度漂移。下一节我们给它喂一份预录的输入序列,让方块在《微光岛》的平面上真正「走」起来,并证明这个 demo 是确定性的——同一份输入跑两遍,结果逐位相同。

阅读导航:上一节:回到目录 · 下一节:18.2 《微光岛》第一个能走动的方块 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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