算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 惯性衰减:没有目标的滑行 待审核 8 / 47
decay · friction · 动量滚动

惯性衰减:没有目标的滑行

弹簧有一个明确的目标 to,系统被它吸引,欠阻尼时还会绕着它振荡。惯性衰减(decay)恰好相反:它没有目标,只有一个初速度和摩擦。物体顺着方向滑行、速度指数衰减、自己减速到停,落在哪里完全由初速度决定。松手后的动量滚动、惯性列表、地图甩动,底层都是这个模型。

位移的闭式解是

x(t)=targetAet/τ,A=powerv0,target=from+Ax(t) = \text{target} - A \cdot e^{-t/\tau}, \qquad A = \text{power} \cdot v_0, \quad \text{target} = \text{from} + A

AA振幅(amplitude),也就是总滑行距离;τ\tautimeConstant,越大滑得越久。落点是初速度的结果而非输入——这是 decay 与弹簧最根本的差别。

1 · 位置与速度两条曲线

settleTime 由「与落点之差小于 restDelta」反解得到 τln(A/restDelta)\tau \ln(|A| / \text{restDelta}),是少见的有闭式解的稳定时刻——弹簧就只能步进扫出来。默认 power = 0.8τ=350\tau = 350restDelta = 0.5v0=2600v_0 = 2600A=2080A = 2080,代入得 2916.6 ms,与引擎返回值逐位相符。

图 1-1 · decay 的位置与速度曲线,以及一条滑行轨道。可调初速度、power 与 τ,打开吸附开关观察落点被对齐到刻度。

2 · power 与 τ 不是两个独立的旋钮

powerτ\tau 看上去分管「滑多远」与「滑多久」,但它们同时决定了运动的起始速度。对 x(t)x(t) 求导得

v(t)=Aτet/τ,v(0)=powerτv0v(t) = \frac{A}{\tau} e^{-t/\tau}, \qquad v(0) = \frac{\text{power}}{\tau} \cdot v_0

于是 v(0)v(0) 与滑块上标的 v0v_0 只在 power 恰好等于 τ\tau(换算成秒)时相等。默认参数下 power/τ=0.8/0.352.29\text{power} / \tau = 0.8 / 0.35 \approx 2.29,实际起始速度是标称值的 2.29 倍:v0=2600v_0 = 2600 时物体真正以 5943 px/s 起步。

警示 · 本页原先照抄模型文档写的是 v(t)=v0et/τv(t) = v_0 e^{-t/\tau}。对着 decay.ts 实跑才发现这一条只在 power=τ\text{power} = \tau 时成立:velocity() 返回的是 value() 的严格导数(数值微分逐位对得上),而 value() 的振幅来自 powerv0\text{power} \cdot v_0,两者衔接处并没有约束 v(0)=v0v(0) = v_0。把 power 调到 0.35 再看,起始速度才回到 2600。这不是实现的 bug——Popmotion 的 inertia 也是这么定义的,powervelocity 本就是一对经验参数——但把它写成「速度按 v0v_0 衰减」会误导调参。

3 · 带惯性的吸附

modifyTarget 在算出理论落点之后、开始运动之前插一手:把落点对齐到最近的刻度,再反推振幅使 tt \to \infty 恰好停在修正后的位置。甩动的手感全部保留(起始速度仍由初速度决定),落点却精确——轮播与滚动选择器正是这么做的。

import { createDecay } from '@vega/anim';

// 没有目标值, 只给初速度 + 摩擦参数; 落点是 power·velocity 的结果
const d = createDecay({
  velocity: 2600,      // px/s, 由手势松手时的瞬时速度提供
  power: 0.8,          // 落点放大: amplitude = power · velocity
  timeConstant: 350,   // 位移衰减的时间常数 (ms), 越大滑越久
  from: 0,
  // 可选: 把理论落点对齐到最近刻度 → 带惯性的吸附
  // modifyTarget: (ideal) => Math.round(ideal / 500) * 500,
});

d.target;        // 理论落点 (t→∞), 与初速度成正比
d.settleTime;    // 减速到停的有效时长 (ms)

// rAF 驱动: 每帧用经过的毫秒数采样位置
const t0 = performance.now();
function tick() {
  const pos = d.value(performance.now() - t0);
  ball.style.transform = `translateX(${pos}px)`;
  if (!d.done(performance.now() - t0)) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

decay 最常见的入口是手势:松手时把指针的瞬时速度交给它,见手势速度拖拽与惯性。越界后被弹回边界的那一档,见橡皮筋