Canvas 最佳實踐(性能篇)


Canvas 想必前端同學們都不陌生,它是 HTML5 新增的「畫布」元素,允許我們使用 JavaScript 來繪制圖形。目前,所有的主流瀏覽器都支持 Canvas。

Canvas 最常見的用途是渲染動畫。渲染動畫的基本原理,無非是反復地擦除和重繪。為了動畫的流暢,留給我渲染一幀的時間,只有短短的 16ms。在這 16ms 中,我不僅需要處理一些游戲邏輯,計算每個對象的位置、狀態,還需要把它們都畫出來。如果消耗的時間稍稍多了一些,用戶就會感受到「卡頓」。所以,在編寫動畫(和游戲)的時候,我無時無刻不擔憂着動畫的性能,唯恐對某個 API 的調用過於頻繁,導致渲染的耗時延長。

為此,我做了一些實驗,查閱了一些資料,整理了平時使用 Canvas 的若干心得體會,總結出這一片所謂的「最佳實踐」。如果你和我有類似的困擾,希望本文對你有一些價值。

本文僅討論 Canvas 2D 相關問題。

計算與渲染

把動畫的一幀渲染出來,需要經過以下步驟:

  1. 計算:處理游戲邏輯,計算每個對象的狀態,不涉及 DOM 操作(當然也包含對 Canvas 上下文的操作)。
  2. 渲染:真正把對象繪制出來。 
    2.1. JavaScript 調用 DOM API(包括 Canvas API)以進行渲染。 
    2.2. 瀏覽器(通常是另一個渲染線程)把渲染后的結果呈現在屏幕上的過程。

之前曾說過,留給我們渲染每一幀的時間只有 16ms。然而,其實我們所做的只是上述的步驟中的 1 和 2.1,而步驟 2.2 則是瀏覽器在另一個線程(至少幾乎所有現代瀏覽器是這樣的)里完成的。動畫流暢的真實前提是,以上所有工作都在 16ms 中完成,所以 JavaScript 層面消耗的時間最好控制在 10ms 以內。

雖然我們知道,通常情況下,渲染比計算的開銷大很多(3~4 個量級)。除非我們用到了一些時間復雜度很高的算法(這一點在本文最后一節討論),計算環節的優化沒有必要深究。

我們需要深入研究的,是如何優化渲染的性能。而優化渲染性能的總體思路很簡單,歸納為以下幾點:

  1. 在每一幀中,盡可能減少調用渲染相關 API 的次數(通常是以計算的復雜化為代價的)。
  2. 在每一幀中,盡可能調用那些渲染開銷較低的 API。
  3. 在每一幀中,盡可能以「導致渲染開銷較低」的方式調用渲染相關 API。

Canvas 上下文是狀態機

Canvas API 都在其上下文對象 context 上調用。

var context = canvasElement.getContext('2d');

我們需要知道的第一件事就是, context 是一個狀態機。你可以改變 context 的若干狀態,而幾乎所有的渲染操作,最終的效果與 context 本身的狀態有關系。比如,調用 strokeRect 繪制的矩形邊框,邊框寬度取決於 context 的狀態 lineWidth ,而后者是之前設置的。

context.lineWidth = 5;
context.strokeColor = 'rgba(1, 0.5, 0.5, 1)';

context.strokeRect(100, 100, 80, 80);

說到這里,和性能貌似還扯不上什么關系。那我現在就要告訴你,對 context.lineWidth 賦值的開銷遠遠大於對一個普通對象賦值的開銷,你會作如何感想。

當然,這很容易理解。Canvas 上下文不是一個普通的對象,當你調用了 context.lineWidth = 5 時,瀏覽器會需要立刻地做一些事情,這樣你下次調用諸如 stroke 或 strokeRect 等 API 時,畫出來的線就正好是 5 個像素寬了(不難想象,這也是一種優化,否則,這些事情就要等到下次 stroke 之前做,更加會影響性能)。

我嘗試執行以下賦值操作 10 6 次,得到的結果是:對一個普通對象的屬性賦值只消耗了 3ms,而對 context 的屬性賦值則消耗了 40ms。值得注意的是,如果你賦的值是非法的,瀏覽器還需要一些額外時間來處理非法輸入,正如第三/四種情形所示,消耗了 140ms 甚至更多。

somePlainObject.lineWidth = 5;  // 3ms (10^6 times)
context.lineWidth = 5;  // 40ms
context.lineWidth = 'Hello World!'; // 140ms
context.lineWidth = {}; // 600ms

對 context 而言,對不同屬性的賦值開銷也是不同的。 lineWidth 只是開銷較小的一類。下面整理了為 context 的一些其他的屬性賦值的開銷,如下所示。

屬性 開銷 開銷(非法賦值)
line[Width/Join/Cap] 40+ 100+
[fill/stroke]Style 100+ 200+
font 1000+ 1000+
text[Align/Baseline] 60+ 100+
shadow[Blur/OffsetX] 40+ 100+
shadowColor 280+ 400+

與真正的繪制操作相比,改變 context 狀態的開銷已經算比較小了,畢竟我們還沒有真正開始繪制操作。我們需要了解,改變 context 的屬性並非是完全無代價的。我們可以通過適當地安排調用繪圖 API 的順序,降低 context 狀態改變的頻率。

分層 Canvas

分層 Canvas 在幾乎任何動畫區域較大,動畫較復雜的情形下都是非常有必要的。分層 Canvas 能夠大大降低完全不必要的渲染性能開銷。分層渲染的思想被廣泛用於圖形相關的領域:從古老的皮影戲、套色印刷術,到現代電影/游戲工業,虛擬現實領域,等等。而分層 Canvas 只是分層渲染思想在 Canvas 動畫上最最基本的應用而已。

分層 Canvas 的出發點是,動畫中的每種元素(層),對渲染和動畫的要求是不一樣的。對很多游戲而言,主要角色變化的頻率和幅度是很大的(他們通常都是走來走去,打打殺殺的),而背景變化的頻率或幅度則相對較小(基本不變,或者緩慢變化,或者僅在某些時機變化)。很明顯,我們需要很頻繁地更新和重繪人物,但是對於背景,我們也許只需要繪制一次,也許只需要每隔 200ms 才重繪一次,絕對沒有必要每 16ms 就重繪一次。

對於 Canvas 而言,能夠在每層 Canvas 上保持不同的重繪頻率已經是最大的好處了。然而,分層思想所解決的問題遠不止如此。

使用上,分層 Canvas 也很簡單。我們需要做的,僅僅是生成多個 Canvas 實例,把它們重疊放置,每個 Canvas 使用不同的 z-index 來定義堆疊的次序。然后僅在需要繪制該層的時候(也許是「永不」)進行重繪。

var contextBackground = canvasBackground.getContext('2d');
var contextForeground = canvasForeground.getContext('2d');

function render(){
  drawForeground(contextForeground);
  if(needUpdateBackground){
    drawBackground(contextBackground);
  }
  requestAnimationFrame(render);
}

記住,堆疊在上方的 Canvas 中的內容會覆蓋住下方 Canvas 中的內容。

繪制圖像

目前,Canvas 中使用到最多的 API,非 drawImage 莫屬了。(當然也有例外,你如果要用 Canvas 寫圖表,自然是半句也不會用到了)。

drawImage 方法的格式如下所示:

context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

數據源與繪制的性能

由於我們具備「把圖片中的某一部分繪制到 Canvas 上」的能力,所以很多時候,我們會把多個游戲對象放在一張圖片里面,以減少請求數量。這通常被稱為「精靈圖」。然而,這實際上存在着一些潛在的性能問題。我發現,使用 drawImage 繪制同樣大小的區域,數據源是一張和繪制區域尺寸相仿的圖片的情形,比起數據源是一張較大圖片(我們只是把數據扣下來了而已)的情形,前者的開銷要小一些。可以認為,兩者相差的開銷正是「裁剪」這一個操作的開銷。

我嘗試繪制 10 4 次一塊 320x180 的矩形區域,如果數據源是一張 320x180 的圖片,花費了 40ms,而如果數據源是一張 800x800 圖片中裁剪出來的 320x180 的區域,需要花費 70ms。

雖然看上去開銷相差並不多,但是 drawImage 是最常用的 API 之一,我認為還是有必要進行優化的。優化的思路是,將「裁剪」這一步驟事先做好,保存起來,每一幀中僅繪制不裁剪。具體的,在「離屏繪制」一節中再詳述。

視野之外的繪制

有時候,Canvas 只是游戲世界的一個「窗口」,如果我們在每一幀中,都把整個世界全部畫出來,勢必就會有很多東西畫到 Canvas 外面去了,同樣調用了繪制 API,但是並沒有任何效果。我們知道,判斷對象是否在 Canvas 中會有額外的計算開銷(比如需要對游戲角色的全局模型矩陣求逆,以分解出對象的世界坐標,這並不是一筆特別廉價的開銷),而且也會增加代碼的復雜程度,所以關鍵是,是否值得。

我做了一個實驗,繪制一張 320x180 的圖片 10 4 次,當我每次都繪制在 Canvas 內部時,消耗了 40ms,而每次都繪制在 Canvas 外時,僅消耗了 8ms。大家可以掂量一下,考慮到計算的開銷與繪制的開銷相差 2~3 個數量級,我認為通過計算來過濾掉哪些畫布外的對象,仍然是很有必要的。

離屏繪制

上一節提到,繪制同樣的一塊區域,如果數據源是尺寸相仿的一張圖片,那么性能會比較好,而如果數據源是一張大圖上的一部分,性能就會比較差,因為每一次繪制還包含了裁剪工作。也許,我們可以先把待繪制的區域裁剪好,保存起來,這樣每次繪制時就能輕松很多。

drawImage 方法的第一個參數不僅可以接收 Image 對象,也可以接收另一個 Canvas 對象。而且,使用 Canvas 對象繪制的開銷與使用 Image 對象的開銷幾乎完全一致。我們只需要實現將對象繪制在一個未插入頁面的 Canvas 中,然后每一幀使用這個 Canvas 來繪制。

// 在離屏 canvas 上繪制
var canvasOffscreen = document.createElement('canvas');
canvasOffscreen.width = dw;
canvasOffscreen.height = dh;
canvasOffscreen.getContext('2d').drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);

// 在繪制每一幀的時候,繪制這個圖形
context.drawImage(canvasOffscreen, x, y);

離屏繪制的好處遠不止上述。有時候,游戲對象是多次調用 drawImage 繪制而成,或者根本不是圖片,而是使用路徑繪制出的矢量形狀,那么離屏繪制還能幫你把這些操作簡化為一次 drawImage 調用。

第一次看到 getImageData 和 putImageData 這一對 API,我有一種錯覺,它們簡直就是為了上面這個場景而設計的。前者可以將某個 Canvas 上的某一塊區域保存為 ImageData 對象,后者可以將 ImageData 對象重新繪制到 Canvas 上面去。但實際上, putImageData 是一項開銷極為巨大的操作,它根本就不適合在每一幀里面去調用。

避免「阻塞」

所謂「阻塞」,可以理解為不間斷運行時間超過 16ms 的 JavaScript 代碼,以及「導致瀏覽器花費超過 16ms 時間進行處理」的 JavaScript 代碼。即使在沒有什么動畫的頁面里,阻塞也會被用戶立刻察覺到:阻塞會使頁面上的對象失去響應——按鈕按不下去,鏈接點不開,甚至標簽頁都無法關閉了。而在包含較多 JavaScript 動畫的頁面里,阻塞會使動畫停止一段時間,直到阻塞恢復后才繼續執行。如果經常出現「小型」的阻塞(比如上述提及的這些優化沒有做好,渲染一幀的時間超過 16ms),那么就會出現「丟幀」的情況,

CSS3 動畫( transition 與 animate )不會受 JavaScript 阻塞的影響,但不是本文討論的重點。

偶爾的且較小的阻塞是可以接收的,頻繁或較大的阻塞是不可以接受的。也就是說,我們需要解決兩種阻塞:

  • 頻繁(通常較小)的阻塞。其原因主要是過高的渲染性能開銷,在每一幀中做的事情太多。
  • 較大(雖然偶爾發生)的阻塞。其原因主要是運行復雜算法、大規模的 DOM 操作等等。

對前者,我們應當仔細地優化代碼,有時不得不降低動畫的復雜(炫酷)程度,本文前幾節中的優化方案,解決的就是這個問題。

而對於后者,主要有以下兩種優化的策略。

  • 使用 Web Worker,在另一個線程里進行計算。
  • 將任務拆分為多個較小的任務,插在多幀中進行。

Web Worker 是好東西,性能很好,兼容性也不錯。瀏覽器用另一個線程來運行 Worker 中的 JavaScript 代碼,完全不會阻礙主線程的運行。動畫(尤其是游戲)中難免會有一些時間復雜度比較高的算法,用 Web Worker 來運行再合適不過了。

然而,Web Worker 無法對 DOM 進行操作。所以,有些時候,我們也使用另一種策略來優化性能,那就是將任務拆分成多個較小的任務,依次插入每一幀中去完成。雖然這樣做幾乎肯定會使執行任務的總時間變長,但至少動畫不會卡住了。

看下面這個 Demo ,我們的動畫是使一個紅色的 div 向右移動。Demo 中是通過每一幀改變其 transform 屬性完成的(Canvas 繪制操作也一樣)。

然后,我創建了一個會阻塞瀏覽器的任務:獲取 4x10 6 次 Math.random() 的平均值。點擊按鈕,這個任務就會被執行,其結果也會打印在屏幕上。

如你所見,如果直接執行這個任務,動畫會明顯地「卡」一下。而使用 Web Worker 或將任務拆分,則不會卡。

以上兩種優化策略,有一個相同的前提,即任務是異步的。也就是說,當你決定開始執行一項任務的時候,你並不需要立刻(在下一幀)知道結果。比如,即使戰略游戲中用戶的某個操作觸發了尋路算法,你完全可以等待幾幀(用戶完全感知不到)再開始移動游戲角色。另外,將任務拆分以優化性能,會帶來顯著的代碼復雜度的增加,以及額外的開銷。有時候,我覺得也許可以考慮優先砍一砍需求。

小結

正文就到這里,最后我們來稍微總結一下,在大部分情況下,需要遵循的「最佳實踐」。

  1. 將渲染階段的開銷轉嫁到計算階段之上。
  2. 使用多個分層的 Canvas 繪制復雜場景。
  3. 不要頻繁設置繪圖上下文的 font 屬性。
  4. 不在動畫中使用 putImageData 方法。
  5. 通過計算和判斷,避免無謂的繪制操作。
  6. 將固定的內容預先繪制在離屏 Canvas 上以提高性能。
  7. 使用 Worker 和拆分任務的方法避免復雜算法阻塞動畫運行

轉自:http://taobaofed.org/blog/2016/02/22/canvas-performance/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM