动画引擎原理 · 从插值到播放控制
动画看上去是「元素动起来」,本质是一连串纯粹的数值问题:给定起点、终点与一个 0→1 的进度,求中间值 (插值);把均匀流逝的时间重新塑形成有快慢的节奏 (缓动);或干脆交给一个物理系统自己演化到静止 (弹簧)。再往上是把多个值串成途经点 (关键帧)、把多段动画排在一条时间轴上 (时间线 / 交错),最后用一个游标和播放速率把这一切组织成可暂停、可 seek、可反向的播放器。
本系列刻意不依赖 CSS transition / animation——那会把这些要素藏进浏览器内部。这里每一页都由本仓库手写的 @vega/anim 引擎逐帧计算并驱动:左边动真实元素,右边画出「值随时间」的曲线,两相对照,看清每个要素的数学本质。引擎本体在 packages/anim,带完整单元测试。
地基:值怎么算、时间怎么塑形
插值:中间值怎么求
动画最底层的要素。数字按比例线性取中点;颜色则不能直接对 sRGB 通道取平均 (中点会发灰),要在线性光空间混合;带单位的字符串先拆出数字再插值。拖动进度条,对照「线性光」与「朴素 sRGB」两条渐变带的差异。
色彩空间:沿什么路径混合颜色
两种颜色之间插值,在不同色彩空间走的路径不同:sRGB 通道平均 (中点发灰)、线性光混合、HSL 沿色相环 (最短弧)、OKLCH (感知均匀)。四条渐变带对照,看同一对端点为何过渡相差甚远。
缓动:把时间重新塑形
真实时间均匀流逝,缓动把它映射成有快慢的「值进度」,起止值不变。选不同曲线,看画板上的曲线形状与小球的运动节奏如何对应;cubic-bezier 如何靠 Newton 迭代反解;ease-in / out / in-out 为何同出一条曲线。
缓动曲线编辑器:拖控制点
拖两个控制点实时塑形 cubic-bezier 曲线,小球同步预览运动节奏,显示当前 cubic-bezier(x1,y1,x2,y2)。印证缓动页讲的:给定时间需 Newton 迭代反解值,手柄斜率即起止速度。
时序:重复、往返与停顿
缓动管「一次迭代内进度怎么走」,时序管它上一层:毫秒如何翻译成「第几次迭代 + 迭代内进度」。一段动画可重复 (repeat)、迭代间停顿 (repeatDelay);重复的方向有三种语义——每次从头顺放 (loop)、奇数次时间反演 (reverse)、奇数次形状镜像 (mirror)。看进度曲线被重复成的完整形状。
滚动数字:计数器与翻牌
数字插值最直接的落地:被补间的「值」就是这个数。难点在呈现——取整 + 千分位 (纯计数器),或把每一位做成可滚动的数字带、低位快高位慢叠成里程表式翻牌 (odometer)。两种吃同一个 0→N 补间。
物理:不给时长的弹簧
弹簧:没有时长的动画
弹簧不是「N 毫秒走完一条曲线」,而是一个由刚度 / 阻尼 / 质量决定的物理系统,自己演化到静止——时长是结果而非给定。调参数看欠阻尼 / 临界 / 过阻尼三种形态,以及它如何「接住」一个进行中的初速度。
惯性衰减:没有目标的滑行
与弹簧相反的物理:没有目标值,只有初速度 + 摩擦,滑行一段后停下 (动量滚动 / flick)。落点 = from + power·velocity,速度按 e^(−t/τ) 衰减。由引擎新原语 createDecay 驱动,可选 modifyTarget 做带惯性的吸附。
向量弹簧:二维联动与链式跟随
标量弹簧推广到二维:x、y 各跑一个 createSpring 但共享参数,合成协调的跟随。再串成链——每个节点用弹簧追前一个节点,移动光标拖出果冻般的尾迹。
橡皮筋:越界阻尼与回弹
iOS 式 over-scroll:边界内 1:1 跟手,越界后位移按 limit + d·(1 − 1/(x/d+1)) 渐近递减 (越拽越难),松手用弹簧弹回边界。界内甩动可由 decay 滑行,越界再被橡皮筋接住。
编排:关键帧 · 时间线 · 交错
关键帧与编排:途经点、时间轴、交错
一对 from→to 推广成一串途经值 (关键帧 + offset);多段动画按各自起点排在一条时间轴上 (时间线);同一段动画给一组元素各自一点延迟,就有了波浪式的先后感 (交错)。
编排进阶:序列、依赖、二维交错
比单段 stagger 更进一步:A→B→C 依次衔接 (sequence)、一段完成回调触发下一段 (依赖)、网格按「从中心扩散 / 行 / 列 / 对角」二维 stagger 入场。轴从时间换成网格距离。
文本动画:逐字、逐词、打字机
把文本拆成字 / 词 / 行,每个单位带 stagger 延迟做 fade + 上浮入场 (可选正序 / 逆序 / 从中间);打字机则是逐字 reveal。都是 stagger 在文本上的落地。
文字变形:一个词逐字长成另一个词
比 split-text 更进一步:两串字之间先要对齐 —— 谁只是搬家 (keep)、谁淡出、谁淡入。按下标硬配 (index) 或按最长公共子序列复用共有的字 (lcs),排出一组槽位后剩下的又全是老朋友:插值 + 缓动 + 交错。全页只有一个 Animation,它的值就是毫秒数,每个字的位置由这一个数经纯函数算出,于是拖播放头能停在任意中间帧、倒放能看着 September 缩回 August。再演示打断:一次 morph 没播完词又变了,该从算法起点重来还是接住当前渲染位置。与 CSS 侧同名页共用同一套对齐算法。
经典动画原则:把「自然」翻译成引擎参数
反过来讲:迪士尼经典动画原则——那些让运动「像真的」的经验法则——具体对应引擎的哪个参数。缓入缓出 = 整体 ease;预备动作 = 关键帧序列前插反向途经点;挤压拉伸 = scaleX/scaleY 反相关键帧 + 底部 origin;跟随重叠 = 主体 easeOut + 附属弹簧过冲;弧线 = x/y 分量各走各的。每条配「朴素 ⇄ 应用」对照。与 CSS 侧的好动画七准则互为表里。
高尔顿钉板:随机过程 × 确定性播放器
小球逐排撞钉、左右各半偏一格,落入底槽累积出二项 → 正态分布。落点只取决于开局抽好的一串左右抉择 (dirs),抉择定了轨迹就是一条确定折线——随机只发生一次,播放本身仍是 currentTime → 位置 的纯函数。每个球一个 Animation,keyframes 是预抽路径,onUpdate 沿折线采样画到 <canvas>,stagger 式连续投放堆出钟形;单球可任意 seek / 反向,印证「随机过程也能塞进可回退播放器」这条主线。
程序化:周期与噪声的无尽动画
不演化到目标、没有起止的另一类运动——三角函数驱动的规则周期与噪声驱动的有机微动。
程序化动画:用噪声做无尽的微动
和「演化到目标」相反:噪声驱动的动画没有起止、不重复,适合漂浮 / 呼吸 / 待机微动。页内手写 value noise + 多倍频 fbm,用 rAF 推进时间映射到一组元素的位置 / 缩放 / 旋转。
三角函数:正弦波与圆周运动
另一类不收敛、永不停止的运动——规则周期。A·sin(ωt+φ) 的振幅 / 频率 / 相位三个旋钮拼出脉动与浮动;单位圆上的点 (cos θ, sin θ) 揭示圆周运动与一维振荡本是同一个 sin/cos 的两种看法;相位逐个错开成行波 (即连续相位版的 stagger);两轴各跑一个正弦得李萨如曲线。与噪声同属「非补间的信号源」,一个规则一个随机。
声音驱动:FFT 频谱与节拍
驱动动画的信号也可来自外部——声音。AnalyserNode 对音频做 FFT,每帧给出频谱;低频 (鼓点) 驱动脉冲缩放、各频段驱动频谱条。页内合成一段节拍 (无需上传 / 麦克风),实时分析驱动可视化。与噪声同属「非补间的信号源」。
渲染目标可换:同一引擎驱动 canvas
引擎产出的是值,「把值写到哪」是另一回事。前面各页落到 DOM (applyStyles),但「时间→值」是纯函数,sink 可随意替换:同一个主 Animation 当时钟,onUpdate(p) 里把每个粒子位置采样出来改画到 <canvas> 2D context——渲染器换了,引擎一行没动。几十个粒子聚环再散开 (mirror 往返),并演示 canvas 专属的拖尾残影。是「render 是纯函数」主线的延伸。
控制与交互:可回退的播放器
播放控制:为什么动画可以回退
播放器只持有一个游标 currentTime 与速率 speed。因为「时间→值」是纯函数,暂停 / 跳转 / 重播 / 反向都只是改游标或给 speed 取负再重新采样,天然可回退。拖动播放头随意 scrub,翻转方向,调整倍速。
换向缓动:reverse 时别把 ease 也倒过来
naive reverse 只给 speed 取负,缓动曲线被一起时间反演——expoOut 倒放变 expoIn,回退段缓动方向翻转。easeReverse (对齐 gsap 3.15) 换向时记下当前位置作锚点,对剩余距离重施一条正向缓动:τ=0 连续不跳变、τ=1 精确落点,中途打断即时重锚。可复用正向 ease 或另给一条退出曲线。
高级交互:拖拽惯性、滚动驱动、FLIP
把输入设备的位置直接喂给动画值,松手由带初速度的弹簧接管 (拖拽惯性);把动画的播放头绑定到滚动位置 (scroll-linked);先量首末位置、用 transform 反推再播放回原状,避免逐帧重排 (FLIP 布局动画)。
中断与重定向:带速度接管
动画几乎总是没播完就被新目标打断。从当前位置重新开始虽位置连续,却把速度清零、画面顿挫;正确做法是读出打断时刻的当前速度,让新弹簧带着它继续 (velocity handoff)。点轨道改目标,对照两种策略下速度曲线是否跳变。createSpring 的 velocity 参数即初速度。
转盘落点:目标未知时先空转再落定
抽奖转盘 / 老虎机这类「不定 → 定」交互:点击时落点还在接口路上,先用 ease:linear + repeat:Infinity 匀速空转占位,接口返回后再按当前角度新建一段减速 Animation,用「整数圈 + 对齐偏移」精确落到中奖扇区。右侧角速度剖面直观呈现平台 (匀速) → 交接点 → 衰减 (落定) 三段。
驱动器与帧:delta-time 与帧率无关
唯一时间来源与帧率无关性:固定每帧 +x 的球会随帧率变快慢,按 delta-time 累加 (pos += v·dt) 的球掉帧也走对距离。模拟丢帧 / 抖动 dt 直观对照,正是引擎 tick 用 delta·speed 累加 currentTime 的原因。
飞行中重定向:animateTo 不丢速度地改目标
引擎新增原生弹簧值源 new Animation({ spring: {…} })——值由物理弹簧实时产出、时长由 settleTime 反推。关键是 animateTo(newTarget):飞行途中改目标时以当前 value+velocity 为新初始条件重建弹簧,速度连续、不从静止重启。对照 keyframe 重启 (cancel+新 easeOut,速度清零会「一顿」)。这是 interrupt 页手搓那套的一等 API,也是 drag() 松手再抓的底层。
时间重映射:局部慢放与速度曲线
在线性时间轴上对播放头再套一层映射:某子区间慢放 (slow-mo)、整体 speed ramp。对照原始线性播放头与重映射后的播放头。缓动是时间→进度的重塑,这里是时间→时间。
进度条:把跳变的真实进度译成可信的运动
进度条的难点全在动画侧:真实进度离散跳变 (下载分块),直接 set 宽度会一段段跳变——让显示值用弹簧 animateTo 续速追赶才平滑;总量未知时先 repeat:Infinity 循环占位、拿到总量再落定 (与 spin-to-target 同构);压根测不到进度时给一条朝渐近线减速逼近、永不到顶、完成才 snap 的「诚实假进度」(trickle);多段 stories 则是一条时间轴游标,暂停 / 跳段都是 playback 控制。圆环再把同一个 0→1 同时喂给 conic-gradient 与 SVG dashoffset。
声明式状态与 variants
命令式之外的主范式:声明一组命名状态 (rest / hover / press / active),每个是一组目标属性;切换时每个属性从当前值打断并过渡到新目标。把中断重定向收进声明式模型。
列表生命周期:入场、出场、重排
列表 UI 最高频也最难的三件事:新项挂载入场;删除时先播出场动画、结束再真正移除 (AnimatePresence 的延后卸载);重排时每项用 FLIP 平移到新位。
进入视口触发:IntersectionObserver
与 scroll-linked 的来回 scrub 相对:元素进入视口时触发一次入场、离开不回退。用 IntersectionObserver 监听,过阈值播一次 fade + 上浮。讲清 trigger 与 scrubbing 的语义差。
手势进阶:磁吸、跟随、滑动消除
拖拽的另一面:磁吸按钮 (光标靠近被弹簧吸附)、hover 弹簧跟随、滑动消除 (过阈值由 decay 带速度甩出,否则弹回)。手势映射 + spring / decay。
shared element:跨节点的 magic-move
比单元素 FLIP 更进一步:列表里的小头像与详情里的大头像是两个不同 DOM 节点,却代表同一对象——切换视图时让它看起来是同一个元素连续飞放过去 (magic-move / shared element transition)。手法仍是 FLIP「量首末、反推、播回 identity」,只是首末取自两个节点:invert(first,last) 求差,invertTransform(d,1) 先反推到来源盒子再播到 0。原生 View Transitions 把这套自动化。
可访问性:prefers-reduced-motion
尊重「减少动态效果」偏好:matchMedia 检测,把大幅位移 / 缩放 / 旋转降级为温和淡入或直接到位。不是关掉所有动画,而是去掉前庭负担大的大幅运动、保留必要反馈。
一条主线
性能与调度:该动什么、怎么驱动
渲染管线代价、合成层、单 rAF 与读写分离——让动画不掉帧的工程地基。
该动什么:合成层 vs 布局/绘制
为何只应动 transform / opacity (合成器线程,不触发布局绘制),而动 left/width (layout 重排)、color/shadow (paint 重绘) 代价高。两组元素同一移动,一用 left + 强制同步布局、一用 translateX,实测帧时间对照。
调度:单 rAF 与读写分离
两个工程要点:多个动画共用一个 rAF (而非各自一个);一帧内把 DOM 读批量做完再批量写,避免读写交错引发的强制同步布局 (layout thrashing)。切换对照实测帧时间。
SVG:几何与笔触
把前面的插值与缓动接到 SVG 的可动画属性上——同一套「时间→值」,落到笔触长度、路径弧长、取景框与形状上。
SVG 描边:让线条自己画出来
最经典的 SVG 动画只动一个数:把虚线段长设成路径总长 (getTotalLength),整条线成了「一段实线 + 等长空白」;让 stroke-dashoffset 从总长滑到 0,实线段便被逐渐推出来——签名重放、对号、loading 圈皆同此理。dashoffset = 总长 × (1 − 进度)。
沿路径运动:取点与切线朝向
让物体沿任意曲线行进,不必解曲线方程:getPointAtLength(d) 按弧长取点,进度 0→1 映射成距离 0→总长。取相邻点用 atan2 求切线角即可让物体朝向前进方向。这是 CSS offset-path 的底层,且按弧长取点天然沿路匀速 (而非贝塞尔参数 t 上匀速)。
viewBox 摄像机:推拉与聚焦
viewBox="x y w h" 决定把世界坐标的哪块矩形铺满视口,动这四个数就是一台摄像机:平移、缩放推拉、聚焦区域。内容一笔未动,只是取景框在补间——同一台引擎接到 dashoffset 是描边、接到 transform 是移动、接到 viewBox 就是摄像机。
SVG 滤镜动画:水波、溶解、发光
被补间的值也可以是滤镜参数:动 feTurbulence 的 baseFrequency 做流动、feDisplacementMap 的 scale 做溶解、feGaussianBlur 的 stdDeviation 做呼吸光晕。同一引擎接到 filter 属性即得程序化质感。
形状形变 morph:path d 插值
两条命令结构相同的 path,把数值参数逐一插值即得平滑 morph(圆 / 方 / 星 / 心,顶点天然对应)。命令数不同的形状(三角 / 十字 / blob)则按弧长重采样成等长点集、再循环移位旋转对齐后 morph——本页两部分分别演示。
与平台对照
手写引擎之外,浏览器原生提供了哪些动画能力,各自的边界在哪。
与原生对照:WAAPI / View Transitions / scroll-driven
同一效果用手写引擎 vs element.animate() (WAAPI) vs View Transitions vs CSS scroll-driven 各做一遍,附能力对照表 (seek / reverse / 可测试 / 跨属性 / 支持度),讲清为何仍要手写引擎。
AnimationWorklet:把动画逻辑搬下主线程
本引擎的 tick 跑在主线程,主线程一忙就掉帧。transform/opacity 的声明式动画能下合成器线程 (见合成层),但只能表达固定关键帧。AnimationWorklet (CSS Houdini) 是平台给「自定义 JS 动画逻辑也跑合成器线程」的答案:写一个 animate(currentTime, effect) 注册成 animator。属实验特性、支持度差——页内用「阻塞主线程」对照说明为何需要它 (rAF 那条卡住、合成器那条照动),并按浏览器支持情况尝试真正的 worklet。
动画即数据:descriptor 与 mini-Lottie
换一种范式:把动画写成纯数据 (图层 + 属性轨 + 关键帧 {f,v,ease}),再写个解释器读它播出来——这正是 Lottie 的本质 (序列化的关键帧 IR,而非更复杂的 path 动画)。用 @vega/anim 手写最小版:每条轨喂给 createKeyframes,整份数据编译成一个主 Animation。于是 seek / 反向 / 变速全免费,改 JSON 一个数即改动画 (现场可编辑重载)。
SVG → Lottie:粘贴静态图,自动补动画再导出
把 descriptor 页「动画即数据」的结论做成可运行工具:粘贴一张静态 SVG,选入场 / idle 预设自动补关键帧,即时生成真正的 Lottie JSON,交给工业渲染器 lottie-web 当场播放并可导出。常见 icon (circle / rect / polygon / 简单 path) 转原生矢量 shape layer,arc / 渐变 / transform / <g> 走 image 回退。讲清「SVG→Lottie 的实质是补关键帧,而非无损格式转换」。
空间:3D 变换与透视
延伸阅读
- Motion — Documentation motion.dev 本系列对标的动画库,API 设计 (animate / spring / stagger / scroll) 可与引擎实现互相印证。
- MDN — <easing-function> developer.mozilla.org cubic-bezier 与 steps 的规范定义。
- MDN — stroke-dasharray / stroke-dashoffset developer.mozilla.org SVG 描边自绘所依赖的两个属性。
- MDN — getPointAtLength / getTotalLength developer.mozilla.org 沿路径运动的取点 API,按弧长度量。
- Damped Springs ryanjuckett.com 阻尼谐振子三种形态的解析解推导,对应 spring 页的实现。
- FLIP Your Animations aerotwist.com FLIP 技术的原始出处。
- Galton Board en.wikipedia.org 高尔顿钉板与二项 → 正态分布的背景,galton 页演示其「随机路径 + 确定播放」实现。
- Lottie Format Docs lottiefiles.github.io Lottie JSON schema 的字段说明 (layers / ks / shapes / 关键帧),svg-to-lottie 页生成的结构据此。
- lottie-web · GitHub github.com svg-to-lottie 页用的运行时渲染器,loadAnimation / setSpeed / goToAndStop。