8.2 蒙皮数据量与显存

蒙皮数据分两块:顶点上的蒙皮属性(4 索引 + 4 权重)与骨骼调色板(骨骼矩阵)。本节把两块都算成整数字节,并让索引位宽决定合法性:4 位最多 16 骨、8 位 256 骨。同一批角色用 idx8_w8 共 203616 字节、调色板只占 75‰,换 16 位索引加浮点权重则涨到 579936 字节。

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 索引位宽决定合法性

顶点上的骨骼索引是有位宽的,位宽直接决定一个骨架能不能用该格式:

格式索引位权重位每顶点字节最大骨骼数
packed444416
idx8_w8888256
idx8_w1681612256
idx16_wf3216322465536

最大骨骼数 = 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 红线
packed41/624807683248236‰远低于
idx8_w86/61881601545620361675‰合格
idx8_w166/62822401545629769651‰超
idx16_wf326/65644801545657993626‰超 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 字节)逐角色拆开看:

角色顶点数骨骼数顶点属性调色板合计调色板占比
innkeeper1800281440013441574485‰
pet_cat6201649607685728134‰
guest_female3400522720024962969684‰
merchant3900583120027843398481‰
chef5200724160034564505676‰
boss_dragon8600966880046087340862‰

这张表给出一个反直觉的结论:顶点属性始终是大头(占比 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 划界:蒙皮数学外链 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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