【前端詞典】實現 Canvas 下雪背景引起的性能思考

前言

去年聖誕節產品提了一個活動需求,其中有一個下雪的背景動畫。在作這個動畫的過程當中加深了對 canvas 動畫的一些瞭解,在這裏我僅是拋磚引玉的分享一下,歡迎各位大佬批評。javascript

代碼已上傳至 github 【https://github.com/wanqihua/koa-canvas】,感興趣的能夠 clone 代碼到本地運行。望給個 star 支持一下。前端

入題

需求給出的 UI 樣式以下:
【前端詞典】實現 Canvas 下雪背景引起的性能思考java

UI 的需求是雪花下落的方向有點傾斜角度,每片雪花的下落速度不同但要保持在一個範圍內。git

需求瞭解的差很少就開始實現這個效果(在看這篇文章以前你須要對 canvas 的一些基本 API 瞭解)。github

drawImage

【前端詞典】實現 Canvas 下雪背景引起的性能思考

drawImage 可傳入 9 個參數,上圖中的 5 個參數是比較經常使用的,另外幾個參數是拿來剪切圖片的。web

直接使用 drawImage 來剪切圖片,其性能不會太好,建議先將須要使用的部分用一個離屏 canvas 保存起來,須要用到的時候直接使用便可。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 的寬高儘可能設置成實際使用的寬高,不然過多空白區域也會形成性能的損耗。

下圖顯示了使用離屏繪製進行預渲染技術所帶來的性能改善狀況:【前端詞典】實現 Canvas 下雪背景引起的性能思考

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 動畫繪製時,若座標是浮點數,可能會出現 CSSSub-pixel 的問題.也就是會自動將浮點數值四捨五入轉爲整數,在動畫的過程當中就可能出現抖動的狀況,同時也可能讓元素的邊緣出現抗鋸齒失真狀況。

雖然 javascript 提供了一些取整方法,像 Math.floor, Math.ceil, parseInt,但 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 動畫的性能優化。一些大佬也已經看出,其餘方面的性能優化方案和這個大抵相同,無非是:

減小 API 的使用

使用緩存(重點)

合併頻繁使用的 API

避免使用高耗能的 API

用 webWorker 來處理一些比較耗時的計算

……

但願經過閱讀這篇文章,能夠在性能優化方面給你做一個參考,多謝閱讀。

前端詞典系列

《前端詞典》這個系列會持續更新,每一期我都會講一個出現頻率較高的知識點。但願你們在閱讀的過程中能夠斧正文中出現不嚴謹或是錯誤的地方,本人將不勝感激;若經過本系列而有所得,本人亦將不勝欣喜。

內容: 前端以及網絡相關知識點的介紹並加以實際應用做爲輔助。

目的: 這個系列的文章能夠對讀者起到一點幫助,解開一些迷惑。

但願各位多指點一二,不吝賜教。

若是你以爲個人文章寫的還不錯,就關注我唄!

相關文章
相關標籤/搜索