JS前端可视化canvas动画原理及其推导实现
作者:尤水就下
前言
到目前为止我们的 fabric.js 雏形已经有了,麻雀虽小五脏俱全,我们不仅能够在画布上自由的添加物体,同时还实现了点选和框选,并且能够对它们做一些变换,不过只有变换这个操作还不够灵活,要是能够让物体动起来就好了,于是就引入了这个章节的主题:动画,以及动画最核心的一个问题,如何保证在不同的电脑上达到同样的动画效果?然后说干就干,立马开撸🚀。
虽然我写的是系列文章,但每个章节单独食用是木问题的,所以,请放心大胆的看🧐。
动画的本质
先来看看在 canvas 库中调用动画的一般方式吧,比如我们要让一个矩形动起来,大体是下面这样的用法:
rect.animate( { top: 50, left: 400, angle: 45 }, // 要动画的属性 { duration: 1000, onChange: canvas.renderAll.bind(canvas) } // 动画执行时间和手动渲染 );
代码浅显易懂,然后我们来想想动画的本质是什么,为什么我们能够看到动画效果呢?这个大家应该都有所了解,不就是画布重新绘制了吗,只要重绘的足够多足够快,根据人的视觉残留效应,就形成了动画。
没错,大体就是这个原因,但我们可以更具体一点,想想画布为什么要重新绘制呢?不就是因为画布中某个物体的某个值改变了,所以我们才要更新一下画面,以此来表示它动了。这个物体状态值的改变才是动画的根本原因🤔。
比如一个物体要花 1s 的时间从 left=100 的地方移动到 left=200 的地方,只要我不断修改 left 值,然后不断 renderAll 就能看到物体从左往右移动了。这很好理解,但是有个新问题出现了,它应该怎样移动呢?匀速、加速还是减速?又或者是其他方式呢?其实都可以,具体要看你希望这个 left 怎么变,以怎样的规律变化。
动画的实现
既然动画的本质就是值的改变,那这个值的改变和哪些因素有关呢?根据刚才的例子我们可以知道大概有以下四个因素:
- 初始值:
startValue
- 结束值:
endValue
- 值的变化时间:
duration
- 怎么变(匀速、缓动还是弹动):
easing
(一个熟悉的单词出现了)
显然动画也是一个通用的东西,所以我们把它写在 Util 工具类里,代码不多,直接食用就行👇🏻:
interface IAnimationOption { /** 初始值 */ startValue?: number; /** 最终值 */ endValue?: number; /** 执行时间 */ duration?: number; /** 缓动函数 */ easing?: Function; /** 动画一开始的回调 */ onStart?: Function; /** 属性值改变都会进行的回调 */ onChange?: Function; /** 属性值变化完成进行的回调 */ onComplete?: Function; } class Util { static animate(options: IAnimationOption) { window.requestAnimationFrame((timestamp: number) => { // requestAnimationFrame 会有个默认参数 timestamp,单位毫秒,表示开始去执行回调函数的时刻 // 初始化一些变量 let start = timestamp || +new Date(), // 开始时间 duration = options.duration || 500, // 动画时间 finish = start + duration, // 结束时间 time, // 当前时间 onChange = options.onChange || (() => {}), // 值改变进行的回调 easing = options.easing || ((t, b, c, d) => -c * Math.cos((t / d) * (Math.PI / 2)) + c + b), // 缓动函数,不用管名字,简单理解为一个普通函数即可,它会返回一个数值 startValue = options.startValue || 0, // 初始值 endValue = options.endValue || 100, // 结束值 byValue = options.byValue || endValue - startValue; // 值的变化范围 function tick(ticktime: number) { // tick 的主要任务就是根据当前时间更新值 time = ticktime || +new Date(); let currentTime = time > finish ? duration : time - start; // 当前已经执行了多久时间(介于0~duration) onChange(easing(currentTime, startValue, byValue, duration)); // 根据当前时间和 easing 函数算出当前的动画值是多少,easing 理解成一个普通函数就行,它会返回一个值,就像这样:curVal = f(x) = easing(currentTime) if (time > finish) { // 动画结束 options.onComplete && options.onComplete(); // 动画完成的回调 return; } window.requestAnimationFrame(tick); // 循环调用 tick,不断更新值,从而形成了动画 } options.onStart && options.onStart(); // 动画开始前的回调 tick(start); // 开始动画 }); } }
相信上面的注释应该解释的清清楚楚、明明白白。不过还是要着重讲下其中的两个点:
- 一个是为什么使用 requestAnimationFrame 这个 api 来完成动画,这应该也是个老生常谈的问题了,因为 setInterval 和 setTimeout 不准,很容易出问题,比如执行时机不准确、切换页面回来会堆积执行、不流畅等,并且它们也不是专门为动画而生(当然如果你不习惯用 requestAnimationFrame 也可以直接把它换成 setTimeout,方便自己理解);
- 而 requestAnimationFrame 是按帧率刷新的,跟着帧率走的期间我们就可以不用做很多无用功,能够更好的知道绘制下一帧的最佳时机,也比较流畅。它们的一个最主要的区别就是:
- setInterval 和 setTimeout 是主动告诉浏览器什么时候去绘制;
- 而 requestAnimationFrame 则是浏览器在它觉得可以绘制下一帧的时候通知我们(你品,你细品,就有那味了)。
当然我们肯定不能直接傻傻的像下面这样调用👇🏻:
// 假设要从左到右运动 let left = 100; function tick() { left++; // 更新值 window.requestAnimationFrame(tick); } tick();
因为每个屏幕刷新频率不一样,如果像上面这样写,在有的电脑上就会快一些,有的电脑上就会慢一些,不仅如此在页面切换到后台的时候帧率也会降低,就会导致各种问题,这显然不是我们期望的。
所以要怎么做呢?
我们应该是以时间为维度来播放动画,因为时间对我们来说流逝的速度是一样的,所以在动画一开始的时候需要记录下开始时间 start
,之后动画播放到哪里都会以这个开始时间为基准,回头看看刚才代码中计算当前动画执行了多长时间的方式:
let currentTime = time > finish ? duration : time - start;
就是以 start
为基准的,这点很重要。
第二点是关于 easing 函数,虽然好像接触过,但还是会有很多同学对此感到疑惑,所以接下来我会专门讲下这方面的内容,比如:这个函数是干嘛的、是怎么推导的、最终又是得到什么结果、和我们平时说的缓动函数是一个东西吗等等之类的。
动画的推导
在讲解 onChange(easing(currentTime, startValue, byValue, duration)) 这个东西之前,我们先来看看如何让每个物体都具有动画的方法,就是在物体基类中扩展就行了,瞟一眼就行👇🏻:
class FabricObject { // 物体基类 _animate(property, to, options: IAnimationOption = {}) { // 某个属性要变化到哪里 options = Util.clone(options); let currentValue = this.get(property); // 获取初始值 if (!options.from) options.from = currentValue; // 一般不传初始值的话就默认取当前属性值 Util.animate({ startValue: options.from, endValue: to, easing: options.easing, // 决定了值如何变化,常用的就缓动和弹动 duration: options.duration, onChange: (value) => { // value 是 easing 函数的返回值,本质就是值的计算,value = easing() this.set(property, value); // 重新设置属性值 options.onChange && options.onChange(); // 值改变之后,调用 onChange 回调就会重新渲染画布,数据和视图分开的优点又体现了出来 }, onComplete: () => { this.setCoords(); // 更新物体自身的一些坐标值等 options.onComplete && options.onComplete(); // 动画结束的回调 }, }); } }
然后再强调一下,动画的核心就是值的变化,Util.animate
中的 easing
函数其实就是计算动画播放到 (0, duration)
中间某一时刻的值是多少,仅此而已。再来简单说下 easing 函数吧,一般可以叫它缓动函数。
它是首先是一个函数,并且会返回一个数值,类似于 y = f(x)
,在我们的例子中就是 value = easing(time, beginValue, changeValue, duration)。这个函数有四个参数(当前时间、初始值、变化量 = 结束值-初始值、动画时间),返回的是当前时间点所对应的值 value,显然后面三个参数是已知的,也是固定的,唯一会变化的就是当前时间,它的取值范围就是从 0 到 duration。
执行动画的时候其实就是改变这个当前时间,根据当前时间我们代入 easing 函数就能够得到对应的 value 值。
可能有同学还是不懂这个缓动函数,其实是因为被上面的公式唬住了,公式都是推导之后的简便写法,直接去看式子是很难理解的,单凭公式在脑海中想象出动画效果也不太现实,所以这里给大家简单推导一下这种式子怎么来的,以最简单的匀速运动为例子,看看下面这张图👇🏻:
上面这个过程很显然,也不用怎么推导,下面我们来看另一个更加通用的例子,首先随便拿一个函数 y = x * x
(其他的也行),顺便简单画下函数图像👇🏻:
绿色代表起点,也就是动画起始值,红色代表终点,也就是动画结束值。x 轴就是动画时间,y 轴就是当前的动画值,为了方便和统一,我们需要把时间换算成 [0, 1]
的范围,0 就是起点,1 就是终点,y 轴代表的值也是一样的道理。
然后我们的起点和终点就是(0,0)和(1,1)点
(注意:虽然xy的范围都是0到1,看起来是个正方形,但它们的单位或者说表达的意思是不一样的,不要混淆了),起点和终点是固定不变的,中间的曲线可以随便怎么画,那怎么将它写成一个缓动函数呢?
我们先看看 x 轴代表什么,x 是一个取值范围从0到1的变量,看看我们的缓动函数有啥变量呢,就一个 currentTime,但是 currentTime 的取值范围是从 [0, duration]
,所以我们需要把它映射成[0, 1]
,其实也就是把 currentTime / duration 就行,然后用 currentTime / duration 代替 x;
那 y 呢,y 根据 x 算出来的值,代表的是当前这个时间点所对应的值,也就是我们缓动函数的 value 值,它的取值范围在 [startValue, startValue + byValue]
之间,所以我们也需要将其变成[0, 1]
,所以 value 的值变成了这样(value - startValue) / byValue
,那么现在 y 值也有了,我们就可以将它们直接代入 y = x * x
这个初始公式,就像这样:
① y = x * x
👇🏻 代入 x、y
② (value - startValue) / byValue = (currentTime / duration) * (currentTime / duration)
👇🏻 整理一下
③ value = (currentTime / duration) * (currentTime / duration) * byValue + startValue
👇🏻 简化一下(简化英文单词而已😂)
④ value = (t, b, c, d) => ((t/d) * (t/d) * c + b)
这个效果其实就是 easeInQuad
先慢后快的缓入效果,其他函数也是一样的推导方式,只要你能写出来。不过即便知道了怎么推导,你也很难有个直观的效果,其实常见和常用的就那么几个,网上也有大把封装好的和演示的,有个印象就行(比如可以搜一下 Tween.js)。
当然你也可以看函数图像简单猜一下效果,具体就是看每一点的斜率,斜率越趋近于水平就越慢,斜率越趋近于竖直就越快;如果你的函数曲线中有 y 值超出了 1,就说明中间点在某一时刻会超过终点,如果有 y 值小于 0,就说明有中间点有某一时刻会小于起始点,大概是这么个意思😂。
缓动函数有个很大的特点,就是起点和终点位置是确定的,中间位置你可以随便算,可快可慢,可以超出终点,也可以小于起点,具体什么效果,你可以随便写个方程运行试试,然后再根据效果调试。相信你肯定见过下面这种类型的图:
现在再看看,不知道会不会感到稍微亲切一点点嘞?🥳
小结
本章我们主要讲解了 canvas 中动画的实现,其中最重要的一点就是如何在不同帧率达到同样的动画效果,那就是要以时间为维度来进行度量,用 canvas 做的游戏也是一样,时间每向前 tick 一次(滴答的意思,挺形象的叫法,古老时钟的那种感觉),画布就会向前推进一次(重新绘制)。
然后再补充两个小点:
- 通常情况下动画的发生总是伴随着画布的重新绘制,但是默认情况下 fabric.js 并不会自动帮我们重新绘制,需要我们手动调用(可以看看开篇代码中 onChange 的回调是咋写的),这是因为如果画布中有很多物体在运动,默认自动重新绘制的话会降低性能。
- 动画不仅仅可以作用于位置,还可以作用于各种属性,比如透明度、颜色等,其实只要是个数值就能够进行动画。并且归功于我们之前将数据和视图分离的架构,这个章节所做的一切也仅仅是改变数据而已,并不涉及画布绘制的内容。
然后这里是简版 fabric.js 的代码
以上就是JS前端可视化canvas动画原理及其推导实现的详细内容,更多关于JS前端可视化canvas动画的资料请关注脚本之家其它相关文章!