Canvas 最佳實踐(性能篇)

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

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 之前做,更加會影響性能)。

我嘗試執行以下賦值操作 106 次,得到的結果是:對一個普通對象的屬性賦值只消耗了 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

分層 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 繪製同樣大小的區域,數據源是一張和繪製區域尺寸相仿的圖片的情形,比起數據源是一張較大圖片(我們只是把數據扣下來了而已)的情形,前者的開銷要小一些。可以認爲,兩者相差的開銷正是「裁剪」這一個操作的開銷。

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

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

視野之外的繪製

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

我做了一個實驗,繪製一張 320x180 的圖片 104 次,當我每次都繪製在 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 兼容性

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

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

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

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

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

小結

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

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

轉載自:淘寶前端團隊

作者:葉齋