蒙皮:一个顶点为什么会被两根骨头同时拉着
全课骨架:动画 = 每一帧先把骨架的姿态算出来,再把网格顶点按权重「挂」到骨头上。
这一课讲骨架那半边的第一步 —— 顶点怎么跟着骨头动。它看起来只是"乘个矩阵",
但里面藏着一个非常容易做错的地方(绑定时的那一乘),而做错的表现是"角色不动的时候网格就跑位了"。
保真度:真机可证(画面与读数都由本页自己的蒙皮管线给出;对照物是 FK 闭式解与 glTF 2.0 规范里的 jointMatrix 定义,读者能拿笔复核)。
这台设备拿不到 canvas 2D 上下文,活例跑不了。
本页的原理与数值表照常可读(画面只是把同一个数画出来)。
(脚本启动中…)
定点观察:先点「回到绑定姿态(复位)」把两个角都归零,然后点「1.2 变体:不含 inverseBind」→ 看网格与骨头的相对位置 → 在绑定姿态下网格就已经跑位了(下探多少见表②):这就是"少了 inverseBind"的唯一症状。
本帧的读数
(脚本启动中…)
1.1 段 · 先让骨头动起来:FK 就是矩阵连乘
本段只增加一样东西:骨骼的世界变换(画面上只有骨架,没有网格;其余控件与本课各段一致)。
保真度:真机可证。网格是一堆静态顶点,想让它动,得先让"骨头"动起来。一根骨头在父骨上的局部姿态(转角 + 偏移),
乘上父骨的世界变换,就是它自己的世界变换 —— 这就是 FK(正向运动学),全部内容就是矩阵连乘。
定点观察:把「前臂角 θ₁」从 0 拉到 −150°,同时把「上臂角 θ₀」也动一动 →
看画面里那一节前臂是不是始终接在上臂的末端上,以及表① 的「实测末端」两列 →
末端坐标恰好等于那条闭式(误差 1e−16 量级):FK 之所以"没有难点",是因为它只是把局部变换按父子顺序乘起来 ——
而这份顺序正是后面所有内容的基础。
表① FK 正解:末端世界坐标对闭式解(L₁ = L₂ = 0.9 m、根骨原点在 (0,0))
| θ₀ (°) | θ₁ (°) | 实测末端 x (m) | 闭式 x | 实测末端 y (m) | 闭式 y |
算式:前臂的世界转角是 θ₀ + θ₁(因为它挂在转过的上臂上),所以末端位置
x = L₁cosθ₀ + L₂cos(θ₀+θ₁)、y = L₁sinθ₀ + L₂sin(θ₀+θ₁)。拿 θ₀ = −90°、θ₁ = 0 验算:
x = 0.9×0 + 0.9×0 = 0,y = 0.9×(−1) + 0.9×(−1) = −1.8 —— 表① 那一行应当逐位对上(差 0)。
本段真正要看的不是这条式子,而是"连乘的顺序":局部变换是"相对父骨"的,所以必须是
父的世界 ∘ 自己的局部,反过来就是把旋转加到了错误的那一支上。
1.2 段 · 把顶点挂上去:那一乘 inverseBind
本段只增加一样东西:jointMatrix(网格出现;是否乘 inverseBind 由按钮切;其余控件一致)。
保真度:真机可证。骨头会动了,可顶点是在建模时的姿态下摆放的(那道姿态叫 bind pose / 绑定姿态)。
如果直接把顶点乘上"骨头现在的世界变换",那么连绑定姿态本身都会被算进去 ——
于是角色一动不动的时候,网格就已经移位了。正确的写法(glTF 2.0 规范原文)是:
jointMatrix = 骨头现在的世界变换 × 绑定姿态下该骨头世界变换的逆。
定点观察:点「回到绑定姿态(复位)」把两个角归零,然后来回点「1.2 蒙皮」与「1.2 变体:不含 inverseBind」 →
看网格相对骨头的位置,以及表② 的「顶点最大位移」一列 →
含 inverseBind 时位移恰好是 0;不含时整条前臂的网格被平移到了别处:
因为绑定姿态里那根骨头本来就有平移(L₁ = 0.9 m),少了它的逆,这一乘就被算了两遍。
表② inverseBind 的作用:绑定姿态下顶点的位移(θ₀ = θ₁ = 0,网格 28×2 个顶点、L₁ = L₂ = 0.9 m)
| 写法 | 顶点最大位移 (m) | 前臂段顶点平均位移 (m) | jointMatrix[1] 的平移 |
算式:jointMatrix = world × inv(bind)(glTF 2.0 规范对 Skins 的定义)。在绑定姿态下
world ≡ bind ⇒ jointMatrix ≡ world × inv(world) = 单位变换 ⇒ 顶点一动不动。
拿前臂验算:它的绑定姿态世界变换是"平移 (0.9, 0)",所以 少了 inverseBind 那一乘时,前臂上的顶点会整体被平移 0.9 m,
后一段还要再叠上它自己的旋转 —— 表② 最后一列就是那个没被抵消掉的平移。
这条"绑定姿态下必须零位移"是蒙皮管线的第一条硬判据,本页把它写进了自检(见页面末尾的 `__selftest`)。
1.3 段 · 一根骨头不够:权重混合
本段只增加一样东西:每个顶点的权重(权重由按钮切出"硬"与"软"两档;其余控件一致)。
保真度:真机可证。1.2 里每个顶点只挂一根骨头,于是关节处会出现一条折线:
关节左边的顶点跟着上臂、右边的跟着前臂,中间的接缝被硬生生拉开。
真实的蒙皮是每个顶点同时挂几根骨头,各自算一遍再按权重加权求和(glTF 规定每顶点最多 4 根,
权重的和 = 1)。
定点观察:把「权重带宽」从 0.02 拉到 0.5,再把「前臂角 θ₁」拉到 −90° →
看关节附近那几个顶点被"拉"开的速度,以及表③ 的「实测 x′」 →
带宽越小,关节处的过渡越窄、折角越硬;w = 0.5 的那一点恰好落在两个结果的中点:
权重不是"哪个更重",而是两根骨头的变换各出多少力,所以它必须满足"和 = 1"。
表③ 权重混合:关节附近顶点的位置(θ₀ = 0、θ₁ = −90°、顶点 y = 0.18 m、L₁ = 0.9 m)
| 顶点 x (m) | w₀ | 只跟上臂 (m) | 只跟前臂 (m) | 实测混合后 x′ (m) | 0.5 中点 只有 w₀=0.5 那行相等 |
算式:p′ = Σᵢ wᵢ · (jointMatrixᵢ · p),且Σᵢ wᵢ = 1。当 w₀ = w₁ = 0.5 时,
p′ 恰好是"只跟上臂"与"只跟前臂"两个结果的中点 —— 拿 x = 0.90 那一行验算:(0.900000 + 1.080000) ÷ 2 = 0.990000,与实测逐位相同。其余四行不是中点(它们按自己的权重加权),所以最后一列只在 w₀ = 0.5 那一行成立 —— 这也是"权重不是平均数、而是各出多少力"的直接证据。
权重带宽决定"过渡有多软":带宽 → 0 就退化成 1.2 的硬绑定(关节处一条折线),
带宽越大越平滑、但"跟着哪根骨头"的归属也越模糊 —— 这是所有蒙皮资产都要调的那个旋钮。
1.4 段 · 线性混合的代价:关节会掉体积
本段只增加一样东西:弯曲角(前臂角 θ₁ 从 0 拧到 −150°,其余控件一致)。
保真度:真机可证。1.3 的中点是"按权重求平均"—— 而平均两个不同朝向的刚体变换,得到的一般不再是刚体变换。
后果很具体:关节附近的顶点会被往关节里吸,弯得越狠越明显,这就是线性混合蒙皮(LBS)最著名的缺陷 ——
肘部"塌陷"、肩膀"糖纸拧紧"。它是可以用一条式子算出来的。
定点观察:把「前臂角 θ₁」从 0 一路拧到 −150° →
看关节附近那一圈网格是不是越弯越"瘪",以及表④「实测半径 ÷ d」一列 →
半径按 d·cos(θ/2) 收缩:弯 90° 就只剩 70.7%(掉 29%),弯 150° 只剩 25.9%:
这不是"调参没调好",而是把两个不同朝向的刚体变换按权重相加这件事本身的代价 ——
要治它就得换更贵的表示(双四元数、或加辅助骨),本页如实标出这一点、不做假动画。
表④ 线性混合的代价:关节处顶点的半径随弯曲角收缩(w = [0.5, 0.5]、顶点垂直偏移 d = 0.18 m、θ₀ = 0)
| 弯曲角 θ (°) | 实测半径 (m) | 算式 d·cos(θ/2) (m) | 实测 ÷ d | 算式 ÷ d |
算式:关节处顶点取 p = (L₁, d)、权重各 0.5。把两个刚体变换的结果按 0.5 相加,化简后
它到关节的距离恰好等于 d·cos(θ/2) —— 与骨长无关、只与垂直偏移 d 和弯曲角 θ 有关。
拿 θ = 90° 验算:cos45° = 0.70711 ⇒ 半径 = 0.18 × 0.70711 = 0.12728 m —— 表④ 那一行应当对上。
θ → 180° 时半径 → 0:关节彻底被吸成一个点。这就是 LBS 的"塌陷/糖纸拧紧",
它也是"为什么后来的引擎要用双四元数蒙皮或辅助骨"的直接理由(附录 A1)。
课末:读完姿态,就该去写姿态了
这一课把骨架那件事的前半段做完了:姿态 → 顶点。四段依次加了四样东西 ——
骨骼的世界变换(FK 连乘)、jointMatrix 里的那一乘 inverseBind、每个顶点的权重、以及弯曲角。
后半段是反过来问:想让手抓到某个点,关节角该是多少?那是 IK(反解),也归骨架这一边。
之后的次级动画(头发、尾巴、挂坠)走的是另一条路:不是"算姿态",而是让骨头自己变成一根弹簧。
如果只记一句话:蒙皮是"读姿态"的人,IK 与弹簧骨是"写姿态"的人;而绑定姿态(bind pose)是这两半之间的那份合同 —— 少了那份合同(inverseBind),两边就对不上。
下一课讲 IK:给定末端目标点,怎么把关节角反解出来,以及为什么多数引擎用迭代而不是解析解。