算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 形状形变:path 的 d 插值 待审核 42 / 47
path morph · d 插值 · 顶点对齐

形状形变:path 的 d 插值

两条 path 之间的平滑形变,本质仍是插值——只不过插的不是一个数,而是 d 属性里的一长串坐标。前提很硬:两条 path 的命令结构必须相同,命令类型与数量一一对应。满足这一点后,d 就退化成一个等长数字数组,与数字插值完全同构,只是维度从 1 变成几十。

1 · 结构对齐时的逐位插值

本页第一组的四个形状刻意都写成「1 个 M 加 8 个 C」,数组长度恒为 50,顶点天然一一对应,任意两者之间都能直接形变。圆用八段三次贝塞尔逼近,控制点长度取经典的 43tan(θ/4)r\frac{4}{3}\tan(\theta/4) \cdot r;方、星、心则先给出八个顶点,再把每条边按三等分点转成 C 段——视觉上仍是折线,但命令结构与圆一致。

图 1-1 · 四个等结构形状之间的形变。打开顶点显示可看到八个顶点如何一一对应地迁移。

2 · 结构不同时的重采样

真实图标很少天生对齐:三角形 3 段直线、十字 12 段、blob 几段曲线,数组长度不同便无法逐位插值。通用做法是重采样——用 getTotalLength()getPointAtLength() 把每个形状按弧长均匀采成同样多的点,任意 path 都化成等长点集。

代价是曲线被离散成折线:采样点越多越平滑,越少越棱角分明。这也是为什么这一组的 blob 在低采样数下看起来像多边形。

图 2-1 · 命令结构不同的四个形状之间的形变。可调采样点数 N,关掉旋转对齐可见不对齐时整体拧成麻花。

3 · 旋转对齐

采样点的编号从各自路径的起点开始,而两个形状的起点在几何上未必对应。直接逐点插值会让整个形状在形变途中拧转一圈。做法是把目标点集循环移位,取与源点集逐点平方距离之和最小的那个位移:

k=argminki=0n1AiB(i+k)modn2k^* = \arg\min_k \sum_{i=0}^{n-1} \left\| A_i - B_{(i+k) \bmod n} \right\|^2

注 · 这是一个 O(n2)O(n^2) 的暴力搜索:N=64N = 64 时是 4096 次距离计算,每次形变开始时跑一遍。之所以没优化,是因为它只在建立动画时算一次,逐帧路径上完全不涉及。真要提速,这个式子展开后是一个循环互相关,可以用 FFT 降到 O(nlogn)O(n \log n)——但那要写三倍的代码去省一次 4096 次浮点乘法,在这个规模上不划算。

警示 · 循环移位只解决「起点错位」,解决不了走向相反。若两个形状的采样方向一个顺时针一个逆时针,任何移位都对不齐,形变会整体翻面。稳妥的做法是先按有向面积判断绕向、必要时把其中一组点反转,再做移位对齐。本页的四个形状绕向一致,所以省掉了这一步。更复杂的拓扑(孔洞、一个形状分裂成多个)则要拆分子路径并配对,flubber 正是做这件事的库。

const lerpArr = (a, b, g) => a.map((v, i) => v + (b[i] - v) * g);

const toD = (arr) => {
  let s = `M ${arr[0]} ${arr[1]}`;
  for (let i = 2; i < arr.length; i += 6)
    s += ` C ${arr.slice(i, i + 6).join(' ')}`;
  return s;
};

new Animation({
  keyframes: [0, 1],
  duration: 900,
  ease: 'easeInOut',
  onUpdate: (g) => {
    const d = lerpArr(fromArr, toArr, g);
    path.setAttribute('d', toD(d));
  },
});

只想画出单条路径本身、或让物体沿路径行进,见 SVG 描边沿路径运动。形变把整段 d 当作高维向量来插,与基础插值同源,区别只在于需要一个「path 插值器」——逐位混合数字再拼回 d