SVG 纯 CSS 两骨骼 IK 腿部动画 (Two-Bone IK Rig in Pure CSS SVG)
目标
在一个纯 SVG + CSS 关键帧的插画里,让骑行者的双脚真实跟随圆周运动的踏板——膝盖按力学关系弯曲,而不是把整条腿硬邦邦地绕髋关节转一个角度。没有 JavaScript,没有 SMIL,只有 @keyframes。
适用场景:骑行、跑步、划船、任何"末端执行器沿已知轨迹运动"的 2D 矢量角色动画。
为什么单段旋转不行
把大腿+小腿画成一条固定路径、整体绕髋关节旋转,脚踝的位移是 L·Δθ(L≈整条腿长)。踏板圆周半径 50px 时脚踝需要上下走 100px,反推 Δθ≈40°,此时脚踝还会水平甩出巨大距离——完全不像踩踏。必须让膝盖参与,即两段骨骼。
核心结构:小腿嵌套在大腿组内
<g class="tN"> <!-- 大腿:绕髋关节旋转 -->
<path d="M348 244C378 262 414 292 445 343"/> <!-- hip -> knee -->
<g class="sN"> <!-- 小腿:嵌套,绕膝关节旋转 -->
<path d="M447 336C454 372 446 414 424 452"/> <!-- knee -> ankle -->
</g>
</g>.tN{animation:thighK 1.2s linear infinite;transform-origin:352px 255px;transform-box:view-box}
.sN{animation:shinK 1.2s linear infinite;transform-origin:445px 343px;transform-box:view-box}关键点:transform-origin 用元素自身局部用户空间的坐标。对子元素 .sN 来说,局部空间就是父组变换之前的坐标系,所以直接填"画出来的那个膝关节坐标" (445,343) 即可,不需要做任何逆变换。
最容易错的一步:小腿关键帧是局部量
小腿继承了父级的旋转,所以它的关键帧必须是世界角增量减去大腿增量:
dShinLocal = (shinWorld(θ) − shinWorld(θ₀)) − (thighWorld(θ) − thighWorld(θ₀))漏减这一次,膝盖位置会完全正确(因为它只由大腿决定),但脚踝会甩到天上去——误差可达 130px 以上。症状极具辨识度:膝对、踝错。
离线解 IK,烘焙成关键帧
浏览器里没法实时解 IK,所以在 Node 里离线求解、把角度采样成关键帧:
function ik(theta){ // theta = 曲柄角
const P = {x: C.x + RP*Math.cos(theta*D2R), y: C.y + RP*Math.sin(theta*D2R)};
const A = {x: P.x + OFF.x, y: P.y + OFF.y}; // 脚踝 = 踏板轴 + 偏移
const dx = A.x-H.x, dy = A.y-H.y, d = Math.hypot(dx,dy);
const phi = Math.atan2(dy,dx);
const a = Math.acos((L1*L1 + d*d - L2*L2)/(2*L1*d)); // 余弦定理
const K = {x: H.x + L1*Math.cos(phi-a), y: H.y + L1*Math.sin(phi-a)};
return { thigh: 角度(H→K), shin: 角度(K→A) };
}膝关节取 phi - a(而不是 phi + a)决定了膝盖朝前弯还是朝后弯——选错会得到反关节。
采样密度:每圈 10 点(36° 步进)线性插值,脚踝最大偏差 3.6px(对比 L2=112px 的小腿,视觉不可见)。8 点 5.7px,12 点 2.5px。注意偏差不随采样数线性下降——折叠相位是病态的,加密采样收益递减,10~12 点即可收手。
远侧腿复用同一组关键帧
不需要第二套关键帧。前提是远侧腿用与近侧完全相同的路径形状绘制,只做一个 translate(-6,-7) 的深度错位,然后:
.tF{animation:thighK 1.2s linear infinite -0.6s} /* -半周期 = 相位差 180° */数学上成立的原因:远腿被画在近腿的姿态 f(θ₀) 上,延迟半周期后被施加 f(θ₀+180°+ωt) − f(θ₀),合成世界角恰好是 f(θ₀+180°+ωt)。
反例:如果远腿画在自己真实的初始姿态 f(θ₀+180°) 上,小腿的局部增量会差一个常数 [g(θ₀)−g(θ₀+180°)] − [f(θ₀)−f(θ₀+180°)],大腿对、小腿错。要么画成同形状,要么老老实实生成第二套关键帧。
变体路线:SMIL d 形变(绝对坐标,不嵌套)
上面是"角度驱动 + 骨骼嵌套"。另有一条等效路线:放弃嵌套,把整条腿画成一条 M 髋 L 膝 L 踝 的折线,把离线解出的膝/踝绝对坐标直接写进关键帧,用 SMIL 逐帧替换 d:
<path fill="none" stroke-width="26" stroke-linecap="round" stroke-linejoin="round"
d="M 328 262 L 411.5 307.3 L 432 400">
<animate attributeName="d" dur="2s" repeatCount="indefinite"
keyTimes="0;0.125;0.25;0.375;0.5;0.625;0.75;0.875;1"
values="M 328 262 L 411.5 307.3 L 432 400;
M 328 262 L 391.1 333.0 L 422.6 422.6;
M 328 262 L 384.7 338.3 L 400 432;
... ;
M 328 262 L 411.5 307.3 L 432 400"/>
</path>两条路线的取舍:
| 角度驱动 + 嵌套组 | SMIL d 形变 | |
|---|---|---|
| 关键帧内容 | 关节角度增量,须减去父级增量 | 关节绝对坐标,无父子耦合 |
| 最易错处 | 小腿漏减大腿增量(症状:膝对踝错) | 无——不存在局部量与世界量的换算 |
| 骨骼长度 | 由路径几何保证 | 由相邻关键帧的 d 自行保证 |
| 相位控制 | 纯 CSS,animation-delay 即半周期 | SMIL 与 CSS 共用文档时间线,同源不漂移 |
| 代价 | 需构造角度关键帧 | 坐标要逐点手算,写死程度更高 |
硬约束:values 里所有路径的命令序列必须完全一致(本例全是 M … L … L …),否则浏览器无法插值,表现为直接跳变而非平滑过渡。
选择建议:需要 animation-delay / CSS 变量做相位与参数化时用角度驱动;只有一条固定姿态曲线、想彻底绕开父子坐标换算时用 d 形变。
踏板必须"公转 + 等量反向自转"
踏板是刚体。跟着曲柄绕五通做圆周公转时,若不反向自转同样的角度,踏板会跟着曲柄翻跟头。一个复合 transform 同时表达两者,且完全不依赖 transform-origin:
@keyframes pedal-near {
from { transform: translate(400px,400px) rotate(0deg) translate(32px,0px) rotate(0deg); }
to { transform: translate(400px,400px) rotate(360deg) translate(32px,0px) rotate(-360deg); }
}
@keyframes pedal-far { /* 相位差 180°:起止角各 +180,净增量仍是 360 */
from { transform: translate(400px,400px) rotate(180deg) translate(32px,0px) rotate(-180deg); }
to { transform: translate(400px,400px) rotate(540deg) translate(32px,0px) rotate(-540deg); }
}读法:translate(五通) → rotate(曲柄角) → translate(曲柄半径, 0) → rotate(−曲柄角)。前三个把局部原点推到踏板轴心,最后一个把踏板朝向转回水平。
因为 translate 是常量、rotate 分量线性插值,中间任意时刻的合成位置严格落在圆周上。
远侧的关键:起始角写 180deg、终止角写 540deg,保证起止角差仍是 360deg;写成 180deg → 180deg 会完全不动。
脚掌与踏板放进同一个组即可自动保持水平,无需单独处理。这个组的旋转周期与上面 IK 关键帧的 dur 必须严格相等(本例同为 2s),否则脚会脱离踏板——这是两条独立动画唯一的耦合点。
验证方法
不要靠肉眼在放大图上判断关节对位。用浏览器实测:把动画 pause() 后逐帧设 currentTime,合成矩阵算出踝点,与踏板槽位做差。参见 SVG use 影子树 CSS 动画的两个陷阱 (Two Traps of CSS Animation in SVG use Shadow Tree) 的矩阵合成写法。
本文对应成品的实测结果:近腿全周期最大偏差 3.3px(= 采样理论误差),远腿 10px(= 采样误差 + 刻意加的 6~7px 深度错位)。
边界
- 关键帧是把 IK 解烘焙死的:曲柄半径、髋关节位置、大腿/小腿长度一变,全部要重算。
- 摆动幅度写死在关键帧里,不能像 SMIL 那样用参数驱动。
- 关节角度是旋转量,骨骼长度靠路径本身;骨骼不能拉伸(要拉伸得改用
scale或直接逐帧替换d)。 - 反关节方向、左右腿相位差这两处是最常见的"改一个数就全崩"的地方。
相关
本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Frontend