OffscreenCanvas - 概念說明及使用解析
這是個人關於 OffscreenCanvas 的第一篇文章,在這篇文章里會對 OffscreenCanvas 的基本概念進行說明,並通過兩個實際的常式來解析它的主要使用方式和應用場景。
OffscreenCanvas 是一個實驗中的新特性,主要用於提升 Canvas 2D/3D 繪圖應用和 H5 遊戲的渲染性能和使用體驗。OffscreenCanvas 的 API 很簡單,但是要真正掌握好如何使用,需要頁端對瀏覽器內部的一些運作機制有較深入的了解,這也是撰寫本文的目的。
跟 OffscreenCanvas 關係比較緊密的還有另外兩個新的 API,ImageBitmap 和 ImageBitmapRenderingContext,在文中也會一併進行講解。
常式的代碼可以通過 GitHub 下載。
目前 OffscreenCanvas 在最新版本的 Chrome 和 Firefox 上都可以通過實驗室開關打開,Chrome 的開關是 chome://flags -> Experimental Web Platform features,本文的常式是在 Chrome 67 Canary 上進行驗證。OffscreenCanvas 的 API 在未來仍有可能會發生一些變化,本文會隨之進行更新。
概念說明
Chrome 開發文檔裡面對 OffscreenCanvas 的定義是:
A new interface that allows canvas rendering contexts (2D and WebGL) to be used in workers.
Making canvas rendering contexts available to workers will increase parallelism in web applications, leading to increased performance on multi-core systems.
簡單的說,就是你現在可以在 Worker 線程調用 Canvas API 啦,通過在 Worker 線程完成 Canvas 渲染任務,可以提升 WebApp 的並發程度,從而提升性能和使用體驗,balabala...
不過 JavaScript 目前並沒有提供一個真正可用的多線程並發編程模型,缺少了互斥,信號量等同步原語,線程間無法共享數據,所以除了一些很特定的應用場景,並且需要頁端對應用/遊戲的引擎設計做出較大的修改,大部分場景下指望簡單地使用 OffscreenCanvas 然後就能獲得並髮帶來的大幅性能提升其實並不太現實。不過即使應用/遊戲無法有效地使用 OffscreenCanvas 來實現自身的多線程並發運行,OffscreenCanvas 仍然提供了很高的使用價值,也讓瀏覽器有機會優化自身的 Canvas 渲染流水線,下文會通過常式來講解如何在實際的應用場景中有效地使用 OffscreenCanvas。
當然你還是可以在主線程使用 OffscreenCanvas,並且即使在主線程使用,取決於應用的場景,也還是可能會帶來一些收益。
JavaScript 未來也許會增加多線程共享數據,數據訪問同步的支持,但是起碼目前是沒有的。
使用解析
OffscreenCanvas 目前主要用於兩種不同的使用場景:
- 一種是在 Worker 線程創建一個 OffscreenCanvas 做後台渲染,然後再把渲染好的緩衝區 Transfer 回主線程顯示;
- 一種是主線程從當前 DOM 樹中的 Canvas 元素產生一個 OffscreenCanvas,再把這個 OffscreenCanvas 發送給 Worker 線程進行渲染,渲染的結果直接 Commit 到瀏覽器的 Display Compositor 輸出到當前窗口,相當於在 Worker 線程直接更新 Canvas 元素的內容;
我自己把第一種使用方式稱之為 Transfer 模式,第二種使用方式稱之為 Commit 模式。
Transfer 模式
Transfer Demo,使用 Transfer 模式
Transfer 模式主要用於後台渲染,避免耗時的渲染任務會阻塞前台線程,導致應用無法及時響應用戶的操作,比如一些 2D/3D 圖表,圖形可視化應用,地圖應用等。
實際上這是 OffscreenCanvas 這個特性的最早需求,來自於 Google Map 團隊。
Transfer Demo 運行流程大致如下:
- 主線程啟動 Worker 線程,並請求初始化;
- Worker 線程創建 OffscreenCanvas;
- Worker 線程獲取 OffscreenCanvas 的 WebGL Context 並進行繪製;
- Worker 線程獲取 OffscreenCanvas 的緩衝區(ImageBitmap),然後 Transfer 回主線程;
- 主線程將 Worker 線程回傳的緩衝區分別繪製在兩個不同的 Canvas 上,一個 Canvas 使用 CanvasRenderingContext2D,一個 Canvas 使用 ImageBitmapRenderingContext;
- 3 ~ 5 重複運行;
代碼解析
下面是一些主要步驟的代碼,展示了 OffscreenCanvas,ImageBitmap,ImageBitmapRenderingContext API 的使用。
在 Worker 線程創建 OffscreenCanvas
function Init(mode, data) { if (mode === "transfer") canvas = new OffscreenCanvas(data.width, data.height); ...}
獲取 OffscreenCanvas 的緩衝區並回傳
function TransferBuffer() { let image_bitmap = canvas.transferToImageBitmap(); postMessage({name:"TransferBuffer", buffer:image_bitmap}, [image_bitmap]);}
主線程接收回傳的緩衝區並繪製
g_render_worker.onmessage = function(msg) { if (msg.data.name === "TransferBuffer") { GetTransferBuffer(msg.data.buffer); }}function GetTransferBuffer(buffer) { let context_2d = g_2d_canvas.getContext("2d"); context_2d.clearRect(0, 0, g_2d_canvas.width, g_2d_canvas.height); context_2d.save(); ... context_2d.drawImage(buffer, 0, 0); context_2d.restore(); ... let bitmap_context = g_bitmap_canvas.getContext("bitmaprenderer"); bitmap_context.transferFromImageBitmap(buffer);}
ImageBitmap 和 ImageBitmapRenderingContext
上面的常式使用到了 ImageBitmap 和 ImageBitmapRenderingContext,它們到底是什麼,跟 ImageData 和 CanvasRenderingContext2D 又有什麼不同?
ImageBitmap 主要是用來封裝一塊 GPU 緩衝區,可以被 GPU 讀寫,並且實現了 Transferable 的介面,可以在不同線程之間 Transfer。跟 ImageData 不一樣,ImageBitmap 並沒有提供 JavaScipt API 供 CPU 進行讀寫,這是因為使用 CPU 讀寫 GPU 緩衝區的成本非常高,需要拷貝到臨時緩衝區進行讀寫然後再寫回。這也是為什麼規範的制定者沒有擴展 ImageData,而是提供了一個新的 ImageBitmap 的緣故。
ImageBitmap 可以被當做普通的 Image 繪製在一個 2D Canvas 上,也可以通過 ImageBitmapRenderingContext Transfer 到一個 Bitmap Canvas,我們通過舉例來說明這兩種方式的區別:
- 但我們使用 OffscreenCanvas,通過 2D/3D 進行繪製時,就好像我們有一塊畫板,上面有一些畫紙,我們可以在畫紙上作畫;
- 調用 OffscreenCanvas.transferToImageBitmap 獲取 ImageBitmap 封裝的緩衝區,就好像我們把當前繪畫的畫紙取下來;
- 把 ImageBitmap 作為 Image 繪製在一個 2D Canvas 上,就好像我們對已經繪製好的圖畫在新的畫紙上進行臨摹;
- 把 ImageBitmap 通過 ImageBitmapRenderingContext.transferFromImageBitmap Transfer 給 Bitmap Canvas,就好像我們把畫紙放入一個畫框里掛在牆上顯示;
簡單的說 ImageBitmap Transfer 語義實現了 Zero Copy 的所有權轉移,不需要對緩衝區進行拷貝,性能更高,但是也限制了顯示的方式,而臨摹意味著我們可以對臨摹的副本進行旋轉,縮放,位移等等,還可以在上面再繪製其它內容。另外 ImageBitmap Transfer 之後所有權就發生了轉移,比如 Transfer Demo 的常式調換一下兩個 Canvas 的繪製順序就會報錯,這是因為 Transfer 之後,原來的緩衝區引用已經被置空變成一個空引用。
具體使用哪種方式取決於應用的場景,如果只是簡單的展現就可以考慮使用性能更高 ImageBitmapRenderingContext,OffscreenCanvas,加 ImageBitmap,加 ImageBitmapRenderingContext 提供了一種最高效的後台渲染,前台展現的方式。
Commit 模式
Commit 模式主要用於 H5 遊戲,它允許應用/遊戲在 Worker 線程直接對 DOM 樹裡面的 Canvas 元素進行更新,瀏覽器在這種模式下提供了一條最短路徑和最佳性能的 Canvas 渲染流水線。
要理解瀏覽器所做的優化,我們首先要了解普通 Canvas 元素更新的渲染流水線,跟其它 DOM 元素一樣,Canvas 元素的更新也是走非合成器動畫的渲染流水線,主要的缺點是:
- 非合成器動畫的渲染流水線比較複雜和冗長,有較多的 Overhead,頁面的結構越複雜,Overhead 就越高;
- 如果同時有其它 DOM 元素一起更新,Canvas 的更新會被其它 DOM 元素的光柵化所阻塞,導致性能下降,性能下降的幅度取決於其它 DOM 元素光柵化的耗時;
關於 Chrome 非合成器動畫的渲染流水線可以參考我的文章 - 瀏覽器渲染流水線解析與網頁動畫性能優化。
如果我們調用 Commit,並且 Commit 的 OffscreenCanvas 是跟當前 DOM 樹裡面的某個 Canvas 元素相關聯,瀏覽器就會直接將 OffscreenCanvas 的當前繪製緩衝區發送給 Display Compositor,然後 Display Compositor 就會合成新的一幀輸出到當前窗口,對瀏覽器來說這就是最短的渲染路徑。
在 Worker 線程使用 Commit 模式,理論上我們會:
- 避免被主線程的其它任務所阻塞,Worker 線程可以完全專註在 Canvas 動畫的運行上;
- 通過 OffscreenCanvas 更新 Canvas 元素,瀏覽器走的是最短的渲染路徑,避免了非合成器動畫的冗長流水線和 Overhead;
- 如果有其它 DOM 元素同時更新,不會阻塞 OffscreenCanvas 的更新,所以通過 OffscreenCanvas,的確實現了 Canvas 更新和其它 DOM 更新的並發運行;
- 如果 DOM 元素需要處理事件,這些事件處理不會被 Worker 線程所阻塞,只是處理的結果數據可能需要發送給 Worker 線程用於後續的繪製;
使用 OffscreenCanvas Commit 模式的副作用是 OffscreenCanvas 的更新和其它 DOM 元素的更新不再是強制同步的,即使它們是同時更新,甚至都在主線程而不使用 Worker 線程,因為兩者已經分別走了不同的流水線,最後呈現在屏幕的時機也可能不會完全一致。如果一定要求同步,就只能參考 Transfer Demo 的做法,將繪製後的緩衝區 Transfer 給 Bitmap Canvas 來顯示,但是這樣就無法發揮 Commit 模式的性能優勢了。
如果頁面除了一個 Canvas 元素外沒有其它 DOM 元素,理論上 OffscreenCanvas 能夠帶來的性能提升也比較有限,具體的一些性能分析可以參考這篇文章,當然蚊子肉再少也是肉,能提升一點也是好的。
Commit Demo 的運行流程大致如下:
- 主線程從當前 DOM 樹中的 Canvas 元素生成 OffscreenCanvas;
- 主線程啟動 Worker 線程並初始化,OffscreenCanvas 作為初始化的參數被 Transfer;
- Worker 線程接收 OffscreenCanvas 後完成初始化;
- Worker 線程使用 WebGL 對 OffscreenCanvas 進行繪製;
- Worker 線程繪製完成後 Commit,然後等待瀏覽器的回調;
- Worker 線程接收到到瀏覽器的回調後繼續繪製下一幀,重複 4 ~ 6;
代碼解析
啟動 Worker 線程並初始化
g_render_worker = new Worker("../common/render.js");let offscreen = g_offscreen_canvas.transferControlToOffscreen();g_render_worker.postMessage( {name:"Init", mode:"commit", canvas:offscreen}, [offscreen]);
Commit 然後等待回調
function renderloop() { // Render buffer first render(); // Wait next begin frame to loop gl.commit().then(renderloop);}renderloop();
動畫驅動
在 Worker 線程驅動 OffscreenCanvas 動畫有很多方式,比如使用傳統的 Timer 和 rAF 的方式。
- 如果使用 Timer,我們可以在 Worker 線程直接使用,參考 Transfer Demo 的例子;
- 如果使用 rAF,我們需要在主線程先獲得 rAF 回調,然後再通知 Worker 線程;
這兩種方式各有其缺陷,都不是理想的選擇。
上面的常式展示了新的動畫方式,gl.commit() 返回了一個 Promise 對象,它會在下一次 Begin Frame 時被 resolve,Begin Frame 基本上可以認為是瀏覽器環境下的 vSync 信號,瀏覽器會在 Begin Frame 的過程中調用 rAF 的回調,resolve Commit Promise。因為目前 Worker 線程並不支持 rAF,所以後者就是我們當前最好的選擇。
更多關於 OffscreenCanvas 動畫驅動的討論請參考這篇文章。
推薦閱讀: