canvas效能優化總結

方帥發表於2021-04-30

canvas的主要功能就是用來繪製內容,有時候為了給使用者流暢的視覺感受,需要繪製的頻率要求很高,這樣對繪製的效能就有要求,那麼怎麼才能寫出高效能的繪製程式碼呢。

儘可能少呼叫api

例如我們繪製一段線條,如果用如下程式碼的話,每移動一次就stroke一次:

1      for (var i = 0; i < points.length - 1; i++) {
2           var p1 = points[i];
3           var p2 = points[i + 1];
4           context.beginPath();
5           context.moveTo(p1.x, p1.y);
6           context.lineTo(p2.x, p2.y);
7           context.stroke();
8       } 

優化後程式碼如下,這樣beginPah和stroke就少呼叫了n次。

1       context.beginPath();
2       for (var i = 0; i < points.length - 1; i++) {
3           var p1 = points[i];
4           var p2 = points[i + 1];
5           context.moveTo(p1.x, p1.y);
6           context.lineTo(p2.x, p2.y);
7       }
8       context.stroke();

儘量少改變CANVAS狀態機

我們可以改變 context 的若干狀態,而幾乎所有的渲染操作,最終的效果與 context 本身的狀態有關係。例如當對context.lineWidth賦值的話,開銷遠遠大於對一個普通物件賦值的開銷。

Canvas 上下文不是一個普通的物件,當呼叫了 context.lineWidth = 5 時,瀏覽器會需要立刻地做渲染上下文環境的工作,這樣你下次呼叫諸如 stroke 或 strokeRect 等 API 時,畫出來的線就正好是 5 個畫素寬了。其實這也是瀏覽器自身的一種優化,否則如果等到stroke呼叫時再臨時準備渲染環境,會更加影響正常繪製情況下的效能。

下面對比優化前後的程式碼:

      for (var i = 0; i < STRIPES; i++) {
          context.fillStyle = (i % 2 ? COLOR1 : COLOR2);
          context.fillRect(i * GAP, 0, GAP, 480);
      } 
      context.fillStyle = COLOR1;
      for (var i = 0; i < STRIPES / 2; i++) {
          context.fillRect((i * 2) * GAP, 0, GAP, 480);
      }
      context.fillStyle = COLOR2;
      for (var i = 0; i < STRIPES / 2; i++) {
          context.fillRect((i * 2 + 1) * GAP, 0, GAP, 480);
      }

上面兩段程式碼,對fillStyle的呼叫時機做了改變,提高了效能。

分層canvas

繪製場景複雜的情況下,一般採用多個canvas,可依據繪製內容的頻率高低來劃分。

如遊戲中的背景繪製頻率低可以放在一層canvas上,上面的小人等繪製頻率高放在一層canvas上,兩層canvas的疊加效果達到完整效果。

如下圖中繪製過程中的圓形在一層canvas上,不斷清除不斷繪製,而下面的已經繪製出來的筆跡內容放在另外一層canvas上,不需要清除重繪。

 

 

  

離屏canvas

也叫作預渲染,在離屏canvas上繪製好一整塊圖形,繪製好後在放到檢視canvas中,適合每一幀畫圖運算複雜的圖形。

比如我們有時候為了儘可能少的請求網路資源,會用到精靈圖,這樣在繪製精靈圖某一塊內容時,需要利用繪圖api的裁剪。

實際發現,使用 drawImage 繪製一張大尺寸圖片到較小畫布區域上,比起繪製一張和繪製區域尺寸一樣大的圖片的情形,開銷要大一些。可以認為,兩者相差的開銷正是「裁剪」這一個操作的開銷。下面三種繪製方式,效能開銷依次增加。

// 將image放到目標canvas指定位置,大小按照原圖大小渲染
void ctx.drawImage(image, dx, dy); 
// 將image放到目標canvas指定位置,指定寬高渲染
void ctx.drawImage(image, dx, dy, dWidth, dHeight);
// 將image裁剪之後放到目標canvas指定位置,指定寬高渲染
void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

而離屏渲染就可以讓我們先把圖片裁剪成想要的尺寸內容儲存起來,等到真正繪製的時候就可以使用第一種寫法簡單的把圖片繪製出來。

// 在離屏 canvas 上繪製
var offscreencanvas = document.createElement('canvas');
// 寬高賦值為想要的圖片尺寸
offscreencanvas.width = dWidth;
offscreencanvas.height = dHeight;
// 裁剪
offscreencanvas.getContext('2d').drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
// 在檢視canvas中繪製
viewcontext.drawImage(canvas, x, y);

有時候,遊戲物件是多次呼叫 drawImage 繪製而成,或者根本不是圖片,而是使用路徑繪製出的向量形狀,那麼離屏繪製還能幫你把這些操作簡化為一次 drawImage 呼叫。

組合圖形組合了多個圖形將它們繪製存放到離屏canvas中,下次未變化的時候直接繪製一次離屏canvas。

裁剪

Canvas (大小一般小於等於螢幕寬高)只是整個大場景下的一個「可視視窗」,如果我們在每一幀中,都把全部內容畫出來,勢必就會有很多東西畫到 Canvas 外面去了,同樣呼叫了繪製 API,但是並沒有任何效果。

那麼視口外的內容是不需要繪製的,但如果繪製對效能影響有多少呢?進行這樣一個實驗,繪製一張 320x180 的圖片 104 次,當每次都繪製在 Canvas 內部時,消耗了 40ms,而每次都繪製在 Canvas 外時,僅消耗了 8ms。雖然繪製在canvas外時,消耗的時間較短。

但考慮到計算的開銷與繪製的開銷相差 2~3 個數量級,所以一般情況下通過計算來過濾掉哪些畫布外的物件,仍然是很有必要的。

區域性重繪

由於 Canvas 的繪製方式是畫筆式的,在 Canvas 上繪圖時每呼叫一次 API 就會在畫布上進行繪製,一旦繪製就成為畫布的一部分。繪製圖形時並沒有物件儲存下來,一旦圖形需要更新,需要清除整個畫布重新繪製

如下圖僅對紅邊框的平行四邊形做改變,如果每次重繪整個畫布內容就不太合適

 Canvas 區域性重新整理的方案:

  1. 清除指定區域的顏色,並設定 clip
  2. 所有同這個區域相交的圖形重新繪製

要實現區域性渲染時,需要考慮的兩個因素是:

  • 單次重新整理時影響的範圍最小
  • 重新整理的圖形不會影響其他圖形的正確繪製

清除畫布內容

有地方說這三種方法效能依次提高,我目前只是使用了clearRect(),沒有做個實驗對照。 

context.fillRect()//顏色填充
context.clearRect(0, 0, w, h)
canvas.width = canvas.width; // 一種畫布專用的技巧

座標值儘量使用整數

避免使用浮點數座標,使用非整數的座標繪製內容,系統會自動使用抗鋸齒功能,嘗試對線條進行平滑處理,這又是一種效能消耗。

可以呼叫 Math.round 四捨五入取整。

避免大量計算造成阻塞

所謂「阻塞」,可以理解為不間斷執行時間超過 16ms 的 JavaScript 程式碼,導致頁面卡頓,丟幀,或者失去響應,這種問題能很快被使用者察覺到,造成很差的互動體驗。

 

 所以我們要把與渲染無關的大量計算交給worker。大量計算可能造成渲染不流暢,但絕對不能讓使用者操作卡頓失去響應。

像下圖的效果,需要計算大量函式曲線上的點來繪製成曲線,我們移動的時候可以看到計算新點座標值的過程是有延遲的,但是並不會讓使用者滑鼠拖拽卡頓失效,渲染的過程再跟隨滑鼠移動。

 

總結

以上便是總結到的提升繪製效率的幾點建議!具體採用哪種需要在實際專案裡面根據情況來定,如果你知道這幾種方式至少不會大腦空白了!

還有幾點開發過程需要注意的:

  • 儘可能使用計算代替canvas渲染
  • 減少改變 context 的狀態,如果要改變請賦值正確的型別,減少瀏覽器的嘗試
  • 減少使用 shadowBlur 效果,陰影渲染的效能開銷通常比較高

相關文章