7.2 材质槽与 Draw Call 预算

材质槽不是美术的内部细节,它直接决定 Draw Call:同一材质槽内可合批,跨槽必须拆。本节用《星屑旅店》大堂场景做一份可跑的耦合账——同槽实例按批上限合批、跨槽各算各的,并给出「家具拆成三个材质槽就超预算」的反例,S 层与 P 层结果逐位一致。

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 预算超支的归因与裁剪顺序 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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