JS前端可视化canvas动画原理及其推导实现


Posted in Javascript onAugust 05, 2022

前言

到目前为止我们的 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 值。

可能有同学还是不懂这个缓动函数,其实是因为被上面的公式唬住了,公式都是推导之后的简便写法,直接去看式子是很难理解的,单凭公式在脑海中想象出动画效果也不太现实,所以这里给大家简单推导一下这种式子怎么来的,以最简单的匀速运动为例子,看看下面这张图??:

JS前端可视化canvas动画原理及其推导实现

上面这个过程很显然,也不用怎么推导,下面我们来看另一个更加通用的例子,首先随便拿一个函数 y = x * x(其他的也行),顺便简单画下函数图像??:

JS前端可视化canvas动画原理及其推导实现

绿色代表起点,也就是动画起始值,红色代表终点,也就是动画结束值。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,就说明有中间点有某一时刻会小于起始点,大概是这么个意思?。

缓动函数有个很大的特点,就是起点和终点位置是确定的,中间位置你可以随便算,可快可慢,可以超出终点,也可以小于起点,具体什么效果,你可以随便写个方程运行试试,然后再根据效果调试。相信你肯定见过下面这种类型的图:

JS前端可视化canvas动画原理及其推导实现

现在再看看,不知道会不会感到稍微亲切一点点嘞??

小结

本章我们主要讲解了 canvas 中动画的实现,其中最重要的一点就是如何在不同帧率达到同样的动画效果,那就是要以时间为维度来进行度量,用 canvas 做的游戏也是一样,时间每向前 tick 一次(滴答的意思,挺形象的叫法,古老时钟的那种感觉),画布就会向前推进一次(重新绘制)。

然后再补充两个小点:

  • 通常情况下动画的发生总是伴随着画布的重新绘制,但是默认情况下 fabric.js 并不会自动帮我们重新绘制,需要我们手动调用(可以看看开篇代码中 onChange 的回调是咋写的),这是因为如果画布中有很多物体在运动,默认自动重新绘制的话会降低性能。
  • 动画不仅仅可以作用于位置,还可以作用于各种属性,比如透明度、颜色等,其实只要是个数值就能够进行动画。并且归功于我们之前将数据和视图分离的架构,这个章节所做的一切也仅仅是改变数据而已,并不涉及画布绘制的内容。

然后这里是简版 fabric.js 的代码

以上就是JS前端可视化canvas动画原理及其推导实现的详细内容,更多关于JS前端可视化canvas动画的资料请关注三水点靠木其它相关文章!

Javascript 相关文章推荐
ExtJS 2.2.1的grid控件在ie6中的显示问题
May 04 Javascript
js中关于String对象的replace使用详解
May 24 Javascript
Javascript数组的排序 sort()方法和reverse()方法
Jun 04 Javascript
JS 获取鼠标左右键的键值方法
Oct 11 Javascript
javascript实现炫酷的拖动分页
May 11 Javascript
javascript实现在网页任意处点左键弹出隐藏菜单的方法
May 13 Javascript
详解Bootstrap四种图片样式
Jan 04 Javascript
jQuery实现右下角可缩放大小的层完整实例
Jun 20 Javascript
vue-router单页面路由
Jun 17 Javascript
React如何避免重渲染
Apr 10 Javascript
在vue中使用express-mock搭建mock服务的方法
Nov 07 Javascript
关于angular引入ng-zorro的问题浅析
Sep 09 Javascript
JS前端使用canvas实现扩展物体类和事件派发
Aug 05 #Javascript
JS前端canvas交互实现拖拽旋转及缩放示例
Aug 05 #Javascript
canvas 中如何实现物体的框选
Aug 05 #Javascript
JS前端使用canvas实现物体的点选示例
Aug 05 #Javascript
前端canvas中物体边框和控制点的实现示例
Aug 05 #Javascript
JS前端轻量fabric.js系列物体基类
Aug 05 #Javascript
JS前端轻量fabric.js系列之画布初始化
Aug 05 #Javascript
You might like
php导出csv格式数据并将数字转换成文本的思路以及代码分享
2014/06/05 PHP
ThinkPHP中pathinfo的访问模式、路径访问模式及URL重写总结
2014/08/23 PHP
PHP把MSSQL数据导入到MYSQL的方法
2014/12/27 PHP
PHP实现的超长文本分页显示功能示例
2018/06/04 PHP
PHP goto语句用法实例
2019/08/06 PHP
windows系统php环境安装swoole具体步骤
2021/03/04 PHP
基于JQuery的密码强度验证代码
2010/03/01 Javascript
解析js中获得父窗口链接getParent方法以及各种打开窗口的方法
2013/06/19 Javascript
如何使用jquery控制CSS样式,并且取消Css样式(如背景色,有实例)
2013/07/09 Javascript
JS获取URL中参数值(QueryString)的4种方法分享
2014/04/12 Javascript
jQuery中:image选择器用法实例
2015/01/03 Javascript
js实现鼠标感应图片展示的方法
2015/02/27 Javascript
jquery实现初次打开有动画效果的网页TAB切换代码
2015/09/06 Javascript
详解JavaScript基于面向对象之创建对象(2)
2015/12/10 Javascript
jquery实现具有嵌套功能的选项卡
2016/02/12 Javascript
BootStrap实现手机端轮播图左右滑动事件
2016/10/13 Javascript
nodejs基础应用
2017/02/03 NodeJs
Vue组件通信之Bus的具体使用
2017/12/28 Javascript
JS实现多功能计算器
2020/10/28 Javascript
浅谈vue.watch的触发条件是什么
2020/11/07 Javascript
[39:32]2014 DOTA2国际邀请赛中国区预选赛 TongFu VS DT 第二场
2014/05/23 DOTA
Python 爬虫多线程详解及实例代码
2016/10/08 Python
python二维列表一维列表的互相转换实例
2018/07/02 Python
python里 super类的工作原理详解
2019/06/19 Python
python系统指定文件的查找只输出目录下所有文件及文件夹
2020/01/19 Python
python右对齐的实例方法
2020/07/05 Python
python Matplotlib数据可视化(2):详解三大容器对象与常用设置
2020/09/30 Python
台湾网购生鲜第一品牌:i3Fresh爱上新鲜
2017/10/26 全球购物
2014年小学体育工作总结
2014/12/11 职场文书
优秀教师申报材料
2014/12/16 职场文书
学习保证书
2015/01/17 职场文书
2015年财务经理工作总结
2015/05/13 职场文书
早上好问候语大全
2015/11/10 职场文书
python基础详解之if循环语句
2021/04/24 Python
Golang 入门 之url 包
2022/05/04 Golang
vue实现在data里引入相对路径
2022/06/05 Vue.js