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 装箱算法与装箱率 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。