簡介
HTML5 畫布最初是 Apple 的實驗,現在是網路上最廣泛支援的 2D 即時模式圖形標準。許多開發人員現在都仰賴這項技術,開發各種多媒體專案、視覺化內容和遊戲。不過,隨著我們建構的應用程式越來越複雜,開發人員不經意間就會遇到效能瓶頸。關於最佳化畫布效能,有許多不連貫的知識。本文旨在將部分內容整合為更易於理解的資源,供開發人員參考。本文包含適用於所有電腦圖形環境的基本最佳化,以及隨著畫布實作改善而可能變更的畫布專屬技術。尤其是當瀏覽器供應商導入畫布 GPU 加速功能時,本文討論的部分效能技術可能就不會那麼有效。我們會在適當的地方註明。請注意,本文不會深入探討 HTML5 畫布的使用方式。如需相關資訊,請參閱 HTML5Rocks 上的畫布相關文章、Dive into HTML5 網站上的這個章節或 MDN 畫布教學課程。
效能測試
為因應快速變化的 HTML5 畫布世界,JSPerf (jsperf.com) 測試會驗證每項建議的最佳化措施是否仍有效。JSPerf 是一款網頁應用程式,可讓開發人員編寫 JavaScript 效能測試。每項測試都著重於您想達成的結果 (例如清除畫布),並包含多種達成相同結果的方法。JSPerf 會在短時間範圍內盡可能多次執行每種方法,並提供具有統計意義的每秒疊代次數。分數越高越好!JSPerf 效能測試頁面的訪客可以在瀏覽器上執行測試,並讓 JSPerf 將標準化測試結果儲存在 Browserscope (browserscope.org) 上。本文中的最佳化技術有 JSPerf 結果佐證,因此您可以回頭查看最新資訊,瞭解該技術是否仍適用。我編寫了一個小型輔助應用程式,將這些結果轉譯為圖表,並嵌入整篇文章中。
本文中的所有效能結果都是以瀏覽器版本為依據。這會造成限制,因為我們不知道瀏覽器是在哪個 OS 上執行,更重要的是,我們不知道 HTML5 畫布在執行效能測試時是否經過硬體加速。如要確認 Chrome 的 HTML5 畫布是否已啟用硬體加速功能,請在網址列中前往 about:gpu。
預先算繪至螢幕外畫布
如果您在多個影格中重新繪製類似的原語到螢幕上 (這在編寫遊戲時很常見),您可以預先算繪場景的大部分內容,大幅提升效能。預先算繪是指使用獨立的螢幕外畫布 (或多個畫布) 算繪暫時圖片,然後將螢幕外畫布算繪回可見畫布。舉例來說,假設您要以每秒 60 影格的速度重新繪製瑪利歐跑步的畫面。您可以重新繪製每個影格中的帽子、鬍子和「M」,也可以在執行動畫前預先算繪瑪利歐。不預先算繪:
// 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 個影格算繪背景。另請注意,如果應用程式更適合這類結構,這種方法也適用於任何數量的複合畫布。
避免使用 shadowBlur
與許多其他圖形環境一樣,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 Canvas 應用程式和遊戲的基本重要作業。如「避免變更畫布狀態」一節所述,通常不建議清除整個畫布,但如果必須這麼做,有兩種做法:呼叫 context.clearRect(0, 0, width, height),或使用畫布專屬的駭客手法:canvas.width = canvas.width;。在撰寫本文時,clearRect 的效能通常優於寬度重設版本,但在某些情況下,在 Chrome 14 中使用 canvas.width 重設駭客手法會快得多
請謹慎使用這項提示,因為這項提示高度仰賴基礎畫布實作,且極有可能變更。詳情請參閱 Simon Sarris 關於清除畫布的文章。
避免使用浮點座標
HTML5 畫布支援子像素算繪,且無法關閉。如果使用非整數的座標繪圖,系統會自動使用反鋸齒功能,嘗試讓線條更平滑。以下是視覺效果,取自 Seb Lee-Delisle 的這篇子像素畫布效能文章:
如果平滑精靈不是您想要的效果,使用 Math.floor 或 Math.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 是相對較新的 API,建議用於在瀏覽器中實作互動式應用程式。您不必命令瀏覽器以特定固定頻率算繪,而是禮貌地要求瀏覽器呼叫算繪常式,並在瀏覽器可用時呼叫。如果網頁不在前景,瀏覽器會聰明地不進行算繪,這也是一項優點。requestAnimationFrame 回呼的目標是每秒 60 影格的回呼率,但無法保證,因此您需要追蹤自上次算繪以來經過的時間。如下所示:
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,因此您應使用這個填充碼。
大多數行動版畫布導入作業都很緩慢
我們來談談行動裝置。可惜的是,在撰寫本文時,只有搭載 Safari 5.1 的 iOS 5.0 Beta 版,才支援 GPU 加速行動畫布。如果沒有 GPU 加速功能,行動瀏覽器通常沒有足夠強大的 CPU,無法執行現代 Canvas 應用程式。上述許多 JSPerf 測試在行動裝置上的成效比電腦版差一個數量級,大幅限制了您預期能成功執行的跨裝置應用程式類型。
結論
總結來說,本文介紹了一套完整的實用最佳化技術,可協助您開發高效能的 HTML5 畫布專案。現在你已學到新知識,可以開始著手最佳化自己的精彩創作。如果目前沒有要最佳化的遊戲或應用程式,可以參考 Chrome Experiments 和 Creative JS 尋找靈感。
參考資料
- 即時模式與保留模式。
- 其他 HTML5Rocks Canvas 文章。
- Dive into HTML5 的 Canvas 區段。
- JSPerf 可讓開發人員建立 JS 效能測試。
- Browserscope 會儲存瀏覽器效能資料。
- JSPerfView:將 JSPerf 測試轉譯為圖表。
- Simon 關於清除畫布的網誌文章,以及他的著作《HTML5 Unleashed》,其中包含 Canvas 效能的章節。
- Sebastian 的網誌文章,探討子像素轉譯效能。
- Ben 的演講:最佳化 JS NES 模擬器。
- Chrome 開發人員工具中的新畫布分析器。