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
两件事值得说清楚:
- 拓扑序保证依赖先到。着色器和贴图(叶子资源)排在最前,材质在它们之后,场景最后。加载器只要按这个顺序依次就绪,就不会出现「材质已加载但它的贴图还没到」的悬空引用。
- 环必须提前拦下。上面那个环是人为造的:贴图反过来依赖材质。真实项目里它可能是「材质 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 异步加载与进度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。