6.2 引用关系与依赖声明

显式引用只写 refs 字段远远不够:图标会被打进图集、变体依赖基础资产、头像复用统一画框,这些都是隐式引用。本节把显式边与隐式边合起来建一张有向图,用 DFS 求强连通分量做环检测、逐边做悬空引用检测,并人为造一个环作为负例断言,证明检测器真的会失败。

6.2.1 清单里的「引用」有两种

6.1 把 refs 定成了「显式依赖的资产 id」。但真正会让打包器翻车的,往往是没人写下来的那部分依赖:一张家具图标被塞进 UI 图集、一个 _v2 变体其实复用了基础资产的色板、一张头像叠在统一的画框上。这些关系在清单里一个字段都没有,可一旦目标缺失或成环,运行时就会拿到一张裂图。本节只做美术侧的依赖声明与图校验;引擎侧怎么按依赖顺序烘焙、怎么调度加载,属于实现,外链 《渲染引擎》的资产管线入口 ,风格方向仍外链 《游戏美术风格指南 2026》 ,不重推。

6.2.2 显式边 + 三条隐式边

依赖图的节点是资产 id,边有两类:

边来源例子
显式清单的 refs 字段vfx_steam_01 → furn_lamp_01
隐式 1type == icon → 同包图集 atlas_ui图标被打进图集,图集没了图标就没了
隐式 2id 形如 X_vN → 基础资产 X变体复用基础的色板与网格
隐式 3type == 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 清单与目录的双向一致 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则