8.3.1 一个总数治不了「哪一类超了」
8.1 算了单张贴图的显存链,8.2 算了格式乘数;这一节把它们收口到整包预算。工程侧的包体总纲在 《移动端与 PC 性能预算 2026》 里,那篇讲的是「包体预算这件事怎么定档」;本节只做美术侧的整包账:把散落在各处的条目按类别汇总成一张「预算 / 实际 / 余量」表。
为什么不能只盯一个总数?因为总数合格会掩盖分类超支:一个 12 MiB 的包,总数卡在 12 MiB 以内,可音频类已经吃掉 3.2 MiB(预算是 3 MiB)——总数看不出来,但音频组必须回去删素材。分类账才是可归因的账。
8.3.2 D 层:预算与条目
config/sp08_package.json 是这份账的静态输入:五类的类别预算、总包预算,以及 20 个打包条目(每条带类别与字节数):
{
"project": "stardust-inn",
"specRef": "config/visual_spec.json",
"totalBudgetBytes": 12582912,
"categories": [
{ "id": "textures", "budget": 6291456 },
{ "id": "audio", "budget": 3145728 },
{ "id": "fonts", "budget": 524288 },
{ "id": "data", "budget": 1572864 },
{ "id": "other", "budget": 1048576 }
],
"items": [
{ "id": "atlas_ui.bc7", "category": "textures", "bytes": 1398128 },
{ "id": "atlas_furn.bc7", "category": "textures", "bytes": 1398128 },
{ "id": "atlas_char.bc7", "category": "textures", "bytes": 1398128 },
{ "id": "atlas_terrain.bc7", "category": "textures", "bytes": 1398128 },
{ "id": "portrait_host_01.bc7", "category": "textures", "bytes": 87380 },
{ "id": "portrait_guest_01.bc7", "category": "textures", "bytes": 87380 },
{ "id": "ui_frame.bc7", "category": "textures", "bytes": 21844 },
{ "id": "furn_chair_01.bc7", "category": "textures", "bytes": 21844 },
{ "id": "vfx_steam_01.bc7", "category": "textures", "bytes": 21844 },
{ "id": "vfx_spark_01.bc7", "category": "textures", "bytes": 21844 },
{ "id": "bgm_theme_a.ogg", "category": "audio", "bytes": 1048576 },
{ "id": "bgm_theme_b.ogg", "category": "audio", "bytes": 1048576 },
{ "id": "sfx_pack.ogg", "category": "audio", "bytes": 786432 },
{ "id": "sfx_ui.ogg", "category": "audio", "bytes": 524288 },
{ "id": "ui_font_regular.ttf", "category": "fonts", "bytes": 262144 },
{ "id": "ui_font_bold.ttf", "category": "fonts", "bytes": 196608 },
{ "id": "level_layout.json", "category": "data", "bytes": 393216 },
{ "id": "dialogue_zh.json", "category": "data", "bytes": 786432 },
{ "id": "shader_pack.bin", "category": "other", "bytes": 524288 },
{ "id": "config_bundle.bin", "category": "other", "bytes": 131072 }
]
}
纹理条目用的是 8.2 的 BC7 整链字节(atlas_*.bc7 都是 1398128),这样两节的账能对上:显存口径看 8.2,包体口径看这里。
8.3.3 S 层:预算 / 实际 / 余量
下面这段是 S 层脚本。它按类别把条目字节汇总,算「预算 − 实际 = 余量」,并对类别与总包各判一次超支,违例是「<类别>:OVER:<超出字节>」:
#!/usr/bin/env python3
# file: sim/sp08_package.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp08_package.py
"""8.3 整包资源预算表的 S 层脚本。按类别把 config/sp08_package.json 的条目字节汇总,
给出「预算 / 实际 / 余量」三列,并对类别与总包各判一次超支。违例是
「<类别>:OVER:<超出字节>」字符串,排序后落 out/sp08_package.json 供 P 层跨语言对照。"""
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def evaluate(pkg: dict):
cats = []
for c in pkg["categories"]:
actual = sum(i["bytes"] for i in pkg["items"] if i["category"] == c["id"])
cats.append({"id": c["id"], "budget": c["budget"], "actual": actual,
"slack": c["budget"] - actual, "over": actual > c["budget"]})
total_actual = sum(c["actual"] for c in cats)
total = {"id": "TOTAL", "budget": pkg["totalBudgetBytes"], "actual": total_actual,
"slack": pkg["totalBudgetBytes"] - total_actual,
"over": total_actual > pkg["totalBudgetBytes"]}
viol = [f"{c['id']}:OVER:{c['actual'] - c['budget']}" for c in cats if c["over"]]
if total["over"]:
viol.append(f"TOTAL:OVER:{total['actual'] - total['budget']}")
return cats, total, sorted(viol)
def main() -> int:
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
pkg = json.load(open(os.path.join(ROOT, "config/sp08_package.json"), encoding="utf-8"))
cats, total, viol = evaluate(pkg)
report = {"project": spec["project"], "categories": cats, "total": total,
"violations": viol, "count": len(viol), "itemCount": len(pkg["items"])}
with open(os.path.join(ROOT, "out/sp08_package.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"items={report['itemCount']} categories={len(cats)} violations={report['count']}")
for c in cats:
flag = "OVER" if c["over"] else "ok"
print(f" {c['id']:<9} budget={c['budget']:<9} actual={c['actual']:<9} "
f"slack={c['slack']:<9} {flag}")
t = total
print(f" {t['id']:<9} budget={t['budget']:<9} actual={t['actual']:<9} "
f"slack={t['slack']:<9} {'OVER' if t['over'] else 'ok'}")
for v in viol:
print(f" VIOLATION {v}")
print("wrote out/sp08_package.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp08_package.py:
items=20 categories=5 violations=1
textures budget=6291456 actual=5854648 slack=436808 ok
audio budget=3145728 actual=3407872 slack=-262144 OVER
fonts budget=524288 actual=458752 slack=65536 ok
data budget=1572864 actual=1179648 slack=393216 ok
other budget=1048576 actual=655360 slack=393216 ok
TOTAL budget=12582912 actual=11556280 slack=1026632 ok
VIOLATION audio:OVER:262144
wrote out/sp08_package.json
8.3.4 P 层:同一套预算判据的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp08/package.ts,与 Python 逐条对应——汇总、余量、两条超支判据、违例字符串格式完全一致:
// file: core/src/sp08/package.ts
// tier: P
// verify: node --test 'core/test/sp08_*.test.ts'
// 8.3 整包资源预算表的 P 层内核。与 sim/sp08_package.py 同一套规则:按类别汇总
// item 字节,算「预算 / 实际 / 余量」,超类别或超总包即产出
// 「<类别>:OVER:<超出字节>」违例。全整数 → 逐位一致。
export function evaluatePackage(pkg: any): any {
const cats = pkg.categories.map((c: any) => {
const actual = pkg.items
.filter((i: any) => i.category === c.id)
.reduce((s: number, i: any) => s + i.bytes, 0);
return { id: c.id, budget: c.budget, actual, slack: c.budget - actual, over: actual > c.budget };
});
const totalActual = cats.reduce((s: number, c: any) => s + c.actual, 0);
const total = {
id: "TOTAL",
budget: pkg.totalBudgetBytes,
actual: totalActual,
slack: pkg.totalBudgetBytes - totalActual,
over: totalActual > pkg.totalBudgetBytes,
};
const violations = cats
.filter((c: any) => c.over)
.map((c: any) => `${c.id}:OVER:${c.actual - c.budget}`);
if (total.over) violations.push(`TOTAL:OVER:${total.actual - total.budget}`);
violations.sort();
return { categories: cats, total, violations, count: violations.length };
}
core/test/sp08_package.test.ts 一边读 Python 产物做跨语言对照,一边用内存对象构造两条负例——类别超支与总包超支都必须 fail:
// file: core/test/sp08_package.test.ts
// tier: P
// verify: node --test 'core/test/sp08_*.test.ts'
// 跨语言对照:读 sim/sp08_package.py 落盘的 out/sp08_package.json,断言 TS 侧复算的
// 预算表与违例集合逐位一致。负例:类别超支、总包超支都必须 fail。
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 { evaluatePackage } from "../src/sp08/package.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const pkg = JSON.parse(readFileSync(resolve(ROOT, "config/sp08_package.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp08_package.json"), "utf8"));
test("整包预算表:TS 复算与 Python 产物逐位一致", () => {
const mine = evaluatePackage(pkg);
assert.deepEqual(mine.categories, py.categories);
assert.deepEqual(mine.total, py.total);
assert.deepEqual(mine.violations, py.violations);
assert.equal(mine.count, py.count);
});
test("正例:样本音频类超支、总包未超,违例恰好一条", () => {
const mine = evaluatePackage(pkg);
assert.ok(mine.violations.includes("audio:OVER:262144"), mine.violations.join(","));
assert.equal(mine.total.over, false);
assert.equal(mine.count, 1);
});
test("负例:类别超支必须 fail", () => {
const bad = {
totalBudgetBytes: 1000,
categories: [{ id: "audio", budget: 100 }],
items: [{ category: "audio", bytes: 140 }],
};
assert.deepEqual(evaluatePackage(bad).violations, ["audio:OVER:40"]);
});
test("负例:总包超支(各类别都不超)也必须 fail", () => {
const bad = {
totalBudgetBytes: 100,
categories: [{ id: "a", budget: 80 }, { id: "b", budget: 80 }],
items: [{ category: "a", bytes: 70 }, { category: "b", bytes: 70 }],
};
const r = evaluatePackage(bad);
assert.equal(r.total.over, true);
assert.ok(r.violations.includes("TOTAL:OVER:40"), r.violations.join(","));
});
实跑 node --test 'core/test/sp08_*.test.ts'(本节 4 条;三节合计 7 条):
✔ 整包预算表:TS 复算与 Python 产物逐位一致 (0.984917ms)
✔ 正例:样本音频类超支、总包未超,违例恰好一条 (0.125584ms)
✔ 负例:类别超支必须 fail (0.089292ms)
✔ 负例:总包超支(各类别都不超)也必须 fail (0.071125ms)
ℹ tests 7
ℹ pass 7
ℹ fail 0
8.3.5 判读:总数绿了,不等于没有超支
样本里 TOTAL 是 ok 的(余量 1026632 字节),但 audio 超支 262144 字节。这正是本节要立的规矩:
- 总包余量不能掩盖分类超支。门禁必须同时看「类别行」和「TOTAL 行」,只判一条都会漏。
- 负例是本节的一半:类别超支(
audio:OVER:40)与总包超支(TOTAL:OVER:40)都要能报,且两条判据互不替代——第二条特意构造了「各类别都不超、只有总数超」的用例。 - 超支的归因顺序:先看是哪一类超(audio),再看该类里最大的条目(两首 BGM 各 1 MiB),最后才决定删素材还是重编码(BGM 降码率),而不是回头砍总数预算。
8.3.6 边界与取舍
- 真打包器 / 真机包体校验需要真实环境(构建机 / 真机安装包分析),命令行不可复现,标 X;本节只对一份条目清单做静态汇总。
- 条目字节是打包后的(纹理已按 8.2 压缩),不是源文件大小;两套口径不能混着比。
- 本节全是整数类(字节、余量),逐位一致;违例是字符串数组,同样逐位一致。
阅读导航:上一节:8.2 压缩格式的显存差异 · 下一节:9.1 栅格、间距与安全区 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。