目标

在一个纯 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