6.1 贴图通道与打包

写实 PBR 一个材质动辄五张贴图,通道打包是把它压回来的第一招。本节把「三张单通道贴图塞进一张 ORM」算成整数字节账,结论反直觉:未压缩时打包反而更大,压缩后打包省到三分之一;再用 PIL 真生成一张通道打包示意 PNG 并回读验证三通道与原图逐点相等。

6.1 贴图通道与打包

站内 渲染原理专题 是纹理压缩与采样原理的家,本节不重推压缩算法。卷二 6.2 参数对齐与打包 讲的是把材质参数塞进寄存器;本节讲的是把贴图通道塞进同一张贴图——两者名字像、对象完全不同。本卷前面几章都在压贴图数量,写实 PBR 恰好相反:一个材质动辄五张图,通道打包是把它压回来的第一招。

三种语义,不是五张图

写实 PBR 的贴图不是「五张图」,是三种语义:颜色、法线、以及三张单通道的物理参数。语义决定了谁能和谁打包——单通道的可以合并,三通道的各有各的编码空间,不能硬塞。

贴图语义通道色彩空间能否打包
baseColor反射率3sRGB否(独占)
normal法线 XY(Z)3linear否(独占)
ao环境光遮蔽1linear可 → ORM 的 R
roughness粗糙度1linear可 → ORM 的 G
metallic金属度1linear可 → ORM 的 B

ao / roughness / metallic 都是单通道、都在线性空间、都只占一个字节,把它们塞进一张贴图的 R/G/B 就是经典的 ORM 打包。下面这段是 D 层规格,把贴图、格式与打包关系记成数据,是 S 层与 P 层的共同输入。

{
  "project": "stardust-inn",
  "version": 1,
  "baseResolution": 1024,
  "formats": {
    "r8": { "block": 1, "bytes": 1, "channels": "R 8-bit" },
    "rgba8": { "block": 1, "bytes": 4, "channels": "RGBA 8-bit" },
    "bc4": { "block": 4, "bytes": 8, "channels": "R 单通道块压缩" },
    "bc5": { "block": 4, "bytes": 16, "channels": "RG 双通道,法线 XY" },
    "bc1": { "block": 4, "bytes": 8, "channels": "RGB" }
  },
  "maps": [
    { "id": "baseColor", "channels": 3, "uncompressed": "rgba8", "compressed": "bc1", "pack": null },
    { "id": "normal", "channels": 3, "uncompressed": "rgba8", "compressed": "bc5", "pack": null },
    { "id": "ao", "channels": 1, "uncompressed": "r8", "compressed": "bc4", "pack": "orm" },
    { "id": "roughness", "channels": 1, "uncompressed": "r8", "compressed": "bc4", "pack": "orm" },
    { "id": "metallic", "channels": 1, "uncompressed": "r8", "compressed": "bc4", "pack": "orm" }
  ],
  "packs": [
    { "id": "orm", "uncompressed": "rgba8", "compressed": "bc1", "members": ["ao", "roughness", "metallic"] }
  ],
  "generated": [
    { "id": "channel_pack", "file": "prism/out/gen/st06_channel_pack.png",
      "width": 256, "height": 64, "mode": "RGB", "tileSize": 64, "planes": ["ao", "roughness", "metallic"] }
  ]
}

通道打包省的是什么:反直觉的字节账

下面这段是 S 层脚本:读上面那份 spec,按「块数 × 块字节 + mip 链 + 4 字节对齐」把未打包 vs 打包两套口径各算一遍,再用 PIL 真生成一张通道打包示意 PNG 并回读验证。整数类逐位一致。

#!/usr/bin/env python3
# file: sim/st06_channels.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st06_channels.py
"""6.1 贴图通道与打包的 S 层账 + PIL 真生成:读 config/st06_channels.json,按「块数 ×
块字节 + mip 链 + 4 字节对齐」算未打包 vs 打包两套整数字节账,再依同一份 spec 用 PIL
生成通道打包示意 PNG 并回读验证。落 out/st06_channels.json 供 P 层 TS 侧对照。"""
import hashlib
import json
import os
import sys

from PIL import Image

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE = os.path.dirname(ROOT)
ALIGN = 4


def chain_bytes(size, block, nbytes):
    """含 1×1 的完整 mip 链字节:每层块数 × 块字节,逐层 4 字节对齐。"""
    total, n = 0, size
    while True:
        blocks = ((n + block - 1) // block) ** 2
        total += ((blocks * nbytes) + ALIGN - 1) // ALIGN * ALIGN
        if n == 1:
            return total
        n //= 2


def account(cfg, mode, merge):
    """merge=True 时把单通道 map 合成一张 pack 贴图。返回贴图清单与总字节。"""
    res, fmt, tex = cfg["baseResolution"], cfg["formats"], []
    for m in cfg["maps"]:
        if not (merge and m["pack"] is not None):
            f = fmt[m[mode]]
            tex.append((m["id"], m[mode], chain_bytes(res, f["block"], f["bytes"])))
    for p in (cfg["packs"] if merge else []):
        f = fmt[p[mode]]
        tex.append((p["id"], p[mode], chain_bytes(res, f["block"], f["bytes"])))
    return {"samplers": len(tex), "totalBytes": sum(t[2] for t in tex),
            "textures": [{"id": i, "format": f, "chainBytes": b} for i, f, b in tex]}


def plane(kind, size):
    """三张单通道原图的整数图案:水平 / 垂直 / 对角梯度,各 0–255。"""
    return [[(x * 4) % 256 if kind == "ao" else (y * 4) % 256 if kind == "roughness"
             else ((x + y) * 2) % 256 for x in range(size)] for y in range(size)]


def pack_png(path, decl):
    """四格横排 ao/roughness/metallic/打包 ORM,落盘后回读验证三通道与原图逐点相等。"""
    s, pl = decl["tileSize"], {k: plane(k, decl["tileSize"]) for k in decl["planes"]}
    im = Image.new("RGB", (decl["width"], decl["height"]), (0, 0, 0))
    for y in range(s):
        for x in range(s):
            for i, k in enumerate(decl["planes"]):
                im.putpixel((i * s + x, y), (pl[k][y][x],) * 3)
            im.putpixel((3 * s + x, y), tuple(pl[k][y][x] for k in decl["planes"]))
    os.makedirs(os.path.dirname(path), exist_ok=True)
    im.save(path)
    with Image.open(path) as back:
        back.load()
        bad = 0 if (back.size == (decl["width"], decl["height"]) and back.mode == decl["mode"]) else 1
        px = back.convert("RGB").load()
    for y in range(s):
        for x in range(s):
            if px[3 * s + x, y] != tuple(pl[k][y][x] for k in decl["planes"]):
                bad += 1
    return bad


def main():
    cfg = json.load(open(os.path.join(ROOT, "config/st06_channels.json"), encoding="utf-8"))
    acc = {(m, g): account(cfg, m, g) for m in ("uncompressed", "compressed") for g in (False, True)}
    uo, up = acc[("uncompressed", False)], acc[("uncompressed", True)]
    co, cp = acc[("compressed", False)], acc[("compressed", True)]
    decl = next(g for g in cfg["generated"] if g["id"] == "channel_pack")
    bad = pack_png(os.path.join(BASE, decl["file"]), decl)
    report = {"project": cfg["project"], "baseResolution": cfg["baseResolution"],
              "mapCount": len(cfg["maps"]), "uncompressedUnpacked": uo, "uncompressedPacked": up,
              "compressedUnpacked": co, "compressedPacked": cp,
              "uncompressedPackedPermille": up["totalBytes"] * 1000 // uo["totalBytes"],
              "compressedPackedPermille": cp["totalBytes"] * 1000 // co["totalBytes"],
              "png": {k: decl[k] for k in ("file", "width", "height", "mode")}, "pngBadPixels": bad}
    canon = json.dumps({"uncompressedUnpacked": uo["totalBytes"], "uncompressedPacked": up["totalBytes"],
                        "compressedUnpacked": co["totalBytes"], "compressedPacked": cp["totalBytes"]},
                       separators=(",", ":"))
    report["digest"] = hashlib.sha256(canon.encode()).hexdigest()[:16]
    with open(os.path.join(ROOT, "out/st06_channels.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    for tag, a in (("uncompressed-unpacked", uo), ("uncompressed-packed", up),
                   ("compressed-unpacked", co), ("compressed-packed", cp)):
        print(f"{tag:<22} samplers={a['samplers']} totalBytes={a['totalBytes']}")
    print(f"uncompressed packed/unpacked={report['uncompressedPackedPermille']}permille")
    print(f"compressed packed/unpacked={report['compressedPackedPermille']}permille")
    print(f"png {decl['file']} badPixels={bad}")
    print(f"digest={report['digest']}")
    return 1 if bad else 0


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

实跑 /opt/homebrew/bin/python3 sim/st06_channels.py(同机相对量级,绝对数值不可移植):

uncompressed-unpacked  samplers=5 totalBytes=15379120
uncompressed-packed    samplers=3 totalBytes=16777212
compressed-unpacked    samplers=5 totalBytes=4194384
compressed-packed      samplers=3 totalBytes=2796256
uncompressed packed/unpacked=1090permille
compressed packed/unpacked=666permille
png prism/out/gen/st06_channel_pack.png badPixels=0
digest=950b9106fbb69de2

第一个反直觉点:未压缩时打包反而更大(1090permille,多 9%)。原因是 GPU 没有「RGB8」这种常用未压缩格式,打包后的 ORM 仍占 4 字节/像素,而三张单通道各占 1 字节/像素——3 张变 1 张,但每张从 1 字节涨到 4 字节;真正省的是采样器槽位(5 → 3)。第二个点才是打包的实战价值:压缩后打包省到三分之一(666permille),因为 bc4 与 bc1 都是 8 字节/块,三张 bc4 变一张 bc1,块数减到 1/3。

口径未打包打包打包/未打包
未压缩字节15379120167772121090‰
压缩字节41943842796256666‰
采样器槽位53—

单张贴图整链字节(含 1×1、4 字节对齐):rgba8 1024² = 5592404、r8 = 1398104、bc1/bc4 = 699064、bc5 = 1398128。生成的 PNG 是 256×64 四格横排,回读 badPixels=0,即打包格 R/G/B 三通道与原图逐点相等——「规范既是生成器、又是闸门」的最小例子。

P 层:跨语言对账

下面这段是 P 层 TypeScript 内核,与 S 层同一套整数算法;测试读的是 Python 落盘的 out/st06_channels.json,不是同一份源码。

// file: core/src/st06/channels.ts
// tier: P
// verify: node --test 'core/test/st06_*.test.ts'
// 6.1 贴图通道与打包的 P 层内核:与 sim/st06_channels.py 同一套整数算法——每层块数
// × 块字节再向上取整到 4 字节对齐,含 1×1。字节、采样器数、千分比全整数地板除 →
// 与 Python 侧逐位一致。只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type Format = { block: number; bytes: number };
export type PbrMap = { id: string; pack: string | null; uncompressed: string; compressed: string };

const ALIGN = 4;

/** 含 1×1 的完整 mip 链字节数:块数 × 块字节,逐层 4 字节对齐。 */
export function chainBytes(size: number, block: number, bytes: number): number {
  let total = 0;
  let n = size;
  for (;;) {
    const b = Math.ceil(n / block);
    const raw = b * b * bytes;
    total += Math.ceil(raw / ALIGN) * ALIGN;
    if (n === 1) break;
    n = Math.floor(n / 2);
  }
  return total;
}

/** 一张写实 PBR 材质的贴图集账:merge=true 时把单通道 map 合成一张 pack 贴图。 */
export function account(
  res: number,
  maps: PbrMap[],
  packs: { id: string; uncompressed: string; compressed: string }[],
  formats: Record<string, Format>,
  mode: "uncompressed" | "compressed",
  merge: boolean,
): { samplers: number; totalBytes: number; textures: { id: string; format: string; chainBytes: number }[] } {
  const textures: { id: string; format: string; chainBytes: number }[] = [];
  for (const m of maps) {
    if (merge && m.pack !== null) continue;
    const f = formats[m[mode]];
    textures.push({ id: m.id, format: m[mode], chainBytes: chainBytes(res, f.block, f.bytes) });
  }
  if (merge) {
    for (const p of packs) {
      const f = formats[p[mode]];
      textures.push({ id: p.id, format: p[mode], chainBytes: chainBytes(res, f.block, f.bytes) });
    }
  }
  return { samplers: textures.length, textures, totalBytes: textures.reduce((a, t) => a + t.chainBytes, 0) };
}

/** 打包相对未打包的字节千分比:整数地板除。 */
export function packedPermille(packed: number, unpacked: number): number {
  return Math.floor((packed * 1000) / unpacked);
}
// file: core/test/st06_channels.test.ts
// tier: P
// verify: node --test 'core/test/st06_*.test.ts'
// 跨语言对照:读 sim/st06_channels.py 落盘的 out/st06_channels.json,断言 TS 侧独立复算的四套
// 整数字节账、采样器数与 sha256 摘要逐位一致。负例:未压缩打包反而更大、压缩打包省到 2/3。
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 { chainBytes, account, packedPermille } from "../src/st06/channels.ts";
import type { Format, PbrMap } from "../src/st06/channels.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/st06_channels.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/st06_channels.json"), "utf8"));
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
const formats = cfg.formats as Record<string, Format>;
const maps = cfg.maps as PbrMap[];

test("mip 链字节:块压缩与未压缩各一条基线", () => {
  assert.equal(chainBytes(1024, 4, 8), 699064);   // bc1 / bc4 单张全链
  assert.equal(chainBytes(1024, 1, 4), 5592404);  // rgba8 全链,与共享基线一致
  assert.equal(chainBytes(1024, 1, 1), 1398104);  // r8 单通道全链
});

test("跨语言:四套字节账、采样器数与打包千分比逐位一致", () => {
  for (const [mode, merge, key] of [
    ["uncompressed", false, "uncompressedUnpacked"], ["uncompressed", true, "uncompressedPacked"],
    ["compressed", false, "compressedUnpacked"], ["compressed", true, "compressedPacked"],
  ] as const) {
    const mine = account(cfg.baseResolution, maps, cfg.packs, formats, mode, merge);
    assert.equal(mine.totalBytes, py[key].totalBytes, key);
    assert.equal(mine.samplers, py[key].samplers, key);
  }
  assert.equal(packedPermille(py.uncompressedPacked.totalBytes, py.uncompressedUnpacked.totalBytes),
    py.uncompressedPackedPermille);
  assert.equal(packedPermille(py.compressedPacked.totalBytes, py.compressedUnpacked.totalBytes),
    py.compressedPackedPermille);
});

test("跨语言:sha256 摘要一致", () => {
  const canon = JSON.stringify({
    uncompressedUnpacked: py.uncompressedUnpacked.totalBytes, uncompressedPacked: py.uncompressedPacked.totalBytes,
    compressedUnpacked: py.compressedUnpacked.totalBytes, compressedPacked: py.compressedPacked.totalBytes,
  });
  assert.equal(sha16(canon), py.digest);
});

test("负例:未压缩打包更大、压缩打包省到 2/3、采样器 5→3", () => {
  assert.ok(py.uncompressedPacked.totalBytes > py.uncompressedUnpacked.totalBytes);
  assert.ok(py.compressedPackedPermille < 700 && py.compressedPackedPermille > 600);
  assert.equal(py.compressedUnpacked.samplers, 5);
  assert.equal(py.compressedPacked.samplers, 3);
});

实跑 node --test 'core/test/st06_*.test.ts'(本节 4 条,三节合计 12 条):

✔ mip 链字节:块压缩与未压缩各一条基线 (0.485ms)
✔ 跨语言:四套字节账、采样器数与打包千分比逐位一致 (0.181ms)
✔ 跨语言:sha256 摘要一致 (0.339ms)
✔ 负例:未压缩打包更大、压缩打包省到 2/3、采样器 5→3 (0.089ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0

通道打包省下的是采样器槽位与压缩后的字节,但写实材质的另一笔开销在分几张材质上——下一节把「分区 × 细节层级」算成账。

阅读导航:上一节:5.3 X:真实渲染外链 · 下一节:6.2 材质分区与细节层级 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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