一张角色图被拆成头、躯干、上臂和前臂后,动画系统首先要解决的不是“怎么动”,而是“每个部件相对于谁来描述位置”。如果所有图层都直接写世界坐标,移动肩膀时就必须手动重算肘、手腕和手掌。骨骼层级的价值,是把这种重复计算变成矩阵乘法。
一、局部坐标、锚点与世界坐标
设上臂是父骨骼,前臂是子骨骼。前臂的原点放在肘关节,它的局部点记作 v_local = [x, y, 1]^T。二维齐次变换把平移、旋转和缩放统一成一个 3 x 3 矩阵:
M = T(tx, ty) R(theta) S(sx, sy)
T = [1 0 tx] R = [cos(theta) -sin(theta) 0]
[0 1 ty] [sin(theta) cos(theta) 0]
[0 0 1] [ 0 0 1]
若图层本身的图像原点不在关节上,还要围绕锚点 p 旋转:
M_pivot = T(p) R(theta) T(-p)
这也是“旋转后部件突然绕画布左上角转圈”的根本原因:实现里漏掉了把坐标移到锚点再移回去的两次平移。
二、父子骨骼为什么要做矩阵连乘
子骨骼的世界矩阵不是自己的局部矩阵,而是父级世界矩阵与子级局部矩阵的乘积:
M_world(child) = M_world(parent) M_local(child)
v_world = M_world(child) v_local
矩阵乘法不交换顺序。T R 表示先在局部空间旋转,再移动到父级位置;R T 则会连同平移向量一起旋转,通常得到完全不同的轨迹。
三、一个可手算的两段骨骼例子
根节点位于 (180, 260),上臂长度为 155,父骨旋转 25 度。肘部坐标为:
elbow.x = 180 + 155 cos(25°) = 320.48
elbow.y = 260 - 155 sin(25°) = 194.49
如果前臂局部再旋转 40 度,它在世界空间中的角度是 25° + 40° = 65°,而不是 40 度。前臂长度为 125 时,末端坐标约为:
hand.x = 320.48 + 125 cos(65°) = 373.31
hand.y = 194.49 - 125 sin(65°) = 81.20
下面的演示把同一计算实时画出来。改变两个角度,观察“子骨局部角度”和“子骨世界角度”的差别。
矩阵 · FK
骨骼变换与正向运动学
从锚点、局部坐标和齐次矩阵推导父子骨骼的世界坐标。
四、SVG 图层如何绑定到骨骼
如果资产文件已经有 <g id="left-forearm">,可以把每个部件的绑定信息写成一个小型 manifest:父级 ID、锚点、初始旋转和层级顺序。运行时只更新外层 transform,不要每帧重写内部 path 数据。
{
"id": "left-forearm",
"parent": "left-upper-arm",
"pivot": [42, 18],
"rotation": 0
}
这种结构还能把绘制顺序与骨骼层级分开。手臂的父子关系决定运动传递,但前臂究竟画在躯干前面还是后面,应由独立的 z-order 决定。
五、常见错误与检查顺序
- 绕错中心:检查锚点是否在部件本地坐标中,以及是否使用
T(p) R T(-p)。 - 子骨角度不跟随:检查是否计算了
M_parent M_child,而不是直接使用子骨局部矩阵。 - 缩放导致关节漂移:确认非均匀缩放发生在哪个空间,并避免把缩放重复施加到子级长度。
- 前后关系错误:把骨骼父子关系和 SVG DOM 绘制顺序分开维护。
六、用不变量验证层级实现
矩阵代码最可靠的测试不是截图,而是不变量。绑定姿态、零旋转和纯父级变换都应该产生可预测结果。下面的表可以直接转成单元测试:
| 测试 | 输入 | 预期结果 | 能发现的问题 |
|---|---|---|---|
| 单位姿态 | 所有局部矩阵为单位矩阵 | 每个顶点保持原坐标 | 默认缩放、隐式画布偏移 |
| 只移动根节点 | 根节点平移 (20, -10) | 所有后代世界坐标增加同一偏移 | 子节点遗漏父矩阵 |
| 只旋转父节点 | 父骨 30°,子骨局部角 0° | 子骨世界角 30°,骨长不变 | 把局部角误当世界角 |
| 旋转锚点 | 锚点本身作为待变换点 | 变换前后锚点世界位置不变 | T(p) R T(-p) 顺序错误 |
| 长度保持 | 只含旋转和平移 | 两关节距离误差小于容差 | 缩放重复应用、矩阵元素写错 |
实现时可以在每帧调试模式下记录 bone_id、局部角、世界角、锚点世界坐标和矩阵行列式。没有缩放时,二维旋转部分的行列式应接近 1;明显偏离通常意味着缩放或剪切被意外带入。
七、更新顺序的参考伪代码
updateWorld(bone, parentWorld):
local = translate(bone.position)
* translate(bone.pivot)
* rotate(bone.angle)
* scale(bone.scale)
* translate(-bone.pivot)
bone.world = parentWorld * local
for child in bone.children:
updateWorld(child, bone.world)
这个遍历必须从根节点开始,并且一帧只计算一次。若 UI、IK 和渲染器分别修改矩阵,很容易出现同一帧中一部分部件使用旧姿态、另一部分使用新姿态的撕裂。更稳定的做法是先收集所有局部通道,再统一求世界矩阵,最后只读地交给蒙皮和渲染。
每帧重建矩阵,不要在上一帧的基础上累乘
上面那张表里有一项是「行列式应接近 1」,它能抓到的最隐蔽的一类问题,是矩阵累乘导致的数值漂移。
写增量式动画时很容易这样实现:角色每帧转动一点,就把上一帧的矩阵乘上一个小的增量旋转矩阵。
bone.matrix = bone.matrix * rotate(delta) // 危险
数学上这是对的,浮点数上不是。每次乘法都引入约 1e-7 量级的舍入误差,旋转部分的两个列向量会慢慢失去正交性和单位长度。跑上几千帧之后,这个矩阵表达的就不再是纯旋转,而是混进了微小的缩放和切变——表现为部件在长时间播放后逐渐变形,或者尺寸缓慢漂移。
这类 bug 的特征是短时间测试完全正常。开发时看几秒钟没问题,只有在待机动画循环播放十几分钟之后才看得出来,而那时候几乎没人会怀疑到矩阵实现上。
可靠的做法是让角度成为唯一的状态,矩阵每帧从角度重新构造:
bone.angle += delta // 状态是标量
bone.matrix = compose(bone.position,
bone.angle,
bone.scale) // 每帧重建
这条推广开来是:不要把浮点矩阵当作状态来累积。状态应该保存在最小的、可以精确表达的量上(这里是角度和位置),矩阵是每帧从状态派生出来的产物。同样的道理适用于四元数——归一化不是可选的优化,而是防止同一类漂移的必要步骤。
如果因为架构原因必须累乘,至少要定期做一次正交化:把旋转部分的第一列归一化,第二列减去它在第一列上的投影后再归一化。代价很小,能把误差压回去。
混合两套姿态时,不能对矩阵逐元素插值
同一个道理还有第二种表现,出现在需要把两套姿态混合起来的时候——比如从走路过渡到跑步,或者上半身用瞄准姿态、下半身用移动姿态。
最省事的写法是把两个矩阵按权重逐元素加起来:
M = (1 - t) * M_a + t * M_b // 错
这在数学上和 candy-wrapper 是同一个问题:旋转矩阵的集合对加法不封闭。两个旋转矩阵按权重相加得到的一般不是旋转矩阵,中间过程会混进缩放和切变。90 度差的两个姿态取中点,得到的矩阵行列式约为 0.85——部件在过渡过程中会缩水 15%,到达终点又恢复,看起来像是「呼吸」了一下。
正确做法是分解到通道再分别插值:平移按线性插值,缩放按线性(或对数)插值,角度按最短路径插值,最后重新组装成矩阵。
pos = lerp(a.pos, b.pos, t)
scale = lerp(a.scale, b.scale, t)
angle = a.angle + shortest_delta(a.angle, b.angle) * t
M = compose(pos, angle, scale)
这也是为什么骨骼系统里应该保存 TRS 通道而不是保存矩阵。矩阵是终点,不是中间表示——任何需要插值、混合或求平均的地方,都要退回到通道层。
角色转身时,绘制顺序要整组翻转
前面提到骨骼层级和绘制顺序应该分开维护。这条在 2D 里有一个三维骨骼不会遇到的后果,值得单独说:角色左右转身时,绘制顺序必须整体翻转,而骨骼层级不变。
角色面向右侧时,靠近观众的是右臂,它画在躯干前面;左臂在躯干后面。角色转向左侧之后,这个关系整个反过来——左臂到了前面。骨骼的父子关系完全没变(左臂始终是躯干的子级),变的只是 z-order。
如果只用一个水平翻转来实现转身(把整个角色沿 x 轴镜像),这个问题会被掩盖,因为镜像同时把两条手臂的位置也换了。但一旦角色需要做「转身过程中的中间帧」,或者上半身朝一侧而下半身朝另一侧,镜像就不够用了,绘制顺序必须能独立于姿态被控制。
实际做法是给每个部件存两套顺序值,按朝向选取:
{
"id": "left-arm",
"parent": "torso",
"z_facing_right": 10,
"z_facing_left": 90
}
渲染时按当前朝向读取对应的那个值排序。朝向本身是一个独立于骨骼角度的状态,通常由根节点的水平缩放符号或一个显式的 facing 字段决定。
这里有个容易漏的细节:朝向切换必须发生在整帧的边界上,不能在遍历骨骼的过程中改变。如果排序用的朝向和计算姿态用的朝向来自同一帧的不同时刻,会出现某一帧里手臂位置已经转过去、绘制顺序还没翻的情况——表现为一帧的闪烁,很难复现也很难定位。
八、下一步:从末端目标反求关节角度
正向运动学回答“给定角度,手会在哪里”。下一篇双骨骼反向运动学反过来回答“给定手的位置,肩和肘应该转多少度”。如果素材还没有稳定锚点,先回到人物图片转 SVG 动画资产管线完成分层和命名。