Canvas 最佳實踐(效能篇)

發表於2016-02-23

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 上呼叫。

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

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

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

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

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 來定義堆疊的次序。然後僅在需要繪製該層的時候(也許是「永不」)進行重繪。

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

繪製影象

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

drawImage 方法的格式如下所示:

資料來源與繪製的效能

由於我們具備「把圖片中的某一部分繪製到 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 來繪製。

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

第一次看到 getImageDataputImageData 這一對 API,我有一種錯覺,它們簡直就是為了上面這個場景而設計的。前者可以將某個 Canvas 上的某一塊區域儲存為 ImageData 物件,後者可以將 ImageData 物件重新繪製到 Canvas 上面去。但實際上,putImageData 是一項開銷極為巨大的操作,它根本就不適合在每一幀裡面去呼叫。

避免「阻塞」

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

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

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

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

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

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

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

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

Web Worker 相容性

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

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

然後,我建立了一個會阻塞瀏覽器的任務:獲取 4×106Math.random() 的平均值。點選按鈕,這個任務就會被執行,其結果也會列印在螢幕上。

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

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

小結

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

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

相關文章