6.1 贴图通道与打包
站内 渲染原理专题 是纹理压缩与采样原理的家,本节不重推压缩算法。卷二 6.2 参数对齐与打包 讲的是把材质参数塞进寄存器;本节讲的是把贴图通道塞进同一张贴图——两者名字像、对象完全不同。本卷前面几章都在压贴图数量,写实 PBR 恰好相反:一个材质动辄五张图,通道打包是把它压回来的第一招。
三种语义,不是五张图
写实 PBR 的贴图不是「五张图」,是三种语义:颜色、法线、以及三张单通道的物理参数。语义决定了谁能和谁打包——单通道的可以合并,三通道的各有各的编码空间,不能硬塞。
| 贴图 | 语义 | 通道 | 色彩空间 | 能否打包 |
|---|---|---|---|---|
| baseColor | 反射率 | 3 | sRGB | 否(独占) |
| normal | 法线 XY(Z) | 3 | linear | 否(独占) |
| ao | 环境光遮蔽 | 1 | linear | 可 → ORM 的 R |
| roughness | 粗糙度 | 1 | linear | 可 → ORM 的 G |
| metallic | 金属度 | 1 | linear | 可 → 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。
| 口径 | 未打包 | 打包 | 打包/未打包 |
|---|---|---|---|
| 未压缩字节 | 15379120 | 16777212 | 1090‰ |
| 压缩字节 | 4194384 | 2796256 | 666‰ |
| 采样器槽位 | 5 | 3 | — |
单张贴图整链字节(含 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 材质分区与细节层级 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。