10.3 图集与批次的耦合
上一节算的是「一图集能装多满」,这一节算的是「图集怎么切,drawcall 会不会涨」。这两件事是一枚硬币的两面:图集越大,合批越容易,但单张越空、更新越贵;图集切得越细,更新越便宜,但同一个画面要跨多张图集,drawcall 就上去了。行业里讲性能预算的那篇短文(game-performance-budget-mobile-pc-2026 )把「drawcall 要压住」讲清楚了,合批在引擎里的实现(RHI、材质状态、排序)另有归属,见 graphics-mobile-rendering-optimization ;本节只做美术侧的一件事:把「图集怎么切」翻译成可跑的 drawcall 账。
核心主张一句:图集切分不是审美决定,是一道「装箱率 vs drawcall」的整数取舍题。
10.3.1 合批的判据只有一条
一批精灵能不能合成一个 drawcall,取决于它们之间有没有需要切换的渲染状态。把问题简化到图集这一层,判据只有一条:
同一个图集里的精灵可以合批;跨图集必须拆。
于是「一个画面的 drawcall 数」= 该画面出现的不同图集数(前提是渲染前按图集排过序)。这条判据是下界:真实引擎还会因为材质、混合模式、层级交错而拆更多批——那部分归引擎侧,本节不碰。本节只用这条判据,把「图集划分」与「drawcall」接起来。
10.3.2 配置:图标、画面与三种切分策略
D 层配置给 16 个图标各打上 kind 与 theme 两个标签,再定义 5 个 UI 画面,以及三种把图标划进图集的策略:
{
"project": "stardust-inn",
"specVersion": 1,
"note": "图集与批次耦合:16 个图标各带 kind 与 theme,5 个 UI 画面(frame)各展示一个子集。策略按不同键把图标划分到图集:single=全部一图集,byKind=按类型,byTheme=按主题。drawcall 数 = 一个画面里出现的不同图集数(假设按图集排序后渲染)。全整数。",
"atlasSize": 1024,
"gutter": 2,
"icons": [
{ "id": "furn_lamp-base_v01", "kind": "furniture", "theme": "cozy-clean", "w": 64, "h": 64 },
{ "id": "furn_rug-round_v02", "kind": "furniture", "theme": "cozy-clean", "w": 128, "h": 64 },
{ "id": "furn_chair-base_v01", "kind": "furniture", "theme": "cozy-clean", "w": 64, "h": 64 },
{ "id": "furn_wardrobe-large_v01", "kind": "furniture", "theme": "lowpoly", "w": 128, "h": 128 },
{ "id": "char_innkeeper-idle_v01", "kind": "character", "theme": "cozy-clean", "w": 128, "h": 128 },
{ "id": "char_guest-idle_v01", "kind": "character", "theme": "cozy-clean", "w": 128, "h": 128 },
{ "id": "ui_panel-wood_v03", "kind": "ui", "theme": "handdrawn", "w": 128, "h": 128 },
{ "id": "ui_slot-class_v01", "kind": "ui", "theme": "handdrawn", "w": 64, "h": 64 },
{ "id": "tile_floor-plank_v01", "kind": "tile", "theme": "cozy-clean", "w": 64, "h": 64 },
{ "id": "tile_grass-dark_v01", "kind": "tile", "theme": "cozy-clean", "w": 64, "h": 64 },
{ "id": "icon_coin-gold_v01", "kind": "icon", "theme": "pixel", "w": 64, "h": 64 },
{ "id": "icon_star-line_v01", "kind": "icon", "theme": "pixel", "w": 64, "h": 64 },
{ "id": "icon_badge-star_v01", "kind": "icon", "theme": "cozy-clean", "w": 32, "h": 32 },
{ "id": "icon_gem-cut_v01", "kind": "icon", "theme": "voxel", "w": 64, "h": 64 },
{ "id": "vfx_steam-puff_v01", "kind": "vfx", "theme": "cozy-clean", "w": 64, "h": 64 },
{ "id": "vfx_magic-spark_v01", "kind": "vfx", "theme": "retro-cyber", "w": 64, "h": 64 }
],
"frames": [
{ "id": "shop", "sprites": ["furn_lamp-base_v01", "furn_rug-round_v02", "icon_coin-gold_v01", "ui_panel-wood_v03", "ui_slot-class_v01"] },
{ "id": "inventory", "sprites": ["icon_coin-gold_v01", "icon_star-line_v01", "icon_badge-star_v01", "ui_slot-class_v01", "furn_chair-base_v01"] },
{ "id": "map", "sprites": ["tile_floor-plank_v01", "tile_grass-dark_v01", "char_innkeeper-idle_v01", "char_guest-idle_v01"] },
{ "id": "inn", "sprites": ["char_innkeeper-idle_v01", "vfx_steam-puff_v01", "ui_panel-wood_v03", "furn_wardrobe-large_v01"] },
{ "id": "shop-special", "sprites": ["icon_gem-cut_v01", "vfx_magic-spark_v01", "furn_lamp-base_v01"] }
],
"strategies": [
{ "id": "single", "key": "all" },
{ "id": "byKind", "key": "kind" },
{ "id": "byTheme", "key": "theme" }
]
}
三种策略的差别只在「按哪个键分组」:single 全部一图集,byKind 按类型(家具/角色/UI/地块/图标/特效),byTheme 按主题(cozy-clean/pixel/handdrawn/…)。键不同,切出来的图集就不同,drawcall 也就不同。
10.3.3 S 层:Python drawcall 账
下面这段是 S 层脚本,对每种策略算图集数、每画面 drawcall、总 drawcall,并复用 10.2 的货架装箱算加权装箱率:
#!/usr/bin/env python3
# file: sim/sp10_batch.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp10_batch.py
"""图集与批次的耦合(S 层,第 10 章)。
读 config/sp10_batch.json:16 个图标(kind/theme/尺寸)+ 5 个 UI 画面 + 3 种切分策略。
每种策略算:atlasCount(分进几个图集)、perFrame(每画面出现的不同图集数 = drawcall)、
totalDrawcalls、permille(各图集货架装箱后的加权装箱率,千分比整数)。
复用 sim/sp10_atlas.py 的确定性 shelf_pack_var。全整数 → 与 TS 逐位一致。落盘 out/sp10_batch.json。
"""
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
from sp10_atlas import shelf_pack_var # noqa: E402
CFG = os.path.join(ROOT, "config/sp10_batch.json")
OUT = os.path.join(ROOT, "out")
def atlas_key(icon: dict, key: str) -> str:
return "__all__" if key == "all" else icon[key]
def main() -> int:
cfg = json.load(open(CFG, encoding="utf-8"))
os.makedirs(OUT, exist_ok=True)
icons = {i["id"]: i for i in cfg["icons"]}
size, gutter = cfg["atlasSize"], cfg["gutter"]
strategies = []
for st in cfg["strategies"]:
key = st["key"]
groups = {}
for ic in cfg["icons"]:
groups.setdefault(atlas_key(ic, key), []).append(
{"id": ic["id"], "w": ic["w"], "h": ic["h"]})
per_frame, total = [], 0
for fr in cfg["frames"]:
dc = len({atlas_key(icons[sid], key) for sid in fr["sprites"]})
per_frame.append({"id": fr["id"], "drawcalls": dc})
total += dc
used = sum(shelf_pack_var(g, size, size, gutter)[3] for g in groups.values())
strategies.append({
"id": st["id"], "key": key, "atlasCount": len(groups),
"atlasIds": sorted(groups.keys()), "perFrame": per_frame,
"totalDrawcalls": total, "usedArea": used,
"permille": (used * 1000) // (len(groups) * size * size),
})
summary = {"project": cfg["project"], "iconCount": len(cfg["icons"]),
"frameCount": len(cfg["frames"]), "atlasSize": size, "strategies": strategies}
with open(os.path.join(OUT, "sp10_batch.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"iconCount={len(cfg['icons'])} frameCount={len(cfg['frames'])}")
for s in strategies:
print(f" {s['id']:8s} atlases={s['atlasCount']} "
f"totalDrawcalls={s['totalDrawcalls']} permille={s['permille']}")
print("wrote out/sp10_batch.json")
return 0
if __name__ == "__main__":
sys.exit(main())
注意 permille 的分母是 图集数 × 图集面积:切成 6 张图集时,分母是 6 个 1024²——这正是切分的代价,每张图集各自装不满。
10.3.4 P 层:TS 内核
TS 侧复用 core/src/sp10/atlas.ts 的 shelfPackVar,独立实现「按键分组 → 数不同图集」:
// file: core/src/sp10/batch.ts
// tier: P
// verify: node --test 'core/test/sp10_*.test.ts'
// 图集与批次耦合的 TS 侧实现:按策略键把图标划进图集,画面里不同图集数 = drawcall 数。
// 复用 core/src/sp10/atlas.ts 的确定性货架装箱算加权装箱率。全整数 → 与 Python 逐位一致。
import { shelfPackVar } from "./atlas.ts";
export type BatchIcon = { id: string; kind: string; theme: string; w: number; h: number };
export type BatchConfig = {
atlasSize: number; gutter: number; icons: BatchIcon[];
frames: { id: string; sprites: string[] }[];
strategies: { id: string; key: string }[];
};
export type StrategyResult = {
key: string; atlasCount: number; atlasIds: string[];
perFrame: { id: string; drawcalls: number }[]; totalDrawcalls: number;
usedArea: number; permille: number;
};
export function atlasKey(icon: BatchIcon, key: string): string {
return key === "all" ? "__all__" : (icon as unknown as Record<string, string>)[key];
}
export function evaluateStrategy(cfg: BatchConfig, key: string): StrategyResult {
const icons = new Map(cfg.icons.map((i) => [i.id, i]));
const groups = new Map<string, { id: string; w: number; h: number }[]>();
for (const ic of cfg.icons) {
const k = atlasKey(ic, key);
if (!groups.has(k)) groups.set(k, []);
groups.get(k)!.push({ id: ic.id, w: ic.w, h: ic.h });
}
const perFrame: { id: string; drawcalls: number }[] = [];
let total = 0;
for (const fr of cfg.frames) {
const n = new Set(fr.sprites.map((sid) => atlasKey(icons.get(sid)!, key))).size;
perFrame.push({ id: fr.id, drawcalls: n });
total += n;
}
let usedArea = 0;
for (const g of groups.values()) usedArea += shelfPackVar(g, cfg.atlasSize, cfg.atlasSize, cfg.gutter).usedArea;
return {
key, atlasCount: groups.size, atlasIds: [...groups.keys()].sort(), perFrame,
totalDrawcalls: total, usedArea,
permille: Math.floor((usedArea * 1000) / (groups.size * cfg.atlasSize * cfg.atlasSize)),
};
}
export function evaluateAll(cfg: BatchConfig): StrategyResult[] {
return cfg.strategies.map((s) => evaluateStrategy(cfg, s.key));
}
10.3.5 P 层测试:读 Python 产物
测试读 Python 落盘的 out/sp10_batch.json,逐策略对照,并带「同图集合批、跨图集拆批」的负例:
// file: core/test/sp10_batch.test.ts
// tier: P
// verify: node --test 'core/test/sp10_*.test.ts'
// 跨语言对照:读 sim/sp10_batch.py 落盘的 out/sp10_batch.json,断言 TS 侧 evaluateAll
// 复算出的 atlasCount/perFrame/totalDrawcalls/permille 与之一致(整数类,逐位一致)。
// 带负例断言:同图集可合批、跨图集必须拆。
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 { evaluateAll } from "../src/sp10/batch.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp10_batch.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp10_batch.json"), "utf8"));
const digest = (ls: string[]): string =>
createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);
test("跨语言:TS evaluateAll == Python(逐位一致)", () => {
const mine = evaluateAll(cfg);
assert.equal(mine.length, py.strategies.length);
const lines: string[] = [];
for (let i = 0; i < mine.length; i += 1) {
const a = mine[i], b = py.strategies[i];
assert.equal(a.key, b.key);
assert.equal(a.atlasCount, b.atlasCount, `${b.id} atlasCount`);
assert.deepEqual(a.atlasIds, b.atlasIds);
assert.deepEqual(a.perFrame, b.perFrame, `${b.id} perFrame`);
assert.equal(a.totalDrawcalls, b.totalDrawcalls, `${b.id} totalDrawcalls`);
assert.equal(a.usedArea, b.usedArea);
assert.equal(a.permille, b.permille);
lines.push(`${b.id}:${a.atlasCount}:${a.totalDrawcalls}:${a.permille}`);
}
console.log(`batch digest = ${digest(lines)}`);
});
test("负例:同图集合批、跨图集拆批,一张大图集 drawcall 最少", () => {
const all = evaluateAll(cfg);
const byKey = (k: string) => all.find((s) => s.key === k)!;
const single = byKey("all"), byKind = byKey("kind"), byTheme = byKey("theme");
assert.equal(single.atlasCount, 1);
assert.equal(single.totalDrawcalls, cfg.frames.length);
assert.ok(byKind.totalDrawcalls > single.totalDrawcalls);
assert.ok(byTheme.totalDrawcalls > single.totalDrawcalls);
assert.ok(byKind.permille < single.permille);
const mapTheme = byTheme.perFrame.find((f) => f.id === "map")!;
const mapKind = byKind.perFrame.find((f) => f.id === "map")!;
assert.equal(mapTheme.drawcalls, 1);
assert.equal(mapKind.drawcalls, 2);
});
10.3.6 实跑输出
S 层 python3 sim/sp10_batch.py 的真实输出:
iconCount=16 frameCount=5
single atlases=1 totalDrawcalls=5 permille=110
byKind atlases=6 totalDrawcalls=15 permille=18
byTheme atlases=6 totalDrawcalls=13 permille=18
wrote out/sp10_batch.json
P 层 node --test 'core/test/sp10_*.test.ts':pass 8 / fail 0。跨语言 digest 620670acf1b68b5f——整数类,逐位一致。
10.3.7 取舍:装箱率 vs drawcall
把三套策略摆在一起,取舍一目了然:
| 策略 | 图集数 | 总 drawcall | 装箱率‰ | 更新粒度 | 适合 |
|---|---|---|---|---|---|
single | 1 | 5 | 110 | 整张重传 | 图标总量小、追求帧率 |
byKind | 6 | 15 | 18 | 按类型 | 类型间不同渲染状态 |
byTheme | 6 | 13 | 18 | 按主题 | 主题包独立热更 |
逐画面的 drawcall(single 全是 1;byKind = 3,3,2,4,3;byTheme = 3,3,1,3,3)揭示了一个反直觉的点:byTheme 的总 drawcall 比 byKind 低(13 < 15),因为 map 画面里的地块与角色恰好都是 cozy-clean 主题,同主题自然合批,塌成了 1 个 drawcall;而按类型切时,地块和角色分属两张图集,必须拆成 2 个。切分键选得好,能顺带把某些画面合批。
10.3.8 三条落地建议
一,先按「同屏共现」切,别按「目录结构」切。 类型/主题是整理资产用的标签,不等于同屏共现;把常在同一个画面里出现的图标放进同一张图集,drawcall 才降得下来。
二,single 只在图标总量小的时候用。 本节 single 的装箱率 110‰、总 drawcall 5——但真实项目图标上千个,一张图集会撑爆显存、且任何一处改动都要重传整张。大图集的代价不在 drawcall,在更新与显存(见 10.2 的四档扫描)。
三,切分是「装箱率换更新粒度」的交易,不是纯优化。 切得越细,单张越空(装箱率从 110‰ 掉到 18‰),但热更时只重传改动的那一张。这笔账要按「多久改一次」来算,不能只看 drawcall。
下一节进入角色与场景规范:图集把图标归了类,接下来要处理会动、会遮、会有剪影的角色与场景资产。
阅读导航:上一节:10.2 图集打包与装箱率 · 下一节:11.1 剪影与可读性度量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。