《游戏引擎实现原理》1.1 引擎的分层与模块边界

从零实现引擎的第一件事是划边界。helio 分成平台、内核、渲染、子系统、游戏五层,依赖只能向下。本节用一张 ASCII 分层图对照 Unity、Unreal、Godot、Bevy,并用 TypeScript 类型把「内核层不得依赖渲染层」钉死在编译期。

1.1 引擎的分层与模块边界

做游戏和做引擎最大的区别,不是你会不会写渲染,而是你有没有先画一张「谁可以依赖谁」的图。游戏项目里随便 import 也能跑,反正代码活不过这个版本;引擎不行——引擎要被很多子系统复用、要活很多年,一旦内核层悄悄依赖了渲染层,你就再也没法把渲染换成另一套实现,也没法在服务器上跑无窗口的 headless 模式。

helio 的第一条设计决策就是分层。这一节只做一件事:把五层画出来,把依赖方向钉死,然后让编译器替我们守住这条边界。凡是「引擎架构该长什么样」的理论讨论,站内已经有更完整的文章,本节不重复。

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

这一章(「引擎是什么」)最容易写成一篇空泛的架构综述。为了避免和站内既有内容打架,先把分工写清楚:

既有文章它负责什么本节只做什么
/game-engine-architecture-ecs/用一篇文章讲清 ECS 引擎骨架「长什么样」把 helio 的分层画成图,并给出可编译的边界约束代码
/posts/game/engine/引擎选型、Unity 与 Unreal 的取舍不选型,只把四大引擎的分层对照成一张表
/defold-ecs-architecture/从现成引擎的子系统的视角讲 ECS从「内核实现者」视角讲边界,不讲子系统用法

一句话:别人讲「是什么、怎么选」,本节讲「怎么从零把边界搭出来、怎么让编译器兜住」。

helio 的五层:平台 / 内核 / 渲染 / 子系统 / 游戏

helio 从下到上分五层。下面这张图是全书的地基,后面每一章都会回到它:你在哪一层写代码,决定了你能看到什么、能被谁看到。

┌──────────────────────────────────────────────────────────┐
│  游戏层 Game        《微光岛》玩法、关卡、UI、存档逻辑      │
├──────────────────────────────────────────────────────────┤
│  子系统层 Subsystem  动画 / 物理 / 音频 / AI / 寻路         │
├──────────────────────────────────────────────────────────┤
│  渲染层 Render      渲染器、材质、pass、GPU 资源、渲染图    │
├──────────────────────────────────────────────────────────┤
│  内核层 Kernel      时间 / 内存 / 句柄 / 数学 / ECS / 任务  │
├──────────────────────────────────────────────────────────┤
│  平台层 Platform    窗口 / 输入 / 文件 / 时钟 / 线程        │
└──────────────────────────────────────────────────────────┘
        依赖方向:上层 → 下层,只准向下,绝不准向上
  • 平台层 Platform:唯一和操作系统打交道的一层。窗口、输入事件、文件读取、高精度时钟、线程原语都在这里。它向上暴露一组纯接口,把 window、fs、performance 这些宿主环境的东西挡在下面。
  • 内核层 Kernel:本卷的主战场。时间步、内存分配、句柄、数学、ECS、任务系统、反射。它只依赖平台层,永远不认识渲染层——这是整本书最重要的一条铁律。
  • 渲染层 Render:渲染器、材质、GPU 资源、渲染图。它可以依赖内核层和平台层,但内核层反过来不认识它。
  • 子系统层 Subsystem:动画、物理、音频、AI 这些「成体系」的功能,建在内核之上。注意它和渲染层是平级的,两者互不依赖。
  • 游戏层 Game:《微光岛》的玩法代码,站在所有层之上,什么都可以用。

为什么内核层必须独立于渲染层?三个直接好处:

  1. 可替换:换渲染后端(WebGPU → 软件光栅 → 空实现)不需要动内核一行代码。
  2. 可测试:内核能在 Node 里无窗口跑单测,这正是本书每节 L1 代码能真跑的原因。
  3. 可复用:服务器端只要内核 + 平台层,不用拖着一整个 GPU 栈。

反过来看,分层最容易犯的三种错,本书会一路刻意避开:

  • 把「工具」当「层」:日志、随机数、数学库不属于任何一层,它们是横切关注点,任何层都能用。硬要把它们塞进某一层,只会让依赖图变成一团乱麻。
  • 让内核反向依赖:最典型的是内核里直接 import 了 GPU 设备。一旦发生,内核就再也不能在无窗口环境跑,本书所有 L1 单测都会失效。
  • 把游戏逻辑写进子系统:子系统只提供「能力」(播放一个动画、解算一次碰撞),「什么时候播、播给谁」是游戏层的事。这条边界一破,子系统就不可复用了。

记住一个判断标准:如果删掉上层,下层还能单独编译、单独测试,分层就是干净的。helio 的每一层都满足这个条件。

对照 Unity / Unreal / Godot / Bevy

同样是分层,四大引擎切法不同。把它们的层和 helio 对齐,能看清「内核」这个概念在工业界到底指什么:

引擎平台抽象内核/核心渲染子系统游戏逻辑
UnityUnityEngine 底层 native 运行时核心运行时 + 对象系统SRP / URP / HDRPUnityEngine.* 模块MonoBehaviour 脚本
UnrealFPlatformMisc 等平台层Core / CoreUObjectRenderer / RHI各 Engine 子系统GameFramework / 蓝图
Godotplatform/ 目录core/(Object/Variant)servers/renderingscene/ 节点体系GDScript / 项目代码
Bevybevy_platformbevy_ecs / bevy_appbevy_render / bevy_wgpu各 bevy_* 插件用户 System
heliosrc/platformsrc/coresrc/rendersrc/subsystemsrc/game

几个值得注意的点:

  • Unity 与 Unreal 的「内核」都带对象系统。Unity 的 GameObject/Component、Unreal 的 UObject,本质是把「反射 + 生命周期」做进了核心层。helio 会在第 16–17 章补反射,第 12–13 章补 ECS,对应这两块。
  • Godot 把「场景」提升为一层(scene/)。这是一种取舍:节点树既是逻辑容器也是编辑器模型。helio 不这么做,场景只是内核 ECS 之上的一层薄封装。
  • Bevy 的 ECS 就是它的内核,bevy_ecs 比渲染还底层。helio 跟随这条路线:ECS 在内核层,渲染层反过来依赖它。
  • 四家的渲染层都平级于、而非低于子系统。这也是 helio 把 Render 和 Subsystem 画在同一高度的原因——动画系统不该知道 GPU 怎么用。

这张表最想让你记住的是:「内核」不是「引擎的全部」,而是「不依赖 GPU、不依赖游戏逻辑的那部分」。Unity 的 Core、Godot 的 core/、Bevy 的 bevy_ecs 都在这个意义上使用「内核」一词。helio 的 src/core 也一样——它只做时间、内存、句柄、数学、ECS、任务这些「和画什么、玩什么无关」的事。

可运行产物:把「依赖只能向下」写成类型

光画图不够。边界必须可执行,否则三个月后一定会被一次随手的 import 打破。helio 的做法是双保险:类型系统在编译期挡住明显越界,运行期扫描兜住从配置读入的动态声明。

先看第一半——带类型约束的模块声明。这个文件定义层序、每层允许 import 的集合,以及一个运行期扫描函数。

// file: src/core/layers.ts
// tier: L1
// verify: npx vitest run src/core/layers.test.ts
//
// helio 内核:分层与模块边界。
// 五层从下到上:Platform < Kernel < Render < Subsystem < Game。
// 铁律:依赖只能向下(上层 → 下层),内核层永远不许 import 渲染层。
// 这条铁律一半靠类型系统在编译期挡住,一半靠运行期扫描配置兜底。

export const LAYER_ORDER = ['Platform', 'Kernel', 'Render', 'Subsystem', 'Game'] as const;
export type LayerName = (typeof LAYER_ORDER)[number];
export type LayerIndex = 0 | 1 | 2 | 3 | 4;

/** 每一层允许 import 的层集合(只允许向下)。内核层因此拿不到 Render。 */
export type AllowedImports = {
  Platform: never;
  Kernel: 'Platform';
  Render: 'Platform' | 'Kernel';
  Subsystem: 'Platform' | 'Kernel';
  Game: 'Platform' | 'Kernel' | 'Render' | 'Subsystem';
};

/** 带类型约束的模块声明:Kernel 模块写 imports: ['Render'] 会直接编译报错。 */
export interface ModuleSpec<L extends LayerName> {
  readonly name: string;
  readonly layer: L;
  readonly imports: readonly AllowedImports[L][];
}

/** 无类型约束的声明,用于从配置/清单读入后再做运行期校验。 */
export interface LooseModule {
  readonly name: string;
  readonly layer: LayerName;
  readonly imports: readonly LayerName[];
}

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

export function layerIndexOf(layer: LayerName): LayerIndex {
  return LAYER_ORDER.indexOf(layer) as LayerIndex;
}

/** 扫描一组模块,找出所有「向上依赖」(目标层比自身层更高)的非法边。 */
export function findUpwardDeps(modules: readonly LooseModule[]): Violation[] {
  const out: Violation[] = [];
  for (const m of modules) {
    const from = layerIndexOf(m.layer);
    for (const dep of m.imports) {
      if (layerIndexOf(dep) > from) {
        out.push({ from: m.name, fromLayer: m.layer, toLayer: dep });
      }
    }
  }
  return out;
}

/** 统计每一层各有多少个模块,用来核对分层是否真的被使用。 */
export function countByLayer(modules: readonly LooseModule[]): Record<LayerName, number> {
  const acc: Record<LayerName, number> = {
    Platform: 0,
    Kernel: 0,
    Render: 0,
    Subsystem: 0,
    Game: 0,
  };
  for (const m of modules) acc[m.layer] += 1;
  return acc;
}

关键在 AllowedImports 这个映射类型:ModuleSpec<'Kernel'> 的 imports 字段类型是 readonly ('Platform')[],你写 ['Render'] 编译器会直接报 Type '"Render"' is not assignable to type '"Platform"'。这不是注释里的一句约定,是类型检查器真正执行的规则。

第二半是测试。注意这里故意用一行 @ts-expect-error 把「编译期拒绝」也变成一条断言——如果哪天有人放宽了 AllowedImports,这行会反过来报「未使用的 expect-error」,测试立刻失败。

// file: src/core/layers.test.ts
// tier: L1
// verify: npx vitest run src/core/layers.test.ts
import { describe, expect, it } from 'vitest';
import {
  countByLayer,
  findUpwardDeps,
  layerIndexOf,
  type LooseModule,
  type ModuleSpec,
} from './layers.ts';

describe('layers', () => {
  it('内核模块只依赖平台层,类型层面即通过', () => {
    const clock: ModuleSpec<'Kernel'> = {
      name: 'core/clock',
      layer: 'Kernel',
      imports: ['Platform'],
    };
    expect(clock.imports).toEqual(['Platform']);
    expect(layerIndexOf(clock.layer)).toBe(1);
  });

  it('内核依赖渲染层被类型系统直接拒绝', () => {
    const illegal: ModuleSpec<'Kernel'> = {
      name: 'core/clock',
      layer: 'Kernel',
      // @ts-expect-error Kernel 的 imports 只允许 'Platform',写 'Render' 编译不过
      imports: ['Render'],
    };
    expect(illegal.name).toBe('core/clock');
  });

  it('运行期能扫出向上的非法依赖', () => {
    const bad: readonly LooseModule[] = [
      { name: 'core/clock', layer: 'Kernel', imports: ['Render'] },
    ];
    const v = findUpwardDeps(bad);
    expect(v).toHaveLength(1);
    expect(v[0].fromLayer).toBe('Kernel');
    expect(v[0].toLayer).toBe('Render');
  });

  it('合法的分层图没有任何非法边', () => {
    const graph: readonly LooseModule[] = [
      { name: 'core/clock', layer: 'Kernel', imports: ['Platform'] },
      { name: 'render/device', layer: 'Render', imports: ['Platform', 'Kernel'] },
      { name: 'subsys/animation', layer: 'Subsystem', imports: ['Kernel'] },
      { name: 'game/island', layer: 'Game', imports: ['Kernel', 'Render', 'Subsystem'] },
    ];
    expect(findUpwardDeps(graph)).toEqual([]);
    expect(countByLayer(graph)).toEqual({
      Platform: 0,
      Kernel: 1,
      Render: 1,
      Subsystem: 1,
      Game: 1,
    });
  });

  it('同层之间依赖不算向上', () => {
    const graph: readonly LooseModule[] = [
      { name: 'kernel/a', layer: 'Kernel', imports: ['Kernel'] },
    ];
    expect(findUpwardDeps(graph)).toEqual([]);
  });
});

本机实跑结果(npx vitest run src/core/layers.test.ts):

✓ src/core/layers.test.ts > layers > 内核模块只依赖平台层,类型层面即通过
✓ src/core/layers.test.ts > layers > 内核依赖渲染层被类型系统直接拒绝
✓ src/core/layers.test.ts > layers > 运行期能扫出向上的非法依赖
✓ src/core/layers.test.ts > layers > 合法的分层图没有任何非法边
✓ src/core/layers.test.ts > layers > 同层之间依赖不算向上
 Test Files  1 passed (1)
      Tests  5 passed (5)

有了这两半,「内核层不得依赖渲染层」就从一句口号变成了 npx tsc --noEmit 和 npx vitest 都会替你把关的硬约束。后面每一章引入新模块时,我们都会先问一句「它在哪一层、允许依赖谁」,再动手写代码。

下一节我们把范围说清楚:本卷到底写什么、哪些问题交给站内既有文章,以及为什么这个边界对读者是好事。

阅读导航:上一节:回到目录 · 下一节:1.2 本书范围与站内分工 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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