10.2 图集打包与装箱率

把一堆大小不一的图标装进 1024×1024 图集,并用一个千分比整数衡量装得好不好:先复现共享真源的基线 256/37/144,再用确定性货架装箱(高度降序 + 固定 tie-break)把 24 个混合尺寸图标装进 256/512/1024/2048 四档,给出可跑的 Python + TypeScript 装箱账。

10.2 图集打包与装箱率

图集(atlas)是把许多小图拼进一张大图,让它们能被一次 drawcall 画完的资产。它值不值得做、做多大、装得多满,本质是一道整数装箱题。行业里那篇讲移动/PC 性能预算的短文(game-performance-budget-mobile-pc-2026 )把「图集能省 drawcall」讲清楚了,它管的是引擎侧的账;纹理显存与压缩格式另属 graphics-texture-memory 。本节只做美术侧的一件事:给定一组图标,算出它们能装进多大的图集、装得有多满——并且让这个数字可复现、可对照。

核心主张一句:装箱率必须是整数千分比,装箱算法必须确定性——否则跨语言、跨机器对不上账。

10.2.1 先复现共享真源的基线

本书有一份共享真源 config/visual_spec.json,它的 atlas 段写着 width=1024 / height=1024 / cell=64 / icons=37。与它配套的 atlas_slots / shelf_pack 口径(见 sim/consume.py 与 core/src/sp00/spec.ts)算出三个基线整数:

atlas slots=256 placed=37 permille=144

含义是:1024×1024 按 64 的格子切,有 (1024//64)×(1024//64) = 256 个槽;37 个图标每个占一格,占掉 37×64×64 = 151552 像素;装箱率 151552×1000 // 1048576 = 144(千分比)。本节第一件事就是复现这三个数——只有先对得上基线,后面的推广才可信。

10.2.2 配置:混合尺寸的真实图标集

真实图标集不会全是一个尺寸。下面是 D 层的配置,24 个图标混了 64/128/32/128×64 四种尺寸:

{
  "project": "stardust-inn",
  "specVersion": 1,
  "note": "图集打包的图标集:24 个图标,尺寸混合(64/128/32/128x64)。货架装箱按高度降序、同高按宽度降序、再按 id 升序——完全确定性,禁随机。gutter=2 是图块间隔。与 config/visual_spec.json 的 atlas 段共用同一套 slots/permille 口径。",
  "atlasSizes": [256, 512, 1024, 2048],
  "gutter": 2,
  "items": [
    { "id": "icon_coin-gold_v01", "w": 64, "h": 64 },
    { "id": "icon_coin-silver_v01", "w": 64, "h": 64 },
    { "id": "icon_heart-full_v01", "w": 64, "h": 64 },
    { "id": "icon_star-line_v01", "w": 64, "h": 64 },
    { "id": "icon_bell-bold_v01", "w": 64, "h": 64 },
    { "id": "icon_bag-wide_v01", "w": 64, "h": 64 },
    { "id": "icon_key-small_v01", "w": 64, "h": 64 },
    { "id": "icon_leaf-mono_v01", "w": 64, "h": 64 },
    { "id": "icon_gem-cut_v01", "w": 64, "h": 64 },
    { "id": "icon_mug-cocoa_v01", "w": 64, "h": 64 },
    { "id": "icon_quill_v01", "w": 64, "h": 64 },
    { "id": "icon_lantern_v01", "w": 64, "h": 64 },
    { "id": "char_innkeeper-portrait_v01", "w": 128, "h": 128 },
    { "id": "char_guest-portrait_v01", "w": 128, "h": 128 },
    { "id": "furn_wardrobe-large_v01", "w": 128, "h": 128 },
    { "id": "furn_bed-canopy_v01", "w": 128, "h": 128 },
    { "id": "ui_shop-panel_v01", "w": 128, "h": 128 },
    { "id": "vfx_hearth-glow_v01", "w": 128, "h": 128 },
    { "id": "icon_badge-star_v01", "w": 32, "h": 32 },
    { "id": "icon_badge-heart_v01", "w": 32, "h": 32 },
    { "id": "icon_dot-ready_v01", "w": 32, "h": 32 },
    { "id": "icon_dot-busy_v01", "w": 32, "h": 32 },
    { "id": "furn_rug-wide_v01", "w": 128, "h": 64 },
    { "id": "ui_banner-wide_v01", "w": 128, "h": 64 }
  ]
}

24 个图标的总面积是 12×64² + 6×128² + 4×32² + 2×128×64 = 167936 像素——这个数是固定的,装箱率只取决于它除以图集面积。

10.2.3 为什么必须用确定性的货架算法

装箱是 NP-hard,工程里用启发式。本书选货架装箱(shelf packing),因为它天然确定:把所有矩形按 高度降序 → 宽度降序 → id 升序 排好,从左到右摆;摆不下就换一行(新货架),高度不够就溢出。没有 Math.random(),没有打乱,没有「大致放一下」——否则 Python 与 TS 摆出来的位置不可能一致,10.1 建立的「整数地址」也就白建了。

tie-break 是这套算法唯一需要写死的地方:同高同宽的图标必须按 id 字典序排,否则两个语言可能各按各的默认顺序,摆出不同结果。

10.2.4 S 层:Python 装箱账

下面这段是 S 层脚本,先复现基线,再把 24 个图标装进四档图集并算装箱率:

#!/usr/bin/env python3
# file: sim/sp10_atlas.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp10_atlas.py
"""图集装箱率(S 层,第 10 章)。

1) 复现共享真源基线:读 config/visual_spec.json 的 atlas 段(1024/1024/64/37),用与
   consume.py 同源的 atlas_slots / shelf_pack 口径算出 slots=256 placed=37 permille=144。
2) 推广到真实图标集:读 config/sp10_atlas.json,用确定性货架装箱(高度降序、同高宽度
   降序、再按 id 升序)把 24 个混合尺寸图标装进 256/512/1024/2048 图集,算
   placed / usedArea / permille / shelves / overflow。全整数、无随机 → 与 TS 逐位一致。
落盘 out/sp10_atlas.json。
"""
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SPEC = os.path.join(ROOT, "config/visual_spec.json")
CFG = os.path.join(ROOT, "config/sp10_atlas.json")
OUT = os.path.join(ROOT, "out")


def atlas_slots(w: int, h: int, cell: int) -> int:
    """与 consume.py / spec.ts 同源:整数地板除。"""
    return (w // cell) * (h // cell)


def shelf_pack(cell: int, count: int, w: int, h: int):
    """与 consume.py / spec.ts 同源:均匀格货架装箱。"""
    placed = min(count, (w // cell) * (h // cell))
    return placed, placed * cell * cell


def shelf_pack_var(items, W: int, H: int, gutter: int):
    """确定性变尺寸货架装箱。排序:高度降序 → 宽度降序 → id 升序(禁随机)。"""
    order = sorted(items, key=lambda it: (-it["h"], -it["w"], it["id"]))
    x = y = shelf_h = shelves = 0
    placed, overflow = [], []
    for it in order:
        if x > 0 and x + it["w"] > W:
            y += shelf_h + gutter
            x = shelf_h = 0
        if y + it["h"] > H:
            overflow.append(it["id"])
            continue
        if x == 0:
            shelves += 1
        placed.append({"id": it["id"], "x": x, "y": y, "w": it["w"], "h": it["h"]})
        x += it["w"] + gutter
        shelf_h = max(shelf_h, it["h"])
    used = sum(p["w"] * p["h"] for p in placed)
    return placed, overflow, shelves, used


def main() -> int:
    spec = json.load(open(SPEC, encoding="utf-8"))
    cfg = json.load(open(CFG, encoding="utf-8"))
    os.makedirs(OUT, exist_ok=True)
    at = spec["atlas"]
    base_slots = atlas_slots(at["width"], at["height"], at["cell"])
    base_placed, base_used = shelf_pack(at["cell"], at["icons"], at["width"], at["height"])
    items, gutter = cfg["items"], cfg["gutter"]
    sweeps = []
    for size in cfg["atlasSizes"]:
        placed, overflow, shelves, used = shelf_pack_var(items, size, size, gutter)
        sweeps.append({
            "size": size, "slots": atlas_slots(size, size, 64), "placed": len(placed),
            "overflow": len(overflow), "shelves": shelves, "usedArea": used,
            "permille": (used * 1000) // (size * size), "overflowIds": sorted(overflow),
        })
    summary = {
        "project": cfg["project"], "itemCount": len(items),
        "itemArea": sum(i["w"] * i["h"] for i in items), "gutter": gutter,
        "baseline": {"width": at["width"], "height": at["height"], "cell": at["cell"],
                     "icons": at["icons"], "slots": base_slots, "placed": base_placed,
                     "usedArea": base_used, "permille": (base_used * 1000) // (at["width"] * at["height"])},
        "sweeps": sweeps,
    }
    with open(os.path.join(OUT, "sp10_atlas.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"baseline slots={base_slots} placed={base_placed} permille={summary['baseline']['permille']}")
    print(f"itemCount={len(items)} itemArea={summary['itemArea']} gutter={gutter}")
    for s in sweeps:
        print(f"  {s['size']}x{s['size']}: placed={s['placed']} overflow={s['overflow']} "
              f"shelves={s['shelves']} permille={s['permille']}")
    print("wrote out/sp10_atlas.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

atlas_slots 与 shelf_pack 是刻意与 consume.py 逐字同源的:复现基线靠的就是同一套整数地板除。

10.2.5 P 层:TS 内核

TS 侧直接 import 共享真源 core/src/sp00/spec.ts 的 atlasSlots / shelfPack,再实现自己的 shelfPackVar——这样基线的三个数在 TS 里也是同一份口径算出来的,不是「抄一遍」:

// file: core/src/sp10/atlas.ts
// tier: P
// verify: node --test 'core/test/sp10_*.test.ts'
// 图集装箱率的 TS 侧实现:复用共享真源 core/src/sp00/spec.ts 的 atlasSlots / shelfPack
// 复现基线,再实现确定性变尺寸货架装箱 shelfPackVar。全整数、无随机 → 与 Python 逐位一致。
import { atlasSlots, shelfPack } from "../sp00/spec.ts";

export type Item = { id: string; w: number; h: number };
export type Placed = { id: string; x: number; y: number; w: number; h: number };

export { atlasSlots, shelfPack };

/** 确定性变尺寸货架装箱:高度降序 → 宽度降序 → id 升序(固定 tie-break,禁随机)。 */
export function shelfPackVar(items: Item[], W: number, H: number, gutter: number): {
  placed: Placed[]; overflow: string[]; shelves: number; usedArea: number;
} {
  const order = [...items].sort((a, b) =>
    b.h - a.h || b.w - a.w || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
  let x = 0, y = 0, shelfH = 0, shelves = 0;
  const placed: Placed[] = [], overflow: string[] = [];
  for (const it of order) {
    if (x > 0 && x + it.w > W) {
      y += shelfH + gutter;
      x = 0;
      shelfH = 0;
    }
    if (y + it.h > H) {
      overflow.push(it.id);
      continue;
    }
    if (x === 0) shelves += 1;
    placed.push({ id: it.id, x, y, w: it.w, h: it.h });
    x += it.w + gutter;
    if (it.h > shelfH) shelfH = it.h;
  }
  const usedArea = placed.reduce((s, p) => s + p.w * p.h, 0);
  return { placed, overflow, shelves, usedArea };
}

/** 装箱率千分比(整数地板除)。 */
export function permille(usedArea: number, size: number): number {
  return Math.floor((usedArea * 1000) / (size * size));
}

10.2.6 P 层测试:读 Python 产物

测试读的是 Python 落盘的 out/sp10_atlas.json,断言 TS 复算的 placed/overflow/shelves/permille 与它逐条相同:

// file: core/test/sp10_atlas.test.ts
// tier: P
// verify: node --test 'core/test/sp10_*.test.ts'
// 跨语言对照:读 sim/sp10_atlas.py 落盘的 out/sp10_atlas.json,断言 TS 侧 shelfPackVar
// 复算出的 placed/overflow/shelves/usedArea/permille 与之一致(整数类,逐位一致),
// 并复现共享真源基线(slots=256 placed=37 permille=144)。带确定性负例断言。
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 { shelfPackVar, permille, atlasSlots, shelfPack } from "../src/sp10/atlas.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp10_atlas.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp10_atlas.json"), "utf8"));
const digest = (ls: string[]): string =>
  createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);

test("复现共享真源基线:slots=256 placed=37 permille=144(逐位一致)", () => {
  const at = spec.atlas;
  assert.equal(atlasSlots(at.width, at.height, at.cell), py.baseline.slots);
  const [placed, used] = shelfPack(at.cell, at.icons, at.width, at.height);
  assert.equal(placed, py.baseline.placed);
  assert.equal(used, py.baseline.usedArea);
  assert.equal(Math.floor((used * 1000) / (at.width * at.height)), py.baseline.permille);
  assert.equal(py.baseline.slots, 256);
  assert.equal(py.baseline.permille, 144);
});

test("跨语言:TS shelfPackVar == Python(placed/overflow/shelves/permille,逐位一致)", () => {
  const lines: string[] = [];
  for (const s of py.sweeps as { size: number; placed: number; overflow: number; shelves: number; permille: number }[]) {
    const r = shelfPackVar(cfg.items, s.size, s.size, cfg.gutter);
    assert.equal(r.placed.length, s.placed, `${s.size} placed`);
    assert.equal(r.overflow.length, s.overflow, `${s.size} overflow`);
    assert.equal(r.shelves, s.shelves, `${s.size} shelves`);
    assert.equal(permille(r.usedArea, s.size), s.permille, `${s.size} permille`);
    lines.push(`${s.size}:${r.placed.length}:${r.shelves}:${s.permille}`);
  }
  console.log(`atlas digest = ${digest(lines)}`);
});

test("负例:确定性 tie-break 锁死;小图集必须真的溢出", () => {
  const a = shelfPackVar(cfg.items, 512, 512, cfg.gutter);
  assert.equal(a.placed.length, 24);
  assert.ok(shelfPackVar(cfg.items, 256, 256, cfg.gutter).overflow.length > 0);
  const pos = a.placed.map((p) => `${p.id}@${p.x},${p.y}`);
  assert.deepEqual(pos, shelfPackVar(cfg.items, 512, 512, cfg.gutter).placed.map((p) => `${p.id}@${p.x},${p.y}`));
  assert.equal(pos[0].split("@")[1], "0,0");
  assert.equal(permille(1024 * 1024, 1024), 1000);
});

10.2.7 实跑输出

S 层 python3 sim/sp10_atlas.py 的真实输出:

baseline slots=256 placed=37 permille=144
itemCount=24 itemArea=167936 gutter=2
  256x256: placed=6 overflow=18 shelves=3 permille=437
  512x512: placed=24 overflow=0 shelves=5 permille=640
  1024x1024: placed=24 overflow=0 shelves=3 permille=160
  2048x2048: placed=24 overflow=0 shelves=1 permille=40
wrote out/sp10_atlas.json

P 层 node --test 'core/test/sp10_*.test.ts':pass 8 / fail 0。跨语言 digest f940c52b5e505b32——整数类,逐位一致。

10.2.8 怎么读这四个数

同一组 24 个图标,装箱率随图集变大而单调下降,这是分母在涨:

图集槽位(64格)放下溢出货架装箱率‰结论
256²166183437装不下,只容得下 6 个大图
512²642405640最小可行,装得满
1024²2562403160装得下但很空
2048²1024240140极浪费

结论很直接:这 24 个图标应该放进 512² 图集——它是「装得下」与「装得满」的交点。256² 的 437‰ 看起来高,但那是「只装了 6 个」的假高,18 个图标溢出了。装箱率必须与「放下几个」一起读,单看千分比会骗人。

下一节换一个角度:如果图标不止一组、还有多张图集,图集怎么切会直接影响 drawcall。装箱率与 drawcall 是一对要一起算的账。

阅读导航:上一节:10.1 图标网格与描边规则 · 下一节:10.3 图集与批次的耦合 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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