开发日志 · 程序化动画

火柴人动画教程:在 HTML Canvas 上用两骨 IK 做程序化动画

我们的火柴人不用一张序列帧,是怎么走、跑、跳、耍长枪的:一个十行的 IK 求解器,一条让脚不再打滑的公式,再加几个让它活起来的廉价小技巧。

更新于 作者 胜遇工作室阅读约 10 分钟

我们火柴人页面上的那个小人,没有序列帧,也没有任何动画文件。每一帧,几百行 JavaScript 算出髋、膝、手和头该在哪里,再把结果画到 HTML <canvas> 上。在动作实验室里,它能走、跑、空手投掷、跳跃,还能打一套 17 式枪法,而且随时切换都不会出现肉眼可见的跳帧。

这个页面现在已经是一款可以玩的小游戏——火柴人长枪跑酷:火柴人扛着一杆长枪边跑边战,用双手向前突刺攻击(空格或 ↑ 跳跃,F 或 → 出枪)。原来的动作实验室作为第二种模式保留下来,你仍然可以逐个翻看这些动作——包括只在实验室里才有的空手投掷——看骨架怎么工作。这篇文章用源码里的真实片段,讲解背后的技术:基于余弦定理的两骨反向动力学(IK)、带缓动的关键帧轨道、落脚定位、挤压与拉伸,以及一点用于次级运动的 Verlet 物理。

程序化动画的火柴人在深紫色舞台上迈步
每一帧,火柴人都是由十几个实时算出的关节位置画出来的。

一副由数字组成的骨架

整个火柴人就是几根以画布单位计的骨骼长度:

const SPINE=54, NECK=13, HEADR=12;
const UARM=27, FARM=25, THIGH=37, SHIN=37;
const HIP_H=66;      // standing hip height: knees always slightly bent

注意 HIP_H(66)比 THIGH + SHIN(74)短。火柴人从不锁死膝盖站着,这正是它即使在待机时也显得有生气的原因。由于这些尺寸是固定的,脚本改为缩放画布:无论元素在屏幕上多大,都选一个缩放比例,让绘图空间始终约 440 单位高。于是在手机和 4K 显示器上,构图和间距完全一致。

每一帧,一个叫 buildPose(dt) 的函数返回一组屏幕坐标点——骨盆、胸口、头、肩、髋、双膝、双脚、双肘和双手——再由单独的绘制步骤把它们画出来。把“关节在哪”和“怎么画它们”分开,我们就能在不碰动作的前提下更换美术风格。

用余弦定理求解两骨 IK

反向动力学回答的问题是:髋在这里,我想让脚到那里,膝盖该在哪?对于只有两根骨骼的链,这个问题有精确的解析解,而且只要几行:

function ik(root, target, L1, L2, sign){
  let dx=target.x-root.x, dy=target.y-root.y;
  let d=Math.hypot(dx,dy);
  d=clamp(d, Math.abs(L1-L2)+.01, L1+L2-.01);
  const base=Math.atan2(dy,dx);
  let ca=clamp((L1*L1+d*d-L2*L2)/(2*L1*d),-1,1);
  const a=Math.acos(ca);
  const jointAng=base+sign*a;
  return V(root.x+Math.cos(jointAng)*L1, root.y+Math.sin(jointAng)*L1);
}

stickman.js 里完整的两骨求解器,返回中间关节(膝或肘)。

root(髋) joint(膝) target(脚) L1 L2 d a
髋、膝、脚构成一个三角形,三条边我们都已知。

推导过程是这样的:大腿(L1)、小腿(L2)和髋到脚的直线(d)构成一个三角形。三边已知,余弦定理就能给出任意一个角:

余弦定理

L2² = L1² + d² − 2·L1·d·cos(a) ⇒ cos(a) = (L1² + d² − L2²) / (2·L1·d)

a 是髋部处、髋到脚连线与大腿之间的夹角。髋到脚连线的方向是 base = atan2(dy, dx),所以大腿指向 base ± a。从髋出发沿这个方向走 L1,就到了膝盖。sign 参数决定取两个镜像解中的哪一个——膝盖朝前弯;双手握枪时,近侧和远侧手肘用相反的符号,让它们朝彼此相反的方向弯。

两处钳制让求解器足够稳健:

  • 钳制 d,让它刚好落在 [|L1 − L2|, L1 + L2] 以内。目标够不着时三角形不存在,acos 会返回 NaN;钳制之后,意思就变成“尽量朝它伸过去”。
  • 钳制余弦值到 [−1, 1]。浮点误差可能把它推到 1.0000001,同样会得到 NaN。

腿部代码在调用 ik() 之前还加了一道保险:如果脚的目标点离髋比 THIGH + SHIN − 1.5 还远,就沿同一条线把它拉回来。腿几乎可以伸直,但永远不会完全锁死,从而避免两骨链伸到极限时那种肉眼可见的“弹一下”。

腿用 IK,手臂主要用 FK

什么都用 IK 很诱人,但我们没这么做。脚必须落在精确的位置——地面上——所以腿用 IK 求解。走路时摆动的手臂没有目标点,它们有的是节奏。这时正向动力学(给定角度、算出位置)更简单,看起来也更自然:

function armFK(aUp, flex){
  const e=add(shoulder,{x:Math.sin(aUp)*UARM, y:Math.cos(aUp)*UARM});
  const h=add(e,        {x:Math.sin(aUp+flex)*FARM, y:Math.cos(aUp+flex)*FARM});
  return {elbow:e, hand:h};
}

这里的角度约定是“0 指向正下方,正值向前摆”,这样思考走路姿势会很直观。走路时每条手臂与同侧的腿反向摆动,向前摆时手肘弯得多些,向后摆时伸直——这个小细节能去掉那种机械的钟摆感。只有当手必须到达某个具体位置——握住枪杆——时,手臂才切换为 IK。

用一个相位变量驱动步态循环

走和跑都由一个从 0 循环到 1 的数字 phase 驱动。近侧脚用 phase,远侧脚用 phase + 0.5,两只脚永远相差半个周期。第二个数字 gait 从 0(走)过渡到 1(跑),所有步态参数都按它插值:

参数走(gait = 0)跑(gait = 1)
步频 cyc(每秒周期数)1.52.55
步幅4460
抬脚高度1526
支撑相占比(一只脚着地时间占整个周期的比例)0.620.40
髋部起伏幅度512

支撑相占比(duty factor)是最关键的那个。大于 0.5 时,每个周期里都有一段双脚同时着地——这正是“走”的定义。小于 0.5 时,会出现双脚都离地的时刻:腾空相,这正是跑看起来像跑的原因。gait 每帧都平滑地缓动向目标值,所以走跑之间切换永远不会跳变。

每只脚的轨迹分成两段。支撑阶段(它周期的前 duty 部分),脚贴着地面,从髋前方半个步幅线性滑到髋后方半个步幅。摆动阶段,它沿 smoothstep 曲线回到前方,同时被半个正弦波抬起:

if(pph<duty){ const u=pph/duty; fx=lerp(stride/2,-stride/2,u); fy=groundY; }
else{ const u=(pph-duty)/(1-duty); fx=lerp(-stride/2,stride/2,smooth(u));
      fy=groundY - stepH*Math.sin(PI*u); }

髋部用 -|sin(phase·2π)| 在每个周期里起伏两次(每步一次);跑步时这条曲线会做相位偏移,让最高点对上腾空相。脚还会按身体前倾的程度稍微往前推,让它们保持在重心下方。

落脚定位:告别太空步

火柴人在屏幕上原地不动,地面从它脚下滚过。如果滚动速度不对,踩实的那只脚就会在地面上打滑——经典的“太空步” bug。修复只要一行:

const scroll = stride*cyc/duty;   // ground speed = stride × cadence ÷ duty factor

为什么这样就对了?支撑阶段,脚相对于髋走过整整一个 stride。支撑阶段占一个周期的 duty,一个周期持续 1/cyc 秒,所以支撑阶段持续 duty/cyc 秒。因此脚相对身体向后移动的速度是

stride ÷ (duty ÷ cyc) = stride × cyc ÷ duty

只要地面恰好以这个速度向后移动,脚就和地面同步,也就踩实了。代入表里的数字,走路时滚动速度为 44 × 1.5 ÷ 0.62 ≈ 106 单位/秒,跑步时为 60 × 2.55 ÷ 0.40 ≈ 383。注意跑步快了三倍多,而步幅只长了三分之一:大部分功劳来自更短的支撑相。一个常见错误是用 stride × cadence,忽略了支撑相占比,结果支撑脚会慢慢漂移。

场景里的地面是一排柔和的圆点,每帧移动 scroll × dt。当火柴人停下移动去投掷、跳跃或演练枪法时,滚动降为零,圆点也随之停下。

带缓动的关键帧轨道

投掷和跳跃不是循环,而是有预备、动作和收势的一次性动作。为此我们用很小的关键帧轨道:由 [时间, 值] 对组成的数组,在 0 到 1 的归一化时间 p 上采样:

function track(t, keys, ease){
  ease=ease||smooth;
  if(t<=keys[0][0]) return keys[0][1];
  for(let i=0;i<keys.length-1;i++){
    const [t0,v0]=keys[i],[t1,v1]=keys[i+1];
    if(t<=t1){ let u=(t-t0)/((t1-t0)||1); return lerp(v0,v1,ease(u)); }
  }
  return keys[keys.length-1][1];
}

每个动画属性都有自己的轨道。跳跃时,身体的垂直偏移是:

rootY = track(p,[[0,0],[0.2,10],[0.32,-6],[0.5,-160],[0.68,-70],[0.82,4],[0.9,-2],[1,0]],
              (p<0.5?easeOutCubic:easeInCubic));

把它当成一个小故事来读:下蹲(+10,别忘了画布的 y 轴朝下)、蹬地、在最高点升到 160 单位、下落、落地时略低于地面来吸收冲击、站稳。缓动在中途切换:上升用 easeOutCubic(起跳快、接近顶点变慢),下落用 easeInCubic(起步慢、越落越快)。这是对重力抛物线一个廉价却很有说服力的模仿。

枪法把这个思路又往前推了一步:18 个路点(17 个姿势,外加一个与起势相同的收尾帧,让整套动作无缝循环),每个路点描述整个身体——枪的角度、握枪位置、前倾、重心转移、下蹲、步宽、抬起与挤压——每一段还可以单独指定缓动。两个技巧让它更好看。连续两个相同的路点会形成一个定格,也就是武术套路里那个“亮相”的瞬间。突刺则用 easeOutCubic,枪飞快刺出、再减速停进姿势,看起来干脆有力。

挤压与拉伸

跳跃还会对垂直缩放 sy 做动画:下蹲时压到 0.84,起跳时拉到 1.14,落地时再压到 0.82。水平缩放由它推导出来,让火柴人看起来体积不变:

sx = 1+(1-sy)*0.7;     // squash down → get wider; stretch up → get thinner
const pivot=V(anchorX, groundY);
const S=p=>V(pivot.x+(p.x-pivot.x)*sx, pivot.y+(p.y-pivot.y)*sy);

在 buildPose 的最后,每个关节都要经过 S()。以火柴人脚下地面上的点(而不是身体中心)为缩放中心,身体被挤压时脚依然踩在地上——正是动画师手绘时会画的样子。头部半径按 sx 和 sy 的平均值缩放。

次级运动:一根弹簧和一条 Verlet 链

主运动是角色在做的事,次级运动是跟着动的东西——头发、衣料、比身体慢半拍的脑袋。它加起来很便宜,效果却天差地别。我们的火柴人有两种。

头部的阻尼弹簧

头并不是死死固定在脖子上的。一根弹簧(刚度 260,阻尼 26)把它拉向理想位置,所以身体猛地一晃,头会滞后一下,再带着一点点过冲稳定下来。阻尼设得足够低,留下一点卡通式的晃动。

Verlet 头发

那撮头发是一条由四个点组成、间距 9 单位的链。Verlet 积分为每个点保存当前位置和上一帧位置,速度就是两者之差。所以更新非常简短:

const s=scarf[i], vx=(s.x-s.px)*0.86, vy=(s.y-s.py)*0.86;
s.px=s.x; s.py=s.y;
s.x+=vx+windX*dt*dt*60; s.y+=vy+g*dt*dt*60;

系数 0.86 是空气阻力。windX 是一个随跑速增大的向后推力(跳跃时还会额外加一下),g 是重力。移动完各点之后,再做六次距离约束迭代,把每对相邻点拉回正好 9 单位的间距,第一个点则钉在后脑勺上。钉住加约束,就是全部:跑步时头发向后飘,走路时垂下来,跳跃时甩动——没有一样是手工做的动画。

画出柔软的四肢

用直线画的火柴人看起来很僵。我们把每条肢体的三个点(比如髋、膝、脚)送进 Catmull-Rom 样条,加密成平滑曲线,再沿曲线填充一条渐细的带子——髋部粗、脚部细——两端是圆头。结果就像橡皮管卡通里的四肢,是弯曲而不是折叶式转动。远侧肢体先画,更细、更暗、透明度 42%,不用任何 3D 就有了纵深感。

最后,这个循环很“懂礼貌”。每帧的 dt 上限为 33 毫秒,一次卡顿不会把头发甩飞到屏幕另一头。舞台滚出视口或标签页被隐藏时,动画会暂停;如果访客的系统要求减少动态效果,它会以一帧静止画面暂停开始。

值得直接抄走的经验

  • 两骨 IK 只要十行。钳制距离和余弦值,它就永远不会产生 NaN。
  • 有目标的地方用 IK,有节奏的地方用 FK。
  • 地面速度 = 步幅 × 步频 ÷ 支撑相占比。算对了,任何步态下脚都能牢牢踩住地面。
  • 每段可单独设缓动的关键帧轨道,足以做出表现力十足的一次性动作。重复一个关键帧就得到定格。
  • 以接触点而不是中心为原点做挤压,并由高度推导宽度来保持体积。
  • 一根弹簧加一条四点 Verlet 链,几乎不费代码,就能带来大部分“活着”的感觉。

这些效果都可以在火柴人页面上看到实际运行——它现在是《火柴人长枪跑酷》:奔跑、跳跃、挺枪突刺,动作实验室作为第二种模式保留。如果你喜欢火柴人,我们即将推出的物理解谜游戏《余墨》的主角也是一个火柴人——被画进了一本没画完的速写本。