《游戏引擎实现原理》5.1 模块边界与依赖方向

卷四开始把 helio 工程化交付。本节把「谁可以 import 谁」从一句口号变成可执行的门禁:先用两件纯函数抽象 import 图,再写一个 CLI 扫描整棵 src/,实测 296 个模块、258 条依赖边,向上依赖 0、循环依赖 0,并演示越界时退出码如何变红。

5.1 模块边界与依赖方向

前三卷把 helio 从零搭到了能跑渲染、动画、物理的程度,但那些代码都长在一棵没人看管的 src/ 里。卷四要做的第一件工程化的事,不是加功能,而是给这棵树立规矩:谁可以 import 谁,必须能被机器检查,而不是靠 review 时的自觉。

这一节只解决一个问题:把 src/ 里真实的 import 关系抽成一张有向图,然后自动回答两件事——有没有「向上依赖」,有没有「循环依赖」。两张答案都是 0,才有资格谈后面几章的裁剪与插件。

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

「模块边界」这个题目,站内已经有人从不同角度写过。先把分工写清楚,避免重复:

既有文章它负责什么本节只做什么
helio-kernel-01-1 引擎的分层与模块边界用类型系统把「内核不得依赖渲染」钉在编译期把约束从「手写清单」升级成「扫描真实 import 图」,并额外抓循环依赖
helio-render-01-2 渲染器的分层渲染内部 RHI / 渲染器 / 场景三层的依赖方向把同一套检查器推广到整棵 src/,是跨层检查而非渲染内部
/game-engine-architecture-ecs/一篇文章讲清 ECS 引擎的骨架长什么样不展开引擎架构,只做「源码级 import 图」这一个工程问题

一句话:别人讲「边界应该长什么样」,本节讲「怎么让一条命令在 CI 里替你守住它」。

依赖方向:层序决定合法边

helio 的层序在第一卷就定死了:Platform < Kernel < Render < Subsystem < Game,依赖只准从高处流向低处。落在目录上就是一张很短的映射表:

目录层允许 import
src/core、src/math、src/ecs、src/generatedKernelPlatform(本卷尚未落地,实际只有同层)
src/renderRenderPlatform、Kernel
src/game、src/appsGame下面所有层

检查的判据极其简单:一条边 A -> B 非法,当且仅当 layer(A) 的序号小于 layer(B) 的序号。同层互相 import 合法,向下合法,只有「低层 import 高层」会被点名。这和第一卷那篇文章里 AllowedImports 映射类型是同一个规则,区别是那里靠编译器挡单条声明,这里靠扫描挡住整棵树——包括从配置或生成代码里冒出来的边。

边界不会在某一天轰然倒塌,它是被一次次「就这一处、下次改」磨穿的。工程上最常见的三种腐烂方式:

  • 抄近路:内核里的工具函数需要知道「当前相机」,于是顺手 import 了渲染层的相机类型。一次没事,两次之后内核就再也没法在无窗口环境跑。
  • 循环依赖:A 里为了少传一个参数 import 了 B,B 又为了复用某个枚举 import 回 A。单测还全绿,因为打包器把两段代码塞进了同一个 chunk;直到某天拆包时才炸。
  • 隐性边:import('../render/x.ts') 这种动态 import 不会出现在文件头的静态 import 列表里,只查「第一屏」的脚本必然漏掉。

所以检查器必须同时干两件事:扫全部相对 import(含动态),并且把结果做成一条能返回退出码的命令。这也是本节不满足于「写一份文档约定」的原因——文档不会拦住 git push,退出码会。

可运行产物:两件纯函数 + 一个扫描器

先把图算法做成纯函数,它不碰文件系统,因此可以单测。这个文件定义了层序表、按路径判层、找向上依赖、找循环依赖。

// file: src/core/module-graph.ts
// tier: L1
// verify: npx vitest run src/core/module-graph.test.ts
//
// helio 内核:模块依赖图。把「谁 import 谁」抽象成有向图,
// 提供两件纯函数:找向上依赖、找循环依赖。文件系统遍历交给 CLI 工具。

export const LAYER_INDEX = {
  Platform: 0,
  Kernel: 1,
  Render: 2,
  Subsystem: 3,
  Game: 4,
} as const;

export type LayerName = keyof typeof LAYER_INDEX;

/** 目录名 -> 层。未列出的目录一律当 Kernel(内核是最底、最安全的默认)。 */
export const DIR_LAYER: Readonly<Record<string, LayerName>> = {
  core: 'Kernel',
  math: 'Kernel',
  ecs: 'Kernel',
  generated: 'Kernel',
  render: 'Render',
  game: 'Game',
  apps: 'Game',
};

export interface Edge {
  readonly from: string;
  readonly to: string;
}

export interface UpwardViolation {
  readonly from: string;
  readonly fromLayer: LayerName;
  readonly to: string;
  readonly toLayer: LayerName;
}

/** 取路径的第一段目录名查层表;查不到时返回 fallback(默认 Kernel)。 */
export function layerOfPath(path: string, fallback: LayerName = 'Kernel'): LayerName {
  const top = path.split('/').filter(Boolean)[0] ?? '';
  return DIR_LAYER[top] ?? fallback;
}

/** 找所有「向上依赖」:起点层序小于终点层序的边。同层与向下都不算。 */
export function findUpwardDeps(
  edges: readonly Edge[],
  layerOf: (path: string) => LayerName = (p) => layerOfPath(p),
): UpwardViolation[] {
  const out: UpwardViolation[] = [];
  for (const e of edges) {
    const fl = layerOf(e.from);
    const tl = layerOf(e.to);
    if (LAYER_INDEX[fl] < LAYER_INDEX[tl]) {
      out.push({ from: e.from, fromLayer: fl, to: e.to, toLayer: tl });
    }
  }
  return out;
}

/** 用递归 DFS + 三色标记找有向环,每个环按发现顺序返回节点路径。 */
export function findCycles(nodes: readonly string[], edges: readonly Edge[]): string[][] {
  const adj = new Map<string, string[]>(nodes.map((n) => [n, []]));
  for (const e of edges) adj.get(e.from)?.push(e.to);

  const WHITE = 0, GRAY = 1, BLACK = 2;
  const color = new Map<string, number>(nodes.map((n) => [n, WHITE]));
  const path: string[] = [];
  const cycles: string[][] = [];

  const visit = (node: string): void => {
    color.set(node, GRAY);
    path.push(node);
    for (const nb of adj.get(node) ?? []) {
      const c = color.get(nb);
      if (c === WHITE) visit(nb);
      else if (c === GRAY) cycles.push(path.slice(path.indexOf(nb)));
    }
    path.pop();
    color.set(node, BLACK);
  };

  for (const n of nodes) if (color.get(n) === WHITE) visit(n);
  return cycles;
}

循环检测用的是「三色 DFS」:白色未访问、灰色在栈上、黑色已完成。当遍历遇到一条指向灰色节点的边,说明回到了当前路径上,从栈里截出这一段就是一个环。这个判据比「检查 A 依赖 B 且 B 依赖 A」的朴素写法正确得多——后者会漏掉三元及以上的环。配套的单测里专门造了二元环和三元环各一条,断言都能被抓到。

// snippet: src/core/module-graph.test.ts 的两条关键断言
it('内核 import 渲染被点名', () => {
  const v = findUpwardDeps([{ from: 'core/clock.ts', to: 'render/renderer.ts' }]);
  expect(v[0].fromLayer).toBe('Kernel');
  expect(v[0].toLayer).toBe('Render');
});

it('二元环与三元环都能找到', () => {
  const cycles = findCycles(['a.ts', 'b.ts', 'c.ts'], [
    { from: 'a.ts', to: 'b.ts' }, { from: 'b.ts', to: 'a.ts' },
    { from: 'b.ts', to: 'c.ts' }, { from: 'c.ts', to: 'b.ts' },
  ]);
  expect(cycles.length).toBe(2);
});

CLI 只负责一件事:把 src/ 下所有 .ts 走一遍,从源码里抠出相对 import,解析成真实文件路径,然后调用上面两件纯函数。注意正则里同时匹配 from '...' 和 import('...'),动态 import 也不能放过;node: 内置模块与 vitest 这类裸包说明符会被过滤掉,它们不参与层间依赖。

// file: tools/module-boundary-check.mjs
// tier: L1
// verify: node tools/module-boundary-check.mjs
//
// helio 工程化:扫描 src/ 的 import 图,检查两件事:
//   1) 依赖方向:只准上层 import 下层(Platform < Kernel < Render < Game);
//   2) 循环依赖:任何 import 环都要被点名。
// 退出码 0 = 干净,1 = 有违规。CI 直接拿它当门禁。

import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { findCycles, findUpwardDeps, layerOfPath } from '../src/core/module-graph.ts';

const ROOT = resolve(process.argv[2] ?? 'src');

function walk(dir) {
  const out = [];
  for (const name of readdirSync(dir)) {
    const p = join(dir, name);
    if (statSync(p).isDirectory()) out.push(...walk(p));
    else if (name.endsWith('.ts')) out.push(p);
  }
  return out;
}

/** 抠出所有相对说明符:`from './x.ts'` 与 `import('./x.ts')`。 */
function importsOf(src) {
  const specs = [];
  const re = /(?:from|import)\s*\(?\s*'([^']+)'/g;
  let m;
  while ((m = re.exec(src)) !== null) specs.push(m[1]);
  return specs.filter((s) => s.startsWith('.'));
}

const files = walk(ROOT).sort();
const nodes = new Set(files);
const edges = [];
for (const file of files) {
  for (const spec of importsOf(readFileSync(file, 'utf8'))) {
    const target = resolve(dirname(file), spec);
    if (nodes.has(target)) edges.push({ from: file, to: target });
  }
}

const upward = findUpwardDeps(edges, (p) => layerOfPath(relative(ROOT, p)));
const cycles = findCycles(files, edges);

const byLayer = {};
for (const f of files) {
  const l = layerOfPath(relative(ROOT, f));
  byLayer[l] = (byLayer[l] ?? 0) + 1;
}

console.log(`modules: ${files.length}`);
console.log(`edges:   ${edges.length}`);
console.log(`layers:  ${JSON.stringify(byLayer)}`);
console.log(`upward:  ${upward.length}`);
for (const v of upward) {
  console.log(`  ${v.fromLayer} -> ${v.toLayer}: ${relative(ROOT, v.from)} -> ${relative(ROOT, v.to)}`);
}
console.log(`cycles:  ${cycles.length}`);
for (const c of cycles) console.log(`  ${c.map((f) => relative(ROOT, f)).join(' -> ')}`);
process.exit(upward.length === 0 && cycles.length === 0 ? 0 : 1);

本机对当前 src/ 实跑(node tools/module-boundary-check.mjs):

modules: 296
edges:   258
layers:  {"Game":11,"Kernel":135,"Render":150}
upward:  0
cycles:  0

296 个 .ts 文件、258 条相对 import 边,三层里 Kernel 135、Render 150、Game 11,向上依赖 0、循环依赖 0。这个数字包含本节新加的检查器自身,所以会随后续小节往 src/ 里加文件而变大——真正要盯的是最后两行恒为 0。

出问题时它长什么样

一个只在干净树上打印 0 的检查器没有说服力。往 src/core/ 里塞一个故意越界的文件,再跑一次:

modules: 297
edges:   259
layers:  {"Game":11,"Kernel":136,"Render":150}
upward:  1
  Kernel -> Render: core/__tmp_violation.ts -> render/renderer.ts
cycles:  0

upward 变成 1,退出码变成 1,并且点名了具体的两端。删除该文件后重新跑,upward 回到 0。这就是它能进 CI 的底气:node tools/module-boundary-check.mjs 的退出码非 0 时,流水线直接红掉,越界的那次 import 根本合不进来。

整个检查链在本机跑过三道,结果如下:

$ npx tsc --noEmit
TypeScript: No errors found

$ npx vitest run src/core/module-graph.test.ts
Test Files  1 passed (1)
     Tests  6 passed (6)

$ node tools/module-boundary-check.mjs ; echo $?
0

三道各自守一段:tsc 守「单条声明里的类型级边界」,vitest 守「图算法的正确性」(二元环、三元环、同层合法、向上非法四个用例),CLI 守「整棵树的实际依赖」。缺任何一道都会留缝——只跑 tsc 挡不住运行期拼出来的边,只跑 CLI 又发现不了算法本身写错。

下一节我们处理另一个和「整棵树」有关的问题:既然边界已经清楚了,哪些模块根本不该进包——用构建期特性开关把它们整个摇掉。

阅读导航:上一节:回到目录 · 下一节:5.2 特性开关与死代码消除 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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