【前端词典】实现 Canvas 下雪背景引发的性能思考

前言

去年圣诞节产品提了一个活动需求,其中有一个下雪的背景动画。在做这个动画的过程中加深了对 canvas 动画的一些了解,在这里我仅是抛砖引玉的分享一下,欢迎各位大佬批评。

代码已上传至 github ,感兴趣的可以 clone 代码到本地运行。望给个 star 支持一下

入题

需求给出的 UI 样式如下:

<img style="max-width:200px;display:block;margin: 0 auto" src="https://user-gold-cdn.xitu.io/2019/3/17/1698c11e8ca80967">

UI 的需求是雪花下落的方向有点倾斜角度,每片雪花的下落速度不一样但要保持在一个范围内。

需求了解的差不多就开始实现这个效果(在看这篇文章之前你需要对 canvas 的一些基本 API 了解)。

drawImage

image

drawImage 可传入 9 个参数,上图中的 5 个参数是比较常用的,另外几个参数是拿来剪切图片的。

直接使用 drawImage 来剪切图片,其性能不会太好,建议先将需要使用的部分用一个离屏 canvas 保存起来,需要用到的时候直接使用即可。

requestAnimationFrame

requestAnimationFrame 相对于 setinterval 处理动画有以下几个优势:

  1. 经过浏览器优化,动画更流畅

  2. 窗口没激活时,动画将停止,省计算资源

  3. 更省电,尤其是对移动终端

这个 API 不需要传入动画间隔时间,这个方法会告诉浏览器以最佳的方式进行动画重绘。

由于兼容性问题,可以使用以下方法对 requestAnimationFrame 进行重写:


window.requestAnimationFrame = (function(){

        return  window.requestAnimationFrame      ||

                window.webkitRequestAnimationFrame ||

                window.mozRequestAnimationFrame    ||

                window.oRequestAnimationFrame      ||

                window.msRequestAnimationFrame    ||

                function (callback) {

                    window.setTimeout(callback, 1000 / 60);

                };

    })();

对于其他 API 烦请查阅文档。

第一次尝试

有一个大概想法后就开心的开始写代码了,基本思路就是使用 requestAnimationFrame 来刷新 canvas 画板。

由于雪花不规则,所以雪花是 UI 提供的图片,既然是图片我们就需要先将图片预加载好,要不然在 转换图片的时候很可能影响性能。

使用的预加载方法如下:


function preloadImg(srcArr){

    if(srcArr instanceof Array){

        for(let i = 0; i < srcArr.length; i++){

            let oImg = new Image();

            oImg.src = srcArr[i];

        }

    }

}

前前后后写了一个下午,算是写好了,在手机上查看的效果发现很是卡顿。100 片雪花 FPS 尽然平均才 40 多。而且在某些机型会出现都懂得情况。

要是产品看到这个效果,恐怕是又要召集相关人员开相关会议了。这么卡顿肯定是写了些开销大的代码,于是乎需要第二次尝试。

晚上还是需要按时下班的。不过下班回家后也不能闲着,开始找相关的资料,以便第二天快速的完成。

第二次尝试前的准备

经过一个晚上的查找学习,大概知道了以下几个优化 canvas 性能的方法:

1. 使用多层画布绘制复杂场景

分层的目的是降低完全不必要的渲染性能开销。

即:将变化频率高、幅度大的部分和变化频率小、幅度小的部分分成两个或两个以上的 canvas 对象。也就是说生成多个 canvas 实例,把它们重叠放置,每个 Canvas 使用不同的 z-index 来定义堆叠的次序。


<canvas style="position: absolute; z-index: 0"></canvas>

<canvas style="position: absolute; z-index: 1"></canvas>

// js 代码

2. 使用 requestAnimationFrame 制作动画

上面有提到。

3. 清除画布尽量使用 clearRect

一般情况下的性能:clearRect > fillRect > canvas.width = canvas.width;

4. 使用离屏绘制进行预渲染

当时用 drawImage 绘制同样的一块区域:

  1. 若数据源(图片、canvas)和 canvas 画板的尺寸相仿,那么性能会比较好;

  2. 若数据源只是大图上的一部分,那么性能就会比较差;因为每一次绘制还包含了裁剪工作。

第二种情况我们就可以先把待绘制的区域裁剪好,保存在一个离屏的 canvas 对象中。在绘制每一帧的时候,在将这个对象绘制到 canvas 画板中。

drawImage 方法的第一个参数不仅可以接收 Image 对象,也可以接收另一个 Canvas 对象。而且,使用 Canvas 对象绘制的开销与使用 Image 对象的开销几乎完全一致。

当每一帧需要调用的对象需要多次调用 canvasAPI 时,我们也可以使用离屏绘制进行预渲染的方式来提高性能。

即:


let cacheCanvas = document.createElement("canvas");

let cacheCtx = this.cacheCanvas.getContext("2d");

cacheCtx.save();

cacheCtx.lineWidth = 1;

for(let i = 1;i < 40; i++){

    cacheCtx.beginPath();

    cacheCtx.strokeStyle = this.color[i];

    cacheCtx.arc(this.r , this.r , i , 0 , 2*Math.PI);

    cacheCtx.stroke();

}

this.cacheCtx.restore();

// 在绘制每一帧的时候,绘制这个图形

context.drawImage(cacheCtx, x, y);

cacheCtx 的宽高尽量设置成实际使用的宽高,否则过多空白区域也会造成性能的损耗。

下图显示了使用离屏绘制进行预渲染技术所带来的性能改善情况(来自于jsperf):

image

5. 尽量少调用 canvasAPI ,尽可能集中绘制

如下代码:


for (var i = 0; i < points.length - 1; i++) {

    var p1 = points[i];

    var p2 = points[i + 1];

    context.beginPath();

    context.moveTo(p1.x, p1.y);

    context.lineTo(p2.x, p2.y);

    context.stroke();

}

可以改成:


context.beginPath();

for (var i = 0; i < points.length - 1; i++) {

    var p1 = points[i];

    var p2 = points[i + 1];

    context.moveTo(p1.x, p1.y);

    context.lineTo(p2.x, p2.y);

}

context.stroke();

tips: 写粒子效果时,可以使用方形替代圆形,因为粒子小,所以方和原看上去差不多。有人问为什么?很容易理解,画一个圆需要三个步骤:先 beginPath,然后用 arc 画弧,再用 fill。而画方只需要一个 fillRect。当粒子对象达一定数量时性能差距就会显示出来了。

6. 像素级别操作尽量避免浮点运算

进行 canvas 动画绘制时,若坐标是浮点数,可能会出现 CSS Sub-pixel 的问题.也就是会自动将浮点数值四舍五入转为整数,在动画的过程中就可能出现抖动的情况,同时也可能让元素的边缘出现抗锯齿失真情况。

虽然 javascript 提供了一些取整方法,像 Math.floorMath.ceilparseInt,但 parseInt 这个方法做了一些额外的工作(比如检测数据是不是有效的数值、先将参数转换成了字符串等),所以,直接用 parseInt 的话相对来说比较消耗性能。

可以直接用以下巧妙的方法进行取整:


function getInt(num){

    var rounded;

    rounded = (0.5 + num) | 0;

    return rounded;

}

另 for 循环的数组效率是最高的,具体感兴趣的可以自行实验。

第二次尝试

通过昨天晚上的查阅,对这个动画做了以下几点优化:

  1. 使用离屏绘制进行预渲染

  2. 减少部分 API 的使用

  3. 浮点数取证

  4. 缓存变量

  5. 使用 for 循环,替代 forEach

  6. 将整体代码使用原型链方式改写了一遍

方案写好了就开始愉快的写代码。上午下班前新的动画写好了。

200 片雪花的时候 FPS 基本稳定在 60,而且抖动的情况也没了;

增加到 1000 片的时候,FPS 还是基本稳定在 60;

增加到 1500 片的时候,稍微有点零星的几次卡帧;

增加到 2000 片的时候,开始卡顿。

这说明这个动画还是没有优化好,还有优化空间,请各位大佬不吝指教。

推荐使用 stats.js 插件,这个插件可以显示动画运行时的 FPS。

主要代码


let snowBox = function () {

    let canvasEl = document.getElementById("snowFall");

    let ctx = canvasEl.getContext('2d');

    canvasEl.width = window.innerWidth;

    canvasEl.height = window.innerHeight;

    let lineList = []; // 雪的容器

    let snow = function () {

        let _this = this;

        _this.cacheCanvas = document.createElement("canvas");

        _this.cacheCtx = _this.cacheCanvas.getContext("2d");

        _this.cacheCanvas.width = 10;

        _this.cacheCanvas.height = 10;

        _this.speed = [1, 1.5, 2][Math.floor(Math.random()*3)];                // 雪花下落的三种速度,便于取整

        _this.posx = Math.round(Math.random() * canvasEl.width);              // 雪花x坐标

        _this.posy = Math.round(Math.random() * canvasEl.height);              // 雪花y坐标

        _this.img = `./img/snow_(${Math.ceil(Math.random() * 9)}).png`;        // img

        _this.w = _this.getInt(5 + Math.random() * 6);

        _this.h = _this.getInt(5 + Math.random() * 6);

        _this.cacheSnow();

    };

    snow.prototype = {

        cacheSnow: function () {

            let _this = this;

            // _this.cacheCtx.save();

            let img = new Image();  // 创建img元素

            img.src = _this.img;

            _this.cacheCtx.drawImage(img, 0, 0, _this.w, _this.h);

            // _this.cacheCtx.restore();

        },

        fall: function () {

            let _this = this;

            if (_this.posy > canvasEl.height + 5) {

                _this.posy = _this.getInt(0 - _this.h);

                _this.posx = _this.getInt(canvasEl.width * Math.random());

            }

            if (_this.posx > canvasEl.width + 5) {

                _this.posx = _this.getInt(0 - _this.w);

                _this.posy = _this.getInt(canvasEl.height * Math.random());

            }

            // 如果雪花在可视区域

            if (_this.posy <= canvasEl.height || _this.posx <= canvasEl.width) {

                _this.posy = _this.posy + _this.speed;

                _this.posx = _this.posx + _this.speed * .5;

            }

            _this.paint();

        },

        paint: function () {

            ctx.drawImage(this.cacheCanvas, this.posx, this.posy)

        },

        getInt: function(num){

            let rounded;

            rounded = (0.5 + num) | 0;

            return rounded;

        }

    };

    let control;

    control = {

        start: function (num) {

            for (let i = 0; i < num; i++) {

                let s = new snow();

                lineList.push(s);

            }

            (function loop() {

                ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);

                for (let i = 0; i < num; i++) {

                    lineList[i].fall();

                }

                requestAnimationFrame(loop)

            })();

        }

    };

    return control;

}();

window.onload = function(){

    snowBox.start(2000)

};

建议从 github clone 代码到本地运行。

后话

这篇文章虽然说是关于 canvas 动画的性能优化。一些大佬也已经看出,其他方面的性能优化方案和这个大抵相同,无非是:

  1. 减少 API 的使用

  2. 使用缓存(重点)

  3. 合并频繁使用的 API

  4. 避免使用高耗能的 API

  5. 用 webWorker 来处理一些比较耗时的计算

  6. ……

希望你通过阅读这篇文章,可以在性能优化方面给你作一个参考,多谢阅读。

前端词典系列

《前端词典》这个系列会持续更新,每一期我都会讲一个出现频率较高的知识点。希望大家在阅读的过程当中可以斧正文中出现不严谨或是错误的地方,本人将不胜感激;若通过本系列而有所得,本人亦将不胜欣喜。

内容: 前端以及网络相关知识点的介绍并加以实际应用作为辅助。

目的: 这个系列的文章可以对读者起到一点帮助,解开一些迷惑。

希望各位多指点一二,不吝赐教。

如果你觉得我的文章写的还不错,就关注我呗!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,761评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,953评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,998评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,248评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,130评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,145评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,550评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,236评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,510评论 1 291
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,601评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,376评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,247评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,613评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,911评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,191评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,532评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,739评论 2 335

推荐阅读更多精彩内容