4.1 依赖图:显式引用与隐式引用

管线打包前必须先知道谁引用谁。本节把清单里显式的 dependsOn 与三类隐式边(命名约定、目录归属、材质槽)合成一张有向图,用 S 层 Python 建图并分类计数、P 层 TS 内核复算,两侧边集合 digest 逐位一致,并说明隐式边为什么是管线里最容易漏的一环。

4.1 依赖图:显式引用与隐式引用

卷一 6.2 引用关系与依赖声明 从清单字段的角度定义了 refs 该写什么;这一节换到管线的角度:打包器与烘焙器真正消费的不是一份清单,而是一张有向图——清单只是这张图的一种输入。图建错了,后面所有闸门(环检测、可达性、增量更新)都建在沙子上。引擎侧怎么按拓扑序烘焙、怎么调度加载属于实现,本节外链 《glTF 资产管线》 ,只做美术侧的图建模与校验。

这张图只回答一个问题:打包 A 之前,必须先有谁? 边 A→B 读作「A 依赖 B」。管线对它有四类用法:拓扑排序定打包顺序、环检测保增量构建能终止、可达性定「哪些资产要进包」、悬空检测保引用不指空。本节只建图并分类计数,后两类留给 4.2 与 4.3。

4.1.1 显式边与三类隐式边

节点是资产 id。边分四类,其中只有第一类是「有人写下来的」:

边来源方向例子
显式清单 dependsOn 字段A 依赖 Bfurn_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 环检测与悬空引用 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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