4.1 图集打包与纹理压缩

图集与纹理是包体里最重的一块:它既是批处理能否发生的前提,也决定了运行时的显存占用。本节用 Pillow 真实生成精灵、跑一遍自写的货架装箱,把打包率、PNG 字节与显存占用都算成确定数字,并给出图集尺寸与纹理格式的取舍判据,让资源预算从手感变成可复算的数字。

本节目标:把「图集怎么拼、纹理用什么格式」从手感问题变成一组可复算的数字——打包率、PNG 字节、显存占用,全部来自本机实跑。

站内有一套 127 篇的 Phaser 引擎专书 ,讲的是引擎 API 的资源加载与使用——this.load.atlas() 怎么调、preload 怎么写、纹理怎么挂到精灵上。本节换一个方向:不教你在某个引擎里怎么加载资源,而是讲可跑的图集打包、体积核算与热更新版本模型——同一批精灵,怎么拼成一张图集、拼得多满、压缩到多少字节、显存占多少、后续怎么增量更新,这些都能在本机算出确定数字。

第 3 章把渲染环境、分辨率适配与批处理讲完了,那里反复提到「合批」能省 draw call。合批的前提是纹理同源:同一张图集里的精灵才能被一次画完。所以资源管线的第一道工序不是「优化」,而是把散图拼成图集——它决定了批处理能不能发生。

4.1 图集打包与纹理压缩

4.1.1 图集解决的不是「少几个文件」

最朴素的图集动机是「少几个 HTTP 请求」,这在小游戏里其实不成立——小游戏的资源走平台的分包与本地缓存,请求数不是瓶颈。图集真正的价值有两个:

  • 合批的前提。WebGL/Canvas 的批处理要求连续绘制共用同一张纹理;散图会让每个精灵各占一次 draw call。图集把「同一屏里的精灵」收进一张纹理,批处理才有机会发生。
  • 显存的可预测性。一张 512×512 的图集,在 GPU 上就是一块固定大小的纹理,不管里面装了多少张精灵。资源规划时你算的是「几张图集 × 每张多大」,而不是「几百张散图各自多大」。

代价是浪费:图集里总有塞不满的空隙,打包率永远到不了 100%。本节要算的,就是这个浪费有多大、值不值得。

4.1.2 D 层:图集规格与纹理格式表

下面这份是 D 层配置:图集边长、内边距、精灵分组规格,以及一张纹理格式对照表。精灵用「分组」描述而不是逐张列出——同一尺寸的精灵只写一次数量,脚本再展开成带名字的清单。

{
  "observation": "公开可观察,观察时点 2026-10;纹理格式的每像素位数为公开规格,实际 PNG 压缩率随画面内容变化",
  "atlas": {
    "width": 512,
    "height": 512,
    "padding": 2,
    "bgGray": 26
  },
  "sheetName": "merge_tiles_atlas",
  "groups": [
    {"prefix": "tile_icon", "w": 128, "h": 128, "count": 3, "hueStart": 0, "hueStep": 30},
    {"prefix": "tile_icon", "w": 96, "h": 96, "count": 5, "hueStart": 15, "hueStep": 30},
    {"prefix": "tile_icon", "w": 80, "h": 80, "count": 6, "hueStart": 8, "hueStep": 30},
    {"prefix": "tile_icon", "w": 64, "h": 64, "count": 7, "hueStart": 0, "hueStep": 30},
    {"prefix": "tile_icon", "w": 48, "h": 48, "count": 10, "hueStart": 12, "hueStep": 30},
    {"prefix": "fx_effect", "w": 32, "h": 32, "count": 15, "hueStart": 0, "hueStep": 24}
  ],
  "textureFormats": [
    {"id": "rgba8888", "label": "RGBA8888 未压缩", "bitsPerPixel": 32},
    {"id": "rgb565", "label": "RGB565 未压缩", "bitsPerPixel": 16},
    {"id": "rgba4444", "label": "RGBA4444 未压缩", "bitsPerPixel": 16},
    {"id": "astc_4x4", "label": "ASTC 4x4", "bitsPerPixel": 8},
    {"id": "astc_6x6", "label": "ASTC 6x6", "bitsPerPixel": 3},
    {"id": "etc2_rgba", "label": "ETC2 RGBA 4bpp", "bitsPerPixel": 4},
    {"id": "pvrtc_4bpp", "label": "PVRTC 4bpp", "bitsPerPixel": 4}
  ]
}

padding 是精灵之间的内边距——它防的是采样时相邻精灵「渗色」(bilinear 采样会读到邻居的像素)。分组里 hueStart / hueStep 只是让程序化生成的精灵颜色错开,方便肉眼区分,与打包逻辑无关。

4.1.3 S 层:Pillow 真跑一遍货架装箱

下面这段是 S 层仿真脚本。它用 Pillow 按分组规格真实生成每一张精灵小图,再用自写的「按高降序的货架装箱」(shelf packing)把它们打进图集:排序后逐张塞进已有货架,塞不下就新开一层。装箱结果会真实粘贴成一张图集 PNG 落盘,同时统计打包率、对比「逐文件 PNG 总字节 vs 单张图集 PNG 字节」,并按纹理格式折算显存。

#!/usr/bin/env python3
# file: sim/e04_atlas.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e04_atlas.py --config config/ --out out/
"""图集打包与纹理体积核算(Pillow 真跑)。

读 config/e04_atlas.json:图集尺寸 + 精灵清单 + 纹理格式表。
1) 用 PIL 按清单**逐张生成**精灵小图(程序化上色,无外部素材);
2) 用自写的「按高降序的货架装箱」(shelf packing)把精灵打进图集,得到每张的 x/y;
3) 把精灵**真实粘贴**进一张图集 PNG 落盘;
4) 统计打包率(已用像素 / 图集像素,千分比),并对比「逐文件 PNG 总字节 vs 单张图集 PNG 字节」;
5) 按各纹理格式的每像素位数折算 GPU 显存占用。
全程整数运算。产出 out/e04_atlas.json + out/e04_atlas.csv + out/e04_atlas.svg + out/e04_atlas.png。
"""

from __future__ import annotations

import argparse
import csv
import colorsys
import json
import os
import sys
import xml.etree.ElementTree as ET

from PIL import Image, ImageDraw

COLORS = ["#2563eb", "#059669", "#dc2626", "#d97706", "#7c3aed", "#0891b2"]


def load(cfg_dir, name):
    with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
        return json.load(f)


def expand_groups(groups):
    """把分组规格展开成带唯一名字的精灵清单(名字 = 前缀 + 两位序号)。"""
    specs = []
    for g in groups:
        for i in range(g["count"]):
            specs.append({
                "name": f"{g['prefix']}_{g['w']:03d}_{i + 1:02d}",
                "w": g["w"], "h": g["h"],
                "hue": (g["hueStart"] + i * g["hueStep"]) % 360,
            })
    return specs


def hue_rgb(hue_deg, sat_pct, val_pct):
    """HSV(整数 0-359 / 0-100) → 8 位 RGB 三元组。"""
    r, g, b = colorsys.hsv_to_rgb(hue_deg / 360, sat_pct / 100, val_pct / 100)
    return (int(r * 255), int(g * 255), int(b * 255))


def make_sprite(spec):
    """按规格程序化生成一张 RGBA 精灵:圆角底 + 描边 + 中心标记。"""
    w, h = spec["w"], spec["h"]
    img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
    d = ImageDraw.Draw(img)
    base = hue_rgb(spec["hue"], 62, 78)
    edge = hue_rgb(spec["hue"], 70, 42)
    inset = max(1, w // 16)
    d.rounded_rectangle((inset, inset, w - 1 - inset, h - 1 - inset),
                        radius=max(2, w // 6), fill=base, outline=edge, width=max(1, w // 32))
    cx, cy, r = w // 2, h // 2, max(2, w // 6)
    mark = hue_rgb(spec["hue"], 30, 96)
    d.ellipse((cx - r, cy - r, cx + r, cy + r), fill=mark)
    return img


def pack_shelf(specs, atlas_w, atlas_h, pad):
    """自写货架装箱:按高降序排,逐张塞进已有货架;放不下就新开一层。

    返回 (placements, ok);placements 里每项含 name/w/h/x/y。
    """
    items = sorted(specs, key=lambda s: (-s["h"], -s["w"], s["name"]))
    shelves = []          # {"y": 顶层 y, "h": 货架高, "x": 下一个可放位置 x}
    placements = []
    for it in items:
        w, h = it["w"], it["h"]
        placed = False
        for sh in shelves:
            if h <= sh["h"] and sh["x"] + w <= atlas_w:
                placements.append({"name": it["name"], "w": w, "h": h, "x": sh["x"], "y": sh["y"]})
                sh["x"] += w + pad
                placed = True
                break
        if placed:
            continue
        top = (shelves[-1]["y"] + shelves[-1]["h"] + pad) if shelves else 0
        if top + h > atlas_h or w > atlas_w:
            return [], False
        shelves.append({"y": top, "h": h, "x": w + pad})
        placements.append({"name": it["name"], "w": w, "h": h, "x": 0, "y": top})
    return placements, True


def build_atlas(placements, specs, atlas_w, atlas_h, bg_gray):
    """把精灵真实粘贴进图集位图,返回 PIL Image。"""
    by_name = {s["name"]: s for s in specs}
    atlas = Image.new("RGBA", (atlas_w, atlas_h), (bg_gray, bg_gray, bg_gray, 255))
    for p in placements:
        atlas.paste(make_sprite(by_name[p["name"]]), (p["x"], p["y"]))
    return atlas


def write_svg(path, placements, atlas_w, atlas_h, used_permille, title):
    scale = 512 / max(atlas_w, atlas_h)
    W = int(atlas_w * scale) + 32
    H = int(atlas_h * scale) + 48
    ox, oy = 16, 32
    p = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">',
         f'<rect width="{W}" height="{H}" fill="#ffffff"/>',
         f'<text x="{ox}" y="20" font-size="13" fill="#111827">{title}</text>',
         f'<rect x="{ox}" y="{oy}" width="{atlas_w * scale:.0f}" height="{atlas_h * scale:.0f}" '
         f'fill="#f3f4f6" stroke="#9ca3af"/>']
    for i, pl in enumerate(placements):
        c = COLORS[i % len(COLORS)]
        x = ox + pl["x"] * scale
        y = oy + pl["y"] * scale
        p.append(f'<rect x="{x:.2f}" y="{y:.2f}" width="{pl["w"] * scale:.2f}" height="{pl["h"] * scale:.2f}" '
                 f'fill="{c}" fill-opacity="0.35" stroke="{c}" stroke-width="1"/>')
    p.append(f'<text x="{ox}" y="{oy + atlas_h * scale + 20:.0f}" font-size="12" fill="#374151">'
             f'图集 {atlas_w}x{atlas_h},已用 {used_permille}permille,{len(placements)} 张精灵</text>')
    p.append("</svg>")
    svg = "\n".join(p)
    with open(path, "w", encoding="utf-8") as f:
        f.write(svg)
    ET.fromstring(svg)
    return len(svg.encode())


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config, "e04_atlas")
    at = cfg["atlas"]
    aw, ah, pad = at["width"], at["height"], at["padding"]
    specs = expand_groups(cfg["groups"])
    os.makedirs(args.out, exist_ok=True)

    placements, ok = pack_shelf(specs, aw, ah, pad)
    if not ok:
        print("FAIL 精灵放不进图集,请调大图集或减少精灵")
        return 1

    sprite_px = sum(s["w"] * s["h"] for s in specs)
    atlas_px = aw * ah
    used_permille = sprite_px * 1000 // atlas_px
    pad_px = sum((p["w"] + pad) * (p["h"] + pad) for p in placements) - sprite_px

    atlas = build_atlas(placements, specs, aw, ah, at["bgGray"])
    atlas_path = os.path.join(args.out, "e04_atlas.png")
    atlas.save(atlas_path)
    atlas_bytes = os.path.getsize(atlas_path)

    sprites_dir = os.path.join(args.out, "e04_sprites")
    os.makedirs(sprites_dir, exist_ok=True)
    separate_bytes = 0
    for s in specs:
        fp = os.path.join(sprites_dir, f"{s['name']}.png")
        make_sprite(s).save(fp)
        separate_bytes += os.path.getsize(fp)

    format_rows = []
    for fmt in cfg["textureFormats"]:
        format_rows.append({
            "id": fmt["id"], "label": fmt["label"], "bitsPerPixel": fmt["bitsPerPixel"],
            "gpuBytes": atlas_px * fmt["bitsPerPixel"] // 8,
        })

    with open(os.path.join(args.out, "e04_atlas.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["name", "w", "h", "x", "y", "areaPx"])
        for p in placements:
            w.writerow([p["name"], p["w"], p["h"], p["x"], p["y"], p["w"] * p["h"]])

    svg_bytes = write_svg(os.path.join(args.out, "e04_atlas.svg"), placements, aw, ah, used_permille,
                          f"《合成小岛》{cfg['sheetName']} 装箱布局")

    report = {
        "source": "sim/e04_atlas.py", "observation": cfg["observation"], "sheetName": cfg["sheetName"],
        "atlas": {"width": aw, "height": ah, "padding": pad, "areaPx": atlas_px},
        "spriteCount": len(specs), "spriteAreaPx": sprite_px, "usedPermille": used_permille,
        "padAreaPx": pad_px,
        "png": {"atlasBytes": atlas_bytes, "separateBytes": separate_bytes,
                "separateCount": len(specs),
                "savePerMille": (separate_bytes - atlas_bytes) * 1000 // max(1, separate_bytes)},
        "textureFormats": format_rows,
        "csv": os.path.join(args.out, "e04_atlas.csv"),
        "svg": os.path.join(args.out, "e04_atlas.svg"), "svgBytes": svg_bytes,
        "pngPath": atlas_path,
    }
    with open(os.path.join(args.out, "e04_atlas.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    print(f"atlas {aw}x{ah} sprites={len(specs)} used={used_permille}permille "
          f"pad={pad_px}px pngBytes={atlas_bytes} separateBytes={separate_bytes}")
    print(f"png save={(separate_bytes - atlas_bytes) * 1000 // max(1, separate_bytes)}permille")
    for r in format_rows:
        print(f"  {r['id']:>10} {r['bitsPerPixel']:>2}bpp gpu={r['gpuBytes']}B")
    print(f"svgBytes={svg_bytes} png={atlas_path}")
    return 0


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

本机实跑输出:

atlas 512x512 sprites=46 used=765permille pad=11192px pngBytes=12889 separateBytes=18421
png save=300permille
    rgba8888 32bpp gpu=1048576B
      rgb565 16bpp gpu=524288B
    rgba4444 16bpp gpu=524288B
    astc_4x4  8bpp gpu=262144B
    astc_6x6  3bpp gpu=98304B
   etc2_rgba  4bpp gpu=131072B
  pvrtc_4bpp  4bpp gpu=131072B
svgBytes=6315 png=out/e04_atlas.png

4.1.4 真实输出:打包率、PNG 字节与显存

逐条读这份输出:

  • 打包率 765‰。46 张精灵的总像素面积占图集面积的 76.5%,剩下 23.5% 是内边距与货架装不满留下的空隙。这是货架装箱的成绩,不是理论最优——MaxRects、断头台式装箱能更高,但货架装箱实现最简单、结果稳定可复现,对一张几百 KB 的图集够用。
  • 图集 PNG 12889 字节 vs 逐文件 18421 字节。46 张散图存成 46 个 PNG 共 18421 字节,合成一张图集后 12889 字节,省下 30.0%。省下来的不是像素——像素一模一样——而是每个 PNG 文件的头部、调色板与块结构开销:46 个文件就有 46 份固定开销,拼成一张只剩一份。
  • 显存占用。512×512 = 262144 像素,按 RGBA8888 就是 1048576 字节(1 MiB)。这张图集的显存是固定的,与里面装了多少张精灵无关。

需要强调:这些字节数是同机相对量级,绝对数值不可移植——换一批精灵、换 Pillow 版本,PNG 压缩率都会变。可移植的是这套流程:配置是输入,打包率与字节数是输出。

4.1.5 纹理压缩:PNG 管文件,GPU 格式管显存

这里要区分两件事,初学者最容易混:

  • PNG 管的是文件字节——它是无损压缩,把图集从磁盘/网络搬到内存时省的是传输与存储。
  • GPU 纹理格式(ASTC / ETC2 / PVRTC 等)管的是显存——它把纹理按块压缩后直接驻留显存,省的是运行时的显存带宽与容量。

上面脚本算出的显存表就是第二种:同样一张 512×512 图集,未压缩 RGBA8888 占 1 MiB,ASTC 6x6 只占 98304 字节(96 KiB)。同一张图,显存差了一个数量级。

但压缩纹理不是白捡的:它需要离线按目标格式预压缩,且不同平台支持的格式不同(iOS 偏好 ASTC/PVRTC,Android 偏好 ETC2,桌面 WebGL 的扩展支持参差)。能否使用某种压缩纹理取决于宿主平台的 WebGL 扩展支持,属于需真实设备验证的项,命令行不可复现。工程上的稳妥策略是:先保证未压缩路径能跑,再对目标平台单独出一份压缩纹理。

4.1.6 三条可复用判断

  1. 图集尺寸贴着内容选。先用基准扫一遍,挑「装得下且打包率最高」的边长,不要凭感觉定 1024。
  2. 内边距是必要的浪费。padding 越小打包率越高,但小到 0 会渗色。2~4 像素是常见折中;换用 mipmap 时要留更多。
  3. 文件压缩与显存压缩分开做。PNG 优化和 GPU 纹理格式解决的是两个不同的问题,别指望 PNG 能省显存。

小结

  • 图集的价值是合批的前提与显存的可预测性,不是「少几个文件」。
  • D 层配置把图集规格、精灵分组、纹理格式写成唯一真源;S 层用 Pillow 真实生成并装箱,落盘一张图集 PNG。
  • 本机实跑:46 张精灵、512×512 图集,打包率 765‰;图集 PNG 12889 字节,比 46 张散图省 30.0%;RGBA8888 显存 1 MiB。
  • 纹理格式是公开可观察规格;能否使用压缩纹理取决于平台 WebGL 扩展,需真实设备验证。

图集定下来之后,包体里第二重的一块就是音频。下一节算音频:什么时长该用什么格式,体积差多少。

阅读导航:上一节:渲染性能与批处理 · 下一节:音频格式与体积权衡 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制