我们火柴人页面上的那个小人,没有序列帧,也没有任何动画文件。每一帧,几百行 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 里完整的两骨求解器,返回中间关节(膝或肘)。
推导过程是这样的:大腿(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.5 | 2.55 |
| 步幅 | 44 | 60 |
| 抬脚高度 | 15 | 26 |
| 支撑相占比(一只脚着地时间占整个周期的比例) | 0.62 | 0.40 |
| 髋部起伏幅度 | 5 | 12 |
支撑相占比(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 链,几乎不费代码,就能带来大部分“活着”的感觉。
这些效果都可以在火柴人页面上看到实际运行——它现在是《火柴人长枪跑酷》:奔跑、跳跃、挺枪突刺,动作实验室作为第二种模式保留。如果你喜欢火柴人,我们即将推出的物理解谜游戏《余墨》的主角也是一个火柴人——被画进了一本没画完的速写本。