10.3 图集与批次的耦合

图集怎么切,直接决定 drawcall:同一图集里的精灵可合批,跨图集必须拆。本节给出「按图集划分 → drawcall 数」的可跑账,并对比一张大图集与按类型/按主题切成多张小图集两套策略——装箱率与 drawcall 是一对必须一起算的取舍。

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装箱率‰更新粒度适合
single15110整张重传图标总量小、追求帧率
byKind61518按类型类型间不同渲染状态
byTheme61318按主题主题包独立热更

逐画面的 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 剪影与可读性度量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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