4.1 依赖图:显式引用与隐式引用
卷一 6.2 引用关系与依赖声明
从清单字段的角度定义了 refs 该写什么;这一节换到管线的角度:打包器与烘焙器真正消费的不是一份清单,而是一张有向图——清单只是这张图的一种输入。图建错了,后面所有闸门(环检测、可达性、增量更新)都建在沙子上。引擎侧怎么按拓扑序烘焙、怎么调度加载属于实现,本节外链 《glTF 资产管线》
,只做美术侧的图建模与校验。
这张图只回答一个问题:打包 A 之前,必须先有谁? 边 A→B 读作「A 依赖 B」。管线对它有四类用法:拓扑排序定打包顺序、环检测保增量构建能终止、可达性定「哪些资产要进包」、悬空检测保引用不指空。本节只建图并分类计数,后两类留给 4.2 与 4.3。
4.1.1 显式边与三类隐式边
节点是资产 id。边分四类,其中只有第一类是「有人写下来的」:
| 边 | 来源 | 方向 | 例子 |
|---|---|---|---|
| 显式 | 清单 dependsOn 字段 | A 依赖 B | furn_lamp_01 → mat_lamp_brass |
| 隐式·命名 | 命名约定 X_vN | 变体依赖基础 | furn_lamp_01_v2 → furn_lamp_01 |
| 隐式·目录 | dirOwners 目录归属 | 归属者依赖成员 | atlas_ui → icon_coin |
| 隐式·槽位 | 材质 slots 的取值 | 材质依赖贴图 | mat_lamp_brass → tex_lamp_brass |
四类边的语义各不相同,但对打包器是同一种东西:都是「先有 B 才能有 A」。把它们分开建模,是为了让报错能说清「这条边是哪来的」——一条隐式边造成的环,和一条显式边造成的环,修复方式完全不同。
4.1.2 图样本:17 节点 / 18 边
下面是 D 层的图样本,覆盖《星屑旅店》一屏大厅 + 一间客房所需的资产:场景、家具 prefab、材质、贴图、UI 图集与图标。roots 是入口(场景),4.3 会用到;dirOwners 声明「哪个目录归谁」:
{
"project": "stardust-inn",
"chapter": 4,
"note": "第 4 章资产依赖图样本:显式 dependsOn + 三类隐式边(命名/目录归属/材质槽)。",
"roots": ["scene_inn_lobby", "scene_inn_room"],
"dirOwners": {
"ui/icons/": "atlas_ui",
"ui/portraits/": "atlas_ui"
},
"assets": [
{ "id": "scene_inn_lobby", "type": "scene", "dir": "scenes/", "dependsOn": ["ui_hud", "furn_lamp_01", "furn_counter_01"] },
{ "id": "scene_inn_room", "type": "scene", "dir": "scenes/", "dependsOn": ["ui_hud", "furn_lamp_01_v2"] },
{ "id": "ui_hud", "type": "ui", "dir": "ui/", "dependsOn": ["atlas_ui", "ui_frame"] },
{ "id": "atlas_ui", "type": "atlas", "dir": "ui/", "dependsOn": [] },
{ "id": "ui_frame", "type": "ui", "dir": "ui/", "dependsOn": [] },
{ "id": "icon_coin", "type": "icon", "dir": "ui/icons/", "dependsOn": [] },
{ "id": "icon_coin_v2", "type": "icon", "dir": "ui/icons/", "dependsOn": [] },
{ "id": "icon_star", "type": "icon", "dir": "ui/icons/", "dependsOn": [] },
{ "id": "portrait_innkeeper", "type": "portrait", "dir": "ui/portraits/", "dependsOn": [] },
{ "id": "furn_lamp_01", "type": "prefab", "dir": "furniture/", "dependsOn": ["furn_lamp_01_v2", "mat_lamp_brass"] },
{ "id": "furn_lamp_01_v2", "type": "prefab", "dir": "furniture/", "dependsOn": [] },
{ "id": "furn_counter_01", "type": "prefab", "dir": "furniture/", "dependsOn": ["mat_counter_wood"] },
{ "id": "prefab_ghost_chair", "type": "prefab", "dir": "furniture/", "dependsOn": [] },
{ "id": "mat_lamp_brass", "type": "material", "dir": "materials/", "dependsOn": [], "slots": { "baseColor": "tex_lamp_brass" } },
{ "id": "mat_counter_wood", "type": "material", "dir": "materials/", "dependsOn": [], "slots": { "baseColor": "tex_missing_wood" } },
{ "id": "tex_lamp_brass", "type": "texture", "dir": "textures/", "dependsOn": [] },
{ "id": "tex_unused_moss", "type": "texture", "dir": "textures/", "dependsOn": [] }
]
}
样本里埋了两个「问题资产」:mat_counter_wood 的槽位指向不存在的 tex_missing_wood(悬空引用),furn_lamp_01 又显式引用了自己的变体 furn_lamp_01_v2(与隐式变体边合起来成环)。它们会在 4.2 被检出。
4.1.3 S 层:建图与边分类计数
下面这段是 S 层脚本,读上面的配置建图,把每条边标上来源,再按来源计数。边集合按 src->dst:kind 排序后取 sha256 前 16 位作 digest——这是字符串类,跨语言必须逐位一致:
#!/usr/bin/env python3
# file: sim/ta04_graph.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta04_graph.py
"""4.1 依赖图的 S 层建图器:读 config/ta04_graph.json,把「谁引用谁」建成一张有向图。
边的四类来源:
* ``explicit`` 清单 ``dependsOn`` 里写明的引用;
* ``implicit:variant`` 命名约定:``X_vN`` 复用基础资产 ``X``(变体依赖基础);
* ``implicit:owner`` 目录归属:``dirOwners`` 里声明的目录归属者依赖目录内每个资产
(图集 ``atlas_ui`` 拥有 ``ui/icons/`` 与 ``ui/portraits/``,故它依赖目录里的每个图标/头像);
* ``implicit:slot`` 材质槽:``slots`` 的每个取值(贴图 id)都是材质对贴图的一条隐式边。
边集合按 ``src->dst:kind`` 排序后取 sha256 前 16 位作为 digest——这是**字符串类**,
跨语言必须逐位一致。落 ``out/ta04_graph.json`` 供 P 层对照。
"""
import hashlib
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
VARIANT_RE = re.compile(r"^(.+)_v[0-9]+$")
def build_graph(spec):
"""返回 (ids, edges)。edges 是 (src, dst, kind) 列表,含显式与三类隐式边。"""
assets = spec["assets"]
ids = [a["id"] for a in assets if a.get("id")]
idset = set(ids)
owners = spec.get("dirOwners", {})
edges = []
for a in assets:
src = a.get("id")
if not src:
continue
for r in a.get("dependsOn", []):
edges.append((src, r, "explicit"))
g = VARIANT_RE.match(src)
if g and g.group(1) in idset:
edges.append((src, g.group(1), "implicit:variant"))
for tex in (a.get("slots") or {}).values():
edges.append((src, tex, "implicit:slot"))
# 目录归属:归属者(图集)依赖目录内每个资产。归属者自身不在被归属目录里,故不会自环。
for a in assets:
owner = owners.get(a.get("dir"))
if owner and owner in idset:
edges.append((owner, a["id"], "implicit:owner"))
return ids, edges
def kind_counts(edges):
out = {}
for _, _, k in edges:
out[k] = out.get(k, 0) + 1
return out
def main():
spec = json.load(open(os.path.join(ROOT, "config/ta04_graph.json"), encoding="utf-8"))
ids, edges = build_graph(spec)
lines = sorted(f"{s}->{t}:{k}" for s, t, k in edges)
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
kinds = kind_counts(edges)
rep = {
"project": spec["project"],
"nodeCount": len(ids),
"edgeCount": len(edges),
"byKind": kinds,
"explicitCount": kinds.get("explicit", 0),
"implicitCount": sum(v for k, v in kinds.items() if k.startswith("implicit")),
"edges": lines,
"digest": digest,
}
with open(os.path.join(ROOT, "out/ta04_graph.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"nodes={rep['nodeCount']} edges={rep['edgeCount']} "
f"explicit={rep['explicitCount']} implicit={rep['implicitCount']}")
for k in sorted(kinds):
print(f" {k:18s} {kinds[k]}")
print(f"digest={digest}")
print("wrote out/ta04_graph.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta04_graph.py:
nodes=17 edges=18 explicit=10 implicit=8
explicit 10
implicit:owner 4
implicit:slot 2
implicit:variant 2
digest=23db8b91e5ea6574
wrote out/ta04_graph.json
17 个节点、18 条边里只有 10 条是显式的,8 条隐式边(4 条目录归属 + 2 条变体 + 2 条槽位)没有任何字段直接写着。如果管线只读 dependsOn,这张图会漏掉将近一半的边。
4.1.4 P 层:同一套建图的 TS 内核
下面这段是 P 层 TS 内核 core/src/ta04/graph.ts,与上面的 Python 逐条对应——同一个 VARIANT_RE、同一个归属规则、同一套排序与 digest 口径:
// file: core/src/ta04/graph.ts
// tier: P
// verify: node --test 'core/test/ta04_*.test.ts'
// 4.1 依赖图的 P 层内核。与 sim/ta04_graph.py 同一套规则:显式 dependsOn + 三类隐式边
// (命名约定 implicit:variant / 目录归属 implicit:owner / 材质槽 implicit:slot)。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { createHash } from "node:crypto";
export const VARIANT_RE = /^(.+)_v[0-9]+$/;
export type Edge = [string, string, string];
export function buildGraph(spec: any): { ids: string[]; edges: Edge[] } {
const assets: any[] = spec.assets;
const ids: string[] = assets.filter((a) => a.id).map((a) => a.id);
const idset = new Set(ids);
const owners: Record<string, string> = spec.dirOwners || {};
const edges: Edge[] = [];
for (const a of assets) {
const src = a.id;
if (!src) continue;
for (const r of a.dependsOn || []) edges.push([src, r, "explicit"]);
const m = VARIANT_RE.exec(src);
if (m && idset.has(m[1])) edges.push([src, m[1], "implicit:variant"]);
for (const tex of Object.values(a.slots || {}) as string[]) {
edges.push([src, tex, "implicit:slot"]);
}
}
// 目录归属:归属者(图集)依赖目录内每个资产。
for (const a of assets) {
const owner = owners[a.dir];
if (owner && idset.has(owner)) edges.push([owner, a.id, "implicit:owner"]);
}
return { ids, edges };
}
export function kindCounts(edges: Edge[]): Record<string, number> {
const out: Record<string, number> = {};
for (const [, , k] of edges) out[k] = (out[k] || 0) + 1;
return out;
}
export function edgeLines(edges: Edge[]): string[] {
return edges.map(([s, t, k]) => `${s}->${t}:${k}`).sort();
}
export function digest16(lines: string[]): string {
return createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
}
4.1.5 跨语言对照:digest 逐位一致
P 层测试读的是 Python 落盘的 out/ta04_graph.json(不是同一份源码),做跨语言对照断言:
// file: core/test/ta04_graph.test.ts
// tier: P
// verify: node --test 'core/test/ta04_*.test.ts'
// 跨语言对照:读 sim/ta04_graph.py 落盘的 out/ta04_graph.json,断言 TS 侧的建图结果与
// 边集合 digest 与之逐位一致(字符串类)。再断言三类隐式边确实被建出来。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildGraph, digest16, edgeLines, kindCounts } from "../src/ta04/graph.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/ta04_graph.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta04_graph.json"), "utf8"));
test("建图:TS 复算与 Python 产物逐位一致", () => {
const { ids, edges } = buildGraph(spec);
assert.equal(ids.length, py.nodeCount);
assert.equal(edges.length, py.edgeCount);
assert.deepEqual(kindCounts(edges), py.byKind);
assert.deepEqual(edgeLines(edges), py.edges);
assert.equal(digest16(edgeLines(edges)), py.digest);
});
test("显式边只来自 dependsOn,条数与 dependsOn 总数相等", () => {
const { edges } = buildGraph(spec);
const declared = spec.assets.reduce((n: number, a: any) => n + (a.dependsOn || []).length, 0);
const explicit = edges.filter(([, , k]) => k === "explicit").length;
assert.equal(explicit, declared);
assert.equal(explicit, py.explicitCount);
});
test("隐式边:命名约定 / 目录归属 / 材质槽三类都建出来", () => {
const { edges } = buildGraph(spec);
const lines = new Set(edgeLines(edges));
assert.ok(lines.has("icon_coin_v2->icon_coin:implicit:variant"));
assert.ok(lines.has("atlas_ui->icon_coin:implicit:owner"));
assert.ok(lines.has("atlas_ui->portrait_innkeeper:implicit:owner"));
assert.ok(lines.has("mat_lamp_brass->tex_lamp_brass:implicit:slot"));
const kinds = kindCounts(edges);
assert.equal(kinds["implicit:variant"], 2);
assert.equal(kinds["implicit:owner"], 4);
assert.equal(kinds["implicit:slot"], 2);
});
实跑 node --test 'core/test/ta04_graph.test.ts':
✔ 建图:TS 复算与 Python 产物逐位一致
✔ 显式边只来自 dependsOn,条数与 dependsOn 总数相等
✔ 隐式边:命名约定 / 目录归属 / 材质槽三类都建出来
ℹ tests 3
ℹ pass 3
ℹ fail 0
两侧的边集合 digest 都是 23db8b91e5ea6574——这是字符串类,逐位一致(不是「容差内一致」;容差那套只适用于色彩派生量,见卷一第 4 章)。node 数、边数、四类边的分类计数同样逐位一致。
4.1.6 隐式边为什么是管线里最容易漏的一环
三类隐式边各自对应管线里一个「默认行为」,而默认行为的特点就是没人会想起来去写它:
- 命名约定:工具按
X_vN自动把变体挂到基础资产上。改个名(furn_lamp_01_v2→furn_lamp_v2)就会静默断开这条边,变体不再继承基础色板。 - 目录归属:图集按目录自动收编成员。把图标从
ui/icons/挪到ui/raw/,它就悄悄掉出图集。 - 材质槽:材质槽里的贴图 id 是引用。删贴图时若没扫过材质,槽位就指向一个空。
这解释了为什么隐式边必须写进规范文档,不能只活在脚本里:新人加一条边、挪一个目录,看不到任何报错,直到运行时裂图才回头查。把这三条规则显式写进 dirOwners 与命名规范,等于把「默认行为」变成「可审查的声明」。
阅读导航:上一节:3.3 导入失败分级与修复 · 下一节:4.2 环检测与悬空引用 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。