动画引擎原理 · 从插值到播放控制
动画看上去是「元素动起来」,本质是一连串纯粹的数值问题:给定起点、终点与一个 0→1 的进度,求中间值 (插值);把均匀流逝的时间重新塑形成有快慢的节奏 (缓动);或干脆交给一个物理系统自己演化到静止 (弹簧)。再往上是把多个值串成途经点 (关键帧)、把多段动画排在一条时间轴上 (时间线 / 交错),最后用一个游标和播放速率把这一切组织成可暂停、可 seek、可反向的播放器。
本系列刻意不依赖 CSS transition / animation——那会把这些要素藏进浏览器内部。这里每一页都由本仓库手写的 @vega/anim 引擎逐帧计算并驱动:左边动真实元素,右边画出「值随时间」的曲线,两相对照,看清每个要素的数学本质。引擎本体在 packages/anim,带完整单元测试。
地基:值怎么算、时间怎么塑形
插值:中间值怎么求
动画最底层的要素是插值:给定起点、终点与进度 p 求中间值。数字按比例取,带单位的字符串拆出数字再回贴单位,颜色则须先解码到线性光再混合,否则中点发灰。
色彩空间:沿什么路径混合颜色
两色之间的插值不是一个点而是一条路径。sRGB 通道平均中点发灰,线性光先解码再混合,HSL 沿色相环且方向可选,OKLCH 在感知均匀的坐标里走。
缓动:把时间重新塑形
缓动不改变起点与终点,只把均匀流逝的时间轴重新映射成有快慢的值进度。引擎只定义 in 曲线,out 与 in-out 由翻转与镜像变换得到;cubic-bezier 需 Newton 迭代反解。
缓动曲线编辑器:拖控制点
cubic-bezier 的形状全由两个控制点决定。手柄线的斜率即起止瞬时速度,控制点的 y 越界即产生过冲;引擎驱动时走的是「给定时间反解参数」的路径,而画曲线不必反解。
时序:重复、往返与停顿
时序层把物理毫秒翻译成「第几次迭代、迭代内进度多少」。重复方向的三种语义 loop / reverse / mirror 只在塑形一步上分岔,repeatDelay 则落在两次迭代之间。
滚动数字:计数器与翻牌
数字补间的难点不在动画而在呈现。纯计数器只需取整加千分位;翻牌 odometer 若按齿轮式取每位的商,静止时会停在两数之间,须改用进位传递。
物理:不给时长的弹簧
弹簧:没有时长的动画
弹簧是阻尼谐振子的解析解,时长不是输入而是参数的结果。阻尼比 ζ 把行为分成欠阻尼、临界、过阻尼三种形态,初速度可被直接承接。
惯性衰减:没有目标的滑行
decay 没有目标值,只有初速度与摩擦。落点是初速度的结果而非输入,配合 modifyTarget 把理论落点对齐到刻度即得带惯性的吸附。
向量弹簧:二维联动与链式跟随
向量弹簧是两个共享参数的标量弹簧。参数共享保证两轴收敛节奏一致,轨迹才协调;每个节点追前一个节点的当前位置即得链式拖尾。
橡皮筋:越界阻尼与回弹
over-scroll 由两段拼成:界内 1:1 跟手,越界后按一条渐近饱和的阻尼函数压缩。该函数在边界处导数恰为 1,与跟手段无缝衔接;松手由 spring 拉回、界内甩动交给 decay。
编排:关键帧 · 时间线 · 交错
关键帧与编排:途经点、时间轴、交错
把一对 from→to 推广出三件要素:关键帧用 times 把「经过哪些值」与「在哪个进度经过」解耦;时间线把多段动画排在一条轴上;交错为一组元素批量生成递增延迟。
编排进阶:序列、依赖、二维交错
序列在排布时一次算定布局,依赖式串接把下一段交给 onComplete 因而容得下运行时才确定的时长,二维交错则把网格坐标折成一条空间轴再交给同一个 stagger。
文本动画:逐字、逐词、打字机
split-text 是「N 个动画各配一个 delay」,typewriter 是「1 个动画驱动一个整数游标」。前者拆 DOM 靠 stagger 编排,后者把离散的敲字事件挂到连续进度上。
文字变形:一个词逐字长成另一个词
两串字先对齐成一组槽位,再由唯一一个 Animation 驱动全部字。render 是时间到画面的纯函数,seek 与反向因而免费;代价是打断时的「接住」要自己实现。
经典动画原则:翻译成引擎参数
迪士尼那批经验法则对应到引擎里都不是新能力,而是关键帧、缓动与弹簧几个既有原语的用法。逐条给出朴素与应用原则的对照。
高尔顿钉板:随机过程与确定性播放器
随机只发生在开局抽路径那一刻,之后的下落是对一条确定折线的采样。因而这台物理玩具不需要碰撞检测,且天然可 seek、可反向。
程序化:周期与噪声的无尽动画
不演化到目标、没有起止的另一类运动——三角函数驱动的规则周期与噪声驱动的有机微动。
噪声:无尽的微动
value noise 在整数格点放随机值、格点间平滑插值,得到连续有界却不重复的信号;倍频叠加成 fractal noise,用于没有终点的待机微动。
三角函数:正弦波与圆周运动
正弦振荡与圆周运动是同一个 sin/cos 的两种看法。振幅、频率、相位三个旋钮各管一件事,相位错开即得行波,两轴各跑一路即得李萨如曲线。
声音驱动:FFT 频谱与节拍
驱动动画的信号也可以来自外部。AnalyserNode 每帧给出一条频谱,低频段映射脉冲缩放、各频段映射频谱条高度。
渲染目标可换:同一引擎驱动 canvas
引擎产出的是值,写到哪是另一回事。把 onUpdate 的最后一步从拼 transform 换成 ctx.arc,渲染器就换了,引擎一行没动。
控制与交互:可回退的播放器
播放控制:为什么动画可以回退
播放器只持有游标与播放速率两样状态。因为「时间到值」是纯函数管线,暂停是停止累加、seek 是直接改游标、反向只是给 speed 取负。
转盘落点:目标未知时先空转再落定
点击时落点还在接口路上,先匀速空转占位,返回后按当前角速度新建一段减速。默认用 decay 让时长由物理反推,再用 modifyTarget 吸附到中奖扇区。
换向缓动:reverse 时别把 ease 倒过来
speed 取负会把缓动曲线一起时间反演,慢收尾倒放成慢启动。easeReverse 在换向点记锚,对剩余距离重施正向缓动。
高级交互:拖拽惯性、滚动驱动、FLIP
三类交互动画都不引入新机制,只是把输入接到动画值上,或把播放头接到别的来源上。FLIP 的 Invert 必须在首帧之前同步写上。
中断与重定向:带速度接管
打断时位置总是连续的,差别只在速度。读出当前速度交给新弹簧当初速度,运动才首尾如一;清零则是一次生硬的顿挫。
驱动器与帧:delta-time 与帧率无关
引擎其余部分都是纯函数,真正让时间前进的只有驱动器这一层。按帧数累加会随刷新率漂移,按两帧之间的真实间隔累加才帧率无关。
飞行中重定向:animateTo 不丢速度地改目标
原生弹簧值源让值由物理实时产出,animateTo 以当前显示值与当前速度为新初始条件重建弹簧;固定曲线值源只能 cancel 再起一条,衔接处丢速度。
时间重映射:局部慢放与速度曲线
在均匀播放头之上再套一层「时间到时间」的映射。慢放区省下的时间必须在区间外补回,因此两侧局部速度必然大于 1。
进度条:把跳变的真实进度译成可信的运动
进度条要解决的不是画一个矩形,而是把离散跳变、甚至不可知的外部信号,在时间上重新塑形成连续可信的运动。五种形态各对应一种信号。
声明式状态与 variants
预先声明一组命名状态,每个状态只是一张目标属性表;运行时只切换当前状态,每个属性各自用一根弹簧从此刻的值过渡到新目标。
一条主线
列表生命周期:入场、出场、重排
三种过渡的差别全在动画与 DOM 变更的先后。出场必须延后卸载,重排必须先改 DOM 再用 transform 反推。
进入视口触发:IntersectionObserver
滚动驱动有两种语义。scrub 把播放头绑死在滚动量上、来回可逆;trigger 只取「跨阈值」这一离散事件,动画播完即定格。
手势进阶:磁吸、跟随、滑动消除
三个手势共用同一套零件:pointer 事件取输入、弹簧平滑逼近目标、decay 接住松手速度。区别只在目标是什么、谁来接管。
shared element:两个节点的连续 morph
列表里的小头像与详情里的大头像是两个 DOM 节点,却要看起来是同一个元素飞过去。手法仍是 FLIP,只是首末两个盒子取自不同节点。
尊重「减少动态效果」
负责任的降级不是关掉一切动画,而是去掉大幅位移、缩放与旋转,保留必要的状态反馈。matchMedia 还能监听偏好变化。
性能与调度:该动什么、怎么驱动
渲染管线代价、合成层、单 rAF 与读写分离——让动画不掉帧的工程地基。
该动什么:合成层与布局绘制
动画每帧改哪个属性,决定浏览器要从渲染管线的哪一层重新算起。transform 与 opacity 只需 composite,且交给合成器线程。
调度:单 rAF 与读写分离
决定每帧成本的不是动画数量而是调度方式。所有动画共用一个 rAF 循环,一帧之内先批量读再批量写。
SVG:几何与笔触
把前面的插值与缓动接到 SVG 的可动画属性上——同一套「时间→值」,落到笔触长度、路径弧长、取景框与形状上。
SVG 描边:让线条自己画出来
把虚线段长设成路径总长,整条线便成了「一段实线加一段等长空白」;让 dashoffset 从总长滑到 0,实线段被逐渐推出来。顺序绘制则把各子路径的长度接成一条轴。
沿路径运动:取点与切线朝向
getPointAtLength 按弧长取点,进度 0→1 映射成距离 0→总长,沿路速度因而与缓动曲线一致;取相邻两点用 atan2 求切线角即得朝向。
viewBox 摄像机:推拉与聚焦
viewBox 决定把世界坐标的哪块矩形铺满视口,动这四个数即得一台摄像机。内容一笔未动,被补间的只是取景框的四个标量。
SVG 滤镜动画:水波、溶解、发光
filter primitive 的属性同样是标量,接到 onUpdate 上逐帧 setAttribute 即得程序化的表面质感。引擎不关心这个数去往坐标还是滤镜。
形状形变:path 的 d 插值
两条 path 命令结构相同时,d 退化成等长数字数组,逐位插值即得形变。结构不同则先按弧长重采样成等长点集,再循环移位对齐。
与平台对照
手写引擎之外,浏览器原生提供了哪些动画能力,各自的边界在哪。
与原生对照:浏览器自带了什么
三套原生动画能力各有清晰边界。手写引擎不与它们竞争省力,而是补齐可 seek、可确定性测试、跨属性统一插值这组属性。
AnimationWorklet:把动画逻辑搬下主线程
rAF 与引擎的 tick 都在主线程,业务 JS 一忙就掉帧。AnimationWorklet 让带自定义逻辑的动画也能跑在合成器侧,代价是它已实质停摆。
动画即数据:timeline 序列化成 descriptor
把动画写成一份纯数据,再写一个解释器读它、用引擎播出来。Lottie 的本质正是把关键帧这套 IR 序列化成 JSON。
SVG 转 Lottie:补关键帧并导出 JSON
粘贴静态 SVG,选一个预设自动补关键帧,生成真正的 Lottie JSON 并交给 lottie-web 播放。转换的实质是补动画,不是无损格式转换。
空间: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。