算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 弹簧:没有时长的动画 待审核 7 / 47
spring · stiffness · damping · mass

弹簧:没有时长的动画

缓动的契约是「预先规定 NN 毫秒走完一条固定曲线」。弹簧(spring)换了一套契约:给定刚度 stiffness、阻尼 damping、质量 mass 与初速度,让系统自己演化到静止。时长不再是输入,而是这组参数的结果——引擎反推出一个 settleTime 告诉调用方「这次大概要跑多久」。

数学上它是阻尼谐振子方程 mx+cx+kx=0m x'' + c x' + k x = 0 的解析解。引擎不做数值积分,而是按阻尼比分情况代入闭式解,因此 value(t) 对任意 tt 都是 O(1)O(1) 求值,可以随意 seek,不必从头逐帧推进。

1 · 三个参数与三种形态

行为完全由一个无量纲数决定——阻尼比(damping ratio)

ζ=c2km\zeta = \frac{c}{2\sqrt{k m}}

ζ<1\zeta < 1 是欠阻尼:振荡着收敛,会过冲超过目标,弹性感即由此而来。ζ=1\zeta = 1 是临界阻尼:不振荡且最快归位。ζ>1\zeta > 1 是过阻尼:两个实指数叠加,缓慢爬向目标,看上去有点迟钝。

图 1-1 · 弹簧位移曲线与它驱动的运动。可调三个参数与初速度,读数区同步给出 ζ、形态、settleTime 与固有频率 ω₀;点「弹一下」对比同时长的 easeOut。

注 · 三个参数只有两个自由度。曲线完全由 ω0=k/m\omega_0 = \sqrt{k/m}ζ\zeta 确定,而把 kkccmm 同倍放大等于给方程两边同乘一个常数,两者都不变。实跑对照过:(120, 12, 1)(240, 24, 2) 在任意时刻的位移逐位相同,settleTime 也同为 996 ms。调 mass 而不动另外两个才会改变行为。

settleTime 的求法与 value 不同:位移有闭式解,稳定时刻却没有——引擎以 4 ms 步长从 0 扫到 10 s,取第一个「位移与速度都进入静止阈值」的时刻。所以它的分辨率是 4 ms,且有上限。滑块量程内就能撞到这个上限:stiffness = 20damping = 60mass = 4ζ=3.35\zeta = 3.35,重度过阻尼)时 settleTime 报 10000 ms,但那一刻位移只走到 0.966,真正静止要到 20 s 之后。这类参数下读数是循环的天花板,不是物理结果。

2 · 承接初速度

固定曲线的缓动从静止出发,起点速度由曲线形状写死。弹簧不同:初速度是求解时的一个初始条件,可以是任意值,包括与目标方向相反。这就是手势松手后惯性的来源——把指针的瞬时速度直接交给弹簧,运动天然连续,看不出接管的痕迹。

警示 · springEasing 把弹簧化成一对 { easing, duration } 塞进通用动画管线,这一步会把初速度归零。一条可复用的 010 \to 1 曲线携带不了一次性的发射速度——它被采样成纯函数的那一刻,初始条件就丢了。需要真正承接中途速度,得直接用 createSpring 并自行驱动,见中断与重定向

import { createSpring, springEasing } from '@vega/anim';

// 物理求解: 给定参数, 算任意时刻的值 / 速度 / 是否静止
const s = createSpring({ stiffness: 120, damping: 12, mass: 1,
                         from: 0, to: 1, velocity: 0 });
s.value(120);      // 120ms 时的位移
s.settleTime;      // 自然稳定耗时 (毫秒) —— 不是给定的

// 化成 duration + easing, 即可塞进通用动画管线 (初速度在此丢失)
const { easing, duration } = springEasing({ stiffness: 120, damping: 12 });

同样没有内建时长、但连目标都没有的另一类物理,见惯性衰减;把标量弹簧推广到二维与链式跟随见向量弹簧;预设曲线那一套对照见缓动