课程 36 - Animation
在这节课中你将学习到以下内容:
- 如何设计动画 API
- 参考 Web Animation API 实现声明式 Keyframe 与控制器
- 实现路径、笔迹、形变等动画效果
- 支持 Lottie 等格式
如何设计动画 API
Motion 是完全兼容声明式动画 WAAPI 的,详见:Improvements to Web Animations API。它直接调用浏览器原生的 element.animate(),享受 GPU 加速、独立渲染线程、不阻塞主线程的优势。同时用 JavaScript 轻量实现 WAAPI 缺失的能力:
- Spring 物理动画(WAAPI 只支持贝塞尔曲线)
- 独立
transform属性(单独动画 x, y, scale 而非组合 transform) - 可以指定缩放和旋转中心
transformOrigin - Timeline 序列控制
sequence(),stagger()
import { animate, stagger } from 'motion';
// 返回动画控制器,可暂停、播放、反向
const controls = animate(
'.box',
{ x: [0, 100], opacity: [0, 1] }, // keyframes
{ duration: 0.5, delay: stagger(0.1), easing: 'spring(1, 100, 10, 0)' },
);
// 可序列化的控制指令
controls.pause();
controls.play();
controls.reverse();其中 Keyframes 和 Options 是纯对象,可直接 JSON 化。但运行时状态:animate() 返回的 Animation 对象包含与 DOM 的绑定、当前播放时间、velocity 等运行时状态,无法序列化。
参考 WAAPI 实现
我们可以参考 WAAPI 的 polyfill web-animations-js,结合我们的 ECS 系统实现。
在数据层使用类 WAAPI 的 Keyframes 格式(可序列化)
interface Keyframe {
offset?: number; // 0-1,对应 WAAPI 的 offset
[property: string]: any; // x, y, scale, fill, strokeWidth...
easing?: string; // "ease-out", "spring(1, 100)"
}
interface AnimationOptions {
duration: number; // ms
delay?: number;
iterations?: number | 'infinite';
direction?: 'normal' | 'reverse' | 'alternate';
fill?: 'forwards' | 'backwards' | 'both'; // This property specifies how the graph will be displayed when the animation is in a non-running state (e.g. before the animation starts, after it ends).
easing?: string; // 全局缓动(若 keyframe 未指定)
}在控制层实现类 WAAPI 的 Animation 控制器。与真正 WAAPI polyfill 的区别:
- 不要解析 CSS 字符串。WAAPI 支持
{ transform: 'translate(100px)' }这类 CSS 字符串,解析成本高。直接支持{ x: 100 }(类似 Motion 的独立 transform 属性) - 内置
ease,ease-in,ease-out,linear,以及 Motion 风格的spring(mass, stiffness, damping) - Composite 模式。参考 WAAPI 的
composite: 'add' | 'replace',支持动画叠加到基础值上(如 Entity 已有位置,动画在其上叠加偏移) - Timeline 支持。像 Motion 的
timeline()或 WAAPI 的 GroupEffect,支持多 Entity 的 stagger 动画
控制器
const animation = api.animate(
node1,
[
{ x: 100, fill: 'green' },
{ x: 200, fill: 'red' },
],
{
duration: 1000,
direction: 'alternate',
iterations: 'infinite',
easing: 'ease-in-out',
},
);
animation.pause();
animation.play();
animation.finish(); 矩形在 x: 100 ↔ 200 之间往复,填充在 green 与 red 之间插值;可用按钮控制同一 AnimationController。
变量插值
像 x/y/opacity 这种数字很容易插值,对于 fill/stroke 这样的颜色值,需要先用 d3-color 解析出 rgba 各个分量再分别插值。
线性渐变的色标数量相同时,逐个插值色标位置与颜色;数量不同时,使用两端色标位置的并集,并保留表示硬边过渡的重复位置。因此,两端关键帧使用同一渐变时,不均匀的色标间距不会被改成均匀分布。
ECS 中,fill / fillOpacity 和 stroke / strokeOpacity 会同时更新第一个启用的绘制层,保留其他层及其启用状态。如果没有启用的层,颜色动画会新增一层;仅有透明度动画时不会创建新层。
function interpolateValue(from: unknown, to: unknown, t: number) {
if (isFiniteNumber(from) && isFiniteNumber(to)) {
return interpolateNumber(from, to, t);
}
const fromColor = parseColor(from);
const toColor = parseColor(to);
if (fromColor && toColor) {
return colorToRgbaString({
r: interpolateNumber(fromColor.r, toColor.r, t),
g: interpolateNumber(fromColor.g, toColor.g, t),
b: interpolateNumber(fromColor.b, toColor.b, t),
a: interpolateNumber(fromColor.a, toColor.a, t),
});
}
return t < 1 ? from : to;
}缓动函数
除了常规的缓动函数,我们还可以支持 spring
function evaluateEasing(easing: string, t: number) {
const p = clamp01(t);
const bezier = EASING_FUNCTION[easing as keyof typeof EASING_FUNCTION];
if (bezier) {
return clamp01(bezier(p));
}
if (easing.startsWith('spring(')) {
return evaluateSpringEasing(p, easing);
}
return p;
} 切换缓动会重新创建动画(新 easing 在构造时生效)。矩形仍在 x: 100 ↔ 200 往复,便于对比不同曲线的加减速感。
变换中心
transformOrigin: { x: 50, y: 50 }scale: 0.5 ↔ 1.2rotation: Math.PI / 4 ↔ -Math.PI / 4
特殊的动画效果
路径动画
Moving graphics along a path is a common requirement, and is accomplished in CSS via MotionPath.
#motion-demo {
animation: move 3000ms infinite alternate ease-in-out;
offset-path: path('M20,20 C20,100 200,0 200,100');
}
@keyframes move {
0% {
offset-distance: 0%;
}
100% {
offset-distance: 100%;
}
}笔迹动画
需要支持获取路径的长度:
const length = api.getTotalLength(path);
api.animate(
path,
[{ strokeDasharray: [0, length] }, { strokeDasharray: [length, 0] }],
{
duration: 3500,
},
);虚线偏移
drawio 中通过动画表示连接线的方向:
Export your diagram to a SVG file to include the connector animation when you publish it in a web page or on a content platform that supports SVG images.
api.animate(node, [{ strokeDashoffset: -20 }, { strokeDashoffset: 0 }], {
duration: 500,
iterations: Infinity,
});形变效果
在很多 SVG 相关的库中都能看到形变动画的例子,例如:
以上部分库会要求变换前后的路径定义包含相同的分段,不然无法进行插值。
参考 Kute.js 中的 CubicMorph,首先将 Path 定义中的各个部分转成三阶贝塞尔曲线表示,然后利用三阶贝塞尔曲线易于分割的特性,将变换前后的路径规范到相同数目的分段,最后对各个分段中的控制点进行插值实现动画效果
只有两端路径都包含单个闭合轮廓时,才对齐绕向和起点。开放路径保留端点顺序,避免平移曲线时交换端点,导致动画中途曲线塌缩。
function mergePaths(
left: { absolutePath: AbsoluteArray; curve: CurveArray | null },
right: { absolutePath: AbsoluteArray; curve: CurveArray | null },
): [CurveArray, CurveArray, (b: CurveArray) => CurveArray] {
let curve1 = left.curve;
let curve2 = right.curve;
if (!curve1 || curve1.length === 0) {
// convert to curves to do morphing & picking later
// @see http://thednp.github.io/kute.js/svgCubicMorph.html
curve1 = path2Curve(left.absolutePath, false) as CurveArray;
left.curve = curve1;
}
if (!curve2 || curve2.length === 0) {
curve2 = path2Curve(right.absolutePath, false) as CurveArray;
right.curve = curve2;
}
let curves = [curve1, curve2];
if (curve1.length !== curve2.length) {
curves = equalizeSegments(curve1, curve2);
}
const closed = [left.absolutePath, right.absolutePath].every(
(path) =>
path[path.length - 1]?.[0] === 'Z' &&
path.filter(([command]) => command === 'M').length === 1,
);
const curve0 =
closed && getDrawDirection(curves[0]) !== getDrawDirection(curves[1])
? reverseCurve(curves[0])
: (clonePath(curves[0]) as CurveArray);
return [
curve0,
closed ? (getRotatedCurve(curves[1], curve0) as CurveArray) : curves[1],
(pathArray: CurveArray) => pathArray,
];
}Lottie
- lottie json schema
- Tips for rendering
- lottie-parser 我们主要参考它的解析逻辑
- velato 是一个使用 vello 的渲染器
使用方法
我们实现了一个插件,将 lottie json 转换成图形和 keyframes,以下是一些实现要点:
- 支持 Shape Layer 中定义的以下元素:
- lottie 中的
anchorX/anchorY表示缩放和旋转中心,相对于图形的包围盒左上角,在映射到transformOrigin时需要注意 - 将多组动画轨道合并成一组 keyframes,补全缺失的属性
import { loadAnimation } from '@infinite-canvas-tutorial/lottie';
const response = await fetch('/bouncy_ball.json');
const animation = loadAnimation(await response.json(), {
loop: true,
autoplay: true,
onDiagnostic: ({ code, path, message }) =>
console.info(code, path, message),
});
await api.edit(() => animation.render(api), { capture: 'NEVER' });
// On component unmount (also automatically cleaned up on canvas destruction):
// await animation.destroy();下面是官方示例的运行效果:Bouncy Ball
兼容性范围
这是将 Lottie 转换成 ECS 节点和关键帧的导入器,目前并非完整的 Lottie 播放器。下表描述的是实际渲染能力,不能把“解析了字段”等同于“支持了效果”。
| 特性 | 当前状态 |
|---|---|
| 矩形、椭圆、路径、分组;基础填充和描边 | 支持基础静态形状、路径 morph 和 2D 位置/旋转/缩放动画;原始图形的尺寸/圆角动画仍有限制 |
| PolyStar | 星形和多边形、正反路径方向;点数、位置、旋转、内外半径和圆角的数值关键帧,支持时间缓动及保持关键帧 |
| Repeater | 复制前面的形状/分组;支持数量、偏移、锚点、位移、缩放、旋转、起止透明度和堆叠顺序,以及数值关键帧动画 |
| Solid、Null、父子层级、Precomp | 基础支持;图层 in/out 可见性、时间拉伸和重映射未完整实现 |
| 渐变、多重填充/描边 | 基础渐变可用;渐变几何动画、径向高光、多重绘制及运算顺序有限制 |
| Trim Paths | 用描边虚线近似;已修正完整/空路径、端点排序和偏移。填充裁切、多路径模式、与原有虚线样式叠加、动画中的零长度圆头描边、路径方向及 modifier 顺序仍有限制 |
| 空间贝塞尔运动、Skew | 未完整实现,导入时报告诊断 |
| Merge Paths、Round Corners 等 modifier | 尚未实现渲染 |
| Image | 部分支持;未处理资源目录和预加载 |
| Text、Mask、Track Matte、Effects、Blend Mode、3D | 尚未实现或无法可靠还原;ECS 支持某项能力不代表 Lottie 已完成映射 |
| 表达式 | 导入时烘焙;默认使用带有限形状图层环境的 lottie-web ExpressionManager,不等同于完整 AE 环境 |
inspectLottie(data) 可在不修改 JSON、不执行表达式的情况下检查已知缺口。animation.getDiagnostics() 和 onDiagnostic 返回 code、severity(partial / unsupported)、JSON Pointer path 和说明。诊断不会中止导入;没有诊断也不代表完全兼容 AE。示例中的 Compatibility notes 会展示具体缺口。
播放器使用统一合成时间轴。goTo(value, true) 按相对于合成起点的帧定位,默认单位为秒,且保留播放/暂停状态。stop() 暂停并回到第 0 帧;setSpeed() 接受正数,setDirection(-1) 保留速度并从当前位置倒放。playSegments([start, end]) 遵守两个端点,逆序端点表示倒放;loop 数字表示额外重复次数。底层 getAnimations() 返回的 ECS 控制器由播放器采样,请通过播放器控制时间。
同一实例重复 render(api) 不重复创建动画,不允许挂载到另一个画布;destroy() 可重复调用,取消时间轴并在安全编辑阶段移除导入的节点树。切换示例或卸载组件还需取消未完成的 fetch 和排队的编辑。固定帧浏览器测试使用锁定版本的 lottie-web 作为参考,覆盖位置动画、单路径描边 Trim Paths,以及整数帧和小数帧下的 PolyStar 几何和 Repeater 分组,不代表整个格式已通过一致性验证。
星形与多边形
PolyStar(ty: "sr")生成闭合贝塞尔路径:sy: 1 为星形,sy: 2 为多边形。点数 pt、位置 p、旋转 r、半径 or / ir 和圆角 os / is 可以独立动画,内半径和内圆角仅用于星形。d: 3 反转路径方向。小数点数与 lottie-web 5.13 一样向下取整,并非不同点数之间的连续形变。
下面的示例同时动画两种图形。可以暂停、倒放,或拖动 Seek frame 查看中间状态。实现先插值参数再生成路径,因此旋转时半径不收缩,点数也在对应时刻切换。
Star (blue) · Polygon (red)
PolyStar 几何动画由插件控制器随合成时间轴采样。目前普通 ECS 关键帧序列化及动画编辑器还不能保留或编辑这些参数轨道,重新加载时需保留原始 Lottie JSON。位置的空间切线、modifier 组合(包括变化几何上的 Trim Paths)、表达式等仍受上述兼容性范围限制。曲线描边还会受到 ECS 渲染器已有的细分接缝透明度问题影响,因此这类描边的参考测试验证轮廓和颜色,不验证 alpha 完全一致。
Repeater
Repeater(ty: "rp")复制当前形状分组中位于它之前的操作符。将填充/描边放在被复制的分组内部,即可分别绘制每个副本。支持嵌套形状分组,以及带动画的 PolyStar 几何。嵌套或串联多个 Repeater 目前按递归方式复制,并产生 shape.repeater.nested 诊断;复合绘制顺序和保留的原始副本可能与 lottie-web 不同。
数量 c 向上取整,零会隐藏所有副本。偏移 o 支持小数和负数。变换 tr 包括锚点 a、位移 p、非等比缩放 s、旋转 r 和起止透明度 so / eo。合成模式 m 控制副本堆叠顺序。数值轨道支持时间缓动和保持关键帧。变换遵循 lottie-web 5.13,包括负整数偏移穿过恒等变换时的行为。
Animated copies · Offset · Scale · Rotation · Opacity
导入时根据数量轨道的上界(包含缓动超调)建立副本池,播放和跳帧复用节点。较大的数量及嵌套 Repeater 会成倍增加节点数。与 PolyStar 一样,目前普通 ECS 关键帧序列化和动画编辑器不能保留这些参数轨道,需要保留原始 Lottie JSON。
位于 Repeater 之后的填充/描边仅部分支持,并会产生 shape.repeater.paint-scope 诊断:逐副本继承样式不能还原 lottie-web 共享复合路径的填充范围和透明度。modifier 顺序、skew、空间运动和图层时间仍受兼容性矩阵限制。无法求逆的缩放(例如负偏移搭配零缩放)会隐藏受影响副本,避免提交无效变换。
表达式
Expressions 描述了 After Effects 导出到 Bodymovin JSON 时,如何在属性上挂一段 JavaScript(属性对象上的字符串字段 x)。本教程里的 Lottie 插件不会在每一帧实时执行这些脚本,而是在 loadAnimation / parse 时按合成时间范围把表达式烘焙成普通关键帧,再由统一时间轴采样 ECS 关键帧。支持范围取决于有限的表达式环境,不能保证与 AE 一致。
下面是一段路径属性上的表达式示例(运行时仍表现为普通 shape 关键帧动画):
{
"ty": "sh",
"ks": {
"a": 0,
"k": {
"i": [],
"o": [],
"v": []
},
"x": "var group = thisLayer.content(\"Quadratic Points\");\nvar num_points = 3;\nvar points = [];\nvar ip = [];\nvar op = [];\nfor ( var i = 0; i < num_points; i++ )\n{\n var pos = group.content(\"p\" + i).position;\n points.push(pos);\n ip.push(pos);\n op.push(pos);\n}\nvar $bm_rt = {\n v: points,\n i: ip,\n o: op\n};\n"
}
}下面的示例来自:Beziers in Lottie
Text layer
尚未实现文本图层和文本动画器的导入。
Clipping mask
现有解析不能可靠还原 mask 模式、反相、动画及 Track Matte。
Layer effects
尚未实现从 Lottie effects 到 ECS 的映射。
Rive

Manim
动画编辑器
参考 lottielab、Jitter 等产品,我们在 Web Components 层实现了一套轻量动画编辑器:右侧 Animation 面板负责编辑当前选中元素的 keyframes,底部 Timeline 面板展示整场景的时间轴并驱动当前画布的播放头。两者共用同一份可序列化的 Keyframes 数据与 AppState 里的场景时钟。

整体布局
Taskbar 提供两个独立开关:
SHOW_ANIMATION_PANEL→ic-spectrum-animation-panel(右侧,与 Properties 面板并列)SHOW_TIMELINE_PANEL→ic-spectrum-timeline-panel(底部 dock,横跨画布宽度)
典型工作流:
- 选中单个元素,在 Animation 面板添加动画或编辑属性轨道。
- 打开 Timeline,查看场景中所有带动画的图层及其时间范围。
- 拖动播放头或点击播放,预览整场景在同一时刻的合成效果。
- 在 Timeline 选中某条轨道会同步选中对应元素,右侧 Animation 面板随即展示该元素的 keyframes。
场景时钟与编辑模式
Timeline 驱动 AppState 中当前画布的播放头,与每个 AnimationController 解耦。共享同一个 ECS world 的多个画布,各自维护独立的播放头、播放状态和场景时长:
| 字段 | 含义 |
|---|---|
animationEditing | 为 true 时进入确定性 scrub 模式:当前画布的所有控制器按同一 animationCurrentTime 采样,而非各自 free-run |
animationCurrentTime | 当前画布的播放头位置(毫秒) |
animationPlaying | 是否自动推进播放头 |
animationLoop | 到达场景末尾是否回到 0 |
AnimationSystem 在 animationEditing === true 时只采样属于当前画布的实体。暂停状态下把控制器固定在 animationCurrentTime;播放状态下用 performance.now() 的 delta 推进播放头。恢复播放时不会计入暂停期间经过的时间,场景时长也只由当前画布的轨道决定。
离开编辑模式后,被编辑器暂停的控制器从当前位置继续 autoplay。调用方在进入编辑模式前已经暂停的控制器仍保持暂停,已取消的控制器不会自动重启。
打开 Timeline 时会自动 setAnimationEditing(true),保证 scrub 与预览行为一致。
Timeline 面板设计
底部为 ic-spectrum-timeline-panel:拖动播放头 scrub、点击 ▶ 预览整场景; 点击轨道可选中元素并在右侧 Animation 面板编辑 keyframes。Hero 淡入 2s,Orbit 延迟 0.5s 后位移 1.5s。
轨道数据
Timeline 不直接读节点 JSON,而是通过 api.getAnimatedTracks() 聚合:
interface Track {
id: string; // 节点 id
name: string; // 图层名,缺省为 id
properties: string[]; // 如 ['opacity', 'x']
delay: number; // ms
duration: number; // 有效动画时长(total − delay)
totalDuration: number;
}场景总时长 = 所有 track 的 totalDuration 最大值。条形块的 left / width 分别由 delay * PX_PER_MS 与 duration * PX_PER_MS 换算。
交互
| 操作 | 行为 |
|---|---|
| 点击轨道标签 | layersSelected = [track.id],高亮轨道,驱动 Animation 面板 |
| 拖动 lane / ruler | scrub 播放头,画布实时预览该帧 |
| Play / Pause | toggleAnimationPlaying(),在编辑模式下推进当前画布时钟 |
| Loop | setAnimationLoop(),到场景末尾是否回绕 |
Animation 面板(与 Timeline 配合)
组件:packages/webcomponents/src/spectrum/animation-panel.ts。
- 仅单选元素时可编辑;多选或未选显示占位提示。
- 全局选项:
duration、delay、默认easing、iterations(Loop 开关)。 - 按属性轨道分组展示 keyframes:每行包含
offset(0–1)、属性值、easing、删除按钮。 - Add keyframe at playhead:读取当前
animationCurrentTime,换算为 normalized offset,并调用controller.getCurrentValues()采样当前属性值——因此应先打开 Timeline 并 scrub 到目标时刻再插入 keyframe。 fill/stroke使用 popover +ic-spectrum-color-picker;其余数值属性用sp-number-field。- 面板宽高可拖拽调整,尺寸持久化到
localStorage(与 Properties 面板相同的 handle 交互)。
所有编辑经 setNodeAnimation / updateNodeAnimationKeyframe 等 API 写入,参与 undo 历史与文档序列化。
与 Lottie 式编辑器的差异
当前实现刻意保持简单,与 lottielab 等完整 DCC 相比:
- Timeline 以节点为轨道,而非 Lottie 的 layer + property 多轨展开;属性名显示在标签后缀(
Rect · opacity, x)。 - 暂不支持在 Timeline 上直接拖动 keyframe 或条形块改 timing;timing 在 Animation 面板通过
offset编辑。 - 表达式由插件在导入时有限烘焙;Text layer、Clipping mask 尚未可靠支持,也不能由该编辑器直接创作。
后续可在此基础上扩展:property 子轨、keyframe 菱形标记、条形块 edge 拖拽改 delay/duration 等。