1.3 一个引擎最少要有哪些零件
前两节把边界划好了:五层、依赖只能向下、本书只写内核。这一节做一件更朴素的事——把「引擎」拆到不能再拆,看它至少需要几个零件才能跑起来。拆得够狠,你才能看清后面每一章到底在补哪一块。
判断标准很简单:删掉这个零件,引擎还能不能跑出「一帧」。能,它就是可选的;不能,它就是必需的。按这个标准筛完,helio 剩下六个零件。
六个零件
- 主循环 Main Loop:把「反复做一件事」抽象出来。它负责推进时间、按顺序调用子系统、在帧末收尾。没有它,引擎只是一堆函数。
- 内存 Memory:内核不信任 GC 的停顿,需要一块自己能控制的生命周期内存。最小版本是一块「帧内只前进、帧末整体回收」的 arena。
- 句柄 Handle:内核不裸传指针/对象引用,而是传一个带代际的整数句柄。这样「对象已删、句柄还在」可以被检测出来。
- 场景 / 实体 Scene / Entity:引擎得知道「世界里有几个东西」。最小版本就是一组活着的实体 id。
- 渲染接口 Render Interface:内核不认识 GPU,只认识一个接口(
beginFrame/draw/endFrame)。谁实现它,内核不关心。 - 资源表 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 帧、时间与帧预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。