canvas高效繪製10萬圖形,你必須知道的高效繪製技巧

最近的一個客戶項目中,簡化的需求是繪製按照行列繪製很多個圓圈。需求看起來不難,上手就可以做,寫兩個for循環。

原始繪製方法

首先定義了很多Circle對象,在遍歷循環中調用該對象的draw方法。代碼如下:

for (var i = 0; i < column; i++) {
    for (var j = 0; j < row; j++) {
        var circle = new Circle({
            x: 8 * i + 3,
            y: 8 * j + 3,
            radius: 3
        })
        box.push(circle);
    }
}

console.time('time');
    for (var c = 0; c < box.length; c++) {
        var circle = box[c];
        circle.draw(ctx);
    }
    console.timeEnd('time');

結果繪製出了按照行列排布的很多個圓圈了,如下圖所示:
原始方法繪製很多圓圈

恩,很簡單嘛,可以回家睡覺了。
等等,客戶要求繪製的極限是10萬個,而且每次繪製不能卡頓。先看下繪製10萬個圓圈的時間是多久,用console.time 統計繪製時間:

console.time('time');
// 實際繪製的代碼
console.timeEnd('time');

時間顯示爲幾百毫秒(3到4百毫秒),如下圖所示:
繪製時間

幾百毫秒的繪製時間,必然是卡頓的。想要流暢操作,肯定還的優化。

批量繪製

首先想到的是批量繪製,前面的代碼中,每次變量都會調用circle.draw(ctx)方法,circle.draw方法代碼如下:

draw: function (ctx) {
    ctx.save();
    ctx.lineWidth=this.lineWidth;
    ctx.strokeStyle=this.strokeStyle;
    ctx.fillStyle=this.fillStyle;
    ctx.beginPath();
    this.createPath(ctx);
    ctx.stroke();
    if(this.isFill){ctx.fill();}
    ctx.restore();
},

可以看出 每次遍歷都調用了一次beginPath和stroke方法。爲了提高繪製效率,我們可以只調用beginPath和stroke方法一次,把所有的子路徑組織成爲一個大的路徑,這就是所謂的批量繪製思路,代碼如下:

    console.time('time');
    ctx.beginPath();
    for (var c = 0; c < box.length; c++) {
        var circle = box[c];
        ctx.moveTo(circle.x + 3, circle.y);
        circle.createPath(ctx);
    }
    ctx.closePath();
    ctx.stroke();
    console.timeEnd('time');

調試發現,確實效率有了很大的提升,時間減少到100毫秒左右,相當於效率提高了3-4倍左右,如下圖所示:
批量繪製時間

需要注意的是上述代碼中的moveTo語句:

ctx.moveTo(circle.x + 3, circle.y);

這是因爲: 當使用arc方法給路徑中添加子路徑的時候,arc所定義的路徑會自動和路徑集合中的最後一個路徑連接起來,如下圖所示:
arc定義的路徑自動連接起來

此處的moveTo就是爲了避免這種連接。

注意:arc 和arcTo都會有上述問題,但是rect定義的路徑卻不存在這種問題。

Pattern 方式

通過以上優化,客戶已經覺得效率挺不錯了。 但是技術研究沒有止境,由於這個分佈很規律,總感覺有更加快速的方法。最終突發靈感想到了一種方法,就是使用canvas 的Pattern功能:
canvas的fillStyle可以指定爲一個pattern對象,而pattern可以實現一個簡單圖像的平鋪。基於這種思路,我們可以實現如下代碼:


var tempCanvas = document.createElement('canvas');

var ctx2 = tempCanvas.getContext('2d');
var w = 5,h = 5;
tempCanvas.width = w;
tempCanvas.height = h;
dpr(tempCanvas);
ctx2.fillStyle = 'red';
ctx2.arc(w/2,h/2,w/2 - 1,0,Math.PI * 2);
ctx2.stroke();

ctx.save();
ctx.beginPath();
var width = tempCanvas.width 500,height = tempCanvas.height 200;
var pattern = ctx.createPattern(tempCanvas, 'repeat');
ctx.clearRect(100,100,width,height);
ctx.rect(100,100,width,height);
ctx.fillStyle = pattern;
ctx.fill();
ctx.restore();

代碼首先定義一個小的canvas,命名爲tempCanvas,在tempCanvas上面繪製一個圓,需要注意的是tempCanvas的尺寸要設置爲正好繪製下這個圓圈。

然後通過通過tempCanvas創建pattern對象,並把canvas的繪製上下文ctx的fillStyle指定爲該pattern對象。
之後通過rect方法指定要fill的區域大小,改區域大小應該是所有最終要繪製的圓圈的大小的總和:var width = tempCanvas.width * 500,height = tempCanvas.height * 200;
最後調用畫筆的fill方法,用tempCanvas填充區域。最終繪製的效果和繪製消耗的時間如下圖所示:
![Canvas Pattern 方式繪製10萬個圓](https://upload-images.jianshu.io/upload_images/6271001-5ff294a3857d334c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/256)

通過上圖可以看出,效率極高,可以達到零點幾毫秒的級別。

## 新的需求
如果客戶需求只是這麼簡單,相信使用canvas pattern對象這種方式,效率是最高的。但是,客戶的實際需求是,先繪製10萬個的圓圈,然後可以用擦除工具,擦除一些區域的圓圈,如下圖所示:
![擦除後的效果](https://upload-images.jianshu.io/upload_images/6271001-0003a7cfa211992a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/256)

原始繪製方法和批量繪製方法要是實現上述效果,都很容易,只要把不需要繪製圓圈的位置,直接忽略掉即可以。
> 比如用一個map記錄需要忽略的圓圈的座標,遍歷的時候判斷在map記錄中的地方就直接跳過不進行繪製操作。

## canvas pattern + 裁剪
如果是canvas pattern的方式,應該怎麼實現上圖的效果呢? 經過思索發現可以通過ctx.clip方法。
>clip,裁剪。如果通過ctx.clip定義了裁剪區域,繪製的圖形只會在裁剪區域的部分顯示出來,裁剪區域之外的,則不會顯示。

沒一個圓圈都會佔用一個矩形區域,本案例中,可以把要顯示的的圓圈所佔的矩形區域都定義到裁剪區域裏面,而不要顯示的圓圈的矩形區域則排除到裁剪區域之外,如下圖所示,繪製圓圈的矩形區域用實線表示出來,不繪製圓圈的區域用虛線表示:
![裁剪區域](https://upload-images.jianshu.io/upload_images/6271001-af193b99aab0e792.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

只需要把所有實線表示的矩形區域都添加到要clip的路徑中去,然後調用fill方法,則只會在實現定義的矩形區域顯示出來圓圈。以下是示例代碼:

for(var i = 0;i < 400; i ++){
for(var j = 0;j < 400;j ++){
var r = Math.random();
if(r <0.2){
templateMap[i+":" + j] = true;
continue;
}

                      var x = 10 + j * tempCanvas.width;
                      var y = 10 + i * tempCanvas.height;
                      var rect = {
                        x : x,
                        y : y,
                        width : tempCanvas.width,
                        height:tempCanvas.height
                      };
                     ctx.rect(rect.x,rect.y,rext.width,rect.height);

}
ctx.clip();

首先遍歷所有的圓圈座標,爲了演示效果,用Math.random爲了模擬隨機產生一個數,如果這個數小於0.2,則當前圓圈的矩形區域不會被加入裁剪區域,也就是該圓圈不會顯示出來。 
通過上面裁剪操作後,“擦除後的效果”算是實現了。但是,經過測試,性能卻低迴去了,爲什麼,因爲增加了很多rect操作。測試下來,一幁的繪製時間大概在80多毫秒,比批量繪製還是高一點,但是感覺還是不夠好。

## Pattern + 合併裁剪 

觀察上面 “裁剪區域” 這個圖,以第一行爲例,第一、第二、第三個矩形區域是連在一塊的,完全沒有必要調用三次ctx.rect方法,而是先用算法把三個區域合併爲一個矩形區域,然後調用一次ctx.rect方法即可,如下圖:
![合併裁剪區域](https://upload-images.jianshu.io/upload_images/6271001-c2fa9b6b448640df.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/256)
下面是合併裁剪區域的算法,目前只是實現了同一行的合併,更加優化的合併算法並沒有實現,代碼如下:

function calRectMap (tempCanvas){
if(rectMap != null){
return;
}
rectMap = rectMap || [];
for(var i = 0;i < 400; i ++){
for(var j = 0;j < 400;j ++){
var r = Math.random();
if(r <0.2){
templateMap[i+":" + j] = true;
continue;
}

                      var x = 10 + j * tempCanvas.width;
                      var y = 10 + i * tempCanvas.height;
                      var rect = {
                        x : x,
                        y : y,
                        width : tempCanvas.width,
                        height:tempCanvas.height
                      };
                      lineRectMap[i] = lineRectMap[i] || [];

                      lineRectMap[i][j] = rect;
                  }
                  unionLineRects(lineRectMap[i],rectMap);
                }
           }

           function unionLineRect(rect1,rect2){
                return {
                    x: rect1.x,
                    y : rect1.y,
                    width:rect1.width + rect2.width,
                    height:rect1.height
                }
           }

           function unionLineRects(lineRectMap,rectMap){
                var lastRect = null,lastNotNullIndex = null;
                for(var j = 0;j < 400;j ++){

                    var currentRect = lineRectMap[j];
                    if(lastRect == null){
                          lastRect = currentRect;
                    }else{
                        if( lastNotNullIndex == j - 1 && currentRect){
                            lastRect = unionLineRect(lastRect,currentRect);
                        }
                    }
                    if(currentRect != null){
                      lastNotNullIndex = j;
                    }else if (lastRect){
                        rectMap.push(lastRect);
                        lastNotNullIndex = null;
                        lastRect = null;
                    }
                }
                if(lastRect){
                  rectMap.push(lastRect);
                }
           }

相關合並的算法,此處不再詳細說明。 合併之後,測試繪製的時間降低到了10幾毫秒,算是比較好的繪製效果了:
![合併裁剪之後的繪製](https://upload-images.jianshu.io/upload_images/6271001-e9e1092b5677b853.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/256)

##webgl繪製
由於筆者本人也長期研究webgl的技術,所以嘗試着用webgl實線了2d的繪製,相關細節不在此處贅述,後面會寫專門的文章如何用webgl繪製2d圖形。最終測試的效率不是很理想,差不多100多毫秒,和上面的批量繪製差不多。 因爲用webgl繪製,單次的繪製效率應該不會太差,但是由於需要遍歷調用10萬次繪製命令,必然效率不高。另外webgl繪製的效果其實是沒有2d繪製的效果好的,鋸齒嚴重。 要實現好的效果,還需要引入去鋸齒相關技術。  繪製的效果如下:
![webgl繪製](https://upload-images.jianshu.io/upload_images/6271001-3a20ad6be600363c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/256)
用webgl繪製2d圖形的相關主題,回頭會另外寫一篇文章介紹。敬請關注。

## webgl2繪製
webgl2 引入了實例化數組,通過這個功能,可以實現把很多次的繪製調用合併爲一個繪製調用,這會極大提高繪製效率。
>有關實例化數組的功能,參考https://www.jianshu.com/p/d40a8b38adfe

繪製10萬個圓形的效率大概在每幀零點零幾毫秒,簡直就是大boss級別的快,如下圖:

![WebGL實例化數組繪製](https://upload-images.jianshu.io/upload_images/6271001-a248b38fbc0987f6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/256)

## 後記
通過這篇文章,除了想給讀者傳遞相關知識點之外,其實還想表達一個觀點:
相比於知識點,程序員更加需要鍛鍊的是底層思維能力。在我看來,底層思維能力包括:學習力、創造力、判斷力和思考力。而勤于思考的人,不拘泥於司空見慣,都能夠從日常枯燥的任務中發現很多有趣的東西,啓發更多深入的思路。
勤于思索是很重要的。 知識是死的,人是活的,同樣的知識點,在思考力強的人手上,就能延伸出很多好的解決方案。
 這就要求人勤於探索,不要滿足於把任務完成,而是要多深入思考,多總結,探索更多的方案和可能性。這本身有助於鍛鍊思考力和創造力,而思考力和創造力又會反過來幫助你解決更多的問題。

其實IT行業的知識更新越來越快,能夠以不變應萬變的人,就是擁有良好的學習力、創造力、判斷力和思考力的人。這些能力會讓你在變換萬千的技術海洋中,屹立不倒,不被淹沒。

> 當然,標書可能有點好爲人師了。 在日常的工作中,彪叔更喜歡做的事情,就是啓迪下屬的思考,而不僅僅是某個問題的解決方案。這是比學習知識更加重要的素質。彪叔也會在我的其他文章中,分享底層能力的相關認知。有興趣的猿們可以關注彪叔的公號:ITman彪叔

歡迎關注公衆號:
![ITman彪叔公衆號](https://upload-images.jianshu.io/upload_images/6271001-aad6ae8e5175418f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/256)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章