2D 线性混合蒙皮:权重数学、关节变形与接缝白点修复
2D 线性混合蒙皮:权重数学、关节变形与接缝白点修复
站内搜索
直接问 AI

2D 线性混合蒙皮:权重数学、关节变形与接缝白点修复

把上臂和前臂做成两个完全独立的 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.25w_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. 对独立图层增加 1 到 2 像素的向内遮盖重叠,不要让两个轮廓刚好相切。
  3. 导出透明 PNG 前做 alpha dilation,让透明边缘继承附近真实颜色,而不是白色。
  4. 使用预乘 Alpha 的纹理管线,并保证导出与运行时的 Alpha 模式一致。
  5. 检查权重总和,并对空间相邻顶点做有限平滑,保留轮廓硬边。

这也是浏览器动画资产生成器修复小碎块和接缝时采用“邻色合并 + 闭合轮廓 + 少量重叠”的原因:描边只能掩盖表面,连续几何与正确 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%。这个数只取决于弯曲角度,与权重画得多好、过渡多宽都无关——过渡宽度只能决定塌陷发生在多大范围内,不能决定塌陷的深度。

知道这一点之后,修法的选择就清楚了。要真正消除它,必须换掉「线性平均矩阵」这个前提:

  • 辅助骨骼(半角骨):在肘部插入一根始终保持父子夹角一半的骨骼,让顶点主要受它影响。这样任意两个有权重的骨骼之间的相对角度都不超过 θ/2cos(θ/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 前置内容见双骨骼反向运动学

发表回复

向下探索