7.1.1 预算为什么要分级
站内已有两篇讲性能预算的短文:移动端与 PC 的预算口径 与 性能优化的生产管理 。那两篇是工程与生产管理口径的预算散文——它们回答「该不该有预算」「谁来定」「预算怎么进排期」。本章不重复那套框架,而是把它落成美术侧可跑的分级预算账:给定每个角色的面数、骨骼、材质槽,谁来判、按什么判、超了多少。第 7 章三节都建立在一份能被机器执行的预算表上。
「注意控制面数」这句话在项目里几乎必然失效,原因很具体:
- 一个数字覆盖不了所有资产:主角有换装与近景特写,家具图标只有 64×64;用同一个上限,要么浪费主角的预算,要么把道具卡死。
- 超支是逐项累积的,判定又必须能被机器执行:单看每个资产都「差不多」,一算总量就爆;而人肉数面数不可靠,工具数才可靠,工具需要一个可比较的阈值。
所以第一步是分级。《星屑旅店》是等距经营 × 装扮的休闲游戏,按「玩家看它的时长与距离」分四档:
| 档 | 谁 | 为什么单独一档 |
|---|---|---|
| 主角 hero | 店长(玩家角色)+ 换装 | 唯一有换装与近景特写,预算最宽 |
| 主要NPC npc-major | 常驻商贩、吟游诗人 | 有对话特写与专属动画 |
| 次要NPC npc-minor | 镇上路人 | 只在中景出现,复用同一套骨架 |
| 道具 prop | 家具、摆件 | 静态为主,不应有骨骼 |
7.1.2 四档上限表
上限写成一份 JSON,它是 Python 与 TypeScript 的共同输入。下面这个块是 D 层(静态 schema),不产出数字,只定义判据:
{
"project": "stardust-inn",
"ruleSet": "budget",
"specVersion": 1,
"specRef": "config/visual_spec.json",
"note": "按角色分级的面数/骨骼/材质槽上限。四档里有两档直接对齐共享真源 visual_spec.json 的 budget 段(道具档 tris==trisLow、materialSlots==materialSlotsLow;次要NPC档 bones==bonesLow;主要NPC档 tris==trisMid),另两档(主角 tris/bones、主要NPC bones)是本节新增的章级参数。所有阈值都是整数,Python 与 TS 两侧逐位一致。",
"metrics": ["tris", "bones", "materialSlots"],
"tiers": [
{ "tier": "hero", "label": "主角", "maxTris": 12000, "maxBones": 90, "maxMaterialSlots": 4 },
{ "tier": "npc-major", "label": "主要NPC", "maxTris": 6000, "maxBones": 60, "maxMaterialSlots": 3 },
{ "tier": "npc-minor", "label": "次要NPC", "maxTris": 3000, "maxBones": 30, "maxMaterialSlots": 2 },
{ "tier": "prop", "label": "道具", "maxTris": 1500, "maxBones": 0, "maxMaterialSlots": 2 }
]
}
关键设计是部分档位引用共享真源:道具档的 maxTris(1500)与 maxMaterialSlots(2)取自 config/visual_spec.json 的 budget.trisLow / budget.materialSlotsLow,次要NPC档的 maxBones(30)取自 bonesLow,主要NPC档的 maxTris(6000)取自 trisMid。只有主角两档与主要NPC的骨骼数是本节新增。S 层会把这四条对齐关系断言出来——预算表不能自己偷偷改口径,否则「一份规范三处消费」就是空话。
7.1.3 资产样本
要判超限,先得有可比数据。样本是 12 个资产的三元组快照(tris / bones / materialSlots),同样落成 D 层配置;真实项目里它由引擎资产导入器批量导出,本机无 DCC,故固化成快照。样本故意留了 5 个超限资产,好让判定真的拦下东西——一份全是绿灯的样本证明不了任何事:
{
"project": "stardust-inn",
"ruleSet": "assets",
"specVersion": 1,
"budgetRef": "config/sp07_budget.json",
"note": "《星屑旅店》角色/道具资产的样本快照:每条的 tris / bones / materialSlots 都是整数,单位分别是三角面、骨骼数、材质槽数。样本故意留了 5 个超限资产(10 条违例),好让分级预算判定真的拦下东西。",
"assets": [
{ "id": "char_innkeeper-base_v01", "tier": "hero", "tris": 11200, "bones": 84, "materialSlots": 3 },
{ "id": "char_innkeeper-coat_v01", "tier": "hero", "tris": 12480, "bones": 96, "materialSlots": 4 },
{ "id": "char_guest-merchant_v01", "tier": "npc-major", "tris": 5820, "bones": 54, "materialSlots": 3 },
{ "id": "char_guest-bard_v01", "tier": "npc-major", "tris": 6340, "bones": 62, "materialSlots": 3 },
{ "id": "char_townsfolk-a_v01", "tier": "npc-minor", "tris": 2860, "bones": 28, "materialSlots": 2 },
{ "id": "char_townsfolk-b_v01", "tier": "npc-minor", "tris": 3120, "bones": 31, "materialSlots": 3 },
{ "id": "furn_chair-base_v01", "tier": "prop", "tris": 1420, "bones": 0, "materialSlots": 2 },
{ "id": "furn_lamp-base_v01", "tier": "prop", "tris": 1180, "bones": 0, "materialSlots": 1 },
{ "id": "furn_wardrobe-base_v01", "tier": "prop", "tris": 1680, "bones": 0, "materialSlots": 3 },
{ "id": "furn_rug-base_v01", "tier": "prop", "tris": 240, "bones": 0, "materialSlots": 1 },
{ "id": "prop_candle-base_v01", "tier": "prop", "tris": 90, "bones": 0, "materialSlots": 1 },
{ "id": "prop_teapot-base_v01", "tier": "prop", "tris": 320, "bones": 12, "materialSlots": 1 }
]
}
7.1.4 S 层:分级预算账
下面这段是 S 层脚本,读上面两份配置与 visual_spec.json 的 budget 段,逐资产逐指标判超限,落 out/sp07_budget.json:
#!/usr/bin/env python3
# file: sim/sp07_budget.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp07_budget.py
"""7.1 按角色分级的面数/骨骼/材质槽预算账(S 层)。
读 config/sp07_budget.json(分级上限)与 config/sp07_assets.json(资产样本),并只读
config/visual_spec.json 的 budget 段做「档位对齐」断言(道具档 tris/materialSlots、
次要NPC档 bones、主要NPC档 tris 直接引用共享真源)。逐资产逐指标判超限(value > cap),
落 out/sp07_budget.json,供 P 层跨语言对照。全部整数比较,Python 与 TS 逐位一致。
"""
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
METRIC_OF = {"tris": "maxTris", "bones": "maxBones", "materialSlots": "maxMaterialSlots"}
def align(spec_budget, tiers):
"""档位与共享真源 visual_spec.json 的整数对齐断言。"""
by = {t["tier"]: t for t in tiers}
return [
{"check": "prop.maxTris==budget.trisLow", "lhs": by["prop"]["maxTris"], "rhs": spec_budget["trisLow"]},
{"check": "prop.maxMaterialSlots==budget.materialSlotsLow", "lhs": by["prop"]["maxMaterialSlots"], "rhs": spec_budget["materialSlotsLow"]},
{"check": "npc-minor.maxBones==budget.bonesLow", "lhs": by["npc-minor"]["maxBones"], "rhs": spec_budget["bonesLow"]},
{"check": "npc-major.maxTris==budget.trisMid", "lhs": by["npc-major"]["maxTris"], "rhs": spec_budget["trisMid"]},
]
def judge(asset, cap):
"""逐指标判超限:value > cap 即违例,超支量 = value - cap(整数)。"""
return [{"id": asset["id"], "tier": asset["tier"], "metric": m,
"value": asset[m], "cap": cap[c], "over": asset[m] - cap[c]}
for m, c in METRIC_OF.items() if asset[m] > cap[c]]
def main():
budget = json.load(open(os.path.join(ROOT, "config/sp07_budget.json"), encoding="utf-8"))
assets = json.load(open(os.path.join(ROOT, "config/sp07_assets.json"), encoding="utf-8"))["assets"]
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
cap = {t["tier"]: t for t in budget["tiers"]}
align_rows = align(spec["budget"], budget["tiers"])
misaligned = [r["check"] for r in align_rows if r["lhs"] != r["rhs"]]
violations = sorted((v for a in assets for v in judge(a, cap[a["tier"]])),
key=lambda v: (v["id"], v["metric"]))
by_tier = {t["tier"]: {"assets": 0, "tris": 0, "violations": 0} for t in budget["tiers"]}
for a in assets:
by_tier[a["tier"]]["assets"] += 1
by_tier[a["tier"]]["tris"] += a["tris"]
for v in violations:
by_tier[v["tier"]]["violations"] += 1
summary = {"project": budget["project"], "ruleSet": budget["ruleSet"],
"assetCount": len(assets), "tierCount": len(cap),
"violationCount": len(violations), "violatingAssets": len({v["id"] for v in violations}),
"align": align_rows, "misaligned": misaligned, "byTier": by_tier, "violations": violations}
with open(os.path.join(ROOT, "out/sp07_budget.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"assetCount={len(assets)} tiers={len(cap)} violations={len(violations)} "
f"violatingAssets={summary['violatingAssets']} misaligned={len(misaligned)}")
for t in budget["tiers"]:
s = by_tier[t["tier"]]
print(f" {t['tier']:<10} assets={s['assets']} tris={s['tris']} violations={s['violations']}")
for v in violations:
print(f" OVER {v['id']} {v['metric']} {v['value']}>{v['cap']} (+{v['over']})")
print("wrote out/sp07_budget.json")
return 1 if misaligned else 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp07_budget.py:
assetCount=12 tiers=4 violations=10 violatingAssets=5 misaligned=0
hero assets=2 tris=23680 violations=2
npc-major assets=2 tris=12160 violations=2
npc-minor assets=2 tris=5980 violations=3
prop assets=6 tris=4930 violations=3
OVER char_guest-bard_v01 bones 62>60 (+2)
OVER char_guest-bard_v01 tris 6340>6000 (+340)
OVER char_innkeeper-coat_v01 bones 96>90 (+6)
OVER char_innkeeper-coat_v01 tris 12480>12000 (+480)
OVER char_townsfolk-b_v01 bones 31>30 (+1)
OVER char_townsfolk-b_v01 materialSlots 3>2 (+1)
OVER char_townsfolk-b_v01 tris 3120>3000 (+120)
OVER furn_wardrobe-base_v01 materialSlots 3>2 (+1)
OVER furn_wardrobe-base_v01 tris 1680>1500 (+180)
OVER prop_teapot-base_v01 bones 12>0 (+12)
wrote out/sp07_budget.json
12 个资产、10 条违例、5 个超限资产,misaligned=0(四条档位对齐全部成立)。逐档看:主角档 2 个资产 2 条违例、道具档 6 个资产 3 条违例——超限率与档位无关,高档位的资产一样会超。最容易被忽略的一条是 prop_teapot-base_v01 的 12 根骨骼:道具档上限是 0。「反正就 12 根」——但道具不需要蒙皮,这 12 根既占蒙皮数据,又永远不动。把它单独列出来,是因为分级预算一半的价值在于「把不该有的东西判出来」,而不只是「把太多的东西砍下去」。
7.1.5 P 层:TS 内核
同一份上限表,TypeScript 侧独立实现一遍。它不 import Python 的任何东西,只读同一份 JSON:
// file: core/src/sp07/budget.ts
// tier: P
// verify: node --test 'core/test/sp07_*.test.ts'
// 7.1 分级预算的 TS 内核。与 sim/sp07_budget.py 同一套规则:逐资产逐指标判超限
// (value > cap,超支量 = value - cap),档位与共享真源 config/visual_spec.json 的
// budget 段做整数对齐断言。全部整数比较,故两侧违例集合逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Tier = {
tier: string; label: string;
maxTris: number; maxBones: number; maxMaterialSlots: number;
};
export type Asset = { id: string; tier: string; tris: number; bones: number; materialSlots: number };
export type Violation = { id: string; tier: string; metric: string; value: number; cap: number; over: number };
export type AlignRow = { check: string; lhs: number; rhs: number };
export const METRIC_OF: Record<string, string> = {
tris: "maxTris", bones: "maxBones", materialSlots: "maxMaterialSlots",
};
export function tierMap(tiers: Tier[]): Record<string, Tier> {
const m: Record<string, Tier> = {};
for (const t of tiers) m[t.tier] = t;
return m;
}
/** 逐指标判超限:value > cap 即违例。 */
export function judge(asset: Asset, cap: Tier): Violation[] {
const out: Violation[] = [];
for (const metric of Object.keys(METRIC_OF)) {
const v = (asset as unknown as Record<string, number>)[metric];
const c = (cap as unknown as Record<string, number>)[METRIC_OF[metric]];
if (v > c) out.push({ id: asset.id, tier: asset.tier, metric, value: v, cap: c, over: v - c });
}
return out;
}
export function judgeAll(assets: Asset[], tiers: Tier[]): Violation[] {
const cap = tierMap(tiers);
const out: Violation[] = [];
for (const a of assets) out.push(...judge(a, cap[a.tier]));
return out.sort((x, y) =>
x.id < y.id ? -1 : x.id > y.id ? 1 : x.metric < y.metric ? -1 : x.metric > y.metric ? 1 : 0);
}
/** 档位与共享真源 visual_spec.json 的 budget 段的整数对齐断言。 */
export function alignTiers(specBudget: Record<string, number>, tiers: Tier[]): AlignRow[] {
const by = tierMap(tiers);
return [
{ check: "prop.maxTris==budget.trisLow", lhs: by.prop.maxTris, rhs: specBudget.trisLow },
{ check: "prop.maxMaterialSlots==budget.materialSlotsLow", lhs: by.prop.maxMaterialSlots, rhs: specBudget.materialSlotsLow },
{ check: "npc-minor.maxBones==budget.bonesLow", lhs: by["npc-minor"].maxBones, rhs: specBudget.bonesLow },
{ check: "npc-major.maxTris==budget.trisMid", lhs: by["npc-major"].maxTris, rhs: specBudget.trisMid },
];
}
7.1.6 P 层测试:读 Python 的产物做对照
「跨语言一致」不能靠「两边写得像」。测试读的是 Python 落盘的 JSON(不是同一份源码),再断言 TS 复算出的违例集合与它逐条相同:
// file: core/test/sp07_budget.test.ts
// tier: P
// verify: node --test 'core/test/sp07_*.test.ts'
// 跨语言对照:读 sim/sp07_budget.py 落盘的 out/sp07_budget.json,断言 TS 侧的分级预算
// 判定逐位一致(整数类),并带负例:超限资产必须真的 fail、档位与共享真源必须对齐。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { judgeAll, alignTiers, tierMap } from "../src/sp07/budget.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const budget = JSON.parse(readFileSync(resolve(ROOT, "config/sp07_budget.json"), "utf8"));
const assets = JSON.parse(readFileSync(resolve(ROOT, "config/sp07_assets.json"), "utf8")).assets;
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp07_budget.json"), "utf8"));
const key = (v: any): string => `${v.id}:${v.metric}:${v.value}:${v.cap}:${v.over}`;
const digest = (ls: string[]): string => createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);
test("分级预算:TS 违例集合 == Python 违例集合(逐位一致)", () => {
const mine = judgeAll(assets, budget.tiers).map(key);
const theirs = (py.violations as any[]).map(key);
assert.deepEqual(mine, theirs);
assert.equal(py.assetCount, assets.length);
assert.equal(py.violationCount, mine.length);
assert.equal(digest(mine), digest(theirs));
console.log(`budget digest = ${digest(mine)}`);
});
test("档位对齐:四档上限与 visual_spec.json 的 budget 段逐位相等", () => {
const rows = alignTiers(spec.budget, budget.tiers);
assert.deepEqual(rows, py.align);
assert.deepEqual(py.misaligned, []);
for (const r of rows) assert.equal(r.lhs, r.rhs, r.check);
});
test("负例:超限资产必须真的 fail(每条超支量 = value - cap)", () => {
const byId = new Map<string, string[]>();
for (const v of judgeAll(assets, budget.tiers)) {
byId.set(v.id, [...(byId.get(v.id) ?? []), `${v.metric}:+${v.over}`]);
}
const cases: [string, string][] = [
["char_innkeeper-coat_v01", "tris:+480"], ["char_innkeeper-coat_v01", "bones:+6"],
["char_guest-bard_v01", "tris:+340"], ["char_townsfolk-b_v01", "materialSlots:+1"],
["furn_wardrobe-base_v01", "tris:+180"], ["prop_teapot-base_v01", "bones:+12"],
];
for (const [id, v] of cases) assert.ok(byId.get(id)?.includes(v), `${id} 应报 ${v},实得 ${byId.get(id)}`);
assert.equal(byId.size, 5);
});
test("正例:达标资产一个错都不报;道具档上限为 0 骨骼时 0 骨骼通过", () => {
const flagged = new Set(judgeAll(assets, budget.tiers).map((v) => v.id));
for (const id of ["char_innkeeper-base_v01", "char_guest-merchant_v01", "char_townsfolk-a_v01",
"furn_chair-base_v01", "furn_lamp-base_v01", "furn_rug-base_v01", "prop_candle-base_v01"]) {
assert.ok(!flagged.has(id), `${id} 不应有违例`);
}
assert.equal(tierMap(budget.tiers).prop.maxBones, 0);
assert.deepEqual(judgeAll([{ id: "x", tier: "prop", tris: 1500, bones: 0, materialSlots: 2 }], budget.tiers), []);
});
实跑 node --test 'core/test/sp07_*.test.ts'(此处只列本节相关的 4 条;三节合计 11 条全绿):
✔ 分级预算:TS 违例集合 == Python 违例集合(逐位一致)
✔ 档位对齐:四档上限与 visual_spec.json 的 budget 段逐位相等
✔ 负例:超限资产必须真的 fail(每条超支量 = value - cap)
✔ 正例:达标资产一个错都不报;道具档上限为 0 骨骼时 0 骨骼通过
budget digest = 8b524cae6c6ee337
ℹ tests 11
ℹ pass 11
ℹ fail 0
把每条违例按 id:metric:value:cap:over 排序拼接后取 sha256 前 16 位,两侧算出的 digest 都是 8b524cae6c6ee337——这是整数类,逐位一致(不是「容差内一致」;容差那套只适用于色彩派生量,见第 4 章)。
7.1.7 边界与取舍
- 真面数与真骨骼数只能从 DCC 导出(Blender / Maya / 3ds Max)或用引擎资产导入器读,本机无任何 DCC,标 X:需真实环境,命令行不可复现。本节样本是整数快照,判定逻辑与真实工具一致,但数字本身是样本。
- 分级只解决「单资产上限」,不解决「同屏总量」。一个场景里 40 件家具各自达标,合起来仍可能爆——那是 7.2 的 Draw Call 账与第 8 章的显存账。
- 材质槽上限(2 / 3 / 4)不是随手填的:一个资产多一个材质槽,同屏就多一次拆批。这条耦合在 7.2 会被算成真金白银的 Draw Call。所有阈值都是整数,Python 与 TS 逐位一致;本节不涉及色彩派生量,故没有浮点容差问题。
阅读导航:上一节:6.3 清单与目录的双向一致 · 下一节:7.2 材质槽与 Draw Call 预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。