7.2.1 材质槽为什么是预算项
7.1 把每个资产的材质槽上限卡在 2~4 个。为什么材质槽要单独设上限?因为它不是「美术内部细节」,而是一个直接决定 Draw Call 的数字。渲染器把同一材质槽下的多个实例合成一个批次,一次提交画完;一旦实例落在不同材质槽里,就必须拆成不同批次分别提交。同槽可合批、跨槽必拆——这就是耦合。
工程与生产管理口径的预算散文(移动端与 PC 的预算口径 、性能优化的生产管理 )讲的是「为什么要有 Draw Call 预算」;本节把它做成一份可跑的耦合账:给定一个场景的可见实例,算出它到底要几次 Draw Call、离预算还差多少、把材质槽拆开会多花几次。
7.2.2 合批规则
模型只有两条规则,全是整数运算:
| 规则 | 含义 | 后果 |
|---|---|---|
| 同槽合批 | 同一材质槽内的可见实例合成批次 | 实例数越多越划算 |
| 批上限 | 每批最多 maxBatchSize 个实例,超了拆批 | 整数上取整 (n + b - 1) // b |
| 跨槽必拆 | 不同材质槽各算各的,不能合批 | 多一个槽,至少多一次 Draw Call |
再加一条:阴影 pass 只统计 castsShadow=true 的实例,不投射的槽不进阴影批次。于是总账是:
不透明 pass drawcall = Σ_槽 ceil(槽内实例数 / maxBatchSize)
阴影 pass drawcall = Σ_投射槽 ceil(投射实例数 / maxBatchSize)
总 drawcall = 不透明 + 阴影
理论下限 floor = ceil(全部实例数 / maxBatchSize) # 假设只有一个材质槽
耦合代价 couplingCost = 不透明 pass drawcall − floor # 拆槽凭空多出来的次数
couplingCost 是本节最有用的一格:它把「材质槽拆得有多碎」直接换算成 Draw Call 的次数。
7.2.3 场景与批上限配置
配置同时给两个场景变体:base(家具共用一个材质槽)与 split-furn-3(反例:家具按美术需要拆成木/金属/布三槽)。下面这个块是 D 层,只定义输入与判据:
{
"project": "stardust-inn",
"ruleSet": "drawcall",
"specVersion": 1,
"specRef": "config/visual_spec.json",
"scene": "inn-lobby-dusk",
"note": "材质槽 → Draw Call 的耦合账。规则:同一材质槽内的可见实例合成批次,每批上限 maxBatchSize 个实例(超了必须拆批,整数上取整);不同材质槽一律不能合批,各算各的。阴影 pass 只算 castsShadow=true 的实例。全部整数。",
"maxBatchSize": 64,
"targetTier": "low",
"drawCallBudget": { "low": 12, "mid": 24 },
"variants": [
{
"id": "base",
"label": "现状:家具共用一个材质槽",
"renderers": [
{ "id": "floor", "slot": "tile_floor", "count": 96, "castsShadow": false },
{ "id": "wall", "slot": "tile_wall", "count": 64, "castsShadow": false },
{ "id": "furniture", "slot": "furn_opaque", "count": 42, "castsShadow": true },
{ "id": "rug", "slot": "furn_opaque", "count": 6, "castsShadow": true },
{ "id": "guests", "slot": "char_skin", "count": 9, "castsShadow": true },
{ "id": "lamp", "slot": "emissive", "count": 12, "castsShadow": false },
{ "id": "ui_icons", "slot": "ui_atlas", "count": 128, "castsShadow": false },
{ "id": "vfx_steam", "slot": "vfx_additive", "count": 24, "castsShadow": false }
]
},
{
"id": "split-furn-3",
"label": "反例:家具按美术需要拆成木/金属/布三个材质槽",
"renderers": [
{ "id": "floor", "slot": "tile_floor", "count": 96, "castsShadow": false },
{ "id": "wall", "slot": "tile_wall", "count": 64, "castsShadow": false },
{ "id": "furn_wood", "slot": "furn_wood", "count": 24, "castsShadow": true },
{ "id": "furn_metal", "slot": "furn_metal", "count": 14, "castsShadow": true },
{ "id": "rug", "slot": "furn_fabric", "count": 10, "castsShadow": true },
{ "id": "guests", "slot": "char_skin", "count": 9, "castsShadow": true },
{ "id": "lamp", "slot": "emissive", "count": 12, "castsShadow": false },
{ "id": "ui_icons", "slot": "ui_atlas", "count": 128, "castsShadow": false },
{ "id": "vfx_steam", "slot": "vfx_additive", "count": 24, "castsShadow": false }
]
}
]
}
maxBatchSize = 64 是一次实例化批次的典型上限,targetTier = low 的预算是 12 次。注意 base 里 furniture 与 rug 共用 furn_opaque 槽——它们合批成一批。
7.2.4 S 层:耦合账
下面这段是 S 层脚本,按材质槽聚合可见实例、逐槽算批次,落 out/sp07_drawcall.json:
#!/usr/bin/env python3
# file: sim/sp07_drawcall.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp07_drawcall.py
"""7.2 材质槽 → Draw Call 的耦合账(S 层)。
规则:同一材质槽内的可见实例合成批次,每批上限 maxBatchSize 个实例(超了拆批,整数
上取整);不同材质槽一律不能合批。阴影 pass 只算 castsShadow=true 的实例。额外给出
「耦合代价」= 不透明 pass 实际 drawcall − 理论下限 ceil(总实例数 / 批上限):槽拆得越碎,
这个差越大。落 out/sp07_drawcall.json,供 P 层跨语言对照。全部整数。
"""
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def ceil_div(a, b):
return (a + b - 1) // b
def draw_calls(renderers, max_batch):
"""按材质槽聚合 → 逐槽批次 → 总数。shadow 只统计 castsShadow=true 的实例。"""
slots = {}
for r in renderers:
s = slots.setdefault(r["slot"], {"instances": 0, "castsShadow": 0})
s["instances"] += r["count"]
if r["castsShadow"]:
s["castsShadow"] += r["count"]
opaque = shadow = total = 0
per_slot = []
for name in sorted(slots):
s = slots[name]
ob = ceil_div(s["instances"], max_batch)
sb = ceil_div(s["castsShadow"], max_batch) if s["castsShadow"] else 0
opaque += ob
shadow += sb
total += s["instances"]
per_slot.append({"slot": name, "instances": s["instances"], "shadowInstances": s["castsShadow"],
"opaqueBatches": ob, "shadowBatches": sb})
floor_dc = ceil_div(total, max_batch)
return {"slots": len(slots), "instances": total, "opaque": opaque, "shadow": shadow,
"total": opaque + shadow, "floor": floor_dc, "couplingCost": opaque - floor_dc,
"perSlot": per_slot}
def main():
cfg = json.load(open(os.path.join(ROOT, "config/sp07_drawcall.json"), encoding="utf-8"))
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
mb = cfg["maxBatchSize"]
budget = cfg["drawCallBudget"][cfg["targetTier"]]
variants = []
for v in cfg["variants"]:
d = draw_calls(v["renderers"], mb)
d.update({"id": v["id"], "label": v["label"], "budget": budget, "pass": d["total"] <= budget})
variants.append(d)
summary = {"project": cfg["project"], "scene": cfg["scene"], "maxBatchSize": mb,
"targetTier": cfg["targetTier"], "budget": budget,
"slotCapFromSpec": spec["budget"]["materialSlotsLow"], "variants": variants}
with open(os.path.join(ROOT, "out/sp07_drawcall.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"scene={cfg['scene']} maxBatchSize={mb} targetTier={cfg['targetTier']} budget={budget}")
for d in variants:
print(f" [{d['id']}] slots={d['slots']} instances={d['instances']} opaque={d['opaque']} "
f"shadow={d['shadow']} total={d['total']} floor={d['floor']} "
f"couplingCost={d['couplingCost']} pass={d['pass']}")
for s in d["perSlot"]:
print(f" {s['slot']:<14} n={s['instances']:<4} shadow={s['shadowInstances']:<4} "
f"opaqueBatch={s['opaqueBatches']} shadowBatch={s['shadowBatches']}")
print("wrote out/sp07_drawcall.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp07_drawcall.py:
scene=inn-lobby-dusk maxBatchSize=64 targetTier=low budget=12
[base] slots=7 instances=381 opaque=9 shadow=2 total=11 floor=6 couplingCost=3 pass=True
char_skin n=9 shadow=9 opaqueBatch=1 shadowBatch=1
emissive n=12 shadow=0 opaqueBatch=1 shadowBatch=0
furn_opaque n=48 shadow=48 opaqueBatch=1 shadowBatch=1
tile_floor n=96 shadow=0 opaqueBatch=2 shadowBatch=0
tile_wall n=64 shadow=0 opaqueBatch=1 shadowBatch=0
ui_atlas n=128 shadow=0 opaqueBatch=2 shadowBatch=0
vfx_additive n=24 shadow=0 opaqueBatch=1 shadowBatch=0
[split-furn-3] slots=9 instances=381 opaque=11 shadow=4 total=15 floor=6 couplingCost=5 pass=False
char_skin n=9 shadow=9 opaqueBatch=1 shadowBatch=1
emissive n=12 shadow=0 opaqueBatch=1 shadowBatch=0
furn_fabric n=10 shadow=10 opaqueBatch=1 shadowBatch=1
furn_metal n=14 shadow=14 opaqueBatch=1 shadowBatch=1
furn_wood n=24 shadow=24 opaqueBatch=1 shadowBatch=1
tile_floor n=96 shadow=0 opaqueBatch=2 shadowBatch=0
tile_wall n=64 shadow=0 opaqueBatch=1 shadowBatch=0
ui_atlas n=128 shadow=0 opaqueBatch=2 shadowBatch=0
vfx_additive n=24 shadow=0 opaqueBatch=1 shadowBatch=0
wrote out/sp07_drawcall.json
读法:base 变体 7 个槽、381 个实例,不透明 9 次 + 阴影 2 次 = 11 次 ≤ 12,过。split-furn-3 变体实例数一个没变(还是 381),只是槽从 7 个变成 9 个,不透明就涨到 11、阴影涨到 4,总共 15 次 > 12,爆。耦合代价从 3 涨到 5——多出来的两次,就是家具那三张材质槽凭空加的。这就是「材质槽是预算项」的直接证据:实例数一个没动,Draw Call 多花了四次。
7.2.5 P 层内核
同一套合批规则,TypeScript 侧独立实现一遍,只读同一份配置:
// file: core/src/sp07/drawcall.ts
// tier: P
// verify: node --test 'core/test/sp07_*.test.ts'
// 7.2 材质槽 → Draw Call 耦合账的 TS 内核。与 sim/sp07_drawcall.py 同一套规则:
// 同槽实例合成批次、每批上限 maxBatchSize(整数上取整),跨槽不合批;阴影 pass 只算
// castsShadow=true 的实例。耦合代价 = 不透明 pass 实际 drawcall − ceil(总实例/批上限)。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Renderer = { id: string; slot: string; count: number; castsShadow: boolean };
export type SlotRow = { slot: string; instances: number; shadowInstances: number; opaqueBatches: number; shadowBatches: number };
export type DrawCallAccount = {
slots: number; instances: number; opaque: number; shadow: number;
total: number; floor: number; couplingCost: number; perSlot: SlotRow[];
};
export function ceilDiv(a: number, b: number): number {
return Math.floor((a + b - 1) / b);
}
export function drawCalls(renderers: Renderer[], maxBatch: number): DrawCallAccount {
const slots: Record<string, { instances: number; castsShadow: number }> = {};
for (const r of renderers) {
const s = (slots[r.slot] ??= { instances: 0, castsShadow: 0 });
s.instances += r.count;
if (r.castsShadow) s.castsShadow += r.count;
}
let opaque = 0;
let shadow = 0;
let totalInstances = 0;
const perSlot: SlotRow[] = [];
for (const name of Object.keys(slots).sort()) {
const s = slots[name];
const ob = ceilDiv(s.instances, maxBatch);
const sb = s.castsShadow ? ceilDiv(s.castsShadow, maxBatch) : 0;
opaque += ob;
shadow += sb;
totalInstances += s.instances;
perSlot.push({ slot: name, instances: s.instances, shadowInstances: s.castsShadow, opaqueBatches: ob, shadowBatches: sb });
}
const floor = ceilDiv(totalInstances, maxBatch);
return { slots: Object.keys(slots).length, instances: totalInstances, opaque, shadow,
total: opaque + shadow, floor, couplingCost: opaque - floor, perSlot };
}
7.2.6 P 层测试:负例是核心
测试读 Python 落盘的 JSON 做跨语言对照,并用负例钉死结论——拆槽的变体必须真的超预算:
// file: core/test/sp07_drawcall.test.ts
// tier: P
// verify: node --test 'core/test/sp07_*.test.ts'
// 跨语言对照:读 sim/sp07_drawcall.py 落盘的 out/sp07_drawcall.json,断言 TS 侧的材质槽
// → Draw Call 耦合账逐位一致,并带负例:拆槽的变体必须真的超预算。
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 { drawCalls, ceilDiv } from "../src/sp07/drawcall.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp07_drawcall.json"), "utf8"));
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp07_drawcall.json"), "utf8"));
const R = (id: string, slot: string, count: number, castsShadow: boolean) => ({ id, slot, count, castsShadow });
test("Draw Call 耦合账:TS 复算与 Python 产物逐位一致", () => {
assert.equal(py.maxBatchSize, cfg.maxBatchSize);
assert.equal(py.budget, cfg.drawCallBudget[cfg.targetTier]);
assert.equal(py.slotCapFromSpec, spec.budget.materialSlotsLow);
cfg.variants.forEach((v: any, i: number) => {
const mine: any = drawCalls(v.renderers, cfg.maxBatchSize);
const theirs = py.variants[i];
for (const k of ["slots", "instances", "opaque", "shadow", "total", "floor", "couplingCost"]) {
assert.equal(mine[k], theirs[k], `${v.id} ${k}`);
}
assert.deepEqual(mine.perSlot, theirs.perSlot, `${v.id} perSlot`);
});
});
test("负例:把家具拆成三个材质槽,场景必须真的超预算", () => {
const base = py.variants.find((v: any) => v.id === "base");
const split = py.variants.find((v: any) => v.id === "split-furn-3");
assert.equal(base.pass, true);
assert.equal(base.total, 11);
assert.equal(split.pass, false);
assert.equal(split.total, 15);
assert.ok(split.total > split.budget);
assert.ok(split.couplingCost > base.couplingCost);
});
test("正例:同槽合批、跨槽必拆、超批上限才拆批、阴影只算投射实例", () => {
const same = drawCalls([R("a", "s", 30, false), R("b", "s", 30, false)], 64);
assert.equal(same.opaque, 1);
assert.equal(same.slots, 1);
const cross = drawCalls([R("a", "s1", 30, false), R("b", "s2", 30, false)], 64);
assert.equal(cross.opaque, 2);
assert.equal(cross.floor, 1);
assert.equal(cross.couplingCost, 1);
assert.equal(drawCalls([R("a", "s", 65, false)], 64).opaque, 2);
assert.equal(ceilDiv(65, 64), 2);
assert.equal(ceilDiv(64, 64), 1);
const sh = drawCalls([R("floor", "tile", 96, false), R("furn", "furn", 48, true)], 64);
assert.equal(sh.perSlot.find((s) => s.slot === "tile")?.shadowBatches, 0);
assert.equal(sh.perSlot.find((s) => s.slot === "furn")?.shadowBatches, 1);
assert.equal(sh.shadow, 1);
});
实跑 node --test 'core/test/sp07_*.test.ts'(此处只列本节相关的 3 条):
✔ Draw Call 耦合账:TS 复算与 Python 产物逐位一致
✔ 负例:把家具拆成三个材质槽,场景必须真的超预算
✔ 正例:同槽合批、跨槽必拆、超批上限才拆批、阴影只算投射实例
ℹ tests 11
ℹ pass 11
ℹ fail 0
7.2.7 边界与取舍
- 真实 Draw Call 只能由引擎 Profiler 采集(RenderDoc / Unity Frame Debugger / Xcode GPU Capture),本机无引擎、无 Profiler,标 X:需真实环境,命令行不可复现。本节是批次估算模型,不是采集结果。
- 模型只保留材质槽这一个维度。真实合批还受顶点数上限、shader 变体、混合模式、光照贴图一致性影响——同槽也可能拆、跨槽偶尔也能合(静态合批)。故本节数字是上界口径的估算。
- 全部整数:上取整用
(n + b - 1) // b,TS 侧用Math.floor((n + b - 1) / b),逐位一致。 - 与 7.1 的衔接:7.1 的材质槽上限(2 / 3 / 4)在这里变成真金白银——每多一个槽,场景就多至少一次 Draw Call。7.3 会把「合槽」列为一条可裁动作。
阅读导航:上一节:7.1 按角色分级的面数与骨骼预算 · 下一节:7.3 预算超支的归因与裁剪顺序 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。