8.2 蒙皮数据量与显存
8.1 骨骼数与调色板上限 定了「一副骨架能有多少根骨骼」;本节把骨骼数换算成字节——蒙皮到底吃多少显存。这笔账和 5.2 mip 链与压缩格式字节账 是同一个套路:把「该不该进包」从争论变成一次整数加法。骨骼矩阵怎么参与顶点变换仍是渲染原理,归 骨骼动画与蒙皮 ,本节不重推;骨骼矩阵存在显存里怎么被硬件取用(骨骼贴图的采样机制)归 纹理显存 ,本节只算它的字节数。
先立一条不变量:蒙皮数据的字节数全是整数——顶点数、骨骼数、每顶点字节、每骨骼字节都是整数,乘加之后还是整数。因此这笔账能跨机器逐位复现,也能直接写进预算表被 CI 拦下。
8.2.1 两块蒙皮数据
蒙皮数据不是一块,是两块,它们的增长因子完全不同:
| 块 | 增长因子 | 字节公式 | 随什么变 |
|---|---|---|---|
| 顶点蒙皮属性 | 顶点数 | 顶点数 × 每顶点字节 | 网格密度 |
| 骨骼调色板 | 骨骼数 | 骨骼数 × vec4PerBone × 16 | 骨架大小 |
顶点蒙皮属性是每顶点都要带的数据:4 个骨骼索引(告诉这个顶点受哪几根骨骼影响)+ 4 个权重(每根影响多少)。调色板是每骨架一份的矩阵数据,8.1 已经算过它的 vec4 数。两块的增长因子不同,所以**「顶点多的角色」和「骨骼多的角色」是两种不同的显存压力**。
本项目里 vec4PerBone=3、每个 vec4 是 16 字节,故 paletteBytesPerBone = 3 × 16 = 48。这是一根骨骼的固定开销——记住这个 48,后面每个角色的调色板字节都是「骨骼数 × 48」。
8.2.2 索引位宽决定合法性
顶点上的骨骼索引是有位宽的,位宽直接决定一个骨架能不能用该格式:
| 格式 | 索引位 | 权重位 | 每顶点字节 | 最大骨骼数 |
|---|---|---|---|---|
packed4 | 4 | 4 | 4 | 16 |
idx8_w8 | 8 | 8 | 8 | 256 |
idx8_w16 | 8 | 16 | 12 | 256 |
idx16_wf32 | 16 | 32 | 24 | 65536 |
最大骨骼数 = 2^索引位。4 位索引能表示 0–15,最多 16 根骨;8 位能表示 0–255,最多 256 根;16 位到 65536。所以「选哪种格式」不是纯省字节的选择——packed4 每顶点只要 4 字节,但一个 28 根骨的骨架根本用不了它,因为索引装不下。
这里有一条容易被忽略的连锁:骨骼数一涨,可能被迫换更宽的索引格式,每顶点字节随之翻倍。8.1 里 chef 是 72 根骨,早就过了 16 根的门槛,packed4 对它无效;而一旦某副骨架超过 256 根,连 idx8_* 都用不了,只能上 idx16_*。
8.2.3 S 层:两块字节与合法性
下面这段是 S 层脚本,读配置,对每个角色 × 每种格式算出两块字节与合法性,再对一组权重做 8 位量化示例:
#!/usr/bin/env python3
# file: sim/ta08_skinning.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta08_skinning.py
"""8.2 蒙皮数据量与显存(S 层,卷二 8.2)。
蒙皮数据分两块:
* **顶点蒙皮属性**:每顶点 4 个骨骼索引 + 4 个权重,字节 = 顶点数 × 每顶点字节;
* **骨骼调色板**:骨骼矩阵数据,字节 = 骨骼数 × vec4PerBone × 16。
索引位宽决定一个骨架能不能用该格式:4 位索引最多 16 根骨、8 位最多 256 根、16 位最多 65536 根。
本脚本读 config/ta08_skinning.json,对每个角色 × 每种格式算出两块字节与合法性,并对一组权重做
8 位 unorm 量化示例。全整数,落 out/ta08_skinning.json。
"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config/ta08_skinning.json")
OUT = os.path.join(ROOT, "out")
def max_bones(index_bits: int) -> int:
"""索引位宽能寻址的最大骨骼数:2^bits。"""
return 1 << index_bits
def quantize_unorm8(weights):
"""把一组权重缩放到 8 位整数(0–255)。返回 (整数列表, 和)。"""
q = [round(w * 255) for w in weights]
return q, sum(q)
def main() -> int:
cfg = json.load(open(CFG, encoding="utf-8"))
os.makedirs(OUT, exist_ok=True)
pal = cfg["palette"]
palette_bytes_per_bone = pal["vec4PerBone"] * pal["bytesPerVec4"]
formats = [{**f, "maxBones": max_bones(f["indexBits"])} for f in cfg["formats"]]
rows = []
for c in cfg["characters"]:
per_fmt = []
for f in formats:
legal = c["bones"] <= f["maxBones"]
attr = c["verts"] * f["bytesPerVertex"]
palette = c["bones"] * palette_bytes_per_bone
per_fmt.append({"id": f["id"], "legal": legal, "attrBytes": attr,
"paletteBytes": palette, "totalBytes": attr + palette})
rows.append({"id": c["id"], "bones": c["bones"], "verts": c["verts"], "formats": per_fmt})
totals = []
for fi, f in enumerate(formats):
legal_rows = [r for r in rows if r["formats"][fi]["legal"]]
attr = sum(r["verts"] * f["bytesPerVertex"] for r in legal_rows)
palette = sum(r["bones"] * palette_bytes_per_bone for r in legal_rows)
total = attr + palette
totals.append({"id": f["id"], "legalCharacters": len(legal_rows),
"attrBytes": attr, "paletteBytes": palette, "totalBytes": total,
"palettePermille": palette * 1000 // total if total else 0})
sample = cfg["sampleWeights"]
q, qsum = quantize_unorm8(sample)
fix = list(q)
fix[q.index(max(q))] -= (qsum - 255)
quant = {"weights": sample, "scaled": q, "naiveSum": qsum,
"normalized": fix, "normalizedSum": sum(fix), "stepPermille": 1000 // 255}
lines = sorted(f"{r['id']}:{f['id']}:{f['totalBytes']}"
for r in rows for f in r["formats"] if f["legal"])
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
report = {"project": cfg["project"], "paletteBytesPerBone": palette_bytes_per_bone,
"vramBudgetBytes": cfg["vramBudgetBytes"], "formats": formats,
"characters": rows, "rosterTotals": totals,
"weightQuantization": quant, "digest": digest}
with open(os.path.join(OUT, "ta08_skinning.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2, sort_keys=True)
fh.write("\n")
print(f"paletteBytesPerBone={palette_bytes_per_bone} vramBudget={cfg['vramBudgetBytes']}")
for f in formats:
print(f" fmt {f['id']:<10} idx{f['indexBits']:<3} w{f['weightBits']:<3} "
f"bytesPerVertex={f['bytesPerVertex']:<3} maxBones={f['maxBones']}")
for t in totals:
print(f" total {t['id']:<10} legal={t['legalCharacters']}/6 attr={t['attrBytes']:<7} "
f"palette={t['paletteBytes']:<6} sum={t['totalBytes']:<7} palette={t['palettePermille']}permille")
print(f"weight8 sample={sample} scaled={q} sum={qsum} normalizedSum={sum(fix)} "
f"step={quant['stepPermille']}permille")
print(f"digest={digest}")
print("wrote out/ta08_skinning.json")
return 0
if __name__ == "__main__":
sys.exit(main())
配置是一份 D 层数据(vramBudgetBytes=262144 是给「整批角色蒙皮数据」定的显存红线,即 256 KiB):
{
"project": "stardust-inn",
"specRef": "config/visual_spec.json",
"note": "卷二 8.2 的蒙皮数据规格:蒙皮数据分两块——顶点上的蒙皮属性(每顶点 4 索引 + 4 权重)与骨骼调色板(骨骼矩阵)。索引位宽决定一个骨架能不能用该格式(4 位 ≤ 16 骨、8 位 ≤ 256 骨、16 位 ≤ 65536 骨)。权重精度用 8 位 unorm 做量化示例。蒙皮矩阵如何参与顶点变换属 graphics 专题,本节只算字节。",
"palette": {
"vec4PerBone": 3,
"bytesPerVec4": 16
},
"vramBudgetBytes": 262144,
"formats": [
{ "id": "packed4", "indexBits": 4, "weightBits": 4, "bytesPerVertex": 4 },
{ "id": "idx8_w8", "indexBits": 8, "weightBits": 8, "bytesPerVertex": 8 },
{ "id": "idx8_w16", "indexBits": 8, "weightBits": 16, "bytesPerVertex": 12 },
{ "id": "idx16_wf32", "indexBits": 16, "weightBits": 32, "bytesPerVertex": 24 }
],
"characters": [
{ "id": "innkeeper", "bones": 28, "verts": 1800 },
{ "id": "pet_cat", "bones": 16, "verts": 620 },
{ "id": "guest_female", "bones": 52, "verts": 3400 },
{ "id": "merchant", "bones": 58, "verts": 3900 },
{ "id": "chef", "bones": 72, "verts": 5200 },
{ "id": "boss_dragon", "bones": 96, "verts": 8600 }
],
"sampleWeights": [0.5, 0.25, 0.15, 0.1]
}
实跑 /opt/homebrew/bin/python3 sim/ta08_skinning.py:
paletteBytesPerBone=48 vramBudget=262144
fmt packed4 idx4 w4 bytesPerVertex=4 maxBones=16
fmt idx8_w8 idx8 w8 bytesPerVertex=8 maxBones=256
fmt idx8_w16 idx8 w16 bytesPerVertex=12 maxBones=256
fmt idx16_wf32 idx16 w32 bytesPerVertex=24 maxBones=65536
total packed4 legal=1/6 attr=2480 palette=768 sum=3248 palette=236permille
total idx8_w8 legal=6/6 attr=188160 palette=15456 sum=203616 palette=75permille
total idx8_w16 legal=6/6 attr=282240 palette=15456 sum=297696 palette=51permille
total idx16_wf32 legal=6/6 attr=564480 palette=15456 sum=579936 palette=26permille
weight8 sample=[0.5, 0.25, 0.15, 0.1] scaled=[128, 64, 38, 26] sum=256 normalizedSum=255 step=3permille
digest=8348cde946c94323
wrote out/ta08_skinning.json
8.2.4 四种格式的对照
把 6 个角色的账加起来,四种格式的差距非常大:
| 格式 | 合法角色 | 顶点属性 | 调色板 | 合计 | 调色板占比 | 对 256 KiB 红线 |
|---|---|---|---|---|---|---|
packed4 | 1/6 | 2480 | 768 | 3248 | 236‰ | 远低于 |
idx8_w8 | 6/6 | 188160 | 15456 | 203616 | 75‰ | 合格 |
idx8_w16 | 6/6 | 282240 | 15456 | 297696 | 51‰ | 超 |
idx16_wf32 | 6/6 | 564480 | 15456 | 579936 | 26‰ | 超 2.2 倍 |
三个读数值得记。其一,packed4 虽然只有 3248 字节,但它只对 1 个角色合法——pet_cat 恰好 16 根骨卡在上限,其余 5 个全装不下。省到极致的格式往往最先被合法性淘汰。
其二,调色板是固定开销,占比随顶点格式变宽而被稀释:idx8_w8 里调色板占 75‰,idx16_wf32 里只占 26‰。调色板字节(15456)在三种合法格式里完全相同——因为它只跟骨骼数有关,跟顶点格式无关。
其三,格式选错直接决定过不过红线:idx8_w8 的 203616 字节在 262144 之内,idx8_w16 的 297696 就超了。同一批角色、同样的顶点数与骨骼数,只因为权重从 8 位换成 16 位,就从合格变成超支——这就是为什么蒙皮属性格式是一条要写进规范的硬约束。
8.2.5 顶点数 vs 骨骼数:谁更该砍
两块数据的增长因子不同,所以「砍顶点」和「砍骨骼」的收益完全不同。以 idx8_w8(每顶点 8 字节)逐角色拆开看:
| 角色 | 顶点数 | 骨骼数 | 顶点属性 | 调色板 | 合计 | 调色板占比 |
|---|---|---|---|---|---|---|
| innkeeper | 1800 | 28 | 14400 | 1344 | 15744 | 85‰ |
| pet_cat | 620 | 16 | 4960 | 768 | 5728 | 134‰ |
| guest_female | 3400 | 52 | 27200 | 2496 | 29696 | 84‰ |
| merchant | 3900 | 58 | 31200 | 2784 | 33984 | 81‰ |
| chef | 5200 | 72 | 41600 | 3456 | 45056 | 76‰ |
| boss_dragon | 8600 | 96 | 68800 | 4608 | 73408 | 62‰ |
这张表给出一个反直觉的结论:顶点属性始终是大头(占比 84% 以上),调色板最多只占 13%。boss_dragon 是顶点数最多的角色(8600),也是骨骼数最多的角色(96),但它的调色板占比反而最低(62‰)——因为顶点属性涨得比调色板快得多:顶点翻倍,顶点属性翻倍,而骨骼从 16 涨到 96 只有 6 倍。
这意味着:在蒙皮字节账上,砍顶点的收益远大于砍骨骼。boss_dragon 把顶点从 8600 砍到 4300,能省下 4300 × 8 = 34400 字节;把骨骼从 96 砍到 48,只省 48 × 48 = 2304 字节——差了 14 倍。当然,砍骨骼还有另一层收益:它可能把角色从 8.1 算出的「2 段」变回「1 段」,那是绘制调用的收益,不在本节字节账里。两笔账要分开算:字节账看顶点,绘制账看骨骼。
8.2.6 权重精度:8 位够不够
选 8 位权重还是浮点权重,表面是「精度」问题,落到账上是每顶点 4 字节的差别(idx8_w8 的 8 字节 vs idx16_wf32 的 24 字节)。那 8 位权重够用吗?做一次量化就知道:把一组 4 影响的权重缩放到 0–255,量化步长是 1000 // 255 = 3‰。
sample=[0.5, 0.25, 0.15, 0.1] → scaled=[128, 64, 38, 26] → sum=256(不是 255)
四舍五入之后权重和是 256 而不是 255——因为四个数各自独立取整,误差会累积。而蒙皮要求权重和恰好为 1(即 255),否则顶点会被整体缩放。所以 8 位权重不能各自独立取整,必须做归一化修正:把差值(这里多出的 1)从最大的那个权重里扣掉,得到 [127, 64, 38, 26],和恰好 255。这一步是每个用 8 位权重的管线都必须做的,不做就会出现「角色整体轻微胀缩」的诡异 bug。
一句话:8 位权重够用(3‰ 的步长远低于肉眼可辨),但前提是权重和必须被强制归一化——这笔账只有整数,能写进测试。
8.2.7 边界
本节算的是数据本身的字节,不是引擎的实际显存占用。真实 GPU 上还有顶点缓冲的页对齐、骨骼贴图的纹理对齐与 mip、双缓冲、以及「整批角色是否同时常驻」等开销。本节算的是下界。至于骨骼矩阵在显存里如何被采样、骨骼贴图对采样器数量的占用,需真实环境(目标 GPU / 引擎 / 渲染器),命令行不可复现,本节标 X,只消费「每顶点字节 × 顶点数 + 每骨骼字节 × 骨骼数」这个整数模型。
阅读导航:上一节:8.1 骨骼数与调色板上限 · 下一节:8.3 划界:蒙皮数学外链 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。