《游戏引擎实现原理》1.1 资源模型、GUID 与依赖图

资源管线的第一课是把「资源」本身建模清楚:一个 GUID、一个类型、一条路径、一张依赖表。本节从零实现 helio 的资源模型与依赖图,用 Kahn 算法算出「依赖在前」的拓扑加载序,用 DFS 三色标记检测循环依赖,再给出反向边供后续热重载定位受影响资源。全部代码在 Node 直跑,11 条用例锁住行为。

1.1 资源模型、GUID 与依赖图

卷一和卷二把《微光岛》画出来了,但到目前为止,岛上的石头、沙地、海浪声还都只是代码里硬编码的字面量。真实游戏不是这么干的——美术在 DCC 工具里做完模型导出成 glTF,程序在代码里只写「加载 assets/mesh/island.gltf」这一句。要让这句引用能成立,引擎必须先回答三个问题:一个资源用什么标识、它依赖谁、按什么顺序加载。这就是资源管线的入口,也是本卷的第一节。

这一节不碰任何具体文件格式(那是第 2 章导入器的事),只做最底层的建模:把资源抽象成一条记录,把记录之间的引用关系建成一张有向图,然后在这张图上做两件必须做的事——拓扑排序(算出依赖在前的加载顺序)和循环检测(把「A 依赖 B、B 又依赖 A」这种会让加载器死锁的图揪出来)。

建模先行还有一个现实理由:资源依赖图是后面几乎所有子系统的公共底座。热重载要知道「改了这张贴图,谁需要重新构建」(反向边);流式加载要知道「这一块场景先加载哪些资源」(拓扑序);引用计数要在资源释放时沿着依赖关系级联(1.3)。所以本节实现的数据结构会被后续章节反复 import,值得一次写扎实。

资源模型:GUID、类型与路径

一个资源在引擎里需要的字段其实很少:一个稳定标识、一个类型、一条磁盘路径,外加它直接依赖的其它资源。标识为什么不用文件名?因为文件名会变——美术把 sand.png 改名成 sand_diffuse.png,所有引用就断了。所以引擎用 GUID:由路径确定性推导,同一路径永远得到同一串 16 位十六进制,跨机器、跨会话稳定。

helio 的 GUID 直接复用了内核 7.3 的 guidFromPath(两路 FNV-1a 拼成 64 位),本节不重推哈希,只把它接到资源模型上。类型用字符串联合而非 enum——本卷全部代码遵守 erasable syntax 约束,enum 与 namespace 一律不用。下面是完整的模型文件。

// file: src/subsys/resource/resource-model.ts
// tier: L1
// verify: npx vitest run src/subsys/resource/resource-model.test.ts
//
// helio 资源管线:资源模型与依赖图。
// 一个资源由「GUID + 类型 + 磁盘路径 + 依赖列表」描述;依赖列表构成一张有向图。
// 本节把这张图建成可查询的结构,给出拓扑加载序(依赖在前)与循环检测,
// 并暴露反向边(谁依赖我)供热重载使用。

import { guidFromPath } from '../../core/string-intern.ts';

/** 引擎认得的资源类型。用字符串联合而非 enum,满足 erasable syntax 约束。 */
export type ResourceType =
  | 'texture'
  | 'mesh'
  | 'material'
  | 'shader'
  | 'audio'
  | 'prefab'
  | 'scene';

/** 一条资源记录:引擎对某个资源的全部认知。 */
export interface AssetRecord {
  /** 64 位 GUID,由路径确定性推导(16 个十六进制字符)。 */
  readonly guid: string;
  /** 项目根目录下的资源路径,例如 `assets/mesh/island.gltf`。 */
  readonly path: string;
  readonly type: ResourceType;
  /** 直接依赖的 GUID 列表。空数组表示叶子资源。 */
  readonly deps: readonly string[];
}

/** 由资源路径推导 GUID。同一路径永远得到同一 GUID,跨机器、跨会话稳定。 */
export function assetGuid(path: string): string {
  return guidFromPath(path);
}

/** 用路径与类型登记一条资源,deps 可传路径(内部会转成 GUID)。 */
export function makeRecord(
  path: string,
  type: ResourceType,
  depPaths: readonly string[] = [],
): AssetRecord {
  return { guid: assetGuid(path), path, type, deps: depPaths.map(assetGuid) };
}

/** 资源注册表:按 GUID 与路径双向索引,登记时校验重复。 */
export class AssetRegistry {
  #byGuid = new Map<string, AssetRecord>();
  #guidByPath = new Map<string, string>();

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

  /** 登记一条资源。GUID 重复或路径被别的 GUID 占用时抛错。 */
  register(rec: AssetRecord): void {
    const sameGuid = this.#byGuid.get(rec.guid);
    if (sameGuid !== undefined && sameGuid.path !== rec.path) {
      throw new Error(`GUID 冲突:${rec.guid} 已被 ${sameGuid.path} 占用`);
    }
    const owner = this.#guidByPath.get(rec.path);
    if (owner !== undefined && owner !== rec.guid) {
      throw new Error(`路径冲突:${rec.path} 已映射到 GUID ${owner}`);
    }
    this.#byGuid.set(rec.guid, rec);
    this.#guidByPath.set(rec.path, rec.guid);
  }

  byGuid(guid: string): AssetRecord | undefined {
    return this.#byGuid.get(guid);
  }

  guidOf(path: string): string | undefined {
    return this.#guidByPath.get(path);
  }

  /** 依赖列表里引用了、但从未登记过的 GUID(资源缺失)。 */
  missingDeps(): string[] {
    const missing = new Set<string>();
    for (const rec of this.#byGuid.values()) {
      for (const dep of rec.deps) {
        if (!this.#byGuid.has(dep)) missing.add(dep);
      }
    }
    return [...missing].sort();
  }
}

/** 依赖图:从注册表建出前向边(我依赖谁)与反向边(谁依赖我)。 */
export class DependencyGraph {
  #deps = new Map<string, readonly string[]>();
  #dependents = new Map<string, string[]>();

  constructor(records: Iterable<AssetRecord>) {
    for (const rec of records) {
      this.#deps.set(rec.guid, rec.deps);
      if (!this.#dependents.has(rec.guid)) this.#dependents.set(rec.guid, []);
    }
    // 建反向边:dep 的 dependents 里加上 rec.guid。
    for (const rec of records) {
      for (const dep of rec.deps) {
        let list = this.#dependents.get(dep);
        if (list === undefined) {
          list = [];
          this.#dependents.set(dep, list);
        }
        list.push(rec.guid);
      }
    }
  }

  /** 谁直接依赖 guid(反向边)。热重载时据此找出受影响的资源。 */
  dependentsOf(guid: string): string[] {
    return [...(this.#dependents.get(guid) ?? [])];
  }

  /**
   * 拓扑加载序:依赖在前、被依赖者在后。用 Kahn 算法。
   * 入度 = 直接依赖个数;每弹出一个节点,就把它的「依赖者」入度减一。
   * 结果里出现的节点数少于总节点数,说明剩下的是环里的节点。
   */
  topoOrder(): string[] {
    const indeg = new Map<string, number>();
    for (const [guid, deps] of this.#deps) indeg.set(guid, deps.length);

    // 用有序数组当就绪队列,保证结果可复现(同层按 GUID 字典序)。
    const ready = [...indeg.keys()].filter((g) => indeg.get(g) === 0).sort();
    const order: string[] = [];
    while (ready.length > 0) {
      const guid = ready.shift()!;
      order.push(guid);
      for (const dependent of this.#dependents.get(guid) ?? []) {
        const left = (indeg.get(dependent) ?? 0) - 1;
        indeg.set(dependent, left);
        if (left === 0) ready.push(dependent);
      }
      ready.sort();
    }
    return order;
  }

  /**
   * 循环检测:DFS 三色标记。遇到指向「正在访问」(GRAY)的边即找到一个环,
   * 从栈里截出环的节点序列返回。返回空数组表示图是 DAG。
   */
  detectCycles(): string[][] {
    const WHITE = 0;
    const GRAY = 1;
    const BLACK = 2;
    const color = new Map<string, number>();
    for (const guid of this.#deps.keys()) color.set(guid, WHITE);

    const stack: string[] = [];
    const cycles: string[][] = [];
    const seen = new Set<string>();

    const visit = (guid: string): void => {
      color.set(guid, GRAY);
      stack.push(guid);
      for (const dep of this.#deps.get(guid) ?? []) {
        const c = color.get(dep) ?? WHITE;
        if (c === GRAY) {
          const from = stack.indexOf(dep);
          const cycle = stack.slice(from);
          const key = [...cycle].sort().join(',');
          if (!seen.has(key)) {
            seen.add(key);
            cycles.push(cycle);
          }
        } else if (c === WHITE) {
          visit(dep);
        }
      }
      stack.pop();
      color.set(guid, BLACK);
    };

    for (const guid of this.#deps.keys()) {
      if (color.get(guid) === WHITE) visit(guid);
    }
    return cycles;
  }
}

可运行产物:拓扑加载序与循环检测

模型建好之后,把《微光岛》的一份最小资源集喂进去:场景依赖材质与网格,材质依赖贴图与着色器,网格又依赖材质。用 topoOrder() 排出加载序,再人为让贴图反过来依赖材质制造一个环,看 detectCycles() 能不能抓住它。这个 demo 用 node 直跑,无需浏览器。

// file: src/subsys/resource/depgraph-demo.ts
// tier: L1
// verify: node src/subsys/resource/depgraph-demo.ts
//
// 打印《微光岛》资源集的拓扑加载序与一次人为制造的环。

import { AssetRegistry, DependencyGraph, makeRecord } from './resource-model.ts';
import type { AssetRecord } from './resource-model.ts';

const records: AssetRecord[] = [
  makeRecord('assets/scene/island.scene', 'scene', ['assets/material/terrain.mat', 'assets/mesh/island.gltf']),
  makeRecord('assets/material/terrain.mat', 'material', ['assets/texture/sand.png', 'assets/shader/lit.wgsl']),
  makeRecord('assets/mesh/island.gltf', 'mesh', ['assets/material/terrain.mat']),
  makeRecord('assets/texture/sand.png', 'texture'),
  makeRecord('assets/shader/lit.wgsl', 'shader'),
];

const reg = new AssetRegistry();
for (const rec of records) reg.register(rec);

const graph = new DependencyGraph(records);
const nameOf = (guid: string) => reg.byGuid(guid)?.path ?? guid;

console.log('拓扑加载序(依赖在前):');
graph.topoOrder().forEach((guid, i) => console.log(`  ${i + 1}. ${nameOf(guid)}`));

// 人为让贴图反过来依赖材质,制造一个环。
const cyclic = [
  ...records.filter((r) => !r.path.includes('sand')),
  makeRecord('assets/texture/sand.png', 'texture', ['assets/material/terrain.mat']),
];
const cycles = new DependencyGraph(cyclic).detectCycles();
console.log(`\n循环检测:发现 ${cycles.length} 个环`);
for (const c of cycles) console.log('  环:', c.map(nameOf).join(' -> '));

本机实跑输出如下:

拓扑加载序(依赖在前):
  1. assets/shader/lit.wgsl
  2. assets/texture/sand.png
  3. assets/material/terrain.mat
  4. assets/mesh/island.gltf
  5. assets/scene/island.scene

循环检测:发现 1 个环
  环: assets/material/terrain.mat -> assets/texture/sand.png

两件事值得说清楚:

  1. 拓扑序保证依赖先到。着色器和贴图(叶子资源)排在最前,材质在它们之后,场景最后。加载器只要按这个顺序依次就绪,就不会出现「材质已加载但它的贴图还没到」的悬空引用。
  2. 环必须提前拦下。上面那个环是人为造的:贴图反过来依赖材质。真实项目里它可能是「材质 A 用了贴图 B 的 alpha、贴图 B 的生成又引用了材质 A」这种间接环。如果不检测,Kahn 算法会静默地少排几个节点(本例里环内两个节点永远入度不归零),加载器等到超时才报错——那时已经很难定位是谁指了谁。

为什么用确定性 GUID,而不是自增 id

自增 id 也能唯一标识资源,但它有个致命缺点:换台机器、换个加载顺序,同一个资源拿到的 id 就不一样了。这在资源管线里会出大问题——烘焙好的缓存文件里记着 id,换台机器就对不上了;网络同步时两端算出的 id 不一致,引用全乱。

确定性 GUID 把「标识」和「分配顺序」解耦:GUID 是路径的纯函数,任何时刻、任何机器,只要路径相同,GUID 就相同。代价是哈希碰撞的概率——64 位 FNV 在几十万资源的量级下碰撞概率极低,且 AssetRegistry.register 会在碰撞时立刻抛错(同 GUID 不同路径即冲突),把问题暴露在导入阶段而非运行阶段。

配套的 11 条用例(resource-model.test.ts)锁住了这些行为:GUID 的确定性与格式、注册表的双向索引与缺失依赖检测、钻石依赖下拓扑序的唯一性、两节点环与自环的检测、以及有环时拓扑序不完整。这些用例在本机 npx vitest run 全绿,本节所有代码都经过 tsc --noEmit 与 vitest 双重校验。

依赖图给出了「先加载谁」,但真正的加载是异步的、并发的、要显示进度条的。下一节我们把这张图接到一个带并发上限、优先级和进度回调的加载器上。

阅读导航:上一节:回到目录 · 下一节:1.2 异步加载与进度 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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