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

小生方勤發表於2019-03-20

前言

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

程式碼已上傳至 github ,感興趣的可以 clone 程式碼到本地執行。望給個 star 支援一下。

入題

需求給出的 UI 樣式如下: 【前端詞典】實現 Canvas 下雪背景引發的效能思考

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

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

drawImage

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

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 的寬高儘量設定成實際使用的寬高,否則過多空白區域也會造成效能的損耗。

下圖顯示了使用離屏繪製進行預渲染技術所帶來的效能改善情況:

【前端詞典】實現 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 動畫繪製時,若座標是浮點數,可能會出現 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. ……

希望通過閱讀這篇文章,可以在效能優化方面給你作一個參考,多謝閱讀。

前端詞典系列

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

如果你覺得我的文章寫的還不錯,可以關注我的微信公眾號,公眾號裡會提前劇透呦。

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

你也可以新增我的微信 wqhhsd, 歡迎交流。

下期預告

【前端詞典】從輸入 URL 到展現涉及哪些快取環節

傳送門

  1. 【前端詞典】和媳婦講代理後的意外收穫
  2. 【前端詞典】滾動穿透問題的解決方案
  3. 繼承(一) - 原型鏈你真的懂嗎?
  4. 【前端詞典】繼承(二) - 回的八種寫法·面試必問
  5. 【前端詞典】進階必備的網路基礎(上)
  6. 【前端詞典】進階必備的網路基礎(下)
  7. 【前端詞典】F5 同 Ctrl+F5 的區別你可瞭解

相關文章