4.3 资源清理与不可达资产

从场景入口沿依赖边做前向可达,可达集之外的资产就是不可达资产,也是候选删除清单。本节用 S 层 Python 与 P 层 TS 各自算一遍可达集,断言删除不可达资产后入口仍可达、可达集逐位不变;最后讨论不可达不等于可删的三条边界,避免清理误伤预留资产、动态引用与热更分包。

4.3 资源清理与不可达资产

4.1 建图、4.2 判环之后,这张图还剩一个直接能变现的用途:判断哪些资产根本进不了包。做法是从入口(场景 / 关卡 / 包清单)出发,沿全部依赖边做前向可达——能走到的资产是「活的」,走不到的就是不可达资产。包体里每多一个不可达资产,就多一份白下载的字节。产能与包体的整体账属于生产优化,本节外链 《游戏性能优化与生产》 ,只算这张图上的可达性。

「不可达」是个纯图论判据:节点不在从任一入口出发的前向闭包里。它不依赖文件系统、不依赖打包器,所以 S 层和 P 层能各自独立算一遍再对照。

4.3.1 入口、可达集、不可达集

三个概念一句话讲清:

  • 入口(root):加载的起点。这里是两个场景 scene_inn_lobby、scene_inn_room;真实项目里还会加上关卡、预载清单、常驻 UI。
  • 可达集:从任一入口沿边能到达的全部节点。它就是「进包清单」的下界。
  • 不可达集:节点全集 − 可达集。它就是「候选删除清单」。

可达性用的是有向图、只看出边:atlas_ui 指向它收编的图标,所以从场景经 ui_hud → atlas_ui → icon_* 能走到图标;反过来,一个孤立的贴图没有任何入边也没有出边,从任何入口都走不到它。

4.3.2 S 层:可达性分析 + 删除后仍可达断言

下面这段是 S 层脚本,读 4.1 的配置,从 roots 做前向可达,输出不可达清单,并断言「删除不可达资产后,入口仍可达」——即删除不动可达集:

#!/usr/bin/env python3
# file: sim/ta04_reach.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta04_reach.py
"""4.3 可达性分析:从入口(场景)出发沿全部边做前向可达,输出不可达资产清单,
并断言「删除不可达资产后,入口仍可达」——即删除不动可达集合。

入口取 config 的 ``roots``(场景/关卡)。可达 = 从任一入口出发沿边能到达的节点集合。
不可达 = 节点全集 − 可达集。删除不可达节点后重算可达集,两者必须**逐位相等**,
否则说明删错了(删掉了入口链路上的资产)。落 out/ta04_reach.json。
"""
import hashlib
import json
import os
import sys

from ta04_graph import build_graph

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def adjacency(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 adj


def reachable(adj, roots):
    seen, stack = set(), [r for r in roots if r in adj]
    while stack:
        v = stack.pop()
        if v in seen:
            continue
        seen.add(v)
        for w in adj[v]:
            if w not in seen:
                stack.append(w)
    return seen


def main():
    spec = json.load(open(os.path.join(ROOT, "config/ta04_graph.json"), encoding="utf-8"))
    ids, edges = build_graph(spec)
    adj = adjacency(ids, edges)
    roots = [r for r in spec["roots"] if r in adj]
    before = reachable(adj, roots)
    unreachable = sorted(set(ids) - before)
    # 删除不可达资产(连同其边)后重算:可达集必须不变。
    keep = set(ids) - set(unreachable)
    adj2 = {n: [w for w in adj[n] if w in keep] for n in keep}
    after = reachable(adj2, roots)
    stable = sorted(before) == sorted(after)
    digest = hashlib.sha256("\n".join(sorted(before)).encode("utf-8")).hexdigest()[:16]
    rep = {
        "nodeCount": len(ids),
        "rootCount": len(roots),
        "reachableCount": len(before),
        "reachable": sorted(before),
        "unreachable": unreachable,
        "unreachableCount": len(unreachable),
        "reachableStableAfterCleanup": stable,
        "digest": digest,
    }
    with open(os.path.join(ROOT, "out/ta04_reach.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
        f.write("\n")
    print(f"nodes={rep['nodeCount']} roots={rep['rootCount']} "
          f"reachable={rep['reachableCount']} unreachable={rep['unreachableCount']}")
    for x in unreachable:
        print(f"  UNREACHABLE:{x}")
    print(f"reachableStableAfterCleanup={stable}")
    print(f"digest={digest}")
    print("wrote out/ta04_reach.json")
    return 0 if stable else 1


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/ta04_reach.py:

nodes=17 roots=2 reachable=15 unreachable=2
  UNREACHABLE:prefab_ghost_chair
  UNREACHABLE:tex_unused_moss
reachableStableAfterCleanup=True
digest=ed5d37165d8cfc70
wrote out/ta04_reach.json

17 个节点里 15 个可达、2 个不可达:prefab_ghost_chair(没有场景引用它)与 tex_unused_moss(没有任何材质引用它)。两个都没有任何入边,所以从入口永远走不到。reachableStableAfterCleanup=True 说明把这两个删掉后,15 个可达资产一个不少。

4.3.3 P 层:可达性的 TS 内核

下面这段是 P 层 TS 内核 core/src/ta04/reach.ts,与上面的 Python 逐条对应:

// file: core/src/ta04/reach.ts
// tier: P
// verify: node --test 'core/test/ta04_*.test.ts'
// 4.3 可达性分析。与 sim/ta04_reach.py 同一套规则:从入口(场景)沿全部边做前向可达,
// 节点全集减可达集即不可达资产。只允许可擦除 TS 语法。
import type { Edge } from "./graph.ts";

export function reachable(ids: string[], edges: Edge[], roots: string[]): Set<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);
  const seen = new Set<string>();
  const stack: string[] = roots.filter((r) => r in adj);
  while (stack.length) {
    const v = stack.pop() as string;
    if (seen.has(v)) continue;
    seen.add(v);
    for (const w of adj[v]) if (!seen.has(w)) stack.push(w);
  }
  return seen;
}

export function unreachable(ids: string[], edges: Edge[], roots: string[]): string[] {
  const seen = reachable(ids, edges, roots);
  return ids.filter((n) => !seen.has(n)).sort();
}

4.3.4 断言:删除后仍可达

P 层测试读 Python 落盘的 out/ta04_reach.json 做跨语言对照,核心断言是「删除不可达资产后可达集逐位不变」:

// file: core/test/ta04_reach.test.ts
// tier: P
// verify: node --test 'core/test/ta04_*.test.ts'
// 4.3 可达性。跨语言对照读 out/ta04_reach.json;核心断言:删除不可达资产后入口仍可达。
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 { createHash } from "node:crypto";
import { buildGraph, type Edge } from "../src/ta04/graph.ts";
import { reachable, unreachable } from "../src/ta04/reach.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_reach.json"), "utf8"));
const digest16 = (ls: string[]): string =>
  createHash("sha256").update(ls.join("\n"), "utf8").digest("hex").slice(0, 16);

test("可达性:TS 复算与 Python 产物逐位一致", () => {
  const { ids, edges } = buildGraph(spec);
  const seen = reachable(ids, edges, spec.roots);
  assert.deepEqual([...seen].sort(), py.reachable);
  assert.equal(seen.size, py.reachableCount);
  assert.deepEqual(unreachable(ids, edges, spec.roots), py.unreachable);
  assert.equal(digest16([...seen].sort()), py.digest);
});

test("断言:删除不可达资产后,入口仍可达(可达集逐位不变)", () => {
  const { ids, edges } = buildGraph(spec);
  const before = [...reachable(ids, edges, spec.roots)].sort();
  const drop = new Set(unreachable(ids, edges, spec.roots));
  const keep = ids.filter((n) => !drop.has(n));
  const edges2: Edge[] = edges.filter(([s, t]) => !drop.has(s) && !drop.has(t));
  const after = [...reachable(keep, edges2, spec.roots)].sort();
  assert.deepEqual(after, before);
  assert.equal(py.reachableStableAfterCleanup, true);
});

test("负例:入口恒可达,不可能被标成不可达", () => {
  const { ids, edges } = buildGraph(spec);
  const seen = reachable(ids, edges, spec.roots);
  for (const r of spec.roots) assert.ok(seen.has(r), `${r} 应可达`);
  const un = unreachable(ids, edges, spec.roots);
  for (const r of spec.roots) assert.ok(!un.includes(r), `${r} 不应出现在不可达集`);
});

实跑 node --test 'core/test/ta04_reach.test.ts':

✔ 可达性:TS 复算与 Python 产物逐位一致
✔ 断言:删除不可达资产后,入口仍可达(可达集逐位不变)
✔ 负例:入口恒可达,不可能被标成不可达
ℹ tests 3
ℹ pass 3
ℹ fail 0

两侧的可达集 digest 都是 ed5d37165d8cfc70——字符串类,逐位一致。「删除后仍可达」这条断言之所以重要,是因为它是清理动作的安全网:如果清理脚本误删了一个仍在链路上的资产,可达集会缩小,断言立刻打红。

4.3.5 不可达 ≠ 可删:三条边界

可达性给的是候选删除清单,不是执行清单。三条边界必须先过一遍:

  • 预留与占位:还没接进场景的下个版本资产、外包待验收的草稿,天然不可达。它们不该进包,但也不该被删——清理应该只删「已确认废弃」的,而不是「暂时没人用」的。
  • 动态引用:代码里按字符串拼出来的资产名(f"icon_{item_id}")、配置表驱动的贴图,图里看不到边。这类资产必须在 dirOwners 或一张 dynamicRefs 清单里显式登记为入口,否则会被误判成不可达。
  • 热更包 / 分包:每个分包有自己的入口清单。清理必须逐包算可达性,不能拿全站一张图一刀切,否则会把别的分包的资产删掉。

一句话总结这一章的三节:4.1 把「谁引用谁」建成图,4.2 保证图能排序(无环、无悬空),4.3 用图算出该进什么包。三节共用同一份 config/ta04_graph.json,这就是「一份规范、多处消费」在依赖图上的落地。

阅读导航:上一节:4.2 环检测与悬空引用 · 下一节:5.1 装箱算法与装箱率 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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