算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 动画即数据:timeline 序列化成 descriptor 待审核 45 / 47
descriptor · mini-Lottie · 动画即数据

动画即数据:timeline 序列化成 descriptor

前面各页都是「写代码调用 animate」的命令式动画。换一种范式:把动画写成一份纯数据(descriptor),再写一个解释器读它、用引擎播出来。

这正是 Lottie 的本质——它不是「更复杂的 SVG path 动画」,而是把关键帧这套「时间到值」的中间表示序列化成 JSON,再交给运行时渲染器逐帧插值。

1 · 一份可编辑的 descriptor

descriptor 描述的不是「画一条什么 path」,而是有哪些图层、每个图层的哪些属性、在哪些帧取什么值:layers[] 每层一个图形加若干属性轨,每条轨是一串 { f, v, ease }——帧号、值、进入这一段的缓动。单帧轨即静止值。

图 1-1 · 完全由下方 JSON 驱动的动画。改任意数字后点重新载入即生效;播放头、变速与 seek 均为播放器现成能力。

2 · 解释器只做两件事

其一,把每条属性轨喂给 createKeyframes(values, { times, ease }) 得到一个采样器——帧号按总长归一化成 times 落在 [0,1][0,1],逐段缓动给到 ease。其二,建一个Animation 当播放头,它的 onUpdate(p) 把进度喂给每条轨的采样器,合成 transformopacity 写到对应图层。

表 2-1 · descriptor 字段与引擎原语的对应。
descriptor 字段 含义 @vega/anim 原语
tracks.x = [{f,v,ease}, …] 一条属性的关键帧轨 createKeyframes(values, { times, ease })
关键帧的 ease 进入这一段的缓动曲线 逐段 ease 数组,长度等于段数
fpsduration 总时长 duration = 帧 / fps × 1000
多个 layers[] 独立图层各跑各的轨 AnimationonUpdate 内分发
整体播放 / 变速 / 跳转 播放器能力 play / speed / seek / reverse 现成

注 · ease 数组的长度是段数而非关键帧数,所以要 kfs.slice(0, -1) 取前 n1n-1 个,且每一项取的是后一个关键帧上写的 ease——「进入这一段用什么曲线」的语义写在段的终点上。这个错位是照抄 Lottie 的约定:After Effects 里缓动手柄挂在关键帧上,而一段的形状由它两端的手柄共同决定。

3 · 动画是数据换来什么

可被程序改写:换色只是改 shape.fill 一个值再重编译,编辑器里改任意数字亦然。

可 seek、反向、变速:整份数据编译成一个主 Animation,而「时间到值」是纯函数,播放控制全部白送——Lottie 的 setSpeedplaySegments 同理。

矢量且轻:数据里没有像素,任意分辨率不糊。

import { Animation, createKeyframes } from '@vega/anim';

// 一条属性轨 → 采样器: 帧号归一化成 times, 逐段 ease
function buildTrack(kfs, total) {
  const values = kfs.map(k => k.v);
  const times  = kfs.map(k => k.f / total);            // 帧 → [0,1]
  const ease   = kfs.slice(0, -1).map((_, i) => kfs[i + 1].ease || 'linear');
  return createKeyframes(values, { times, ease });     // ← 引擎原语
}

// descriptor → 一个主 Animation 当播放头
function compile(desc) {
  const layers = desc.layers.map(layer => {
    const g = makeSvgGroup(layer.shape);
    const samplers = {};
    for (const prop in layer.tracks)
      samplers[prop] = buildTrack(layer.tracks[prop], desc.duration);
    return { g, samplers };
  });
  const render = (p) => layers.forEach(({ g, samplers }) => {
    const at = (k, d) => samplers[k] ? samplers[k](p) : d;
    g.setAttribute('transform',
      `translate(${at('x',0)} ${at('y',0)}) rotate(${at('rotate',0)}) scale(${at('scale',1)})`);
    g.setAttribute('opacity', at('opacity', 1));
  });
  return new Animation({                               // ← seek / reverse / speed 全免费
    keyframes: [0, 1], duration: desc.duration / desc.fps * 1000,
    ease: 'linear', repeat: Infinity,
    onUpdate: render,
  });
}

每条轨的值如何插出来见插值,逐段快慢由缓动塑形,多轨多层如何排在一条轴上见关键帧与编排。与浏览器原生能力的边界对照见与原生对照;真实工业格式的细节见 CSS 之外的动画做法