把上臂和前臂做成两个完全独立的 SVG 图层很容易在肘部出现裂缝:旋转后两个轮廓不再共享同一条边,透明背景从缝隙中露出来,看起来就是白点或白线。更连续的做法,是在关节附近建立一个网格,让每个顶点同时受两根骨骼影响。
一、线性混合蒙皮的公式
设顶点绑定到 n 根骨骼,权重为 w_j,当前骨骼矩阵为 M_j,绑定姿态矩阵为 B_j。线性混合蒙皮(Linear Blend Skinning, LBS)为:
v' = sum_j w_j M_j B_j^-1 v
subject to: w_j >= 0, sum_j w_j = 1
B_j^-1 先把顶点从绑定姿态的世界空间移回骨骼局部空间,M_j 再把它送到当前姿态。漏掉逆绑定矩阵会让角色在第一帧就发生二次偏移。
二、两根骨骼的数值例子
某个肘部顶点在父骨变换后位于 p=(10,0),在子骨变换后位于 c=(6,8)。若权重为 w_parent=0.25、w_child=0.75,则:
v' = 0.25 p + 0.75 c
= 0.25(10,0) + 0.75(6,8)
= (7,6)
如果权重误写成 0.25 + 0.60 = 0.85 而没有归一化,顶点会向原点收缩。正确做法是先除以权重和,使总和重新等于 1。
三、权重过渡宽度如何影响关节
关节附近可以按顶点到肘部的横向距离生成初始权重。最简单的线性过渡是:
w_child(x) = clamp((x - (pivot - r)) / (2r), 0, 1)
w_parent(x) = 1 - w_child(x)
r 太小,关节像硬折纸;r 太大,变形会扩散到本应保持刚性的区域。下面的演示用蓝色表示父骨权重、红色表示子骨权重。改变关节角和过渡宽度,观察点阵如何连续弯曲。
权重 · 变形
蒙皮权重与接缝
理解线性混合蒙皮、权重归一化,以及关节处为什么会塌陷或露白。
四、接缝白点来自哪里
- 几何裂缝:两块图层边缘没有共享顶点,旋转后出现亚像素空隙。
- 透明边缘污染:PNG 的透明像素 RGB 是白色,双线性采样时白色被混进可见边缘。
- SVG 抗锯齿:相邻 path 分别栅格化,两个半透明边缘叠加或错开。
- 权重不连续:相邻顶点权重突变,边界在运动时被拉开。
五、生产中的修复顺序
- 优先让关节区域使用同一个网格或共享边界顶点。
- 对独立图层增加 1 到 2 像素的向内遮盖重叠,不要让两个轮廓刚好相切。
- 导出透明 PNG 前做 alpha dilation,让透明边缘继承附近真实颜色,而不是白色。
- 使用预乘 Alpha 的纹理管线,并保证导出与运行时的 Alpha 模式一致。
- 检查权重总和,并对空间相邻顶点做有限平滑,保留轮廓硬边。
这也是浏览器动画资产生成器修复小碎块和接缝时采用“邻色合并 + 闭合轮廓 + 少量重叠”的原因:描边只能掩盖表面,连续几何与正确 Alpha 才是根本。
六、LBS 的局限
LBS 是多个刚体变换的线性平均,弯曲角度很大时会出现体积损失,也就是常说的 candy-wrapper 或关节塌陷。二维角色常用补偿形状、辅助骨骼或手绘 corrective mesh 修正关键姿态,而不是盲目增加更多权重。
candy-wrapper 不是经验现象,是可以手算的退化
上一节说 LBS 在大角度弯曲时会体积损失。这件事经常被当成一个需要靠经验规避的现象,但它其实可以精确算出来,而且算一遍就明白为什么加权重解决不了。
问题的根源是:LBS 对矩阵做线性平均,而旋转矩阵的集合对加法不封闭——两个旋转矩阵的平均,一般不是旋转矩阵。
取最极端的情况:某个顶点被两根骨骼各占一半权重,两根骨骼的相对旋转是 180 度。设一根是单位旋转 R(0),另一根是 R(π):
R(0) = [ 1 0] R(π) = [-1 0]
[ 0 1] [ 0 -1]
0.5 R(0) + 0.5 R(π) = [0 0]
[0 0]
平均得到的是零矩阵。任何顶点乘上它都会塌到原点。这就是 candy-wrapper 的极限形态:手臂对折时,肘部那圈权重各半的顶点被压到骨骼轴上,截面收缩成一条线,看起来就像糖纸被拧了一下。
180 度是极端,一般角度下退化是连续的。权重各半、相对旋转 θ 时,平均矩阵的缩放因子是 cos(θ/2):90 度时截面缩到 71%,120 度缩到 50%。这个数只取决于弯曲角度,与权重画得多好、过渡多宽都无关——过渡宽度只能决定塌陷发生在多大范围内,不能决定塌陷的深度。
知道这一点之后,修法的选择就清楚了。要真正消除它,必须换掉「线性平均矩阵」这个前提:
- 辅助骨骼(半角骨):在肘部插入一根始终保持父子夹角一半的骨骼,让顶点主要受它影响。这样任意两个有权重的骨骼之间的相对角度都不超过
θ/2,cos(θ/4)的损失通常已经看不出来。二维里这是性价比最高的做法。 - 对偶四元数蒙皮:在刚体变换的李群上做插值而不是线性平均,从原理上消除塌陷。代价是关节外侧会出现轻微膨胀,而且实现复杂度高出一截。
- 补偿形状:在几个关键角度手绘正确的截面,中间插值。最可控,也最费人工。
反过来说,增加影响骨骼数、反复调整权重曲线,对这个问题完全无效——因为它们都没有改变「线性平均」这个根源。这是值得记住的一条:在动手调参数之前,先确认要修的东西是不是参数能影响的。
截断到四根骨骼时,必须先排序再截断
最后一节会提到「截断到前四个权重后要重新归一化」。这里有个更前置的问题:「前四个」指的是权重最大的四个,不是遍历时先遇到的四个。
影响列表在内存里的顺序通常来自绑定时的写入顺序,或者骨骼索引顺序,两者都和权重大小无关。如果实现是「取前四个然后归一化」,很可能把权重 0.7 的主导骨骼丢掉,留下四根各占 0.05 的次要骨骼。归一化会把这四个 0.05 放大到各 0.25,于是这个顶点完全被无关的骨骼控制。
infl.sort(key=lambda i: i.weight, reverse=True) # 先排序
infl = infl[:4] # 再截断
total = sum(i.weight for i in infl)
for i in infl: i.weight /= total # 最后归一化
这个 bug 的表现很有迷惑性:大部分顶点正常,只有少数几个在动起来时飞到奇怪的位置。因为只有影响骨骼数超过四根的顶点才会被截断,而那通常只发生在多个部件交汇的地方——腋下、胯部、领口。
还有一个配套的检查:导出前的权重和运行时的权重要用同一套截断规则验证。导出工具算出来的权重和为 1,如果运行时用不同的规则再截断一次,实际生效的权重和就不再是 1,顶点会朝原点收缩一点。这类偏差很小,通常表现为「模型在引擎里比在编辑器里瘦一圈」,而不会有任何报错。
权重不能按欧氏距离算,否则会穿过空隙渗透
前面给的初始权重是按顶点到关节的横向距离生成的,那是针对单个关节的一维简化。推广到整个角色、用「顶点到骨骼的欧氏距离」自动生成权重时,会遇到一个很典型的错误。
角色的手臂自然垂在身侧时,上臂骨骼和躯干之间可能只隔十几个像素。按欧氏距离算,躯干侧面那一片顶点离上臂骨骼非常近,于是被赋予了可观的权重。结果是抬起手臂时,躯干侧面会跟着被拉起来一块,像衣服粘在了手上。
这个 bug 的诊断特征很明确:变形的影响穿过了本不该连通的空隙。它在绑定姿态下完全看不出来,只在做出让两个部件分开的动作时才暴露。
根本原因是欧氏距离不理解「这两块是分开的」。正确的度量应该沿着模型本身走——测地距离,也就是在网格表面上从顶点走到骨骼的最短路径长度。躯干侧面的顶点要绕过肩膀才能到达上臂骨骼,测地距离很大,权重自然就低了。
完整的测地距离计算比较重,实践中常用热扩散近似:把骨骼当作热源,在网格上解一次扩散方程,得到的温度场直接当权重用。它天然满足非负、连续、随距离衰减,而且因为热量只能沿网格传播,不会跨过空隙。
如果工具链不支持这些,还有一个粗糙但有效的兜底:给每个骨骼限定一个部件范围,只有属于该部件及其相邻部件的顶点才允许被它影响。这等于用人工标注替代了几何度量,虽然笨,但把「渗透」这个问题彻底关掉了。
七、把接缝问题变成可测指标
“看起来没有白边”会受背景颜色、缩放比例和抗锯齿影响。更可靠的做法是保存绑定姿态和几个极端姿态,并对几何、权重和像素边缘分别检查。
| 检查项 | 计算或操作 | 通过条件 | 失败时先查 |
|---|---|---|---|
| 绑定姿态恒等性 | 用绑定姿态矩阵执行一次完整蒙皮 | 最大顶点位移小于 1e-6 | 逆绑定矩阵和矩阵乘法顺序 |
| 权重归一 | 对每个顶点计算 sum(w_j) | 与 1 的误差小于 1e-5 | 未初始化权重、导入精度和漏绑骨骼 |
| 邻域连续性 | 比较相邻顶点权重差和变形后边长 | 关节区没有孤立突变点 | 自动权重半径和轮廓硬边标记 |
| 像素边缘 | 在黑、白、红三种底色下以 1x/2x/4x 缩放截图 | 没有固定颜色的亮线或暗线 | 透明像素 RGB、预乘 Alpha 和纹理外扩 |
| 极端姿态 | 在设计允许的最大弯曲角渲染 | 轮廓不穿插,体积损失在可接受范围 | 补偿形状、辅助骨骼和权重过渡宽度 |
八、CPU 参考实现的关键循环
for vertex in mesh:
out = (0, 0, 0)
weight_sum = 0
for influence in vertex.influences:
w = max(influence.weight, 0)
out += w * bone_world[influence.bone]
* inverse_bind[influence.bone]
* vertex.bind_position
weight_sum += w
vertex.position = out / max(weight_sum, epsilon)
真实引擎会在 GPU 上并行执行相同逻辑,并限制每个顶点的影响骨骼数。无论 CPU 还是 GPU,截断到前四个权重后都要重新归一化;否则导出前权重和为 1,运行时截断后却会收缩。调试时保留一个 CPU 版本很有价值,它可以作为 shader 输出的数值基线。
九、下一步:让姿态随时间自然变化
骨骼和蒙皮解决“空间上在哪里”,关键帧曲线解决“什么时候到达”。继续阅读关键帧、插值与缓动,比较线性运动、缓入缓出和回弹曲线。IK 前置内容见双骨骼反向运动学。