算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 向量弹簧:二维联动与链式跟随 待审核 9 / 47
2D spring · trailing · follow

向量弹簧:二维联动与链式跟随

标量弹簧只解一个维度上的位移。要让一个点在平面上平滑跟随指针,把它推广成向量弹簧xy 各跑一个 createSpring,两条弹簧共享同一组 stiffness / damping / mass

参数共享不是实现上的偷懒,而是这个推广成立的前提。两轴用同一个 ω0\omega_0 与同一个 ζ\zeta,收敛节奏才一致,合成出的轨迹是一条协调的曲线;若给 xy 不同参数,物体会先在一个方向到位、再在另一个方向慢慢补齐,轨迹被拉成折线,看上去歪斜。

1 · 链式跟随

在向量弹簧之上做链式跟随(trailing)只需一句话:第 kk 个节点的目标是第 k1k-1 个节点此刻的位置,头节点的目标是指针。每帧先把头节点 setTarget 到最新指针坐标,再从头到尾依次把前一节点的当前坐标交给后一节点。弹簧的滞后逐级累积,就得到果冻链般的拖尾波浪。

图 1-1 · 一串向量弹簧组成的跟随链。可调 stiffness、damping 与链长,在框内移动指针或点「随机抛一下」观察拖尾。

2 · setTarget 的接管语义

createSpring 求解一次就固定了初始条件,改目标只能重建。setTarget 因此做三件事:取当前时刻的位置与速度,以它们为初始条件建一条指向新目标的弹簧,把计时原点挪到此刻。位置与速度都被承接,所以指针连续移动时看不出每帧都在换弹簧。

注 · 这个 lab 每帧对每个节点重建两条弹簧,24 节点就是每秒约 2900 次 createSpring。之所以不心疼,是因为 createSpring 的主体只是算 ω0\omega_0ζ\zeta 与两三个系数,闭式解不需要预积分。真正贵的是它顺带扫出的 settleTime——那是一个 4 ms 步长、上限 10 s 的循环。链式跟随用不上 settleTime,这笔开销纯属浪费,但在这个规模下仍看不出掉帧。

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

// 向量弹簧: x、y 各一个标量弹簧, 共享同一组参数
function vectorSpring(x0, y0, opts) {
  let sx = createSpring({ ...opts, from: x0, to: x0 });
  let sy = createSpring({ ...opts, from: y0, to: y0 });
  let t0 = performance.now();
  return {
    setTarget(now, x, y) {                 // 接住当前位置 + 速度
      const dt = now - t0;
      sx = createSpring({ ...opts, from: sx.value(dt), to: x, velocity: sx.velocity(dt) });
      sy = createSpring({ ...opts, from: sy.value(dt), to: y, velocity: sy.velocity(dt) });
      t0 = now;
    },
    sample(now) { const dt = now - t0; return { x: sx.value(dt), y: sy.value(dt) }; },
  };
}

// 链式跟随: 每帧头节点追指针, 其余节点追前一个节点的当前位置
function frame(now) {
  nodes[0].setTarget(now, cursor.x, cursor.y);
  for (let i = 1; i < nodes.length; i++) {
    const prev = nodes[i - 1].sample(now);   // to = 前一节点此刻坐标
    nodes[i].setTarget(now, prev.x, prev.y);
  }
  for (const n of nodes) draw(n.sample(now));
  requestAnimationFrame(frame);
}

单轴弹簧的三种物理形态见弹簧;本页「每帧重建并承接速度」的做法,是中断与重定向那套接管逻辑的连续版本;把指针换成真实手势的跟手与抛掷见手势