6.2.1 清单里的「引用」有两种
6.1 把 refs 定成了「显式依赖的资产 id」。但真正会让打包器翻车的,往往是没人写下来的那部分依赖:一张家具图标被塞进 UI 图集、一个 _v2 变体其实复用了基础资产的色板、一张头像叠在统一的画框上。这些关系在清单里一个字段都没有,可一旦目标缺失或成环,运行时就会拿到一张裂图。本节只做美术侧的依赖声明与图校验;引擎侧怎么按依赖顺序烘焙、怎么调度加载,属于实现,外链 《渲染引擎》的资产管线入口
,风格方向仍外链 《游戏美术风格指南 2026》
,不重推。
6.2.2 显式边 + 三条隐式边
依赖图的节点是资产 id,边有两类:
| 边 | 来源 | 例子 |
|---|---|---|
| 显式 | 清单的 refs 字段 | vfx_steam_01 → furn_lamp_01 |
| 隐式 1 | type == icon → 同包图集 atlas_ui | 图标被打进图集,图集没了图标就没了 |
| 隐式 2 | id 形如 X_vN → 基础资产 X | 变体复用基础的色板与网格 |
| 隐式 3 | type == portrait → 统一画框 ui_frame | 头像换装不改画框 |
隐式边只在目标存在时才补出来;目标不存在时,显式边照报悬空,隐式边直接跳过(否则每条规则都会在空清单上刷一屏噪音)。
两类问题要分开报:
- 悬空引用(dangling):边的目标不在节点集合里——多半是资产被删了、id 打错了。
- 环(cycle):依赖成环意味着没有任何一个资产可以「先」打包,增量构建会死循环。
6.2.3 S 层:建图 + DFS 环检测 + 悬空检测
下面这段是 S 层脚本,读 config/sp06_manifest.json 建图,用 DFS 求强连通分量(Tarjan) 找环,逐边判悬空,落 out/sp06_dep.json。环用「分量节点排序后以 | 连接」规范化,这样环的表示与语言、与遍历顺序都无关:
#!/usr/bin/env python3
# file: sim/sp06_dep.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp06_dep.py
"""6.2 依赖图的 S 层校验器:显式 refs + 三条隐式约定 → 有向图,DFS 求强连通分量做
环检测,逐边做悬空引用检测。环按「分量节点排序后 | 连接」规范化(与语言无关),
落 out/sp06_dep.json,供 P 层跨语言对照。"""
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_edges(m):
ids = [a["id"] for a in m["assets"] if a.get("id")]
idset = set(ids)
edges = []
for a in m["assets"]:
src = a.get("id")
if not src:
continue
edges += [(src, r, "explicit") for r in a.get("refs", [])]
if a.get("type") == "icon" and "atlas_ui" in idset:
edges.append((src, "atlas_ui", "implicit:atlas"))
g = VARIANT_RE.match(src)
if g and g.group(1) in idset:
edges.append((src, g.group(1), "implicit:variant"))
if a.get("type") == "portrait" and "ui_frame" in idset:
edges.append((src, "ui_frame", "implicit:frame"))
return ids, edges
def tarjan(nodes, adj):
idx, low, on, stack, out, c = {}, {}, {}, [], [], [0]
def strong(v):
idx[v] = low[v] = c[0]
c[0] += 1
stack.append(v)
on[v] = True
for w in adj.get(v, []):
if w not in idx:
strong(w)
low[v] = min(low[v], low[w])
elif on.get(w):
low[v] = min(low[v], idx[w])
if low[v] == idx[v]:
comp = []
while True:
w = stack.pop()
on[w] = False
comp.append(w)
if w == v:
break
out.append(comp)
for v in sorted(nodes):
if v not in idx:
strong(v)
return out
def find_cycles(ids, edges):
adj = {n: [] for n in ids}
for s, t, _ in edges:
if s in adj and t in adj:
adj[s].append(t)
for n in adj:
adj[n] = sorted(set(adj[n]))
return sorted("CYCLE:" + "|".join(sorted(k)) for k in tarjan(ids, adj)
if len(k) > 1 or k[0] in adj[k[0]])
def main():
m = json.load(open(os.path.join(ROOT, "config/sp06_manifest.json"), encoding="utf-8"))
ids, edges = build_edges(m)
idset = set(ids)
dang = sorted(f"DANGLING:{s}->{t}" for s, t, _ in edges if t not in idset)
cyc = find_cycles(ids, edges)
rep = {"project": m["project"], "nodeCount": len(ids), "edgeCount": len(edges),
"dangling": dang, "cycles": cyc, "count": len(dang) + len(cyc)}
with open(os.path.join(ROOT, "out/sp06_dep.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']} dangling={len(dang)} cycles={len(cyc)}")
for x in dang + cyc:
print(f" {x}")
print("wrote out/sp06_dep.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp06_dep.py:
nodes=13 edges=10 dangling=1 cycles=0
DANGLING:furn_chair_01->furn_cushion_01
wrote out/sp06_dep.json
13 个节点、10 条边(3 条显式 + 4 条图集 + 1 条变体 + 2 条画框),悬空 1 条、环 0 条。悬空那条正是 6.1 样本里 furn_chair_01 引用的 furn_cushion_01——它不在清单里。
6.2.4 P 层:DFS 强连通分量的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp06/dep.ts,与上面的 Python 逐条对应。Tarjan 的 low 更新与分量弹出顺序完全一致,故两语言得到的环集合逐位相同:
// file: core/src/sp06/dep.ts
// tier: P
// verify: node --test 'core/test/sp06_*.test.ts'
// 6.2 依赖图的 P 层内核。与 sim/sp06_dep.py 同一套规则:显式 refs + 三条隐式约定
// → 有向图 → DFS 求强连通分量做环检测 + 悬空引用检测。环用「分量节点排序后以 |
// 连接」规范化,与语言无关。只允许可擦除 TS 语法(禁 enum / namespace / 参数属性)。
export const VARIANT_RE = /^(.+)_v[0-9]+$/;
export type Edge = [string, string, string];
export function buildEdges(manifest: any): { ids: string[]; edges: Edge[] } {
const ids: string[] = manifest.assets.filter((a: any) => a.id).map((a: any) => a.id);
const idset = new Set(ids);
const edges: Edge[] = [];
for (const a of manifest.assets) {
const src = a.id;
if (!src) continue;
for (const r of a.refs || []) edges.push([src, r, "explicit"]);
if (a.type === "icon" && idset.has("atlas_ui")) edges.push([src, "atlas_ui", "implicit:atlas"]);
const m = VARIANT_RE.exec(src);
if (m && idset.has(m[1])) edges.push([src, m[1], "implicit:variant"]);
if (a.type === "portrait" && idset.has("ui_frame")) edges.push([src, "ui_frame", "implicit:frame"]);
}
return { ids, edges };
}
export function danglingRefs(idset: Set<string>, edges: Edge[]): string[] {
return edges
.filter(([, t]) => !idset.has(t))
.map(([s, t]) => `DANGLING:${s}->${t}`)
.sort();
}
export function findCycles(ids: string[], edges: Edge[]): string[] {
const adj: Record<string, string[]> = {};
for (const n of ids) adj[n] = [];
for (const [s, t] of edges) if (s in adj && t in adj) adj[s].push(t);
for (const n of Object.keys(adj)) adj[n] = [...new Set(adj[n])].sort();
const index: Record<string, number> = {};
const low: Record<string, number> = {};
const on: Record<string, boolean> = {};
const stack: string[] = [];
const comps: string[][] = [];
let ctr = 0;
const strong = (v: string): void => {
index[v] = ctr;
low[v] = ctr;
ctr += 1;
stack.push(v);
on[v] = true;
for (const w of adj[v]) {
if (!(w in index)) {
strong(w);
low[v] = Math.min(low[v], low[w]);
} else if (on[w]) low[v] = Math.min(low[v], index[w]);
}
if (low[v] === index[v]) {
const comp: string[] = [];
for (;;) {
const w = stack.pop() as string;
on[w] = false;
comp.push(w);
if (w === v) break;
}
comps.push(comp);
}
};
for (const v of [...ids].sort()) if (!(v in index)) strong(v);
return comps
.filter((c) => c.length > 1 || (c.length === 1 && adj[c[0]].includes(c[0])))
.map((c) => `CYCLE:${[...c].sort().join("|")}`)
.sort();
}
6.2.5 负例:人为造一个环
本节最重要的不是「干净图报 0」,而是「有环必须报出来」。 core/test/sp06_dep.test.ts 一边读 Python 产物做跨语言对照,一边在内存里造环验证检测器真的会失败:
// file: core/test/sp06_dep.test.ts
// tier: P
// verify: node --test 'core/test/sp06_*.test.ts'
// 跨语言对照:读 sim/sp06_dep.py 落盘的 out/sp06_dep.json,断言 TS 侧的悬空引用与环
// 检测结果逐位一致。**负例是本节核心**:人为造一个环,检测器必须报出来。
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 { buildEdges, danglingRefs, findCycles } from "../src/sp06/dep.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const manifest = JSON.parse(readFileSync(resolve(ROOT, "config/sp06_manifest.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp06_dep.json"), "utf8"));
const ui = (id: string, refs: string[]) => ({ id, type: "ui", refs });
test("依赖图:TS 复算与 Python 产物逐位一致", () => {
const { ids, edges } = buildEdges(manifest);
assert.equal(ids.length, py.nodeCount);
assert.equal(edges.length, py.edgeCount);
assert.deepEqual(danglingRefs(new Set(ids), edges), py.dangling);
assert.deepEqual(findCycles(ids, edges), py.cycles);
});
test("负例:人为造一个环,环检测必须报出来(干净图不报)", () => {
const g = buildEdges({ assets: [ui("a", ["b"]), ui("b", ["a"]), ui("c", ["a"])] });
assert.deepEqual(findCycles(g.ids, g.edges), ["CYCLE:a|b"]);
const g2 = buildEdges({ assets: [ui("a", ["b"]), ui("b", [])] });
assert.deepEqual(findCycles(g2.ids, g2.edges), []);
});
test("负例:自环与三节点环都规范成稳定字符串", () => {
const self = buildEdges({ assets: [ui("solo", ["solo"])] });
assert.deepEqual(findCycles(self.ids, self.edges), ["CYCLE:solo"]);
const tri = buildEdges({ assets: [ui("x", ["y"]), ui("y", ["z"]), ui("z", ["x"])] });
assert.deepEqual(findCycles(tri.ids, tri.edges), ["CYCLE:x|y|z"]);
});
test("负例:悬空引用必须被检出,指向存在的 id 不算悬空", () => {
const g = buildEdges({ assets: [ui("a", ["b", "ghost"]), ui("b", [])] });
assert.deepEqual(danglingRefs(new Set(g.ids), g.edges), ["DANGLING:a->ghost"]);
});
test("隐式引用:图标进图集、变体依赖基础、头像复用画框", () => {
const g = buildEdges({ assets: [
{ id: "atlas_ui", type: "atlas", refs: [] }, { id: "icon_x", type: "icon", refs: [] },
{ id: "icon_x_v2", type: "icon", refs: [] }, { id: "ui_frame", type: "ui", refs: [] },
{ id: "portrait_p", type: "portrait", refs: [] }] });
const pairs = new Set(g.edges.map(([s, t]) => `${s}->${t}`));
assert.ok(pairs.has("icon_x->atlas_ui"));
assert.ok(pairs.has("icon_x_v2->icon_x"));
assert.ok(pairs.has("portrait_p->ui_frame"));
});
实跑 node --test 'core/test/sp06_*.test.ts'(此处只列本节相关的 5 条;三节合计 13 条全绿):
✔ 依赖图:TS 复算与 Python 产物逐位一致
✔ 负例:人为造一个环,环检测必须报出来(干净图不报)
✔ 负例:自环与三节点环都规范成稳定字符串
✔ 负例:悬空引用必须被检出,指向存在的 id 不算悬空
✔ 隐式引用:图标进图集、变体依赖基础、头像复用画框
ℹ tests 13
ℹ pass 13
ℹ fail 0
6.2.6 边界与取舍
- 隐式约定要写进规范文档,不能只活在脚本里。否则新人加一条
icon却发现打包器自动多了一条边,会以为脚本有 bug。 - 环的规范化很关键:如果报的是「DFS 碰到的第一条环」,两语言、两次运行的输出都可能不同,跨语言对照就没法做。用 SCC 分量做规范化后,环集合是字符串数组,逐位一致。
- 真 DCC 的引用图(材质 → 贴图 → 采样器)由 DCC 自己维护,本机无 DCC、不可复现,标 X;本节只管导出清单这一层。
阅读导航:上一节:6.1 资产清单的字段设计 · 下一节:6.3 清单与目录的双向一致 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。