8.1 骨骼数与调色板上限
卷一 7.1 面数与骨骼分级预算 从美术分级侧给了每个档位的骨骼数红线;本节换到引擎侧:红线之外还有一道物理上限——一次绘制能送进顶点着色器的骨骼矩阵就那么多。这两条线经常被混为一谈,但它们是独立的:美术预算管「该做多少根」,调色板上限管「一次画得下多少根」。预算合格不等于装得下。
「移动端该给角色多少根骨骼」这个问题,既有短文 移动端与 PC 的性能预算 讲的是预算道理——为什么要有上限、超了会掉什么帧;本节做的是可跑的整数账:把上限从 uniform 预算推导出来,再逐个角色核对。骨骼矩阵怎么参与顶点变换——逆绑定矩阵、线性混合蒙皮、双四元数——是渲染原理,归 骨骼动画与蒙皮 ,本节不重推,只消费「骨骼数」与「每骨骼占几个 vec4」这两个整数。
8.1.1 什么算一根骨骼
在算账之前必须先统一「一根骨骼」的定义——不同的人数出来的骨骼数不一样,账就白算了。进调色板的只有蒙皮骨骼(skinning bones),即真正参与顶点权重的那批;其余三类通常不进调色板:
| 类别 | 是否进调色板 | 说明 |
|---|---|---|
| 蒙皮骨骼 | 进 | 顶点权重直接引用的骨骼,调色板就是它们的矩阵集合 |
| 扭曲骨(twist) | 进 | 前臂/小腿的辅助扭转骨,确实参与权重,算一根 |
| IK 目标 / 解算器节点 | 不进 | 是约束的输入输出,不是顶点权重的引用对象 |
| 空节点 / 分组骨 | 不进 | 只用来组织层级,没有权重引用 |
所以「这个角色有多少根骨骼」这句话,必须先问「问的是骨架总节点数,还是进调色板的蒙皮骨骼数」。预算表里的数、调色板里的数、DCC 里看到的数,三者可能都不一样。本节统一按蒙皮骨骼数记账——因为它才是调色板的实际大小。
还有一个容易漏的乘数:每顶点骨骼影响数。本节配置里是 influencesPerVertex=4,即每顶点最多受 4 根骨骼影响。这个 4 不进调色板(调色板是「一次绘制用到哪些骨骼」的全集),但它决定顶点蒙皮属性的字节数——那是 8.2 的账。骨骼数管调色板,影响数管顶点属性,两者别混。
8.1.2 调色板:一次绘制能装下多少骨骼
顶点着色器要把顶点从绑定姿势变换到当前姿势,必须拿到该顶点所属骨骼的矩阵。这个「当前姿势下的骨骼矩阵集合」就是调色板(bone palette)。它每帧、每次绘制都要重新上传,因为它随姿势变。
上传路线有两条:uniform 数组(骨骼矩阵放在常量缓冲 / 默认 uniform 块里,用骨骼索引直接下标取)与骨骼贴图(矩阵烘进一张浮点纹理,用骨骼索引当纹理坐标采样)。两条路线各有上限:uniform 数组受每阶段 uniform vec4 预算约束,骨骼贴图受纹理尺寸约束。本节只做 uniform 数组路线——它最容易踩到「骨骼数超过预算」的坑。
这里的关键认知是:调色板不是「骨架有多大」,而是「一次绘制要取多少根骨骼」。一个 96 根的骨架,如果某次绘制只用到其中 40 根,理论上只需要 40 根的调色板。但绝大多数管线为省事,直接把整副骨架塞进调色板——于是「骨架大小」就成了「调色板大小」。上限是给后者定的。
两条路线的选择不纯是性能问题,还牵扯硬件支持:老移动 GPU 常不支持顶点着色器里的动态下标,只能用骨骼贴图。那是硬件成因,8.3 会把它外链出去;本节只按 uniform 数组路线算账。
8.1.3 上限的整数公式
uniform 数组路线下,调色板上限由两个数决定,取小:
paletteLimitBones = min( (vertexUniformVec4 - reservedVec4) // vec4PerBone, hardPaletteCap )
vertexUniformVec4:每阶段可用的 uniform vec4 数量(顶点着色器一侧);reservedVec4:要留给 MVP、光照、材质参数等的名额,必须从预算里先扣掉;vec4PerBone:一根骨骼的矩阵占几个 vec4。用 3×4 仿射矩阵(省掉恒定的最后一行)是 3 个 vec4,用完整 4×4 是 4 个;hardPaletteCap:引擎侧为「一次绘制最多取几根骨骼」设的硬上限,防止美术把整个骨架无节制地塞进来。
reservedVec4 为什么必须显式预留?因为 uniform vec4 是所有顶点着色器 uniform 共享的一个池子,骨骼调色板只是其中最大的一块。本项目的 32 个预留名额大致这么分:
| 用途 | 约占 vec4 |
|---|---|
| MVP / 视图投影矩阵 | 8 |
| 骨骼数量与蒙皮参数 | 4 |
| 材质与光照参数 | 12 |
| 时间 / 环境等杂项 | 8 |
不预留、直接把 256 全给调色板,算出的是 256//3 = 85 根——但真正跑起来时 MVP 就没地方放了。预留不是保守,是算术的一部分。
代入本项目的规格(vertexUniformVec4=256、reservedVec4=32、vec4PerBone=3、hardPaletteCap=64):(256-32)//3 = 74,与硬上限 64 取小,得 64。也就是说 uniform 预算允许 74 根,但引擎硬上限卡在 64 根——真正生效的是 64。这条「两个数取小」的写法很重要:只算 uniform 预算会得出 74,与引擎实际行为差 10 根。
把 vec4PerBone 动一下,就能看出「为什么是 3 个 vec4」的量化答案:
vec4PerBone | uniform 上限 (256-32)//n | 硬上限 | 实际上限 |
|---|---|---|---|
| 4(完整 4×4 矩阵) | 56 | 64 | 56 |
| 3(3×4 仿射矩阵) | 74 | 64 | 64 |
骨骼矩阵的最后一行恒为 (0, 0, 0, 1)(仿射变换不产生投影),存 4×4 是白白多占一个 vec4。省掉它,uniform 上限从 56 抬到 74,实际生效上限从 56 抬到 64——多换出 8 根骨骼的名额。这就是为什么几乎所有骨骼调色板都按 3×4 存。
8.1.4 S 层:上限与每个角色的账
下面这段是 S 层脚本,读配置算出调色板上限,再对每个角色算三件事:是否超档位预算、需要几个蒙皮段、调色板本身占多少字节。全整数、无浮点:
#!/usr/bin/env python3
# file: sim/ta08_bones.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta08_bones.py
"""8.1 骨骼数与调色板上限(S 层,卷二 8.1)。
调色板(bone palette)是「一次绘制里送进顶点着色器的骨骼矩阵集合」。走 uniform 数组路线时,
它的上限由每阶段 uniform vec4 预算与「每骨骼占几个 vec4」共同决定:
paletteLimitBones = min((vertexUniformVec4 - reservedVec4) // vec4PerBone, hardPaletteCap)
本脚本读 config/ta08_bones.json,先算出调色板上限,再对每个角色算三件事:骨骼数是否超档位预算、
需要几个蒙皮段(ceil(bones / paletteLimit))、调色板本身占多少字节。全整数,落 out/ta08_bones.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_bones.json")
OUT = os.path.join(ROOT, "out")
def palette_limit(p):
"""uniform 数组路线的调色板上限(整数地板除 + 引擎硬上限取小)。"""
from_uniform = (p["vertexUniformVec4"] - p["reservedVec4"]) // p["vec4PerBone"]
return min(from_uniform, p["hardPaletteCap"]), from_uniform
def main() -> int:
cfg = json.load(open(CFG, encoding="utf-8"))
os.makedirs(OUT, exist_ok=True)
p = cfg["palette"]
limit, from_uniform = palette_limit(p)
tiers = {t["id"]: t for t in cfg["tiers"]}
chars = []
for c in cfg["characters"]:
budget = tiers[c["tier"]]["boneBudget"]
sections = (c["bones"] + limit - 1) // limit
palette_vec4 = c["bones"] * p["vec4PerBone"]
chars.append({
"id": c["id"], "tier": c["tier"], "bones": c["bones"],
"tierBudget": budget, "withinBudget": c["bones"] <= budget,
"overBudgetBy": max(0, c["bones"] - budget),
"skinningSections": sections,
"paletteVec4": palette_vec4,
"paletteBytes": palette_vec4 * 16,
})
over = [c["id"] for c in chars if not c["withinBudget"]]
split = [c["id"] for c in chars if c["skinningSections"] > 1]
lines = sorted(f"{c['id']}:{c['bones']}:{c['tierBudget']}:{c['skinningSections']}" for c in chars)
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
report = {
"project": cfg["project"], "paletteLimit": limit,
"fromUniformBudget": from_uniform, "hardPaletteCap": p["hardPaletteCap"],
"vec4PerBone": p["vec4PerBone"], "influencesPerVertex": p["influencesPerVertex"],
"characters": chars, "overBudgetIds": over, "sectionSplitIds": split, "digest": digest,
}
with open(os.path.join(OUT, "ta08_bones.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"paletteLimit={limit} (uniform={from_uniform} cap={p['hardPaletteCap']})")
for c in chars:
flag = "OVER" if not c["withinBudget"] else "ok "
print(f" {c['id']:<14} {c['tier']:<5} bones={c['bones']:<3} budget={c['tierBudget']:<3} "
f"{flag} sections={c['skinningSections']} paletteBytes={c['paletteBytes']}")
print(f"overBudget={len(over)} sectionSplit={len(split)} digest={digest}")
print("wrote out/ta08_bones.json")
return 0
if __name__ == "__main__":
sys.exit(main())
配置本身是一份 D 层数据(influencesPerVertex=4 是「每顶点最多受 4 根骨骼影响」的标准取值,8.2 会用到):
{
"project": "stardust-inn",
"specRef": "config/visual_spec.json",
"note": "卷二 8.1 的骨骼与调色板规格:调色板(bone palette)走 uniform 数组路线,上限由每阶段 uniform vec4 预算与每骨骼占用的 vec4 数共同决定,再与引擎侧硬上限取小。档位预算(boneBudget)是美术侧的分级红线,调色板上限(paletteLimit)是引擎侧一次绘制的物理上限——两者独立。骨骼矩阵的数学属 graphics 专题,本节只算骨骼数与上限。",
"palette": {
"strategy": "uniform-array",
"vertexUniformVec4": 256,
"reservedVec4": 32,
"vec4PerBone": 3,
"hardPaletteCap": 64,
"influencesPerVertex": 4
},
"tiers": [
{ "id": "low", "boneBudget": 30, "triBudget": 1500 },
{ "id": "mid", "boneBudget": 60, "triBudget": 6000 },
{ "id": "high", "boneBudget": 90, "triBudget": 20000 }
],
"characters": [
{ "id": "innkeeper", "tier": "low", "bones": 28 },
{ "id": "pet_cat", "tier": "low", "bones": 16 },
{ "id": "guest_female", "tier": "mid", "bones": 52 },
{ "id": "merchant", "tier": "mid", "bones": 58 },
{ "id": "chef", "tier": "high", "bones": 72 },
{ "id": "boss_dragon", "tier": "high", "bones": 96 }
]
}
实跑 /opt/homebrew/bin/python3 sim/ta08_bones.py:
paletteLimit=64 (uniform=74 cap=64)
innkeeper low bones=28 budget=30 ok sections=1 paletteBytes=1344
pet_cat low bones=16 budget=30 ok sections=1 paletteBytes=768
guest_female mid bones=52 budget=60 ok sections=1 paletteBytes=2496
merchant mid bones=58 budget=60 ok sections=1 paletteBytes=2784
chef high bones=72 budget=90 ok sections=2 paletteBytes=3456
boss_dragon high bones=96 budget=90 OVER sections=2 paletteBytes=4608
overBudget=1 sectionSplit=2 digest=ccf2fe02d4f4dd19
wrote out/ta08_bones.json
digest 是「角色:骨骼:预算:段数」六行排序后的 sha256 前 16 位,是字符串类指纹——它不含浮点,只用来在配置改动后一眼看出账有没有变。改一个角色的骨骼数,digest 必变。
8.1.5 两条独立约束:档位预算 vs 调色板上限
把 6 个角色的账摊开看,两条约束各自抓住了不同的角色:
| 角色 | 档位 | 骨骼 | 档位预算 | 预算判定 | 调色板上限 | 蒙皮段 |
|---|---|---|---|---|---|---|
| innkeeper | low | 28 | 30 | 合格 | 64 | 1 |
| pet_cat | low | 16 | 30 | 合格 | 64 | 1 |
| guest_female | mid | 52 | 60 | 合格 | 64 | 1 |
| merchant | mid | 58 | 60 | 合格 | 64 | 1 |
| chef | high | 72 | 90 | 合格 | 64 | 2 |
| boss_dragon | high | 96 | 90 | 超 6 根 | 64 | 2 |
两个结论一眼可见。其一,chef 预算合格却要拆两个蒙皮段:72 根 ≤ 90 根的红线,但 72 > 64 的调色板上限,于是这一次绘制装不下整副骨架,必须按骨骼切分成两个蒙皮段分别绘制。只查预算的闸门会放过它——这正是本节存在的理由。
其二,boss_dragon 两条线都越界:96 根既超档位预算 6 根,又超上限,同样拆两段。而它的「超预算」和「拆段」是两个独立的问题——就算把预算抬到 100 根,它照样因为 96 > 64 而拆段。
蒙皮段数会直接变成绘制调用。本批 6 个角色合计 1+1+1+1+2+2 = 8 段蒙皮——也就是说,光是这批角色的骨架,就至少要 8 次绘制。这把它和 7.2 屏幕占比与三角预算
以及第 5 章的图集批次耦合接上了:一个角色多一段蒙皮,就多一次绘制,就多一个材质槽的消耗。骨骼数不是「美术内部的事」,它一路传导到绘制调用预算。
反过来也成立:如果把 hardPaletteCap 从 64 抬到 96,chef 立刻从 2 段回到 1 段,整批段数从 8 降到 7。但抬上限意味着每次绘制的调色板更大,uniform 预算与带宽压力都上去了——上限不是越高越好,它是在「多画几次」和「每次画更多」之间选一个。这个取舍的量化(每段多少绘制、每段多少 uniform 字节)才是本节账的用处。
还有一条不改骨骼数的路:只上传这次绘制真正用到的骨骼。前文说过,调色板是「一次绘制要取多少根骨骼」,不是「骨架有多大」。如果某个角色的手臂网格只用到 30 根骨骼,理论上它的调色板只需要 30 根,而不是整副 96 根。把网格按骨骼使用集切成几块、每块只上传自己用到的骨骼子集,就能在不砍骨骼的前提下把每段调色板压到上限以内。代价是切分本身又多了绘制调用——所以这条路和「拆蒙皮段」往往是二选一,要看哪边更省。
8.1.6 边界
本节算的是上限与段数,不是帧率。真实 GPU 上,调色板上传的带宽、uniform 数组的动态索引是否被硬件真正支持(有的老移动 GPU 不支持顶点着色器里的动态下标,只能用骨骼贴图)、骨骼贴图路线下纹理采样的开销——需真实环境(目标 GPU / 引擎 / 渲染器),命令行不可复现,本节标 X。本节只断言:给定 uniform 预算与每骨骼 vec4 数,上限是多少;给定骨骼数与上限,段数是多少。这两件事是整数题,能逐位复现。
把「上限」和「段数」分开断言,是因为它们是两种不同的失败:上限算错了,闸门会放过装不下的角色;段数算错了,绘制调用预算会跟着错。两者都由同一份配置驱动——改一个 vec4PerBone,两个数一起变,这正是把「配置」与「账」分开写的好处。
一句话总结:骨骼预算和调色板上限是两条独立的线,闸门必须同时查。只查档位预算,chef 这种「预算内、装不下」的角色会静默滑过去,直到运行时才发现要多画一段。
阅读导航:上一节:7.3 LOD 切换的视觉跳变 · 下一节:8.2 蒙皮数据量与显存 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。