算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 播放控制:为什么动画可以回退 待审核 21 / 47
play · pause · reverse · seek · speed

播放控制:为什么动画可以回退

播放器看上去要维护很多状态,实际只持有两样东西:一个游标 currentTime 与一个播放速率 speed

因为「时间到值」是一条纯函数管线——时序算出迭代内进度、塑形施加缓动与方向、采样得到值——所以暂停只是停止累加游标,跳转只是直接改游标再重采样,重播只是把游标归零,反向只是给 speed 取负。没有任何一步需要「反着把动画再算一遍」。

1 · 一个游标能做的事

图 1-1 · 完整的播放控制面板。拖播放头可任意 scrub,点反向立刻倒着走;切换 repeatType 到 reverse 或 mirror,观察往返时方向翻转发生在缓动之前还是之后。

拖播放头是在直接给游标赋值、再让纯函数算出该时刻的值,与动画是否在播放无关;点反向只是把 speed 取负,下一帧起游标递减。

2 · 纯函数换来了什么

可回退性不是额外实现的功能,而是纯函数管线的推论。若值依赖于历史(比如每帧在上一帧基础上累加),seek 就必须重放中间所有帧才能得到正确状态,scrub 会随跳转距离变慢,反向更要另写一套逆运算。

警示 · 这条性质对弹簧不成立。createSpringvalue(t) 虽然也是闭式解、也能任意 seek,但一旦用 setTarget 在飞行中改目标(见中断与重定向),新弹簧的初始条件取自改目标那一刻的位置与速度——历史被写进了参数里。此后 seek 到改目标之前的时刻,得到的是新弹簧在负时间上的外推,而不是当时真正显示过的画面。可 seek 与可无缝打断因而是一对需要取舍的性质,文字变形那页记过同一件事。

sampleAt(t) 是这条性质的直接兑现:无副作用地预览任意时刻的值,不动游标、不触发 onUpdate。做时间轴缩略图、算某一帧的碰撞位置都靠它。

const anim = new Animation({ keyframes: [0, 1], duration: 2000, ease: 'easeInOut' });

anim.pause();          // 停止累加游标
anim.seek(1200);       // 直接把游标设到 1200ms, 纯函数重采样
anim.reverse();        // speed = -speed, 游标开始递减
anim.speed = 2;        // 时间流速翻倍
anim.currentTime;      // 当前游标 (ms)
anim.sampleAt(t);      // 无副作用地预览任意时刻的值

这套可回退性正是滚动与拖拽驱动能把播放头绑到滚动条上的前提。驱动游标流动的唯一来源是 requestAnimationFrame,把它换成手动驱动器,动画就完全可被确定性测试,见驱动器。整条时间线继承同一套控制的机制见关键帧与编排