算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 沿路径运动:取点与切线朝向 待审核 39 / 47
getPointAtLength · 切线朝向 · motion-path

沿路径运动:取点与切线朝向

让一个物体沿任意曲线行进,不必解析这条曲线的方程。SVG 的 path 自带按弧长取点的能力:getTotalLength() 给出总长,getPointAtLength(d) 给出距起点 dd 处的坐标。动画只需把进度 010 \to 1 映射成距离 0L0 \to L,逐帧取点。CSS 的 offset-path 底层做的是同一件事。

1 · 取点与朝向

要让物体朝向前进方向,在当前位置两侧各取一点,用 atan2 求这两点连线的角度即可——路径上足够近的两点,连线方向就是切线方向。

图 1-1 · 沿一条三次贝塞尔路径行进的物体。可切换缓动与时长,关掉朝向切线可见位置正确但姿态不对。

2 · 弧长参数化的意义

贝塞尔曲线的参数 tt 与弧长不成正比。控制点稀疏的一段,tt 每走一小步对应的实际距离更长;密集处则更短。直接拿 tt 驱动物体,位置仍在曲线上,但沿路速度会忽快忽慢,且这种快慢与选定的缓动毫无关系——它来自曲线的参数化,不来自动画。

getPointAtLength 以弧长为度量,「进度」就是已走过的真实长度比例。沿路速度这才与缓动曲线一致:选 linear 是匀速,选 easeInOut 是两端慢中间快,而不是叠加上一层曲线自身的畸变。

注 · 浏览器实现 getPointAtLength 也不是解析求解,而是把曲线细分成足够多的直线段后累加长度、再查表反解——本质是数值方法。这意味着它有精度上限,也意味着调用不便宜:本页每帧调三次(当前点加两侧各一点),路径复杂时这一项会成为逐帧开销的大头。真要压成本,可以在建立动画时预采样一张「弧长到坐标」的表,逐帧只做查表与线性插值。

切线角用差分近似而非解析导数,取的邻域是 ±1\pm 1 像素。这个值不能太小——getPointAtLength 的数值误差会让两点几乎重合、角度抖动;也不能太大——拐弯半径小的地方,割线方向会明显偏离切线。

const L = road.getTotalLength();

new Animation({
  keyframes: [0, 1],
  duration: 2600,
  ease: 'linear',
  onUpdate: (g) => {
    const d = g * L;
    const p = road.getPointAtLength(d);
    const a = road.getPointAtLength(d - 1);
    const b = road.getPointAtLength(d + 1);
    const deg = Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
    mover.setAttribute('transform', `translate(${p.x} ${p.y}) rotate(${deg})`);
  },
});

本页动的是物体的位置与姿态;让路径本身像被笔画出来,见 SVG 描边。沿路进度同样是一次插值,被缓动塑形后再换算成弧长。经典动画原则里的弧线是它的手工简化版——两个分量各走各的曲线,不必真有一条 path。