
点这里看会动的版本 →(页面就是动图本体,约 18KB,无任何图片素材)
起因很简单:想练手做一个完整的 SVG 场景动画。选了个好画的题材——一只戴工程师气质的鹈鹕,骑着红色自行车,在乡间公路上匀速前进,天上飘着云,路边的路牌写着 408(细心的话你会发现这个数字在我的文章里出现频率很高)。
三类运动,三种做法
做下来发现整个画面里的运动刚好分三类,各有各的工具:
① 循环装饰运动 —— 纯 CSS。 云的漂移、太阳光芒的旋转、路牌的轻微摆动,全是 @keyframes + animation。一个小技巧是用负的 animation-delay 让同类元素错相:三朵云共用一个 drift 动画,animation-delay: -26s / -70s / -45s,一加载就已经各就各位,不会出现「开场所有云挤在一起出发」的尴尬。
② 骨骼运动 —— 一点 JS + 逆运动学。 这是整个动画最有趣的部分,见下节。
③ 相对运动 —— 数字对齐。 车不动,路在动。地面虚线、路旁的树和路牌、远近两层山都在向左滚,速度必须满足透视关系。
精髓:腿部逆运动学
如果鹈鹕的腿是预先画好的一段循环动画,踩踏板时脚和踏板一定对不上——踏板是绕中轴的圆周运动,脚必须严格跟住踏板。所以腿不能是动画,得是解出来的。
已知:髋关节位置、大腿长 L1、小腿长 L2、踏板当前坐标(由曲柄角 φ 决定)。求:膝盖在哪。这就是标准的两段式 IK:
function knee(hx, hy, px, py) {
var dx = px - hx, dy = py - hy;
var d = Math.hypot(dx, dy) || 0.001;
// 距离夹在 [|L1-L2|, L1+L2] 之间,避免解不存在
var dc = Math.min(Math.max(d, Math.abs(L1 - L2) + 0.5), L1 + L2 - 0.5);
var a = (L1 * L1 - L2 * L2 + dc * dc) / (2 * dc);
var h = Math.sqrt(Math.max(L1 * L1 - a * a, 0));
var ux = dx / d, uy = dy / d;
// 两个候选解,取膝盖朝前弯的那个
var k1x = hx + a * ux - h * uy, k1y = hy + a * uy + h * ux;
var k2x = hx + a * ux + h * uy, k2y = hy + a * uy - h * ux;
return k1x >= k2x ? [k1x, k1y] : [k2x, k2y];
}
几何意义:膝盖在「髋-踏板连线」的两侧各有一个候选位置,选朝前弯的那只(骑车的常识:膝盖朝前)。每帧执行两次(两条腿相位差 π),把大腿、小腿两条线段的端点和脚掌的位置直接写回 SVG 属性。
几处细节让画面「可信」了很多:
- 踏板反向旋转:曲柄转 φ,踏板组转 -φ,踏板面始终保持水平;
- 身体随踩踏起伏:
bob = 2.2·sin(2φ),身体高度和踩踏频率锁定,还带一点前倾晃动; - 链条虚线流动:
stroke-dashoffset的流速对齐牙盘线速度。
物理一致性:别让轮子空转
最容易穿帮的地方是「速度对不上」:轮子转得飞快,地面却慢悠悠地滑。我的做法是把所有速度挂在一个变量上:
车轮角速度 WW = 2π/1.2 rad/s,半径 R = 50
地面线速度 v = WW · R
地面虚线的滚动距离、路边景物的循环位移,全部用 v 或它的倍数(远山 0.55v / 近山 0.85v 做视差),轮辐旋转角和虚线位移严格同步。这样无论速度改成多少,整个画面永远像一个刚体在前进。
收尾
CSS 部分记得加 @media (prefers-reduced-motion: reduce) 关掉装饰动画——尊重系统的无障碍设置是基本修养。
整个过程最大的收获是理解了「动画的真实感来自约束的一致性」:脚贴住踏板、地面速度等于轮缘线速度、身体起伏对齐踩踏频率——每个元素都被物理关系锁住,观感自然就「对」了。单个元素动得再华丽,约束打架就一眼假。