《游戏引擎实现原理》3.2 PAL 平台抽象层

内核的其余部分不应该知道 node:fs 或 window 的存在。本节定义 helio 的 IPlatform 接口(时间、文件、线程、日志四个能力面),给出 NodePlatform 的真实实现,说明浏览器 WebPlatform 的差异,并真跑一段「同一份 measureMillis 代码在 PAL 上读时间戳」的用例。

3.2 PAL 平台抽象层

上一节我们把子系统编好了序,但子系统里干的活(读文件、取时间、开线程、写日志)最终都要落到某个具体宿主上。这一节处理的就是「宿主」这层:内核绝不直接 import 任何平台 API,而是只依赖一个接口 IPlatform。这个接口就是 PAL——Platform Abstraction Layer,平台抽象层。

为什么内核不能直接 import node:fs

先看反例。假设资源加载器里直接写:

// snippet: 反面写法,本节不采用
import { readFileSync } from 'node:fs';
const data = readFileSync('assets/isle.mesh', 'utf8');

这在 Node 里跑得好好的,一到浏览器就整条 import 链炸掉——node:fs 不存在。反过来,如果写成 await fetch('assets/isle.mesh'),浏览器没问题,Node 命令行工具(离线烘焙、CI 校验)又要另写一套。

PAL 的解法是:把「宿主能做什么」抽成接口,内核只依赖接口。内核代码里出现的是 p.file.readText(path),至于这个 p 在 Node 上是 node:fs、在浏览器上是 fetch,内核完全不知道。换平台 = 换一个 IPlatform 实现,内核一行不改。

对《微光岛》来说,这条边界直接决定了两件事能不能共用一套逻辑:浏览器里跑的游戏本体,和 Node 里跑的离线资源烘焙工具。

IPlatform:四个能力面

PAL 只暴露内核真正需要的最小面:时间、文件、线程、日志。面越窄,实现一个新平台就越便宜。下面的文件还带了两个与平台无关的辅助函数——注意 measureMillis 的参数类型是 IPlatform,这就是「同一段代码在所有平台通用」的写法。

下面 src/core/platform.ts 层级 L1,完整可编译。

// file: src/core/platform.ts
// tier: L1
// verify: npx vitest run src/core/platform.test.ts
//
// helio 内核:平台抽象层 PAL。内核其余部分只依赖 IPlatform,
// 不直接 import node:fs / window / performance。
// 换平台 = 换一个 IPlatform 实现,调用方代码一行不动。

export interface PlatformTime {
  /** 单调时钟,单位秒。用于帧计时、超时,不用于日历。 */
  nowSeconds(): number;
  /** 单调时钟,单位毫秒。 */
  nowMillis(): number;
}

export interface PlatformFile {
  readText(path: string): string;
  writeText(path: string, data: string): void;
  exists(path: string): boolean;
}

export interface PlatformThread {
  readonly hardwareConcurrency: number;
  /** 在另一条线程上跑 task,返回其完成 Promise。 */
  spawn(task: () => void): Promise<void>;
}

export interface PlatformLog {
  write(level: string, category: string, message: string): void;
}

/** 内核与宿主环境的唯一契约面。 */
export interface IPlatform {
  readonly name: string;
  readonly time: PlatformTime;
  readonly file: PlatformFile;
  readonly thread: PlatformThread;
  readonly log: PlatformLog;
}

/**
 * 与平台无关的代码:只吃 IPlatform。
 * 这段函数在 Node 与浏览器上是同一份源码。
 */
export function measureMillis(p: IPlatform, fn: () => void): number {
  const t0 = p.time.nowMillis();
  fn();
  const t1 = p.time.nowMillis();
  return t1 - t0;
}

/** 内核启动横幅:展示平台名与可用并发度。 */
export function describePlatform(p: IPlatform): string {
  return `${p.name}: ${p.thread.hardwareConcurrency} hw threads`;
}

时间用单调时钟(nowMillis),不是 Date.now()——单调时钟不受系统时间被改影响,帧计时才可信。文件接口只给同步文本读写,够内核启动期加载配置用;异步流式加载留给渲染卷。

NodePlatform:Node 上的实现

Node 实现就是把四个面分别接到 node:perf_hooks、node:fs、node:os 与 process.stdout。下面 src/core/node-platform.ts 层级 L1。

// file: src/core/node-platform.ts
// tier: L1
// verify: npx vitest run src/core/platform.test.ts
//
// NodePlatform:IPlatform 的 Node 实现,用于命令行工具、离线烘焙与单测。
// 时间走 node:perf_hooks,文件走 node:fs,并发度走 node:os。

import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { availableParallelism } from 'node:os';
import type {
  IPlatform,
  PlatformFile,
  PlatformLog,
  PlatformThread,
  PlatformTime,
} from './platform.ts';

const nodeTime: PlatformTime = {
  nowSeconds: () => performance.now() / 1000,
  nowMillis: () => performance.now(),
};

const nodeFile: PlatformFile = {
  readText: (path) => readFileSync(path, 'utf8'),
  writeText: (path, data) => writeFileSync(path, data, 'utf8'),
  exists: (path) => existsSync(path),
};

const nodeThread: PlatformThread = {
  hardwareConcurrency: availableParallelism(),
  // 真正的并行要 worker_threads;这里用微任务队列模拟「交给调度器」。
  spawn: (task) => Promise.resolve().then(task),
};

const nodeLog: PlatformLog = {
  write: (level, category, message) => {
    process.stdout.write(`[${level}][${category}] ${message}\n`);
  },
};

export const NodePlatform: IPlatform = {
  name: 'node',
  time: nodeTime,
  file: nodeFile,
  thread: nodeThread,
  log: nodeLog,
};

本工程没装 @types/node(tsconfig 的 types 只列了 @webgpu/types),所以还得补一个只声明用到的宿主 API 的类型垫片,让 tsc 能过。真实工程直接 npm i -D @types/node 即可,不需要这个文件。src/core/node-shims.d.ts 层级 L3(仅类型)。

// file: src/core/node-shims.d.ts
// tier: L3
//
// 本工程未安装 @types/node,这里只声明 NodePlatform 用到的宿主 API。
// 真实工程 `npm i -D @types/node` 后本文件可删。

declare module 'node:fs' {
  export function readFileSync(path: string, encoding: 'utf8'): string;
  export function writeFileSync(path: string, data: string, encoding?: 'utf8'): void;
  export function existsSync(path: string): boolean;
  export function mkdtempSync(prefix: string): string;
}

declare module 'node:os' {
  export function availableParallelism(): number;
  export function tmpdir(): string;
}

declare module 'node:path' {
  export function join(...parts: string[]): string;
}

declare module 'node:perf_hooks' {
  export const performance: { now(): number };
}

declare const process: {
  readonly stdout: { write(chunk: string): boolean };
};

WebPlatform 的差异

浏览器上同样实现 IPlatform,但每个面的底层 API 都不同,差异不是「换个函数名」那么小:

能力面Node 实现浏览器 WebPlatform
时间performance.now()(node:perf_hooks)performance.now()(全局,语义一致)
文件node:fs 同步读写无同步文件系统;改走 fetch + Cache Storage / IndexedDB
线程worker_threadsWeb Worker;主线程上 spawn 常退化成 queueMicrotask
日志process.stdoutconsole.*,或转发到远端日志端点

关键差异是文件:浏览器没有同步文件读取,所以 PlatformFile.readText 的同步签名在浏览器上无法诚实实现。工程上的两种做法:(1) 把 PlatformFile 改成全异步(Promise<string>),代价是内核启动流程也要异步化;(2) 保持同步签名,但 WebPlatform 在启动前把所有必需资源预取进内存,readText 只查内存表。helio 选 (2):内核启动路径保持同步、可预测,异步加载被挡在 PAL 之外。

WebPlatform 是 L4(命令行不可复现)——它依赖 fetch、Web Worker 等浏览器全局,本节的 Node 环境跑不了,需在浏览器里手动验证,这里只给差异说明,不给可运行代码。

可运行产物:同一段代码在 PAL 上读时间戳

下面这个测试文件做了两件事:用 measureMillis(它只认 IPlatform)在 NodePlatform 上量一段计算耗时;以及验证文件接口能读回刚写入的内容。层级 L1,命令 npx vitest run src/core/platform.test.ts。

// file: src/core/platform.test.ts
// tier: L1
// verify: npx vitest run src/core/platform.test.ts
import { describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describePlatform, measureMillis } from './platform.ts';
import { NodePlatform } from './node-platform.ts';

describe('PAL on NodePlatform', () => {
  it('同一段 measureMillis 代码在 PAL 上读时间戳', () => {
    const elapsed = measureMillis(NodePlatform, () => {
      let acc = 0;
      for (let i = 0; i < 1000; i++) acc += i;
      expect(acc).toBe(499500);
    });
    expect(elapsed).toBeGreaterThanOrEqual(0);
  });

  it('nowMillis 单调不减', () => {
    const a = NodePlatform.time.nowMillis();
    const b = NodePlatform.time.nowMillis();
    expect(b).toBeGreaterThanOrEqual(a);
  });

  it('文件接口读回写入的内容', () => {
    const dir = mkdtempSync(join(tmpdir(), 'helio-'));
    const path = join(dir, 'cvar.cfg');
    NodePlatform.file.writeText(path, 'r.vsync=true\n');
    expect(NodePlatform.file.exists(path)).toBe(true);
    expect(NodePlatform.file.readText(path)).toBe('r.vsync=true\n');
  });

  it('describePlatform 汇总平台信息', () => {
    expect(describePlatform(NodePlatform)).toMatch(/^node: \d+ hw threads$/);
  });

  it('spawn 异步跑完 task', async () => {
    let ran = false;
    await NodePlatform.thread.spawn(() => {
      ran = true;
    });
    expect(ran).toBe(true);
  });
});

本机实跑:Test Files 4 passed、Tests 26 passed(本节贡献其中 5 条),tsc --noEmit 零错误。注意第一条用例里 measureMillis 完全不知道自己在 Node 上——它只吃 IPlatform,这正是 PAL 存在的意义。

小结

  • PAL 把宿主能力收敛成 IPlatform 一个接口,内核只依赖它,不碰任何平台 API。
  • NodePlatform 是真实实现(node:fs / node:perf_hooks / node:os),浏览器 WebPlatform 的最大差异在文件——没有同步读,helio 用「启动前预取进内存」保持同步签名。
  • 平台无关的代码(如 measureMillis)在所有平台上只有一份源码。

下一节补齐内核的三件「基础设施」:断言、日志分级与配置系统 CVar。

阅读导航:上一节:3.1 子系统注册、启动顺序与逆序关闭 · 下一节:3.3 断言、日志分级与配置系统 CVar 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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