8.1 纹理分辨率与 mip 链字节账

纹理显存不是一个数字,而是一条从底级到 1×1 的 mip 链。本节用一份真跑的 S 层脚本,读共享真源复现 1024×1024 RGBA 全链基线 5592404 字节,再按贴图集逐张算整链字节与千分比,并用 P 层 TS 内核做跨语言逐位对照与漏算 1×1 的负例断言。

8.1.1 一张贴图占多少显存,是可以算出来的

第 7 章把面数、骨骼、材质槽算成了可核对的账;本章往下走一层,算纹理占的显存。先划界:工程侧的预算总纲(面数、包体、帧率怎么分档)在 《移动端与 PC 性能预算 2026》 里,那篇讲的是「预算这件事怎么分」;压缩算法本身——块怎么编、色差怎么量化、为什么 BC7 比 BC3 好——是渲染原理,见 《纹理与显存原理》 。本节只做美术侧的那半件事:按分辨率与格式,把一张贴图从底级到 1×1 的整条 mip 链算成整数个字节,算法原理一律外链,不重推。

8.1.2 为什么按「链」算,而不是按「一张」

一张 1024×1024 的 RGBA8 贴图,底级是 1024 × 1024 × 4 = 4194304 字节(4 MiB)。但 GPU 实际驻留的是整条 mip 链——底级加每一级边长减半,直到 1×1:

级边长字节(bpp = 4)
010244194304
15121048576
2256262144
………
1014

三件事必须写进规范,否则账永远对不上:

  • mip 链是标配,不是可选项:没有 mip,远处贴图会闪(摩尔纹);有 mip,显存就涨。这是个取舍,不是开关。
  • 链的字节不是「底级 + 一点点」:等比数列 1 + 1/4 + 1/16 + … = 4/3,所以整条链恰好是底级的 4/3。
  • 1×1 那一级也算:它只有 4 字节,但少算它账就对不上——这正是下面负例断言要抓的。

8.1.3 D 层:把贴图集固化成清单

要算账,先要有清单。config/sp08_textures.json 把《星屑旅店》的八张贴图连分辨率一起固化——它是 D 层静态数据,同时也是 S 层的输入:

{
  "project": "stardust-inn",
  "specRef": "config/visual_spec.json",
  "alignment": 4,
  "textures": [
    { "id": "atlas_ui", "base": 1024, "bpp": 4, "tier": "low", "role": "atlas" },
    { "id": "portrait_host_01", "base": 256, "bpp": 4, "tier": "mid", "role": "portrait" },
    { "id": "portrait_guest_01", "base": 256, "bpp": 4, "tier": "mid", "role": "portrait" },
    { "id": "ui_frame", "base": 128, "bpp": 4, "tier": "low", "role": "ui" },
    { "id": "furn_chair_01", "base": 64, "bpp": 4, "tier": "low", "role": "icon" },
    { "id": "vfx_steam_01", "base": 64, "bpp": 4, "tier": "mid", "role": "vfx" },
    { "id": "vfx_spark_01", "base": 64, "bpp": 4, "tier": "mid", "role": "vfx" },
    { "id": "tile_wall_01", "base": 32, "bpp": 4, "tier": "low", "role": "tile" }
  ]
}

8.1.4 S 层:整链字节账

下面这段是 S 层脚本。它先读共享真源 config/visual_spec.json 的 budget.baseTexture = 1024 与 bytesPerPixel = 4,复现出基线 5592404,再逐张贴图算整链字节与千分比:

#!/usr/bin/env python3
# file: sim/sp08_mip.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp08_mip.py
"""8.1 纹理分辨率与 mip 链字节账的 S 层脚本。
读 config/visual_spec.json 复现 1024×1024 RGBA 全链基线 5592404,
再按 config/sp08_textures.json 的贴图集逐张算整链字节并汇总。
全部整数(含 1×1、4 字节对齐),落 out/sp08_mip.json 供 P 层跨语言对照。"""
import json
import os
import sys

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


def mip_chain_bytes(base: int, bpp: int) -> int:
    """含 1×1 的完整 mip 链字节数(整数,向上取整到 4 字节对齐)。"""
    total = 0
    n = base
    while True:
        raw = n * n * bpp
        total += (raw + ALIGN - 1) // ALIGN * ALIGN
        if n == 1:
            break
        n //= 2
    return total


def main() -> int:
    spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
    tex = json.load(open(os.path.join(ROOT, "config/sp08_textures.json"), encoding="utf-8"))
    base = spec["budget"]["baseTexture"]
    bpp = spec["budget"]["bytesPerPixel"]
    baseline = mip_chain_bytes(base, bpp)

    rows = []
    total_chain = 0
    total_base = 0
    for t in tex["textures"]:
        chain = mip_chain_bytes(t["base"], t["bpp"])
        one = t["base"] * t["base"] * t["bpp"]
        rows.append({"id": t["id"], "base": t["base"], "bpp": t["bpp"],
                     "baseBytes": one, "chainBytes": chain,
                     "overheadPermille": (chain - one) * 1000 // one})
        total_chain += chain
        total_base += one

    report = {
        "project": spec["project"],
        "baselineBase": base, "baselineBpp": bpp, "baselineChainBytes": baseline,
        "alignment": ALIGN,
        "textureCount": len(rows),
        "textures": rows,
        "totalBaseBytes": total_base,
        "totalChainBytes": total_chain,
        "mipOverheadPermille": (total_chain - total_base) * 1000 // total_base,
    }
    with open(os.path.join(ROOT, "out/sp08_mip.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")

    print(f"baseline {base}x{base} bpp={bpp} chainBytes={baseline}")
    for r in rows:
        print(f"  {r['id']:<18} base={r['base']:<5} chain={r['chainBytes']:<9} "
              f"base-only={r['baseBytes']:<9} +{r['overheadPermille']}permille")
    print(f"textures={len(rows)} totalBase={total_base} totalChain={total_chain} "
          f"overhead={report['mipOverheadPermille']}permille")
    print("wrote out/sp08_mip.json")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/sp08_mip.py:

baseline 1024x1024 bpp=4 chainBytes=5592404
  atlas_ui           base=1024  chain=5592404   base-only=4194304   +333permille
  portrait_host_01   base=256   chain=349524    base-only=262144    +333permille
  portrait_guest_01  base=256   chain=349524    base-only=262144    +333permille
  ui_frame           base=128   chain=87380     base-only=65536     +333permille
  furn_chair_01      base=64    chain=21844     base-only=16384     +333permille
  vfx_steam_01       base=64    chain=21844     base-only=16384     +333permille
  vfx_spark_01       base=64    chain=21844     base-only=16384     +333permille
  tile_wall_01       base=32    chain=5460      base-only=4096      +333permille
textures=8 totalBase=4837376 totalChain=6449824 overhead=333permille
wrote out/sp08_mip.json

8.1.5 P 层:同一套整数算法的 TS 内核

下面这段是 P 层 TS 内核 core/src/sp08/mip.ts。它直接复用 core/src/sp00/spec.ts 里共享的 mipChainBytes,不另写一份——跨语言一致的前提是算法只有一份:

// file: core/src/sp08/mip.ts
// tier: P
// verify: node --test 'core/test/sp08_*.test.ts'
// 8.1 mip 链字节账的 P 层内核。复用 core/src/sp00/spec.ts 的 mipChainBytes
// (同一组整数算法:含 1×1、4 字节对齐),再按贴图集逐张与汇总。
// 字节、千分比全整数 → 与 sim/sp08_mip.py 逐位一致。
import { mipChainBytes } from "../sp00/spec.ts";

export function textureRow(base: number, bpp: number): any {
  const one = base * base * bpp;
  const chain = mipChainBytes(base, bpp);
  return { baseBytes: one, chainBytes: chain, overheadPermille: Math.floor(((chain - one) * 1000) / one) };
}

export function setTotals(textures: any[]): any {
  let totalBase = 0;
  let totalChain = 0;
  for (const t of textures) {
    const r = textureRow(t.base, t.bpp);
    totalBase += r.baseBytes;
    totalChain += r.chainBytes;
  }
  return {
    totalBaseBytes: totalBase,
    totalChainBytes: totalChain,
    mipOverheadPermille: Math.floor(((totalChain - totalBase) * 1000) / totalBase),
  };
}

P 层测试读的不是同一份源码,而是 Python 落盘的 out/sp08_mip.json,逐张比对整链字节。core/test/sp08_mip.test.ts:

// file: core/test/sp08_mip.test.ts
// tier: P
// verify: node --test 'core/test/sp08_*.test.ts'
// 跨语言对照:读 sim/sp08_mip.py 落盘的 out/sp08_mip.json,断言 TS 侧复算的逐张
// 整链字节与汇总与之逐位一致。负例:漏算 1×1、分辨率减半的账必须对得上。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { mipChainBytes } from "../src/sp00/spec.ts";
import { textureRow, setTotals } from "../src/sp08/mip.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp08_mip.json"), "utf8"));
const tex = JSON.parse(readFileSync(resolve(ROOT, "config/sp08_textures.json"), "utf8"));

test("mip 链字节:1024 RGBA 全链复现共享基线 5592404", () => {
  assert.equal(mipChainBytes(py.baselineBase, py.baselineBpp), py.baselineChainBytes);
  assert.equal(py.baselineChainBytes, 5592404);
});

test("mip 链字节:TS 复算逐张与 Python 产物逐位一致", () => {
  for (const r of py.textures) {
    const mine = textureRow(r.base, r.bpp);
    assert.equal(mine.chainBytes, r.chainBytes, r.id);
    assert.equal(mine.baseBytes, r.baseBytes, r.id);
    assert.equal(mine.overheadPermille, r.overheadPermille, r.id);
  }
  const tot = setTotals(tex.textures);
  assert.equal(tot.totalBaseBytes, py.totalBaseBytes);
  assert.equal(tot.totalChainBytes, py.totalChainBytes);
  assert.equal(tot.mipOverheadPermille, py.mipOverheadPermille);
});

test("负例:1×1 必须计入,分辨率减半差一份底级字节", () => {
  assert.equal(mipChainBytes(1, 4), 4);
  assert.equal(mipChainBytes(2, 4), 20);
  assert.equal(mipChainBytes(1024, 4) - mipChainBytes(512, 4), 4194304);
  assert.equal(textureRow(1024, 4).overheadPermille, 333);
});

实跑 node --test 'core/test/sp08_*.test.ts'(本节 3 条;三节合计 7 条):

✔ mip 链字节:1024 RGBA 全链复现共享基线 5592404 (0.494208ms)
✔ mip 链字节:TS 复算逐张与 Python 产物逐位一致 (0.150875ms)
✔ 负例:1×1 必须计入,分辨率减半差一份底级字节 (0.065833ms)
ℹ tests 7
ℹ pass 7
ℹ fail 0

8.1.6 判读:+333‰ 与 4 字节对齐何时才起作用

  • mip 链把显存放大约 1/3:八张贴图底级合计 4837376 字节,整链 6449824 字节,多出 1612448 字节,千分比 +333‰(就是 4/3 减 1)。字节与千分比都是整数类,逐位一致。
  • 4 字节对齐在这组数据里是「惰性」的:bpp = 4 时每一级 n² × 4 本来就是 4 的倍数,向上取整不会改变任何一级。它只在 bpp 不是 4 的倍数(比如单通道 bpp = 1 的遮罩)时才真的生效。规范里保留这条,是为了以后加单通道贴图时账不飘。
  • 决策:显存吃紧时,优先降底级分辨率(1024→512 省 3/4 底级字节),而不是砍 mip 级——砍 mip 会立刻在远处出现闪烁。

8.1.7 边界与取舍

  • 真 GPU 采样 / 真纹理上传需要真实环境(渲染器 / 真机 GPU profiler),命令行不可复现,标 X;本节只算静态字节账。
  • 非正方形贴图要按 W×H 各算一层;本节为与共享基线对齐,全部用正方形 2 的幂贴图。若底级不是 2 的幂,mip 级数要按 floor(log2(max(W,H))) 截断。
  • 本节全是整数类(字节、千分比),逐位一致,没有浮点容差问题。

阅读导航:上一节:7.3 预算超支的归因与裁剪顺序 · 下一节:8.2 压缩格式的显存差异 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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