《游戏引擎实现原理》3.1 子系统注册、启动顺序与逆序关闭

引擎启动时几十个子系统谁先谁后不是拍脑袋定的。本节用 TypeScript 从零实现 helio 内核的 ISubsystem 接口与 SubsystemRegistry,用 DFS 三色标记做依赖拓扑排序、检测循环依赖,并保证关闭顺序严格是启动顺序的逆序,最后在 Node 里真跑断言把顺序验出来。

3.1 子系统注册、启动顺序与逆序关闭

一个 3D 引擎不是一整块代码,而是几十个各自管一摊的子系统:平台层、文件系统、渲染设备、资源加载器、场景、输入、音频、UI……它们之间还有先后关系——渲染设备必须在窗口/平台层之后才能创建,场景必须在资源加载器之后才能反序列化实体。

如果把这段启动逻辑写成一条写死的 main():initPlatform(); initFile(); initRenderer(); ...,很快就会失控:删一个子系统要手工调所有后续代码,加一个又要重新数一遍位置,谁依赖谁只存在于作者脑子里。本节把这件事变成一个可查询、可断言的数据结构。

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

既有文章它负责什么本节只做什么
/game-engine-architecture-ecs/用一篇文章讲清 ECS 引擎的整体骨架不重复骨架,只把「子系统编排」这一层拆出来从零实现
/defold-ecs-architecture/从 Defold 视角介绍现成引擎的子系统划分不介绍别人的引擎怎么分层,只写 helio 自己的 ISubsystem
/posts/game/engine/引擎选型:该用哪个引擎不碰选型,假设已经决定自研

一句话:别人已经讲过的「是什么、怎么选」本节全部外链,本节只交付「怎么从零搭出来、跑出什么结果」。

为什么启动顺序不能拍脑袋

先把约束说清楚。设子系统 A 依赖 B,含义是 A 启动前 B 必须已经启动,A 关闭时 B 必须还在运行。于是:

  • 启动顺序 = 依赖图的一个拓扑序,被依赖者排前面。
  • 关闭顺序 = 启动顺序的严格逆序。这条不是「最好这样」,而是正确性要求:资源加载器关闭时场景已经不在,渲染设备关闭时场景和资源都已销毁。
  • 依赖图里不能有环。若 A → B → A,则无任何合法启动顺序,必须在注册阶段就报错,而不是启动到一半才发现。

《微光岛》的内核启动链就是一条典型依赖:asset(资源表)← scene(场景)← render(渲染设备)。资源表无依赖,最先起;渲染设备最后起,也最先关。

ISubsystem 与 SubsystemRegistry

子系统只暴露四个钩子,注册表只认这张脸,不认识任何具体类型。下面这个文件同时给出接口与注册表本体,resolveOrder() 用 DFS 三色标记(白=未访问、灰=在栈上、黑=已完成)做拓扑排序,灰点被再次访问即说明成环。

下面 src/core/subsystem.ts 是完整可编译文件,层级 L1(纯 TypeScript 内核,Node 直跑)。

// file: src/core/subsystem.ts
// tier: L1
// verify: npx vitest run src/core/subsystem.test.ts
//
// helio 内核:子系统注册与生命周期编排。
// 每个子系统声明自己的名字与依赖;注册表负责拓扑排序、
// 按序 onInit/onStart、逆序 onStop/onShutdown,并在注册阶段就拒绝循环依赖。

/** 传给 onInit 的上下文:内核在启动时注入的公共设施。 */
export interface SubsystemContext {
  readonly frameCount: number;
  log(msg: string): void;
}

/** 一个内核子系统。四个生命周期钩子都会按确定顺序被调用。 */
export interface ISubsystem {
  readonly name: string;
  /** 本子系统启动前必须先启动的子系统名。 */
  readonly dependencies: readonly string[];
  onInit(ctx: SubsystemContext): void;
  onStart(): void;
  onStop(): void;
  onShutdown(): void;
}

const WHITE = 0;
const GRAY = 1;
const BLACK = 2;

/**
 * 子系统注册表。启动顺序 = 依赖拓扑序(被依赖者先启动),
 * 关闭顺序 = 启动顺序的严格逆序。
 */
export class SubsystemRegistry {
  readonly #subsystems = new Map<string, ISubsystem>();
  #order: ISubsystem[] = [];
  #phase: 'idle' | 'running' | 'stopped' = 'idle';

  /** 注册。一旦启动过就不再允许注册(避免顺序在运行期变化)。 */
  register(sub: ISubsystem): void {
    if (this.#phase === 'running') {
      throw new Error(`cannot register ${sub.name}: registry already running`);
    }
    if (this.#subsystems.has(sub.name)) {
      throw new Error(`duplicate subsystem: ${sub.name}`);
    }
    this.#subsystems.set(sub.name, sub);
  }

  /** 只读地取出拓扑序,不触发任何生命周期钩子——方便测试与工具。 */
  resolveOrder(): ISubsystem[] {
    const color = new Map<string, number>();
    const out: ISubsystem[] = [];
    const path: string[] = [];

    const visit = (name: string): void => {
      const c = color.get(name) ?? WHITE;
      if (c === BLACK) return;
      if (c === GRAY) {
        const start = path.indexOf(name);
        const cycle = path.slice(start).concat(name);
        throw new Error(`cyclic subsystem dependency: ${cycle.join(' -> ')}`);
      }
      const sub = this.#subsystems.get(name);
      if (sub === undefined) {
        throw new Error(`unknown subsystem dependency: ${name}`);
      }
      color.set(name, GRAY);
      path.push(name);
      for (const dep of sub.dependencies) visit(dep);
      path.pop();
      color.set(name, BLACK);
      out.push(sub);
    };

    for (const name of this.#subsystems.keys()) visit(name);
    return out;
  }

  /** 按拓扑序 onInit 全部,再按拓扑序 onStart 全部。 */
  boot(ctx: SubsystemContext): void {
    if (this.#phase === 'running') throw new Error('registry already booted');
    this.#order = this.resolveOrder();
    for (const sub of this.#order) sub.onInit(ctx);
    for (const sub of this.#order) sub.onStart();
    this.#phase = 'running';
  }

  /** 按启动顺序的逆序 onStop 全部,再逆序 onShutdown 全部。 */
  shutdown(): void {
    if (this.#phase !== 'running') throw new Error('registry is not running');
    const reverse = [...this.#order].reverse();
    for (const sub of reverse) sub.onStop();
    for (const sub of reverse) sub.onShutdown();
    this.#order = [];
    this.#phase = 'stopped';
  }

  /** 当前生效的启动顺序(boot 之前为空)。 */
  get startOrder(): readonly ISubsystem[] {
    return this.#order;
  }
}

几个刻意的设计决定:

  • #order 在 boot() 里算一次并缓存,shutdown() 直接取逆序,保证两次用的是同一个序,不会因为中途注册变动而对不上。
  • resolveOrder() 是纯函数式的读取,不碰任何钩子,所以测试和构建期工具可以单独调用它来检查依赖图。
  • boot() 之后禁止 register(),把「启动期配置冻结」这条规则变成运行期强约束。
  • 环检测不只抛「有环」,还拼出 a -> b -> a 的路径——线上排查时这一行能省很多时间。

可运行产物:把顺序真跑出来

下面这个测试文件用假子系统把所有生命周期调用写进一个数组,然后逐条断言顺序。层级 L1,命令是 npx vitest run src/core/subsystem.test.ts。

// file: src/core/subsystem.test.ts
// tier: L1
// verify: npx vitest run src/core/subsystem.test.ts
import { describe, expect, it } from 'vitest';
import { SubsystemRegistry } from './subsystem.ts';
import type { ISubsystem, SubsystemContext } from './subsystem.ts';

const ctx: SubsystemContext = { frameCount: 0, log: () => {} };

/** 造一个把所有生命周期调用写进 log 的假子系统。 */
function fake(name: string, deps: string[], log: string[]): ISubsystem {
  return {
    name,
    dependencies: deps,
    onInit: () => log.push(`init:${name}`),
    onStart: () => log.push(`start:${name}`),
    onStop: () => log.push(`stop:${name}`),
    onShutdown: () => log.push(`shutdown:${name}`),
  };
}

describe('SubsystemRegistry', () => {
  it('按依赖拓扑序启动(被依赖者先启动)', () => {
    const log: string[] = [];
    const r = new SubsystemRegistry();
    // 注册顺序刻意打乱:render 依赖 scene,scene 依赖 asset,asset 无依赖。
    r.register(fake('render', ['scene'], log));
    r.register(fake('scene', ['asset'], log));
    r.register(fake('asset', [], log));
    r.boot(ctx);
    expect(log).toEqual([
      'init:asset', 'init:scene', 'init:render',
      'start:asset', 'start:scene', 'start:render',
    ]);
  });

  it('关闭顺序是启动顺序的严格逆序', () => {
    const log: string[] = [];
    const r = new SubsystemRegistry();
    r.register(fake('render', ['scene'], log));
    r.register(fake('scene', ['asset'], log));
    r.register(fake('asset', [], log));
    r.boot(ctx);
    const startOrder = r.startOrder.map((s) => s.name);
    log.length = 0;
    r.shutdown();
    const stopOrder = log.filter((e) => e.startsWith('stop:')).map((e) => e.slice(5));
    expect(stopOrder).toEqual([...startOrder].reverse());
  });

  it('先全部 onStop 再全部 onShutdown', () => {
    const log: string[] = [];
    const r = new SubsystemRegistry();
    r.register(fake('a', [], log));
    r.register(fake('b', ['a'], log));
    r.boot(ctx);
    log.length = 0;
    r.shutdown();
    expect(log).toEqual(['stop:b', 'stop:a', 'shutdown:b', 'shutdown:a']);
  });

  it('检测循环依赖并给出环路径', () => {
    const log: string[] = [];
    const r = new SubsystemRegistry();
    r.register(fake('a', ['b'], log));
    r.register(fake('b', ['a'], log));
    expect(() => r.resolveOrder()).toThrow(/cyclic subsystem dependency: a -> b -> a/);
  });

  it('依赖不存在的子系统时报错', () => {
    const r = new SubsystemRegistry();
    r.register(fake('a', ['ghost'], []));
    expect(() => r.resolveOrder()).toThrow(/unknown subsystem dependency: ghost/);
  });

  it('重复注册同名子系统报错', () => {
    const r = new SubsystemRegistry();
    r.register(fake('a', [], []));
    expect(() => r.register(fake('a', [], []))).toThrow(/duplicate subsystem: a/);
  });

  it('boot 之后禁止再注册', () => {
    const r = new SubsystemRegistry();
    r.register(fake('a', [], []));
    r.boot(ctx);
    expect(() => r.register(fake('b', [], []))).toThrow(/already running/);
  });
});

本机实跑结果:Test Files 4 passed、Tests 26 passed(这一节贡献其中 7 条),tsc --noEmit 零错误。其中「关闭顺序是启动顺序的严格逆序」那条断言,正是本节的核心契约——它把「逆序关闭」从口头约定变成了 CI 里会红的东西。

小结

  • 子系统用「名字 + 依赖列表 + 四个钩子」描述,注册表用 DFS 三色标记求拓扑序并在成环时给出环路径。
  • 启动顺序 = 拓扑序,关闭顺序 = 严格逆序;两者由同一份缓存序保证一致。
  • 启动后冻结注册、重复注册报错、未知依赖报错——所有错误都尽量在 boot() 之前暴露。

下一节换到最底层:内核如何做到「同一份代码既跑 Node 又跑浏览器」——平台抽象层 PAL。

阅读导航:上一节:目录 · 下一节:3.2 PAL 平台抽象层 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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