算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 缓动:把时间重新塑形 待审核 3 / 47
easing · cubic-bezier · steps

缓动:把时间重新塑形

真实时间是均匀流逝的。缓动(easing)是一个 [0,1]R[0,1] \to \mathbb{R} 的函数,它不动起点也不动终点,只重新分配中途的快慢:先慢后快(ease-in)、先快后慢(ease-out)、两端慢中间快(ease-in-out)。经典的 power 与 circ、back 之外,@vega/anim 还带 sine(最温和)、expo(最强烈)、elastic(弹片往复)与 bounce(落地回弹)几族,easingFns 里共 23 条具名曲线。

值域并不限制在 [0,1][0,1]backelastic 会越过 1 再回落,anticipate 先反向蓄力跌到 0 以下,这些过冲正是曲线形状的一部分——引擎不做截断,越界由调用方的值域自行承受。

1 · 曲线形状与运动节奏的对应

一条缓动曲线的横轴是时间进度,纵轴是值进度。曲线在某处越陡,对应时刻的运动就越快;曲线贴近水平,运动就近乎停滞。两颗小球同时出发、同时到达,差别只在中途。

图 1-1 · 缓动曲线与它驱动的运动。橙球走选中的缓动,蓝球是 linear 参考;下拉可切换具名曲线,或切到 cubic-bezier 与 steps 自定义档。

2 · 三个朝向同源

ease-out 是 ease-in 的时间与值双翻转,easeOut(t) = 1 − easeIn(1 − t);ease-in-out 是把 ease-in 关于中点镜像。引擎据此每族只写一条曲线,另外两个朝向由 reverseEasingmirrorEasing 两个高阶函数变换得到:23 条具名曲线里手写的实现只有 9 条,其余 14 条都是这两个变换的产物。

有一族是反过来的。bounce 的标准定义在 out 朝向——落地回弹这个动作本身就是「先大后小」的,写成 in 反而别扭——所以 easing.ts 里手写的是 bounceOutbounceInreverseEasing(bounceOut) 派生,bounceInOut 再由它镜像。anticipate 则两者都不是:它把前半段的 backIn 与后半段的 easeOut 拼在一起,两段在中点都取 0.5 而连续衔接,终点回到 1 且不过冲。

建议 · 这条同源关系也解释了为什么对称的缓动(easeInOutlinear)看不出 reversemirror 两种重复方向的区别——两种变换作用在对称函数上结果相同。要观察差别得先把缓动换成单向的 easeOut,见时序

3 · cubic-bezier 的反解

cubic-bezier(x1,y1,x2,y2) 不是一个 y=f(x)y = f(x) 的函数,而是一条以参数 uu 描述的曲线:xxyy 各自是 uu 的三次多项式。动画引擎手上只有时间进度 xx,要先求出满足 sampleX(u)=x\text{sampleX}(u) = xuu,才能代入求 yy。这一步没有闭式解,引擎用 Newton-Raphson 迭代求根,导数过小时退回二分——浏览器内部对 cubic-bezier() 做的是同一件事。

steps(n) 走另一条路:把进度切成 nn 级台阶,值在台阶之间不连续地跳变,用于逐帧动画与打字机效果。

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

const anim = new Animation({
  keyframes: [0, 1],
  duration: 1500,
  ease: cubicBezier(0.42, 0, 0.58, 1), // 或具名 'easeInOut'
  repeat: Infinity,
  onUpdate: (value) => {               // value = ease(进度)
    ball.style.transform = `translateX(${value * maxX}px)`;
  },
});

手动指定四个控制点的效果见缓动曲线编辑器。曲线作用的「值」从何而来见插值;把这套预设塑形整个换成物理系统,见弹簧;曲线如何被一个可暂停、可反向的游标驱动见播放控制