5.2 mip 链与压缩格式字节账
卷一 8.1 纹理分辨率与 mip 链字节账 与 8.2 压缩格式的显存差异 从美术规范侧定了「贴图该多大、该选哪种压缩」;本节从管线侧把这条链算到更细的一层:每一级、每一块的字节,并让它在 Python 与 TypeScript 两侧各自独立算出来再逐位对照。压缩算法本身(BC / ASTC / ETC 各自怎么编码一个块)是渲染原理,归 graphics-texture-memory ,本节不重推,只把它当成一张「块边长 × 块字节」的静态表来消费。
先立两条不变量:mip 链的每一级尺寸是整数(ceil(w/2^i) × ceil(h/2^i),末级至少 1×1),字节账全整数。有了这两条,「一张 2048×1024 的图集到底吃多少显存」才能变成一个可以写进预算表、可以被 CI 拦下的数。
5.2.1 mip 链的整数公式
mip 链是同一张图逐级折半到 1×1 的完整金字塔,第 i 级尺寸为 ceil(w/2^i) × ceil(h/2^i),末级必须收口到 1×1。注意是 ceil 不是 floor:512 折到第 9 级是 ceil(512/512)=1,而 1024×128 这种非方形纹理,宽与高会分别到达 1,因此级数由两者中较大的那个决定。
两种计费方式:
- 未压缩:
level = ceil(w/2^i) * ceil(h/2^i) * bpp,且每级按 4 字节对齐((raw + 3) // 4 * 4)。对齐很重要——1024×128 的末几级只有几十字节,不对齐会与引擎实际分配差几个字节。 - 块压缩:
level = ceil(ceil(w/2^i)/block) * ceil(ceil(h/2^i)/block) * blockBytes。按块向上取整:一张 1024 宽的图在 6×6 块下是ceil(1024/6)=171块宽,不是 170。
由「块边长 × 块字节」还能直接推出有效位深 bpp×1000 = blockBytes*8*1000 // (block*block):BC1 是 8*8*1000//16 = 4000(4 bpp),ASTC 6×6 是 16*8*1000//36 = 3555(约 3.56 bpp)。这张表是后面所有对照的基准。
5.2.2 S 层:mip 链与八种格式的字节账
下面这段是 S 层脚本,先复现共享真源基线(1024×1024、bpp=4 的全链 5592404),再做 bpp 扫描(1/2/3/4),最后对四张源纹理 × 八种格式(RGBA8 / BC1 / BC3 / BC7 / ASTC 4×4 / ASTC 6×6 / ETC2 RGB / ETC2 RGBA)算出底级与全链的整数字节:
#!/usr/bin/env python3
# file: sim/ta05_mip.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta05_mip.py
"""mip 链与压缩格式字节账(S 层,卷二 5.2)。
一条完整 mip 链从 base 逐级折半到 1×1,第 i 级尺寸为 ``ceil(w/2^i) × ceil(h/2^i)``(末级
至少 1×1)。未压缩按 bpp 计:``level = ceil(w/2^i)*ceil(h/2^i)*bpp``(每级 4 字节对齐);
块压缩按块计:``level = ceil(ceil(w/2^i)/block)*ceil(ceil(h/2^i)/block)*blockBytes``。
1) 复现共享真源基线:1024×1024 RGBA8(bpp=4) 全链 = 5592404(与 consume.json 一致)。
2) bpp 扫描(1/2/3/4)展示公式本身。
3) 四张源纹理 × 8 种格式(BC1/BC3/BC7/ASTC4x4/ASTC6x6/ETC2 RGB/ETC2 RGBA)的整数字节账。
压缩原理属 graphics 专题,本节只算字节。落盘 out/ta05_mip.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/ta05_formats.json")
OUT = os.path.join(ROOT, "out")
ALIGN = 4
def mip_dims(w: int, h: int):
"""逐级折半到 1×1:第 i 级 = ceil(w/2^i) × ceil(h/2^i),末级至少 1×1。"""
out, i = [], 0
while True:
cw = max(1, -(-w // (1 << i)))
ch = max(1, -(-h // (1 << i)))
out.append((cw, ch))
if cw == 1 and ch == 1:
break
i += 1
return out
def chain_bpp(w: int, h: int, bpp: int) -> int:
"""未压缩全链字节(每级 4 字节对齐)。"""
total = 0
for cw, ch in mip_dims(w, h):
raw = cw * ch * bpp
total += (raw + ALIGN - 1) // ALIGN * ALIGN
return total
def base_bpp(w: int, h: int, bpp: int) -> int:
raw = w * h * bpp
return (raw + ALIGN - 1) // ALIGN * ALIGN
def chain_block(w: int, h: int, block: int, block_bytes: int) -> int:
"""块压缩全链字节:每级按块向上取整。"""
total = 0
for cw, ch in mip_dims(w, h):
bx = -(-cw // block)
by = -(-ch // block)
total += bx * by * block_bytes
return total
def base_block(w: int, h: int, block: int, block_bytes: int) -> int:
return (-(-w // block)) * (-(-h // block)) * block_bytes
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)
base = spec["budget"]["baseTexture"]
bpp0 = spec["budget"]["bytesPerPixel"]
baseline = chain_bpp(base, base, bpp0)
sweep = [{"bpp": b, "chainBytes": chain_bpp(base, base, b),
"permilleOfBpp4": chain_bpp(base, base, b) * 1000 // baseline}
for b in cfg["bppSweep"]]
ref = next(f for f in cfg["formats"] if f["id"] == "rgba8")
textures = []
for t in cfg["sourceTextures"]:
w, h = t["w"], t["h"]
rgba_base = base_block(w, h, ref["block"], ref["bytes"])
formats = []
for f in cfg["formats"]:
b = base_block(w, h, f["block"], f["bytes"])
c = chain_block(w, h, f["block"], f["bytes"])
formats.append({
"id": f["id"], "block": f["block"], "blockBytes": f["bytes"],
"bpp1000": f["bytes"] * 8 * 1000 // (f["block"] * f["block"]),
"baseBytes": b, "chainBytes": c,
"permilleOfRgba": b * 1000 // rgba_base,
})
textures.append({"id": t["id"], "w": w, "h": h, "levels": len(mip_dims(w, h)),
"rgbaBaseBytes": rgba_base, "formats": formats})
report = {"project": cfg["project"], "alignment": ALIGN,
"baseline": {"base": base, "bpp": bpp0, "chainBytes": baseline},
"bppSweep": sweep, "textures": textures}
with open(os.path.join(OUT, "ta05_mip.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2, sort_keys=True)
fh.write("\n")
print(f"baseline {base}x{base} bpp={bpp0} chainBytes={baseline}")
for s in sweep:
print(f" bpp={s['bpp']} chain={s['chainBytes']} {s['permilleOfBpp4']}permille")
for t in textures:
print(f" {t['id']:<26} {t['w']}x{t['h']} levels={t['levels']}")
for f in t["formats"]:
print(f" {f['id']:<10} block={f['block']}x{f['block']} bpp1000={f['bpp1000']:<5} "
f"base={f['baseBytes']:<9} chain={f['chainBytes']:<9} {f['permilleOfRgba']}permille")
print("wrote out/ta05_mip.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出(节选,完整 32 行见 out/ta05_mip.json):
baseline 1024x1024 bpp=4 chainBytes=5592404
bpp=1 chain=1398104 250permille
bpp=2 chain=2796204 500permille
bpp=3 chain=4194304 750permille
bpp=4 chain=5592404 1000permille
atlas_ui_1024 1024x1024 levels=11
rgba8 block=1x1 bpp1000=32000 base=4194304 chain=5592404 1000permille
bc1 block=4x4 bpp1000=4000 base=524288 chain=699064 125permille
bc3 block=4x4 bpp1000=8000 base=1048576 chain=1398128 250permille
astc6x6 block=6x6 bpp1000=3555 base=467856 chain=626288 111permille
etc2_rgb block=4x4 bpp1000=4000 base=524288 chain=699064 125permille
atlas_scene_wide_2048x1024 2048x1024 levels=12
rgba8 block=1x1 bpp1000=32000 base=8388608 chain=11184812 1000permille
bc1 block=4x4 bpp1000=4000 base=1048576 chain=1398120 125permille
astc6x6 block=6x6 bpp1000=3555 base=935712 chain=1250704 111permille
strip_1024x128 1024x128 levels=11
rgba8 block=1x1 bpp1000=32000 base=524288 chain=699068 1000permille
bc1 block=4x4 bpp1000=4000 base=65536 chain=87432 125permille
astc6x6 block=6x6 bpp1000=3555 base=60192 chain=81088 114permille
wrote out/ta05_mip.json
5.2.3 四张纹理的对照
把「全链 ÷ 底级」算出来,会看到一个稳定的经验值:未压缩纹理开满 mip 链大约多花 1/3(5592404 // 4194304 = 1.333…,699068 // 524288 = 1.333…,11184812 // 8388608 = 1.333…),因为 1 + 1/4 + 1/16 + … = 4/3。块压缩同样如此(699064 // 524288、1398120 // 1048576 都在 1.333 附近),因为每一级也按同样比例折半。
| 源纹理 | 级数 | RGBA8 底级 | RGBA8 全链 | BC1 底级 | BC1 全链 | ASTC6×6 底级 |
|---|---|---|---|---|---|---|
| atlas_ui_1024(1024²) | 11 | 4194304 | 5592404 | 524288 | 699064 | 467856 |
| atlas_scene_wide(2048×1024) | 12 | 8388608 | 11184812 | 1048576 | 1398120 | 935712 |
| portrait_512(512²) | 10 | 1048576 | 1398100 | 131072 | 174776 | 118336 |
| strip_1024x128(1024×128) | 11 | 524288 | 699068 | 65536 | 87432 | 60192 |
两个容易被忽略的读数:ASTC 6×6 比 BC1 还小(3555 < 4000 bpp,467856 < 524288),块大不一定吃亏——块越大、每块摊到的纹素越多,位深越低;1024×128 的条带级数是 11 而不是 8,因为高度只有 128,宽先折到 1 后高还要继续折,级数由较长边决定。
5.2.4 P 层:TS 内核
下面这段是 P 层内核,把同一套公式用 TypeScript 重写,并复用共享真源 core/src/sp00/spec.ts 的 mipChainBytes 复现基线。Math.ceil 只在整数除上用(Math.ceil(w / (1 << i))),块数与对齐全部走整数,因此与 Python 逐位一致:
// file: core/src/ta05/mip.ts
// tier: P
// verify: node --test 'core/test/ta05_*.test.ts'
// 卷二 5.2:mip 链与压缩格式字节账的 TS 侧实现。未压缩按 bpp 计(每级 4 字节对齐),
// 块压缩按块向上取整。全整数、无随机 → 与 sim/ta05_mip.py 逐位一致。
import { mipChainBytes as mipChainBytesSquare } from "../sp00/spec.ts";
export { mipChainBytesSquare };
/** 逐级折半到 1×1:第 i 级 = ceil(w/2^i) × ceil(h/2^i),末级至少 1×1。 */
export function mipDims(w: number, h: number): number[][] {
const out: number[][] = [];
for (let i = 0; ; i += 1) {
const cw = Math.max(1, Math.ceil(w / (1 << i)));
const ch = Math.max(1, Math.ceil(h / (1 << i)));
out.push([cw, ch]);
if (cw === 1 && ch === 1) break;
}
return out;
}
/** 未压缩全链字节(每级 4 字节对齐)。 */
export function chainBpp(w: number, h: number, bpp: number): number {
let total = 0;
for (const [cw, ch] of mipDims(w, h)) {
const raw = cw * ch * bpp;
total += Math.ceil(raw / 4) * 4;
}
return total;
}
/** 块压缩:一级的块数。 */
export function levelBlocks(w: number, h: number, block: number): number {
return Math.ceil(w / block) * Math.ceil(h / block);
}
/** 块压缩底级字节。 */
export function baseBlock(w: number, h: number, block: number, blockBytes: number): number {
return levelBlocks(w, h, block) * blockBytes;
}
/** 块压缩全链字节。 */
export function chainBlock(w: number, h: number, block: number, blockBytes: number): number {
let total = 0;
for (const [cw, ch] of mipDims(w, h)) total += levelBlocks(cw, ch, block) * blockBytes;
return total;
}
/** 有效位深 ×1000(整数):blockBytes*8*1000 // (block*block)。 */
export function bpp1000(block: number, blockBytes: number): number {
return Math.floor((blockBytes * 8 * 1000) / (block * block));
}
5.2.5 P 层测试:跨语言对照与负例
P 层测试读的是 S 层落盘的 out/ta05_mip.json,断言四张纹理 × 八种格式的底级、全链、有效位深逐项相等,并复现基线 5592404;负例锁住两个最容易写错的边界——非方形纹理末级必须收口到 1×1、6×6 块下 1024 宽必须是 171 块:
// file: core/test/ta05_mip.test.ts
// tier: P
// verify: node --test 'core/test/ta05_*.test.ts'
// 卷二 5.2 跨语言对照:读 sim/ta05_mip.py 落盘的 out/ta05_mip.json,断言 TS 侧 mip 链与
// 压缩格式字节账与 Python 逐位一致;并复现共享真源基线 5592404。带负例:非方形纹理的末级
// 必须收口到 1×1,块压缩尺寸必须按块向上取整。
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 { mipDims, chainBpp, baseBlock, chainBlock, bpp1000, mipChainBytesSquare } from "../src/ta05/mip.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/ta05_formats.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta05_mip.json"), "utf8"));
test("复现共享真源基线:1024² bpp=4 全链 = 5592404(逐位一致)", () => {
const base = spec.budget.baseTexture;
const bpp = spec.budget.bytesPerPixel;
assert.equal(chainBpp(base, base, bpp), py.baseline.chainBytes);
assert.equal(mipChainBytesSquare(base, bpp), py.baseline.chainBytes);
assert.equal(py.baseline.chainBytes, 5592404);
});
test("bpp 扫描:整数地板除口径逐位一致", () => {
const base = spec.budget.baseTexture;
for (const s of py.bppSweep as { bpp: number; chainBytes: number; permilleOfBpp4: number }[]) {
assert.equal(chainBpp(base, base, s.bpp), s.chainBytes, `bpp ${s.bpp} chain`);
assert.equal(Math.floor((s.chainBytes * 1000) / py.baseline.chainBytes), s.permilleOfBpp4);
}
});
test("跨语言:四张纹理 × 8 格式的 base/chain/bpp1000 逐位一致", () => {
const lines: string[] = [];
for (const t of py.textures as { id: string; w: number; h: number; levels: number; formats: Record<string, number>[] }[]) {
assert.equal(mipDims(t.w, t.h).length, t.levels, `${t.id} levels`);
for (const f of t.formats) {
const base = baseBlock(t.w, t.h, f.block, f.blockBytes);
const chain = chainBlock(t.w, t.h, f.block, f.blockBytes);
assert.equal(base, f.baseBytes, `${t.id}/${f.id} base`);
assert.equal(chain, f.chainBytes, `${t.id}/${f.id} chain`);
assert.equal(bpp1000(f.block, f.blockBytes), f.bpp1000, `${t.id}/${f.id} bpp`);
lines.push(`${t.id}:${f.id}:${chain}`);
}
}
console.log(`mip digest = ${createHash("sha256").update(lines.join("\n")).digest("hex").slice(0, 16)}`);
});
test("负例:非方形末级收口到 1×1;块压缩尺寸按块向上取整", () => {
const wide = mipDims(2048, 1024);
assert.deepEqual(wide[wide.length - 1], [1, 1]);
assert.equal(wide.length, 12);
// 6×6 块:1024 宽 → 171 块(ceil(1024/6)),不是 170
assert.equal(Math.ceil(1024 / 6), 171);
assert.equal(baseBlock(1024, 1024, 6, 16), 171 * 171 * 16);
// 未压缩底级必须等于 w*h*bpp(4 字节对齐后不变,因为 1024²*4 已是 4 的倍数)
assert.equal(baseBlock(1024, 1024, 1, 4), 1024 * 1024 * 4);
assert.equal(bpp1000(1, 4), 32000);
});
实跑输出(node --test core/test/ta05_mip.test.ts):
mip digest = ffc766612fb9911e
✔ 复现共享真源基线:1024² bpp=4 全链 = 5592404(逐位一致)
✔ bpp 扫描:整数地板除口径逐位一致
✔ 跨语言:四张纹理 × 8 格式的 base/chain/bpp1000 逐位一致
✔ 负例:非方形末级收口到 1×1;块压缩尺寸按块向上取整
ℹ tests 4
ℹ pass 4
ℹ fail 0
5.2.6 三条边界
- 字节账 ≠ 显存占用。真实 GPU 上还有对齐到页、纹理数组、mip 常驻级别(有的引擎只常驻前几级)等开销,本节算的是纹理数据本身的整数字节——它是预算表的下界,不是实测占用。
- 压缩格式不是「越小越好」。BC1 只有 4 bpp 且无独立 alpha,ASTC 6×6 更小但块大、对高频细节更糊;选型要在位深、质量、平台支持三者间权衡,而三者都不是本节的整数账能决定的。
- X 层:真实压缩(把一张 PNG 编成 BC7 并回读质量指标)需要真实的压缩工具链与 GPU 支持,需真实环境(引擎 / 压缩工具 / 目标平台),命令行不可复现;本节只消费「块边长 × 块字节」这张静态表。
一句话总结:mip 链的字节账只有两个整数公式,但它决定了整包预算的 1/3。 把公式写死成可跑的断言,才能让「这张图该不该进包」从争论变成一次 node --test。
阅读导航:上一节:5.1 装箱算法与装箱率 · 下一节:5.3 图集变更的传播与回归 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。