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) |
|---|---|---|
| 0 | 1024 | 4194304 |
| 1 | 512 | 1048576 |
| 2 | 256 | 262144 |
| … | … | … |
| 10 | 1 | 4 |
三件事必须写进规范,否则账永远对不上:
- 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 压缩格式的显存差异 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。