提升 HTML5 畫布效能

Boris Smus
Boris Smus

簡介

HTML5 畫布最初是 Apple 的實驗項目,是網路上 2D 即時模式圖形最廣泛支援的標準。如今許多開發人員都依賴這個方式處理各種多媒體專案、圖表和遊戲。不過,隨著我們建構的應用程式越來越複雜,開發人員不小心就會碰到效能牆。將畫布效能最佳化時,有很多缺點。本文旨在將這部分內容整合為開發人員更容易消化的資源。本文包含適用於所有電腦圖形環境的基本最佳化技巧,以及隨著畫布實作改善而可能變更的畫布專屬技巧。特別是,隨著瀏覽器供應商導入畫布 GPU 加速功能,上述提及的部分效能技巧可能就會失效。我們會在適當的地方註明這項資訊。請注意,本文不會說明 HTML5 畫布的用法。為此,請參閱 HTML5Rocks 上的畫布相關文章深入探索 HTML5 網站MDN 畫布的教學課程。

效能測試

為了因應快速變化的 HTML5 畫布,JSPerf (jsperf.com) 測試會驗證每項最佳化建議是否仍有效。JSPerf 是網路應用程式,可讓開發人員編寫 JavaScript 效能測試。每項測試都著重於您想達成的結果 (例如清理畫布),並包含多個方法可以達到相同結果。JSPerf 會在短時間內盡可能執行每個方法,並提供每秒疊代次數的統計顯著數字。分數越高越好!造訪 JSPerf 效能測試頁面的訪客可以在瀏覽器上執行測試,並讓 JSPerf 將正規化的測試結果儲存在 Browserscope (browserscope.org) 中。由於本文中的最佳化技巧有 JSPerf 結果做為後盾,您可以返回查看最新資訊,瞭解該技巧是否仍適用。我編寫了一個小型輔助應用程式,會將這些結果內嵌為圖表,並嵌入本文中。

本文中的所有效能結果都以瀏覽器版本為依據。這項限制是因為我們不知道瀏覽器執行的是哪個作業系統,更重要的是,我們不知道在效能測試執行時,HTML5 畫布是否使用硬體加速功能。如要瞭解 Chrome 的 HTML5 畫布是否啟用硬體加速功能,請在網址列中輸入 about:gpu

預先轉譯至螢幕外畫布

如果在多個影格之間重新繪製類似的原始版本 (就像編寫遊戲時經常出現的情況),就可以預先轉譯場景中的大部分,大幅提升效能。預先算繪是指使用單獨的離螢幕畫布 (或多個畫布) 算繪暫時圖片,然後將離螢幕畫布算繪回可見的畫布。舉例來說,假設您以每秒 60 張影格的速度重繪 Mario 跑步的畫面,您可在每個畫面重新繪製帽子、鬍子和「M」,或是在執行動畫前預先算繪 Mario。不使用預先算繪:

// canvas, context are defined
function render() {
  drawMario(context);
  requestAnimationFrame(render);
}

預先算繪:

var m_canvas = document.createElement('canvas');
m_canvas.width = 64;
m_canvas.height = 64;
var m_context = m_canvas.getContext('2d');
drawMario(m_context);

function render() {
  context.drawImage(m_canvas, 0, 0);
  requestAnimationFrame(render);
}

請注意 requestAnimationFrame 的用法,我們會在後續章節中詳細說明。

在算繪作業 (上例中的 drawMario) 成本高昂時,這項技巧特別有效。文字轉譯就是一個很好的例子,這是相當昂貴的作業。

不過,「預先算繪鬆散」測試案例的效能不佳。進行預先算繪時,請務必確保臨時畫布與您繪製的圖片相符,否則,在螢幕外算繪所帶來的效能提升,會因將一個大型畫布複製到另一個畫布而導致效能損失 (這會因來源目標大小而異)。上方測試中,緊密畫布只是較小:

can2.width = 100;
can2.height = 40;

相較於產生效能較差的寬鬆測試:

can3.width = 300;
can3.height = 100;

將畫布呼叫批次處理

由於繪圖是耗用資源的作業,因此使用長串指令載入繪圖狀態機器,然後將所有指令轉儲至影片緩衝區,會更有效率。

舉例來說,繪製多條線條時,建立包含所有線條的路徑,並透過單一繪圖呼叫來繪製,會更有效率。換句話說,請不要繪製個別的線條:

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

繪製單一折線可獲得更佳效能:

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

這也適用於 HTML5 畫布的世界。舉例來說,繪製複雜路徑時,最好將所有點放進路徑中,而不是分別算繪線段 (jsperf)。

不過,請注意,此規則在 Canvas 中有一項重要的例外狀況:如果繪製所需物件的原始元素具有較小的邊界框 (例如水平和垂直線),實際上可能更有效率地個別轉譯這些元素 (jsperf)。

避免不必要的畫布狀態變更

HTML5 畫布元素是在狀態機器上實作,該機器會追蹤填充和筆劃樣式等項目,以及組成目前路徑的先前點。在嘗試最佳化圖像效能時,您可能會想專注於圖像算繪。不過,操控狀態機器也可能會造成效能額外負擔。舉例來說,如果您使用多種填充顏色來算繪場景,以顏色算繪會比在畫布上算繪更省錢。如要算繪細條紋圖案,您可以算繪條紋、變更顏色、算繪下一個條紋等:

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);
}

如預期,由於變更狀態機器的成本高昂,因此交錯方法的速度較慢。

只算繪畫面差異,而非整個新狀態

如您所料,在螢幕上顯示的內容越少,渲染成本就越低。如果重繪之間只有漸進差異,只要繪製差異,就能大幅提升效能。換句話說,與其在繪圖前清除整個畫面:

context.fillRect(0, 0, canvas.width, canvas.height);

持續追蹤繪製的定界框,只清除這些資料。

context.fillRect(last.x, last.y, last.width, last.height);

如果您熟悉電腦圖形,可能也知道這項技巧稱為「重繪區域」,其中會儲存先前算繪的邊界框,然後在每次算繪時清除。這項技巧也適用於以像素為基礎的轉譯內容,如這篇 JavaScript Nintendo 模擬器講座所示。

使用多個分層畫布處理複雜場景

如前所述,繪製大型圖片的成本很高,因此應盡量避免。除了使用另一個畫布來轉譯螢幕外內容 (如預先轉譯部分所述),我們也可以使用彼此堆疊的畫布。在前景畫布中使用透明度,我們就能在算繪期間依賴 GPU 將 alpha 組合在一起。您可以按照下列方式設定此做法,讓兩個絕對位置的畫布疊在一起。

<canvas id="bg" width="640" height="480" style="position: absolute; z-index: 0">
</canvas>
<canvas id="fg" width="640" height="480" style="position: absolute; z-index: 1">
</canvas>

有別於這裡只有一個畫布的優點,就是在繪製或清除前景畫布時,我們絕不會修改背景。如果您的遊戲或多媒體應用程式可分為前景和背景,建議您在不同的畫布上算繪這些內容,以大幅提升效能。

您通常可利用不完美的人類觀感,只需以與前景相比的速度,以較慢的速度或較慢的速度轉譯背景,因為這很可能會佔據大部分的使用者註意力。舉例來說,您可以在每次轉譯時算繪前景,但只在每 N 個影格算繪背景。另外請注意,如果應用程式與這類結構搭配得更好,這種做法就能將任何數量的複合畫布推廣至一般情況。

避免使用陰影模糊

與許多其他圖形環境一樣,HTML5 畫布可讓開發人員模糊處理基本元素,但這項作業可能會耗費大量資源:

context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 4;
context.shadowColor = 'rgba(255, 0, 0, 0.5)';
context.fillRect(20, 20, 150, 100);

瞭解清除畫布的各種方式

由於 HTML5 畫布是即時模式繪圖典範,因此需要在每個影格中明確重繪場景。因此,清除畫布對 HTML5 畫布應用程式和遊戲而言,是相當重要的操作。如「避免畫布狀態變更」一節所述,您通常不希望清除整個畫布,但如果您「必須」執行這項作業,有兩種做法:呼叫 context.clearRect(0, 0, width, height) 或使用畫布專用 Hack 進行:canvas.width = canvas.width。在本文撰寫期間,clearRect 通常會比在 Chrome 14 中使用 canvas.width 入侵來重設寬度重設版本的速度明顯加快。

請謹慎處理這項提示,因為這主要取決於基礎畫布實作,而且可能會隨時變動。詳情請參閱 Simon Sarris 關於清除畫布內容的文章

避免使用浮點座標

HTML5 畫布支援子像素算繪,且無法關閉。如果您使用非整數的座標繪圖,系統會自動使用反鋸齒效果,嘗試讓線條更平滑。以下是視覺效果,摘自 Seb Lee-Delisle 撰寫的這篇關於子像素畫布效能的文章

子像素

如果您不想要使用平滑的圖像,可以使用 Math.floorMath.round (jsperf) 將座標轉換為整數,速度會快得多:

如要將浮點座標轉換為整數,您可以使用一些巧妙的技術,其中成效最出色的方法包括在目標編號中加入半數,然後對結果執行位元運算來消除部分元素。

// With a bitwise or.
rounded = (0.5 + somenum) | 0;
// A double bitwise not.
rounded = ~~ (0.5 + somenum);
// Finally, a left bitwise shift.
rounded = (0.5 + somenum) << 0;

完整的效能分析請見此處 (jsperf)。

請注意,一旦將畫布實作項目加速至 GPU,這類最佳化就不再重要,因為 GPU 可快速算繪非整數座標。

使用 requestAnimationFrame 最佳化動畫

在瀏覽器中實作互動式應用程式時,建議使用相對較新的 requestAnimationFrame API。您可以請瀏覽器在可用時呼叫算繪例行程序,而非指揮瀏覽器以特定固定的時間間隔算繪。這項功能的附帶好處是,如果網頁不在前景中,瀏覽器會聰明地不進行轉譯。requestAnimationFrame 回呼的目標是 60 FPS 回呼率,但無法保證達到這個目標,因此您需要追蹤上次轉譯後經過了多久的時間。如下所示:

var x = 100;
var y = 100;
var lastRender = Date.now();
function render() {
  var delta = Date.now() - lastRender;
  x += delta;
  y += delta;
  context.fillRect(x, y, W, H);
  requestAnimationFrame(render);
}
render();

請注意,這種使用 requestAnimationFrame 適用於畫布和其他轉譯技術,例如 WebGL。在撰寫本文時,這個 API 僅適用於 Chrome、Safari 和 Firefox,因此您應使用這個 shim

大部分行動裝置畫布的導入速度都很慢

讓我們談談行動廣告。不幸的是,在撰寫本文時,只有執行 Safari 5.1 的 iOS 5.0 Beta 版支援 GPU 加速的行動畫布實作。沒有 GPU 加速功能的話,行動瀏覽器通常沒有足夠強大的 CPU,無法支援以新式畫布為基礎的應用程式。與電腦版相比,上述許多 JSPerf 測試在行動裝置上的效能差了好幾個數量級,因此您可以執行的跨裝置應用程式類型將受到嚴重限制。

結論

總結來說,本文涵蓋了一系列實用的最佳化技巧,有助您開發效能出色的 HTML5 以畫布為基礎的專案。在瞭解了這項新功能後,請盡情發揮創意,製作出精彩的內容。如果您目前沒有要最佳化的遊戲或應用程式,不妨參考 Chrome 實驗Creative JS 的內容。

參考資料