8.2.1 同样一张图,换个格式显存差几倍
8.1 算的是同一格式下分辨率怎么影响显存。这一节换一个乘数:格式。同样一张 1024×1024 的贴图,用 BC1 还是 BC7,显存差一倍;用 ASTC 6×6 还能再省。先划界:为什么 BC7 比 BC3 画质好、块内 128 位怎么切分通道、ASTC 的 footprint 怎么影响质量——这些是压缩算法原理,属渲染原理,见 《纹理与显存原理》 ,本节一律外链、不重推。本节只做美术侧的显存字节账:把格式规格固化成静态表,按「块数 × 块字节」算出整数个字节,让「选哪个格式」变成一道可核算的题。
8.2.2 D 层:块大小与块字节
所有块压缩格式的账都长同一个样子:贴图被切成固定边长的块,每块固定字节数。所以显存 = 块数 × 块字节,其中块数 = ceil(W / 块边长) × ceil(H / 块边长)。config/sp08_formats.json 把八种格式固化成 D 层规格表:
{
"project": "stardust-inn",
"specRef": "config/visual_spec.json",
"sourceTexture": 1024,
"note": "块大小 × 块字节是 D 层静态规格;压缩算法原理外链 graphics 专题,本节只做显存字节账",
"formats": [
{ "id": "rgba8", "block": 1, "bytes": 4, "channels": "RGBA 8-bit", "lossy": false },
{ "id": "bc1", "block": 4, "bytes": 8, "channels": "RGB + 1-bit alpha", "lossy": true },
{ "id": "bc3", "block": 4, "bytes": 16, "channels": "RGBA", "lossy": true },
{ "id": "bc7", "block": 4, "bytes": 16, "channels": "RGBA", "lossy": true },
{ "id": "astc4x4", "block": 4, "bytes": 16, "channels": "RGBA", "lossy": true },
{ "id": "astc6x6", "block": 6, "bytes": 16, "channels": "RGBA", "lossy": true },
{ "id": "etc2_rgb", "block": 4, "bytes": 8, "channels": "RGB", "lossy": true },
{ "id": "etc2_rgba", "block": 4, "bytes": 16, "channels": "RGBA", "lossy": true }
]
}
注意 rgba8 也在表里,用 block = 1、bytes = 4 表示——它没有块,每个像素 4 字节。这样它就和其他格式用同一个公式算,不需要特判。
8.2.3 S 层:把格式表算成显存字节
下面这段是 S 层脚本。它按上表逐格式算 1024×1024 的底级与整条 mip 链字节,并给出相对未压缩 RGBA8 的千分比(整数地板除):
#!/usr/bin/env python3
# file: sim/sp08_compression.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp08_compression.py
"""8.2 压缩格式显存差异的 S 层脚本。格式规格是 D 层静态表(config/sp08_formats.json),
本脚本按「块数 × 块字节」把 1024×1024 的底级与整条 mip 链算成整数字节,
并给出相对未压缩 RGBA8 的千分比。落 out/sp08_compression.json 供正文引用。"""
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def level_blocks(size: int, block: int) -> int:
"""一层 mip 的块数:块边长向上取整,至少 1 块。"""
n = (size + block - 1) // block
return n * n
def base_bytes(size: int, block: int, nbytes: int) -> int:
return level_blocks(size, block) * nbytes
def chain_bytes(size: int, block: int, nbytes: int) -> int:
total = 0
n = size
while True:
total += level_blocks(n, block) * nbytes
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"))
fmt = json.load(open(os.path.join(ROOT, "config/sp08_formats.json"), encoding="utf-8"))
size = fmt["sourceTexture"]
ref = next(f for f in fmt["formats"] if f["id"] == "rgba8")
ref_base = base_bytes(size, ref["block"], ref["bytes"])
rows = []
for f in fmt["formats"]:
b = base_bytes(size, f["block"], f["bytes"])
c = chain_bytes(size, f["block"], f["bytes"])
rows.append({
"id": f["id"], "block": f["block"], "footprint": f["block"] * f["block"],
"blockBytes": f["bytes"], "bitsPerBlock": f["bytes"] * 8,
"baseBytes": b, "chainBytes": c,
"permilleOfRgba": b * 1000 // ref_base,
})
report = {"project": spec["project"], "sourceTexture": size,
"rgba8BaseBytes": ref_base, "formats": rows}
with open(os.path.join(ROOT, "out/sp08_compression.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"source {size}x{size} rgba8 base={ref_base}")
for r in rows:
print(f" {r['id']:<10} block={r['block']}x{r['block']} bytes={r['blockBytes']:<3} "
f"base={r['baseBytes']:<9} chain={r['chainBytes']:<9} {r['permilleOfRgba']}permille")
print("wrote out/sp08_compression.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp08_compression.py:
source 1024x1024 rgba8 base=4194304
rgba8 block=1x1 bytes=4 base=4194304 chain=5592404 1000permille
bc1 block=4x4 bytes=8 base=524288 chain=699064 125permille
bc3 block=4x4 bytes=16 base=1048576 chain=1398128 250permille
bc7 block=4x4 bytes=16 base=1048576 chain=1398128 250permille
astc4x4 block=4x4 bytes=16 base=1048576 chain=1398128 250permille
astc6x6 block=6x6 bytes=16 base=467856 chain=626288 111permille
etc2_rgb block=4x4 bytes=8 base=524288 chain=699064 125permille
etc2_rgba block=4x4 bytes=16 base=1048576 chain=1398128 250permille
wrote out/sp08_compression.json
8.2.4 S 层:把整张贴图集一起算
单张看完了,但项目里从来不是一张图。下面这段 S 层脚本复用 sim/sp08_compression.py 的 chain_bytes,把 8.1 那八张贴图在每种格式下的整链字节加总,给出整集口径的千分比:
#!/usr/bin/env python3
# file: sim/sp08_vram_set.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp08_vram_set.py
"""8.2 的整集显存账:复用 sim/sp08_compression.py 的 chain_bytes,把 config/sp08_textures.json
的八张贴图在每种格式下的整链字节加总,给出相对未压缩 RGBA8 整集的千分比。
落 out/sp08_vram_set.json,与 8.1 的 6449824 字节基线对得上。"""
import json
import os
import sys
from sp08_compression import chain_bytes
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def main() -> int:
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
fmt = json.load(open(os.path.join(ROOT, "config/sp08_formats.json"), encoding="utf-8"))
tex = json.load(open(os.path.join(ROOT, "config/sp08_textures.json"), encoding="utf-8"))
totals = {}
for f in fmt["formats"]:
totals[f["id"]] = sum(chain_bytes(t["base"], f["block"], f["bytes"]) for t in tex["textures"])
ref = totals["rgba8"]
rows = [{"id": f["id"], "chainBytes": totals[f["id"]],
"permilleOfRgba": totals[f["id"]] * 1000 // ref} for f in fmt["formats"]]
report = {"project": spec["project"], "textureCount": len(tex["textures"]),
"rgba8ChainBytes": ref, "formats": rows}
with open(os.path.join(ROOT, "out/sp08_vram_set.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"textures={report['textureCount']} rgba8 chain={ref}")
for r in rows:
print(f" {r['id']:<10} chain={r['chainBytes']:<9} {r['permilleOfRgba']}permille")
print("wrote out/sp08_vram_set.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp08_vram_set.py:
textures=8 rgba8 chain=6449824
rgba8 chain=6449824 1000permille
bc1 chain=806336 125permille
bc3 chain=1612672 250permille
bc7 chain=1612672 250permille
astc4x4 chain=1612672 250permille
astc6x6 chain=726128 112permille
etc2_rgb chain=806336 125permille
etc2_rgba chain=1612672 250permille
wrote out/sp08_vram_set.json
关键对照:rgba8 整集 6449824 字节,正好等于 8.1 的整链汇总——两个脚本从不同角度算,数字对得上,这是「账只有一套」的检验。
8.2.5 判读:三档、一个坑、一张选型表
把输出整理成一张对比表(1024×1024,底级与整链都是整数字节):
| 格式 | 块 | 块字节 | 位/块 | 底级字节 | 整链字节 | 相对 RGBA8 |
|---|---|---|---|---|---|---|
| RGBA8(未压缩) | 1×1 | 4 | 32 | 4194304 | 5592404 | 1000‰ |
| BC1 / ETC2 RGB | 4×4 | 8 | 64 | 524288 | 699064 | 125‰ |
| BC3 / BC7 / ASTC 4×4 / ETC2 RGBA | 4×4 | 16 | 128 | 1048576 | 1398128 | 250‰ |
| ASTC 6×6 | 6×6 | 16 | 128 | 467856 | 626288 | 111‰ |
三条判读:
- BC1 是 1/8,带 alpha 的块是 1/4。BC1 每像素 0.5 字节,BC3/BC7/ASTC 4×4/ETC2 RGBA 每像素 1 字节。所以「这张图要不要 alpha」直接决定显存翻不翻倍。
- ASTC 6×6 更省,是因为块更大:块字节固定 16(128 位),但 footprint 从 16 像素涨到 36 像素,平均每像素降到
128 / 36 ≈ 3.56位——比 BC7 的 8 位省一半多。代价是块内细节更糊,边缘更明显。 - 一个坑:
astc6x6的块边长 6 不是 2 的幂,1024 不能被 6 整除,ceil(1024 / 6) = 171,最后一行/列会补空块。补出来的块一样占显存,这就是为什么它的账必须用ceil而不是整除。
一句话记法:块字节 ÷ footprint 像素 = 每像素字节。BC1 是 8 / 16 = 0.5,带 alpha 的块是 16 / 16 = 1,ASTC 6×6 是 16 / 36 ≈ 0.44。凡是块压缩,显存账都从这一条推出来,不需要背表。
选型取舍表(美术侧口径):
| 场景 | 建议 | 理由 |
|---|---|---|
| 图集 / 不透明 UI | BC1 / ETC2 RGB | 只要 RGB,省一半 |
| 带 alpha 的图标 / 特效 | BC7 / ASTC 4×4 | 有 alpha 且要画质 |
| 远处小贴图、地平线 | ASTC 6×6 | 分辨率本来就低,省显存优先 |
| 需要被 CPU 读回的图 | RGBA8 | 块压缩不能逐像素随机访问 |
8.2.6 边界与取舍
- 真编码器 / 真画质评估(真跑 BC7 压缩、肉眼看块状伪影)需要真实环境(DCC / 真机),命令行不可复现,标 X;本节只算显存字节,不评画质。
- 移动端与桌面端的格式可用性不同(iOS 走 ASTC、Android 走 ETC2/ASTC、桌面走 BC 系),规范里要按平台各留一档,不能只写一种。
- 块压缩的代价是「不可随机访问」:BC / ETC / ASTC 都是块编码,CPU 要读某一个像素得先解一整块;需要逐像素读回的图(程序化采样、CPU 端高度图)只能留 RGBA8。
- 质量与字节不是同一条曲线:同是 4×4 / 16 字节,BC7 与 BC3 显存完全一样,但 BC7 画质明显更好——「同样显存为什么不用 BC7」该写进规范的默认项。
- 格式账与 mip 账要相乘:本节每张表都已含 mip 链;若某平台关掉 mip,整链会退回底级(8.1 的 4/3 关系),两笔账不能只算一笔。
- 本节全部是整数类(块数、字节、千分比),逐位一致;画质是派生感知量,不在本节的账里。
一句话:格式选型的账是先定字节、再挑画质——字节由块规格唯一确定,画质在同字节的候选中再比,选型就从「感觉」变成了一道有约束的题。
阅读导航:上一节:8.1 纹理分辨率与 mip 链字节账 · 下一节:8.3 整包资源预算表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。