《游戏引擎实现原理》1.3 一个引擎最少要有哪些零件

一个引擎最少要有哪些零件?helio 的答案是六个:主循环、内存、句柄、场景/实体、渲染接口、资源表。本节给出这六个零件的最小实现,并用一段 30 行的启动代码在 Node 里真跑出「第 1 帧到第 3 帧」的日志,再用 vitest 断言帧数。

1.3 一个引擎最少要有哪些零件

前两节把边界划好了:五层、依赖只能向下、本书只写内核。这一节做一件更朴素的事——把「引擎」拆到不能再拆,看它至少需要几个零件才能跑起来。拆得够狠,你才能看清后面每一章到底在补哪一块。

判断标准很简单:删掉这个零件,引擎还能不能跑出「一帧」。能,它就是可选的;不能,它就是必需的。按这个标准筛完,helio 剩下六个零件。

六个零件

  1. 主循环 Main Loop:把「反复做一件事」抽象出来。它负责推进时间、按顺序调用子系统、在帧末收尾。没有它,引擎只是一堆函数。
  2. 内存 Memory:内核不信任 GC 的停顿,需要一块自己能控制的生命周期内存。最小版本是一块「帧内只前进、帧末整体回收」的 arena。
  3. 句柄 Handle:内核不裸传指针/对象引用,而是传一个带代际的整数句柄。这样「对象已删、句柄还在」可以被检测出来。
  4. 场景 / 实体 Scene / Entity:引擎得知道「世界里有几个东西」。最小版本就是一组活着的实体 id。
  5. 渲染接口 Render Interface:内核不认识 GPU,只认识一个接口(beginFrame / draw / endFrame)。谁实现它,内核不关心。
  6. 资源表 Resource Table:引擎得登记「有哪些资源被加载了」。最小版本只记「路径 → 字节数」。

这六个零件对应后面 17 章的骨架:第 2–3 章把主循环做扎实,第 4–6 章替换掉内存和句柄的玩具实现,第 12–13 章把场景升级成真正的 ECS,第 18 章把它们组装起来。

可运行产物:30 行启动代码跑出前三帧

下面把六个零件写成一个最小文件。它不追求性能,只追求「六个零件各就各位、能被替换」。注意句柄用了 branded type(number & { __brand }),防止裸数字混进实体 id。

// file: src/core/minimal-engine.ts
// tier: L1
// verify: npx vitest run src/core/minimal-engine.test.ts
//
// helio 内核:一个「最少」的引擎骨架。
// 只保留六个零件:主循环、内存、句柄、场景/实体、渲染接口、资源表。
// 其余一切(数学、ECS 查询、任务系统……)都在后面章节往里加,这里只保证能跑起来。

/** 带代际的实体句柄。用 branded type 防止裸 number 混进来。 */
export type EntityId = number & { readonly __brand: 'EntityId' };

/** 渲染接口:内核层只认这个 interface,不认识任何具体 GPU API。 */
export interface RenderBackend {
  beginFrame(frame: number): void;
  draw(entity: EntityId): void;
  endFrame(frame: number): void;
}

/** 零件二:帧内存。每帧开头 reset,帧内只前进不回收。 */
export class FrameArena {
  readonly capacity: number;
  #used = 0;

  constructor(capacityBytes: number) {
    this.capacity = capacityBytes;
  }

  get used(): number {
    return this.#used;
  }

  alloc(bytes: number): number {
    if (this.#used + bytes > this.capacity) {
      throw new RangeError(`frame arena overflow: need ${bytes}, free ${this.capacity - this.#used}`);
    }
    const at = this.#used;
    this.#used += bytes;
    return at;
  }

  reset(): void {
    this.#used = 0;
  }
}

/** 零件四:场景。这里先退化成「一组活着的实体」,真正的 ECS 存储在第 12 章。 */
export class Scene {
  #next = 0;
  readonly #alive = new Set<EntityId>();

  spawn(): EntityId {
    const id = (this.#next++ as number) as EntityId;
    this.#alive.add(id);
    return id;
  }

  despawn(id: EntityId): void {
    this.#alive.delete(id);
  }

  get count(): number {
    return this.#alive.size;
  }

  entities(): EntityId[] {
    return [...this.#alive];
  }
}

/** 零件六:资源表。这里只登记「路径 → 字节数」,加载器留到渲染卷。 */
export class ResourceTable {
  readonly #sizes = new Map<string, number>();

  register(path: string, sizeBytes: number): void {
    this.#sizes.set(path, sizeBytes);
  }

  totalBytes(): number {
    let sum = 0;
    for (const n of this.#sizes.values()) sum += n;
    return sum;
  }
}

export interface EngineOptions {
  readonly backend: RenderBackend;
  readonly maxFrames: number;
  readonly onFrame?: (frame: number) => void;
}

/** 零件一:主循环。这里用「固定帧数」的确定性循环,便于在 Node 里复现。 */
export class MinimalEngine {
  readonly #backend: RenderBackend;
  readonly #maxFrames: number;
  readonly #onFrame: ((frame: number) => void) | undefined;
  readonly #arena: FrameArena;
  readonly #scene: Scene;
  readonly #resources: ResourceTable;
  #frame = 0;
  #running = false;

  constructor(opts: EngineOptions) {
    this.#backend = opts.backend;
    this.#maxFrames = opts.maxFrames;
    this.#onFrame = opts.onFrame;
    this.#arena = new FrameArena(64 * 1024);
    this.#scene = new Scene();
    this.#resources = new ResourceTable();
  }

  get frame(): number {
    return this.#frame;
  }
  get scene(): Scene {
    return this.#scene;
  }
  get resources(): ResourceTable {
    return this.#resources;
  }

  spawn(): EntityId {
    return this.#scene.spawn();
  }

  despawn(id: EntityId): void {
    this.#scene.despawn(id);
  }

  start(): void {
    this.#running = true;
    while (this.#running && this.#frame < this.#maxFrames) {
      this.#step();
    }
  }

  stop(): void {
    this.#running = false;
  }

  #step(): void {
    this.#frame += 1;
    this.#arena.reset(); // 零件二:每帧回收帧内存
    this.#arena.alloc(256); // 模拟本帧临时分配
    this.#backend.beginFrame(this.#frame);
    for (const e of this.#scene.entities()) this.#backend.draw(e);
    this.#backend.endFrame(this.#frame);
    this.#onFrame?.(this.#frame);
  }
}

启动代码不到 30 行:造一个控制台渲染后端、生成三个实体、跑三帧、打印结果。这就是《微光岛》第一版骨架的全部。

// file: src/apps/helio-boot.ts
// tier: L1
// verify: node src/apps/helio-boot.ts
import { MinimalEngine, type RenderBackend } from '../core/minimal-engine.ts';

// 《微光岛》最小骨架:控制台渲染后端,把每帧画了哪些实体打到日志。
const consoleBackend: RenderBackend = {
  beginFrame(frame) {
    console.log(`[helio] frame ${frame} begin`);
  },
  draw(entity) {
    console.log(`[helio]   draw entity #${entity}`);
  },
  endFrame(frame) {
    console.log(`[helio] frame ${frame} end`);
  },
};

const engine = new MinimalEngine({ backend: consoleBackend, maxFrames: 3 });
engine.resources.register('/glimmer-isle/player.gltf', 2048);
engine.spawn(); // 玩家
engine.spawn(); // 光点
engine.spawn(); // 岛屿地块
engine.start();
console.log(
  `[helio] stopped after ${engine.frame} frames, ` +
    `${engine.scene.count} entities alive, ${engine.resources.totalBytes()} B registered`,
);

本机实跑(node src/apps/helio-boot.ts),输出确实是第 1 帧到第 3 帧:

[helio] frame 1 begin
[helio]   draw entity #0
[helio]   draw entity #1
[helio]   draw entity #2
[helio] frame 1 end
[helio] frame 2 begin
[helio]   draw entity #0
[helio]   draw entity #1
[helio]   draw entity #2
[helio] frame 2 end
[helio] frame 3 begin
[helio]   draw entity #0
[helio]   draw entity #1
[helio]   draw entity #2
[helio] frame 3 end
[helio] stopped after 3 frames, 3 entities alive, 2048 B registered

光有日志还不够——日志是给人看的,测试才是给机器看的。下面的 vitest 用假后端记录每帧调用,断言「主循环恰好跑满 3 帧」「3 实体 × 3 帧 = 9 次 draw」。

// file: src/core/minimal-engine.test.ts
// tier: L1
// verify: npx vitest run src/core/minimal-engine.test.ts
import { describe, expect, it } from 'vitest';
import {
  FrameArena,
  MinimalEngine,
  ResourceTable,
  Scene,
  type EntityId,
  type RenderBackend,
} from './minimal-engine.ts';

/** 记录每帧调用的假后端,用来断言主循环真的跑了 N 帧。 */
class RecordingBackend implements RenderBackend {
  readonly began: number[] = [];
  readonly drew: Array<{ frame: number; entity: EntityId }> = [];
  #frame = 0;

  beginFrame(frame: number): void {
    this.#frame = frame;
    this.began.push(frame);
  }
  draw(entity: EntityId): void {
    this.drew.push({ frame: this.#frame, entity });
  }
  endFrame(): void {}
}

describe('minimal-engine', () => {
  it('主循环恰好跑满 maxFrames 帧', () => {
    const backend = new RecordingBackend();
    const engine = new MinimalEngine({ backend, maxFrames: 3 });
    engine.spawn();
    engine.spawn();
    engine.spawn();
    engine.start();
    expect(engine.frame).toBe(3);
    expect(backend.began).toEqual([1, 2, 3]);
    expect(backend.drew).toHaveLength(9); // 3 实体 × 3 帧
  });

  it('onFrame 回调按帧序触发', () => {
    const seen: number[] = [];
    const engine = new MinimalEngine({
      backend: new RecordingBackend(),
      maxFrames: 3,
      onFrame: (f) => seen.push(f),
    });
    engine.start();
    expect(seen).toEqual([1, 2, 3]);
  });

  it('场景能生成与回收实体', () => {
    const scene = new Scene();
    const a = scene.spawn();
    const b = scene.spawn();
    expect(scene.count).toBe(2);
    scene.despawn(a);
    expect(scene.entities()).toEqual([b]);
  });

  it('帧内存 reset 后可复用,越界抛错', () => {
    const arena = new FrameArena(1024);
    arena.alloc(512);
    expect(arena.used).toBe(512);
    arena.reset();
    expect(arena.used).toBe(0);
    expect(() => arena.alloc(2048)).toThrow(RangeError);
  });

  it('资源表累计字节数', () => {
    const res = new ResourceTable();
    res.register('a.gltf', 1024);
    res.register('b.gltf', 512);
    expect(res.totalBytes()).toBe(1536);
  });
});

本机实跑(npx vitest run src/core/minimal-engine.test.ts):Test Files 1 passed (1)、Tests 5 passed (5)。

从骨架到全书

这个骨架里的每个零件都是「玩具版」,后面每一章会把它换成真正的实现。对照表如下:

零件本节的玩具版哪一章把它做真
主循环固定 3 帧的 while第 2 章帧预算与固定步长
内存一个 FrameArena第 4–5 章 arena 与分配器
句柄自增 EntityId第 6 章 slotmap 代际句柄
场景一个 Set<EntityId>第 12–13 章 ECS
渲染接口控制台后端渲染卷(本卷只留接口)
资源表路径 → 字节数第 17 章反射序列化

第 18 章会把这张表里所有「做真」的零件重新组装成一个完整内核,让《微光岛》渲染出第一个立方体。下一节进入第 2 章,先解决主循环里最容易被忽略的东西——时间。

阅读导航:上一节:1.2 本书范围与站内分工 · 下一节:2.1 帧、时间与帧预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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