Canvas 想必前端同學們都不陌生,它是 HTML5 新增的「畫布」元素,允許我們使用 JavaScript 來繪製圖形。目前,所有的主流瀏覽器都支援 Canvas。
Canvas 最常見的用途是渲染動畫。渲染動畫的基本原理,無非是反覆地擦除和重繪。為了動畫的流暢,留給我渲染一幀的時間,只有短短的 16ms。在這 16ms 中,我不僅需要處理一些遊戲邏輯,計算每個物件的位置、狀態,還需要把它們都畫出來。如果消耗的時間稍稍多了一些,使用者就會感受到「卡頓」。所以,在編寫動畫(和遊戲)的時候,我無時無刻不擔憂著動畫的效能,唯恐對某個 API 的呼叫過於頻繁,導致渲染的耗時延長。
為此,我做了一些實驗,查閱了一些資料,整理了平時使用 Canvas 的若干心得體會,總結出這一片所謂的「最佳實踐」。如果你和我有類似的困擾,希望本文對你有一些價值。
本文僅討論 Canvas 2D 相關問題。
計算與渲染
把動畫的一幀渲染出來,需要經過以下步驟:
- 計算:處理遊戲邏輯,計算每個物件的狀態,不涉及 DOM 操作(當然也包含對 Canvas 上下文的操作)。
- 渲染:真正把物件繪製出來。
2.1. JavaScript 呼叫 DOM API(包括 Canvas API)以進行渲染。
2.2. 瀏覽器(通常是另一個渲染執行緒)把渲染後的結果呈現在螢幕上的過程。
之前曾說過,留給我們渲染每一幀的時間只有 16ms。然而,其實我們所做的只是上述的步驟中的 1 和 2.1,而步驟 2.2 則是瀏覽器在另一個執行緒(至少幾乎所有現代瀏覽器是這樣的)裡完成的。動畫流暢的真實前提是,以上所有工作都在 16ms 中完成,所以 JavaScript 層面消耗的時間最好控制在 10ms 以內。
雖然我們知道,通常情況下,渲染比計算的開銷大很多(3~4 個量級)。除非我們用到了一些時間複雜度很高的演算法(這一點在本文最後一節討論),計算環節的優化沒有必要深究。
我們需要深入研究的,是如何優化渲染的效能。而優化渲染效能的總體思路很簡單,歸納為以下幾點:
- 在每一幀中,儘可能減少呼叫渲染相關 API 的次數(通常是以計算的複雜化為代價的)。
- 在每一幀中,儘可能呼叫那些渲染開銷較低的 API。
- 在每一幀中,儘可能以「導致渲染開銷較低」的方式呼叫渲染相關 API。
Canvas 上下文是狀態機
Canvas API 都在其上下文物件 context
上呼叫。
1 |
var context = canvasElement.getContext('2d'); |
我們需要知道的第一件事就是,context
是一個狀態機。你可以改變 context
的若干狀態,而幾乎所有的渲染操作,最終的效果與 context
本身的狀態有關係。比如,呼叫 strokeRect
繪製的矩形邊框,邊框寬度取決於 context
的狀態 lineWidth
,而後者是之前設定的。
1 2 3 4 |
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 甚至更多。
1 2 3 4 |
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 來定義堆疊的次序。然後僅在需要繪製該層的時候(也許是「永不」)進行重繪。
1 2 3 4 5 6 7 8 9 10 |
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
方法的格式如下所示:
1 |
context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight); |
資料來源與繪製的效能
由於我們具備「把圖片中的某一部分繪製到 Canvas 上」的能力,所以很多時候,我們會把多個遊戲物件放在一張圖片裡面,以減少請求數量。這通常被稱為「精靈圖」。然而,這實際上存在著一些潛在的效能問題。我發現,使用 drawImage
繪製同樣大小的區域,資料來源是一張和繪製區域尺寸相仿的圖片的情形,比起資料來源是一張較大圖片(我們只是把資料扣下來了而已)的情形,前者的開銷要小一些。可以認為,兩者相差的開銷正是「裁剪」這一個操作的開銷。
我嘗試繪製 104 次一塊 320×180 的矩形區域,如果資料來源是一張 320×180 的圖片,花費了 40ms,而如果資料來源是一張 800×800 圖片中裁剪出來的 320×180 的區域,需要花費 70ms。
雖然看上去開銷相差並不多,但是 drawImage
是最常用的 API 之一,我認為還是有必要進行優化的。優化的思路是,將「裁剪」這一步驟事先做好,儲存起來,每一幀中僅繪製不裁剪。具體的,在「離屏繪製」一節中再詳述。
視野之外的繪製
有時候,Canvas 只是遊戲世界的一個「視窗」,如果我們在每一幀中,都把整個世界全部畫出來,勢必就會有很多東西畫到 Canvas 外面去了,同樣呼叫了繪製 API,但是並沒有任何效果。我們知道,判斷物件是否在 Canvas 中會有額外的計算開銷(比如需要對遊戲角色的全域性模型矩陣求逆,以分解出物件的世界座標,這並不是一筆特別廉價的開銷),而且也會增加程式碼的複雜程度,所以關鍵是,是否值得。
我做了一個實驗,繪製一張 320×180 的圖片 104 次,當我每次都繪製在 Canvas 內部時,消耗了 40ms,而每次都繪製在 Canvas 外時,僅消耗了 8ms。大家可以掂量一下,考慮到計算的開銷與繪製的開銷相差 2~3 個數量級,我認為通過計算來過濾掉哪些畫布外的物件,仍然是很有必要的。
離屏繪製
上一節提到,繪製同樣的一塊區域,如果資料來源是尺寸相仿的一張圖片,那麼效能會比較好,而如果資料來源是一張大圖上的一部分,效能就會比較差,因為每一次繪製還包含了裁剪工作。也許,我們可以先把待繪製的區域裁剪好,儲存起來,這樣每次繪製時就能輕鬆很多。
drawImage
方法的第一個引數不僅可以接收 Image
物件,也可以接收另一個 Canvas
物件。而且,使用 Canvas
物件繪製的開銷與使用 Image
物件的開銷幾乎完全一致。我們只需要實現將物件繪製在一個未插入頁面的 Canvas
中,然後每一幀使用這個 Canvas
來繪製。
1 2 3 4 5 6 7 8 |
// 在離屏 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 繪製操作也一樣)。
然後,我建立了一個會阻塞瀏覽器的任務:獲取 4×106 次 Math.random()
的平均值。點選按鈕,這個任務就會被執行,其結果也會列印在螢幕上。
如你所見,如果直接執行這個任務,動畫會明顯地「卡」一下。而使用 Web Worker 或將任務拆分,則不會卡。
以上兩種優化策略,有一個相同的前提,即任務是非同步的。也就是說,當你決定開始執行一項任務的時候,你並不需要立刻(在下一幀)知道結果。比如,即使戰略遊戲中使用者的某個操作觸發了尋路演算法,你完全可以等待幾幀(使用者完全感知不到)再開始移動遊戲角色。
另外,將任務拆分以優化效能,會帶來顯著的程式碼複雜度的增加,以及額外的開銷。有時候,我覺得也許可以考慮優先砍一砍需求。
小結
正文就到這裡,最後我們來稍微總結一下,在大部分情況下,需要遵循的「最佳實踐」。
- 將渲染階段的開銷轉嫁到計算階段之上。
- 使用多個分層的 Canvas 繪製複雜場景。
- 不要頻繁設定繪圖上下文的 font 屬性。
- 不在動畫中使用 putImageData 方法。
- 通過計算和判斷,避免無謂的繪製操作。
- 將固定的內容預先繪製在離屏 Canvas 上以提高效能。
- 使用 Worker 和拆分任務的方法避免複雜演算法阻塞動畫執行。