← 图形原理 · 交互课 课题六 · 动画与骨骼 · 第 1 课

蒙皮:一个顶点为什么会被两根骨头同时拉着

全课骨架:动画 = 每一帧先把骨架的姿态算出来,再把网格顶点按权重「挂」到骨头上。 这一课讲骨架那半边的第一步 —— 顶点怎么跟着骨头动。它看起来只是"乘个矩阵", 但里面藏着一个非常容易做错的地方(绑定时的那一乘),而做错的表现是"角色不动的时候网格就跑位了"。 保真度:真机可证(画面与读数都由本页自己的蒙皮管线给出;对照物是 FK 闭式解与 glTF 2.0 规范里的 jointMatrix 定义,读者能拿笔复核)。

(脚本启动中…)

定点观察:先点「回到绑定姿态(复位)」把两个角都归零,然后点「1.2 变体:不含 inverseBind」→ 看网格与骨头的相对位置 → 在绑定姿态下网格就已经跑位了(下探多少见表②):这就是"少了 inverseBind"的唯一症状。

本帧的读数

(脚本启动中…)

1.1 段 · 先让骨头动起来:FK 就是矩阵连乘

本段只增加一样东西:骨骼的世界变换(画面上只有骨架,没有网格;其余控件与本课各段一致)。 保真度:真机可证。网格是一堆静态顶点,想让它动,得先让"骨头"动起来。一根骨头在父骨上的局部姿态(转角 + 偏移), 乘上父骨的世界变换,就是它自己的世界变换 —— 这就是 FK(正向运动学),全部内容就是矩阵连乘。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 上臂角 θ₀ 前臂角 θ₁(相对父) 骨长 L₁ = L₂ = 0.9 变换只需要三样 (转角 a、平移 x,y) —— 骨骼是刚体变换 父的世界 ∘ 自己的局部 function fkWorld(th0, th1) { const w0 = { a: th0, x: 0, y: 0 }; // 根骨 const w1 = mul(w0, { a: th1, x: L1, y: 0 }); // ★ 连乘 return [w0, w1]; } // 变换的复合:p ∘ q 表示"先走 q 再走 p" mul(p,q) = { a: p.a+q.a, x: p.x + q.x*cos(p.a) - q.y*sin(p.a), y: p.y + q.x*sin(p.a) + q.y*cos(p.a) } 末端世界坐标 x = L₁cosθ₀ + L₂cos(θ₀+θ₁) y = L₁sinθ₀ + L₂sin(θ₀+θ₁) 这条闭式可直接手算 连乘的顺序不能反 (先父后子 = 先局部后世界) 见表① 逐行核对
定点观察:把「前臂角 θ₁」从 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 = 骨头现在的世界变换 × 绑定姿态下该骨头世界变换的逆。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 顶点 p(模型空间) 骨头现在的世界变换 绑定姿态的世界变换 缺了 inverseBind ⇒ bind pose 也被算进去 ⇒ 不动的时候网格就跑了 这是本段唯一的坑 function jointMatrix(world, bind, withBind) { return withBind ? mul(world, inv(bind)) : world; } function skinVertex(p, js, ws) { let x = 0, y = 0; for (let i = 0; i < js.length; i++) { const q = apply(js[i], p); x += ws[i] * q.x; y += ws[i] * q.y; // ★ 按权重求和 } return { x, y }; 绑定姿态下 jointMatrix ≡ 单位变换 ⇒ 顶点位移恰为 0 这是蒙皮管线的 第一条硬判据 缺了它 ⇒ 位移 = 绑定姿态的平移量(见表②)
定点观察:点「回到绑定姿态(复位)」把两个角归零,然后来回点「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)。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 权重 w₀、w₁ (由顶点离关节的 距离算出来) 硬权重 0/1 ⇒ 关节处出现折线 软权重(线性带) ⇒ 平滑过渡 w₀ + w₁ = 1 function mkMesh(nc, band) { for (let i = 0; i <= nc; i++) { const w0 = clamp((L1 + band - x) / (2*band), 0, 1); push({ x, y, w: [w0, 1 - w0] }); // ★ 和恒为 1 } // 于是关节处(w₀ = w₁ = 0.5)的顶点位置 = p′ = 0.5 · (jm₀·p) + 0.5 · (jm₁·p) —— 恰好是两条结果的中点(可手算) w = 1 / 0.5 / 0 从"只跟一根" 到"各占一半" w = 0.5 时 顶点落在中点 但中点不在圆弧上 —— 这是 1.4 的缺口
定点观察:把「权重带宽」从 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)最著名的缺陷 —— 肘部"塌陷"、肩膀"糖纸拧紧"。它是可以用一条式子算出来的。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 弯曲角 θ = θ₁ 关节处顶点的 垂直偏移 d 两根骨的旋转不同 ⇒ 平均出来的变换 不是刚体 半径 ⇒ d·cos(θ/2) // 关节处那个顶点:w = [0.5, 0.5]、p = (L1, d) const q0 = apply(jm0, { x: L1, y: d }); // 只跟上臂 const q1 = apply(jm1, { x: L1, y: d }); // 只跟前臂 const p1 = { x: 0.5*(q0.x + q1.x), y: 0.5*(q0.y + q1.y) }; // 关节的世界位置(它不随 θ₁ 动) const J = apply(w0, { x: L1, y: 0 }); const r = Math.hypot(p1.x - J.x, p1.y - J.y); // 半径 ★ 上式化简后恰好等于 d · cos(θ/2) —— 与 d 成正比、与 θ 无关地收缩 θ = 90° ⇒ 0.707 d(掉了 29%);θ = 180° ⇒ 0(完全塌) 关节处半径 r = d · cos(θ/2) θ = 0 → 1.000 d θ = 90° → 0.707 d θ = 180° → 0 这就是 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:给定末端目标点,怎么把关节角反解出来,以及为什么多数引擎用迭代而不是解析解。