5.2 mip 链与压缩格式字节账

mip 链的字节账是整数题:第 i 级尺寸是 ceil(w/2^i)×ceil(h/2^i),未压缩按 bpp 逐级累加并按 4 字节对齐,块压缩按块向上取整。本节复现共享真源基线 5592404,给出四张纹理 × 八种格式的整数字节账与有效位深,压缩原理外链 graphics 专题。

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²)1141943045592404524288699064467856
atlas_scene_wide(2048×1024)1283886081118481210485761398120935712
portrait_512(512²)1010485761398100131072174776118336
strip_1024x128(1024×128)11524288699068655368743260192

两个容易被忽略的读数: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 图集变更的传播与回归 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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