跨平台Web Canvas渲染引擎架構的設計與思考

楚奕 淘系技術

跨平台Web Canvas渲染引擎架構的設計與思考

這篇文章主要從技術視角介紹下跨平台WebCanvas的架構設計以及一些關鍵模塊的實現方案(以Android為主),限於作者水平,有不準確的地方歡迎指正或者討論。

設計目標

標準化:Web
Canvas標準主要指的是W3C的Canvas2D和WebGL。標準化的好處一方面是學習成本低,另一方面上層的遊戲引擎也可以以很低的適配成本得到復用;

跨平台:跨平台主要目地是為了擴寬使用場景、提升研發效率、降低維護成本;

跨容器: 由於業務形態的不同,Canvas需要能夠跑在多種異構容器上,如小程序、小遊戲、小部件、Weex等等;

高性能: 正所謂「勿在浮沙築高台」,上層業務的性能很大程度取決於Canvas的實現;

可擴展:
從下文的Canvas分層設計上可以看到,每一層的技術選型都是多樣化的,不同場景可能會選擇不同的實現方案,因此架構上需要有一定的可擴展性,最好能夠做到關鍵模塊可插拔、可替換。

Canvas渲染引擎原理概覽

▐ 工作原理

工作原理其實比較簡單,一句話就可以說明白。首先封裝圖形API(OpenGL、Vulkan、Metal…)以支持WebGL和Canvas 2D矢量圖渲染能力,對下橋接到不同作業系統和容器之上,對上通過language binding將渲染能力以標準化接口透出到業務容器的JS上下文。

舉個例子,以下是淘寶小程序容器Canvas組件的渲染流程,省略了「億」點點細節。

Canvas在Android上其實是一個SurfaceView/TextureView,通過同層渲染的方式嵌入到UCWebView中。開發者調用Canvas
JS接口,最終會生成一系列的渲染指令送到GPU,渲染結果寫入圖形緩衝區,在合適時機通過SwapBuffer交換緩衝區,然後作業系統進行圖層合成和送顯。

跨平台Web Canvas渲染引擎架構的設計與思考

▐ 分層架構

從業務形態上看,不管是小程序、小遊戲還是其他容器,實現上都是相似的,如下圖所示,通過JSBinding實現標準Canvas接口,開發者可以通過適配在上面跑web遊戲引擎(laya、egret、threejs…),下邊是JS引擎,這一層可以有不同的技術選型,如老牌的v8、JSC,後起之秀quickjs、hermes等等,在這之下就是Canvas核心實現了,這一層需要分別提供WebGL、Canvas2D的能力。WebGL較為簡單,基本與OpenGLES接口一一對應,簡單封裝即可。

Canvas
2D如果要從零開始實現的話相對來說會複雜一些(特別是文字、圖片、路徑的渲染等),不過技術選型上仍然有很多選擇比如cairo、skia、nanovg等等,不管使用哪種方案,只要是硬體渲染,其backend只有vulkan/OpenGLES/metal/Direct3D等幾種選擇。

目前OpenGL使用最為廣泛,還可以通過google的Angle項目適配到vulkan/directx等不同backend上。Canvas實現層之下是WAL窗體抽象層,這一層的職責就是為渲染提供宿主環境,通過EGL/EAGL等方式綁定GL上下文與平台窗體系統。下文將對相關模塊的實現分別進行介紹。考慮到性能、可移植性等因素,除了與平台/容器橋接的部分需要使用OC/Java等語言實現之外,其餘部分基本採用C++實現。

跨平台Web Canvas渲染引擎架構的設計與思考

JS Binding機制

JS引擎通常會抽象出VM、JSContext、JSValue、GlobalObject等概念,VM代表一個JS虛擬機實例,擁有獨立的堆棧空間,有點類似進程的概念,不同的VM相互是隔離的(因此在v8中以v8::Isolate命名),一個VM中可以有多個JSContext,JSContext代表一個JS的執行上下文,可以執行JS代碼,JSValue代表一個JS值類型,可以是基礎數據類型也可以是Object類型,每個JSContext中都會擁有一個GlobalObject對象,GlobalObject在JSContext整個生命週期內,都可以直接進行訪問,它默認是可讀可寫的,因此可以在GlobalObject上綁定屬性或者函數等,這樣就可以在JSContext執行上下文中訪問它們了。

要想在JS環境中使用Canvas,需要將Canvas相關接口注入到JS環境,正如Java JNI、Python Binding、Lua Binding等類似,JS引擎也提供了Extension機制,稱之為JS
Binding,它允許開發者使用c++等語言向JS上下文中注入變量、函數、對象等。

// V8函數綁定示例
static void LogCallback(const v8::FunctionCallbackInfo<v8::Value>& args){...}
...
// Create a template for the global object and set the
// built-in global functions.
v8::Local<v8::ObjectTemplate> global = v8::ObjectTemplate::New(isolate);
global->Set(v8::String::NewFromUtf8(isolate, "log"),
v8::FunctionTemplate::New(isolate, LogCallback));
// Each processor gets its own context so different processors
// do not affect each other.
v8::Persistent<v8::Context> context =
v8::Context::New(isolate, nullptr, global);

以小程序環境為例,小程序容器初始化時,會分別創建Render和Worker,Render負責介面渲染,Worker負責執行業務邏輯,擁有獨立JSContext,Canvas提供了createCanvas()和createOffscreenCanvas()
全局函數需要綁定到該JSContext的GlobalObject上,因此Worker需要有一個時機通知canvas注入API,從小程序視角來看,Worker依賴Canvas顯然不合理,因此小程序提供了插件機制,每個插件都是一個動態庫,Canvas作為插件先註冊到Worker,隨後Worker創建之後會掃瞄一遍插件,依次dlopen每個插件並執行插件的初始化函數,將JSContext作為參數傳給插件,這樣插件就可以向JSContext中綁定API了。

跨平台Web Canvas渲染引擎架構的設計與思考

關於JSEngine和Binding有兩個需要注意的點(以V8為例):

  • 關於線程安全。JSContext通常設計為非線程安全的,需要注意不要在非JS線程中訪問JS資源。其次,在V8中一個線程可能有多個JSContext,需要使用v8::Context::Scope切換正確的JSContext;
  • 關於Binding對象的生命週期。眾所周知,C與JS語言記憶體管理方式不一樣,C需要開發者手動管理記憶體,JS由虛擬機管理。對於C++ Binding的JS對象的生命週期理論上需要跟普通JS對像一致,因此需要有一種機制,當JS對象被GC回收時,需要通知到C++
    Binding對象,以便執行相應的析構函數釋放記憶體。事實上,JS引擎通常會提供讓一個JS對象脫離/回歸GC管理的機制,且JS對象的生命週期均有鉤子函數可以進行監聽。V8中有Handle(句柄)的概念,Handle分為LocalHandle、PersistentHandle、Weak
    PersistentHandle。LocalHandle在棧上分配,由HandleScope控制其作用域,超出作用域即被標記為可釋放,PersistentHandle在堆上分配,生命週期長,通常需要開發者顯式通過PersistentHandle#Reset的方式釋放對象。通過SetWeak函數可以讓一個PersistentHandle轉為一個Weak
    PersistentHandle,當沒有其他引用指向Weak句柄時就會觸發回調,開發者可以在回調中釋放記憶體。

最後再討論下Binding代碼如何跨JSEngine的問題。

當前主流的JSEngine有V8、JavaScriptCore、QuickJS等,如果需要更換JSEngine的話,Binding代碼需要重寫,成本有點高(Canvas接口非常多),因此理論上可以再封裝一個抽象層,屏蔽不同引擎的差異,對外提供一致接口,基於抽象層編寫一次Binding代碼,就可以適配到多個JSEngine(使用IDL生成代碼是另外一條路),目前我們使用了UC團隊提供的JSI
SDK適配多JS引擎。

平台窗體抽象層設計

要想做到跨平台,就需要設計一個抽象的平台膠水層,膠水層的職責是對下屏蔽各個平台間的實現差異,對上為Canvas提供統一的接口操作Surface,封裝MakeCurrent、SwapBuffer等行為。實現上可以借鑑Flutter Engine,Flutter Engine的Shell模塊對GL膠水層做了較好的封裝,可以無縫接入到Android、iOS等主流平台,擴展到新平台比如鴻蒙OS也不在話下。

跨平台Web Canvas渲染引擎架構的設計與思考

當設計好GL膠水層接口後,分平台進行實現即可。以Android為例,如果想創建一個GL上下文並繪製到螢幕上,必須通過EGL綁定平台窗體環境,即Surface或者是ANativeWindow對象,而能夠創建Surface的View只有SurfaceView和TextureView(如果是一個全屏遊戲沒有其他Native
View的話,還可以考慮直接使用NativeActivity,這里先不考慮這種情況),應該如何選擇?這里可以從渲染原理上分析下兩者的差異再分場景進行決策。

先看SurfaceView的渲染流程,簡單來說分為如下幾個步驟(硬體加速場景):

  • 通過SurfaceView申請的Surface創建EGL環境;
  • Surface通過dequeueBuffer向SurfaceFlinger請求一塊GraphicBuffer(可理解為一塊記憶體,用於存儲繪圖數據),隨後所有繪製內容都會寫到這塊Buffer上;
  • 當調用EGL swapBuffer之後,會將GraphicBuffer入隊到BufferQueue;
  • SurfaceFlinger在下一個VSYNC信號到來時,取GraphicBuffer,進行合成上屏;

跨平台Web Canvas渲染引擎架構的設計與思考

對比SurfaceView,TextureView的渲染流程更長一些,主要經歷以下關鍵階段:

  • 通過TextureView綁定的SurfaceTexture創建EGL環境;
  • 生產端(Surface)通過dequeueBuffer從SurfaceTexture管理的BufferQueue中獲得一塊GraphicBuffer,後續所有繪製內容都會寫到這塊Buffer上;
  • 當調用EGL swapBuffer之後,會將GraphicBuffer入隊到SurfaceTexture內部的BufferQueue;
  • 隨後TextureView觸發frameAvailable,通知系統進行重繪(view#invalidate);
  • 系統在下次VSYNC信號到來的時候進行重繪,在UI線程生成DisplayList,然後驅動渲染線程進行真正渲染;
  • 渲染線程會將步驟2中的GraphicBuffer作為一張特殊的紋理(GL_TEXTURE_EXTERNAL_OES)上傳,與View Hierarchy上其他視圖一起通過SurfaceFlinger進行合成;

跨平台Web Canvas渲染引擎架構的設計與思考

由以上兩者的渲染流程對比可發現,SurfaceView的優勢是渲染鏈路短、性能好,但是相比普通的View,沒法支持Transform動畫,通常全屏的遊戲、視頻播放器優先選擇SurfaceView。而TextureView則彌補了SurfaceView的缺陷,它跟普通的View完全兼容,同樣會走HWUI渲染,不過缺陷是記憶體占用比SurfaceView高,渲染需要在多個線程之間同步整體性能不如SurfaceView。

具體如何選擇需要分場景來看,以我們為例,我們這邊同時支持在SurfaceView和TextureView中渲染,但是由於目前主要服務於淘寶小程序互動業務,而在小程序容器中,需要通過UC提供的WebView同層渲染技術將Canvas嵌入到WebView中,由於業務上需要同時支持全屏和非全屏互動,且需要支持各種CSS效果,因此只能選擇EmbedSurface模式,而EmbedSurface不支持SurfaceView,因此我們選擇的是TextureView。

渲染管線

Canvas渲染引擎的核心當然是渲染了,上層的互動業務的性能表現,很大程度取決於Canvas的渲染管線設計是否足夠優秀。這一部分會分別討論Canvas2D/WebGL的渲染管線技術選型及具體的方案設計。

▐ Canvas2D Rendering Context

  • 基礎能力

從Canvas2D標準來看,引擎需要提供的原子能力如下:

  • 路徑繪製,包括直線、矩形、貝塞爾曲線等等;
  • 路徑填充、描邊、裁剪、混合,樣式與顏色設置等;
  • 圖元變換(transform)操作;
  • 文本與位圖渲染等。
  • 軟體渲染 VS 硬體渲染

軟體渲染指的是使用CPU渲染圖形,而硬體渲染則是利用GPU。使用GPU的優勢一方面是可以降低CPU的使用率,另外GPU的特性(擅長並行計算、浮點數運算等)也使其性能通常會更好。但是GPU在發展的過程中,更多關注的是三維圖形的運算,二維矢量圖形的渲染似乎關注的較少,因此可以看到像freetype、cairo、skia等早期主要都是使用CPU渲染,雖然khronos組織推出了OpenVG標準,但是也並沒有推廣開來。目前主流的移動設備都自帶GPU,因此對於Canvas2D的技術選型來說,我們更傾向於使用硬體加速的引擎,具體分析可以接著往下看。

  • 技術選型

Canvas2D的實現成本頗高,從零開始寫也不太現實,好在社區中有很多關於Canvas 2D矢量繪製的庫,這里僅列舉了一部分比較有影響力的,主要從backend、成熟度、移植成本等角度進行評判,詳細如下表所示。

跨平台Web Canvas渲染引擎架構的設計與思考

Cairo和Skia是老牌的2D矢量圖形渲染引擎了,成熟度和穩定性都很高,且同時支持軟體與硬體渲染(cairo的硬體渲染支持比較晚),性能上通常skia占優(也看具體case),不過體積大的多。nanovg和GCanvas以小而美著稱,性能上GCanvas更優秀一點,nanovg需要經過特別的定製與調優,文字渲染也不盡如人意。Blend2D是一個後起之秀,通過引入並發渲染、JIT編譯等特性宣稱比Caico性能更優,不過目前還在beta階段,且硬傷是只支持軟體渲染,沒辦法利用GPU硬體能力。最後ejecta項目最早是為了在非瀏覽器環境支持W3C
Canvas標準,有OpenGLES backend,自帶JSBinding實現,不過可惜的是現在已無人維護,性能表現也比較一般。

我認為技術選型沒有最好的方案,只有最適合團隊的方案,從實現角度來看,以上列舉的方案均可以達到目標,但是沒有銀彈,選擇不同的方案對技術同學的要求、產品的維護成本、性能&穩定性、擴展性等均會產生深遠的影響。以我們團隊為例,業務形態上看主要服務於淘系互動小程序業務,面向的是淘寶開放平台上的商家、ISV開發者等,
我們對於Canvas渲染引擎最主要的訴求是跨平台渲染一致性、性能、穩定性,因此nanovg、blend2d、ejecta不滿足需求。從團隊資源的角度看,我們更傾向於使用開箱即用、維護成本低的方案,ejecta、GCanvas不滿足需求。最後從組織架構上看,我們團隊主要負責手淘跨平台相關產品,其中包括Flutter,而Flutter自帶了skia,它同時滿足開箱即用、高性能&高可用等特點,而且由於Chromium同樣使用了skia,因此渲染一致性也得到了保證,所以復用skia對於我們來說是相對比較優的選擇,但與此同時我們的包大小也增大了很多,未來需要持續優化包大小。

  • 渲染管線細節

這里主要介紹下基於Skia的Canvas 2D渲染流程。JSBinding代碼的實現較簡單,可以參考chromium Canvas 2D的實現,這里就不展開了。

看下渲染的流程,關鍵步驟如下,其中4~6步與當前Flutter Engine基本保持一致:

  • 開發者創建Canvas對象,並通過 Canvas.getContext(‘2d’) 獲取2D上下文;
  • 通過2D上下文調用Canvas Binding API,內部實際上通過SkCanvas調用Skia的繪圖API,不過此時並沒有繪製,而是將繪圖命令記錄下來;
  • 當平台層收到Vsync信號時,會調度到JS線程通知到Canvas;
  • Canvas收到信號後,停止記錄命令,生成SkPicture對象(其實就是個DisplayList),封裝成PictureLayer,添加到LayerTree,發送到GPU線程;
  • GPU線程Rasterizer模塊收到LayerTree之後,會拿到Picture對象,交給當前Window Surface關聯的SkCanvas;
  • 這個SkCanvas先通過Picture回放渲染命令,再根據當前backend選擇vulkan、GL或者metal圖形API將渲染指令提交到GPU。

跨平台Web Canvas渲染引擎架構的設計與思考

  • 文字渲染

文字渲染其實非常複雜,這里僅作簡要介紹。

目前字體的事實標準是OpenType和TrueType,它們通過使用貝塞爾曲線的方式定義字體的形狀,這樣可以保證字體與解析度無關,可以輸出任意大小的文字而不會變形或者模糊。

眾所周知,OpenGL並沒有提供直接的方式用於繪製文字,最容易想到的方式是先在CPU上加載字體文件,光柵化到記憶體,然後作為GL紋理上傳到GPU,目前業界用的最廣泛的是 Freetype
庫,它可以用來加載字體文件、處理字形,生成光柵化的位圖數據。如果每個文字對應一張紋理顯然代價非常高,主流的做法是使用 texture atlas 的方式將所有可能用到的文字全部寫到一張紋理上,進行緩存,然後根據uv坐標選擇正確的文字,有點類似雪碧圖。

跨平台Web Canvas渲染引擎架構的設計與思考

以上還只是文字的渲染,當涉及到多語言、國際化時,情況會變得更加複雜,比如阿拉伯語、印度語中連字(Ligatures)的處理,LTR/RTL佈局的處理等,Harfbuzz 庫就是專門用來幹這個的,可以開箱即用。

跨平台Web Canvas渲染引擎架構的設計與思考

從Canvas2D的文字API來看,只需要提供文本測量和基本的渲染的能力即可,使用OpenGL+Freetype+Harfbuzz通常就夠用了,但是如果是一個GUI應用如Android、Flutter,那麼還需要處理斷句斷行、排版、emoji、字體庫管理等邏輯,Android提供了一個minikin庫就是用來幹這個的,Flutter中的txt模塊二次封裝了minikin,提供了更友好的API。目前我們的Canvas引擎的文字渲染模塊跟Flutter保持一致,直接復用libtxt,使用起來比較簡單。

跨平台Web Canvas渲染引擎架構的設計與思考

上面涉及到的一些庫連結如下:

  • Freetype: https://www.freetype.org/
  • Harfbuzz: https://harfbuzz.github.io/
  • minikin: https://android.googlesource.com/platform/frameworks/minikin/
  • flutter txt:https://github.com/flutter/engine/blob/master/third_party/txt

  • 位圖渲染

位圖渲染的基本流程是下載圖片 -> 圖片解碼 -> 獲得位圖像素數據 -> 作為紋理上傳GPU -> 渲染位圖,拿到像素數據後,就可以上傳到GPU作為一張紋理進行渲染。不過由於上傳像素數據也是個耗時過程,可以放到獨立的線程做,然後通過Share
GLContext的方式使用紋理,這也是Flutter目前的做法,Flutter會使用獨立的IO線程用於異步上傳紋理,通過Share Context與GPU線程共享紋理,與Flutter不一樣的是,我們的圖片下載和解碼直接代理給原生的圖片庫來做。

跨平台Web Canvas渲染引擎架構的設計與思考

▐ WebGL Rendering Context

WebGL實現比2D要簡單的多,因為WebGL的API基本與OpenGLES一一對應,只需要對OpenGLES API簡單進行封裝即可。這里不再介紹OpenGL本身的渲染管線,而主要關注下WebGL
Binding層的設計,從技術實現上主要分為單線程模型和雙線程模型。

單線程模型即直接在JS線程發起GL調用,這種方式調用鏈路最短,在一般場景性能不會有大的問題。但是由於WebGL的API調用與業務邏輯的執行都在JS線程,而某些複雜場景每幀會調用大量的WebGL API,這可能會導致JS線程阻塞。

跨平台Web Canvas渲染引擎架構的設計與思考

通過profile可以發現,這個場景JS線程的阻塞可能並不在GPU,而是在CPU,原因是JS引擎Binding調用本身的性能損耗也很可觀,有一種優化方案是引入Command Buffer優化JSBinding鏈路損耗,如下圖所示。

跨平台Web Canvas渲染引擎架構的設計與思考

這個方案的思路是這樣的,JS側封裝一個虛擬的 WebGLRenderingContext 對象,API與W3C標準一致,但是其實現並不調用Native側的JSBinding接口,而是按照指定規則對WebGL
Call進行編碼,存儲到ArrayBuffer中,然後在特定時機(如收到VSync信號或者時執行到同步API時)通過一個Binding接口(上圖flushCommands)將ArrayBuffer一次性傳到Native側,之後Native對ArrayBuffer中的指令查表、解析,最後執行渲染,這樣做可以減少JSBinding的調用頻率,假設ArrayBuffer中存儲了N條同步指令,那麼只需要執行1次Binding調用,減少了(N-1)次Binding調用的耗時,從而提升了整體性能。

跨平台Web Canvas渲染引擎架構的設計與思考

雙線程模型指的是將GL調用轉移到獨立的渲染線程執行,解放JS線程的壓力。具體的做法可以參考chromium GPU Command Buffer(注意這里的Command
Buffer與上面提到的解決的並不是同一個問題,不要混淆),思路是這樣的,JS線程收到Binding調用後,並不直接提交,而是先encode到Command Buffer(通常使用Ring buffer數據結構)緩存起來,隨後在渲染線程中訪問CommandBuffer,進行Decode,調用真正的GL命令,雙線程模型實現要複雜的多,需要考慮Lock
Free&WaitFree、同步、參數拷貝等問題,寫的不好可能性能還不如單線程模型。

最後再提一句,在chromium中,不僅實現了多線程的WebGL渲染模型,還支持了多進程Command Buffer的模型,使用多進程模型可以有效屏蔽各種硬體兼容性問題,帶來更好的穩定性。

▐ 離屏渲染

離屏Canvas在Web中還是個實驗特性,不過因為其實用性,目前主流的小遊戲/小程序容器基本都實現了。使用到離屏Canvas的主要是2D的 drawImage 接口以及WebGL的 texImage2D/texSubImage2D
接口,WebGL通常會使用離屏Canvas渲染文本或者做一些遊戲場景的預熱等等。

離屏渲染通常會使用PBuffer或者FBO來實現:

  • PBuffer: 需要通過PBuffer創建新的GL Context,每次渲染都需要切換GL上下文;
  • FBO: FBO是OpenGL提供的能力,通過 glGenFramebuffers 創建FBO,可以綁定並渲染到紋理,並且不需要切換GL上下文,性能通常會更好些(沒有做過測試,嚴格來說也不一定,因為目前移動端GPU主要採用TBR架構,切換FrameBuffer可能會造成Tile
    Cache失效,導致性能下降)。

除了上面兩種方案之外,Android上還可以通過SurfaceTexture(本質上是EGLImage)實現離屏渲染,不過這是一種特殊的紋理類型,只能綁到GL_TEXTURE_EXTERNAL_OES上。特別地,對於2D來說,還可以通過CPU軟體渲染來間接實現離屏渲染。

離屏渲染中比較影響性能的地方是上傳離屏Canvas數據到在屏Canvas,如果先readPixels再upload性能會比較差。解決方案是將離屏Canvas渲染到紋理,再通過OpenGL
shareContext的方式與在屏Canvas共享紋理。這樣,對於在屏Canvas來說就可以直接復用這個紋理了,具體點,對於在屏2D Context的drawImage來說,可以基於該紋理創建texture backend SkImage,然後作為圖片上傳。對於在屏WebGL
Context的texImage2D來說,有幾種方式,一種方式提供非標API,調用該API將直接綁定離屏Canvas所對應的紋理,開發者不用自己再創建紋理。另一種方式是texImage2D時,通過FBO拷貝離屏紋理到開發者當前綁定的紋理上。還有一種方式是在texImage2D時,先刪除用戶當前綁定的紋理,然後再綁定到離屏Canvas所對應的紋理,這種方案有一定使用風險,因為被刪除的紋理可能還會被開發者用到。

跨平台Web Canvas渲染引擎架構的設計與思考

幀同步機制

所謂幀同步指的是遊戲渲染循環與作業系統的顯示子系統(在Android平台即為SurfaceFlinger)和底層硬體之間的同步。眾所周知,在GPU加速模式下,我們在螢幕上看到的遊戲或者動畫需要先在CPU上完成遊戲邏輯的運算,然後生成一系列渲染指令,再交由GPU進行渲染,GPU的渲染結果寫入FrameBuffer,最終會由顯示設備刷新到螢幕。

顯示設備的刷新頻率(即刷新率)通常是固定的,移動設備主流的刷新頻率是60HZ,也即每秒刷新60次,但是GPU渲染的速度卻是不固定的,它取決於繪製幀的複雜程度。這會導致兩個問題,一是幀率不穩定,用戶體驗差;二是當GPU渲染頻率高於刷新頻率時,會導致丟幀、抖動或者螢幕tearing的現象。

解決這個問題的方案是引入雙緩衝和垂直同步(VSYNC),雙緩衝指的是準備兩塊圖形緩衝區,BackBuffer給GPU用於渲染,FrontBuffer由顯示設備進行顯示,這樣可以提高系統的吞吐量,提高幀率並減少丟幀的情況。垂直同步是為了協調繪製的步調與螢幕刷新的步調一致,GPU必須等到螢幕完整刷新上一幀之後再進行渲染,因為GPU渲染頻率高於刷新率通常是沒有意義的。在PC機上早期的垂直同步是用軟體模擬的,不過NVIDA和AMD後來分別出了G-SYNC和FreeSync,需要各家的硬體配合。

而Android平台上是在Android4.x引入了VSYNC機制,在之後的版本還引入了RenderThread、TripleBuffer(三緩衝)等關鍵特性,極大提高了Android應用的流暢度。

以下是Android平台的渲染模型,一次完整的渲染(GPU加速下)大致會經過如下幾個階段:

  • HWC產生VSYNC事件,分別發給SurfaceFlinger合成進程與App進程;
  • App UI線程(通過Choreographer)收到VSYNC信號後,處理用戶輸入(input)、動畫、視圖更新等事件,然後將繪圖指令更新到DisplayList中,隨後驅動渲染線程執行繪製;
  • 渲染線程解析DisplayList,調用hwui/skia繪圖模塊將渲染指令發給GPU;
  • GPU進行繪製,繪製結果寫入圖形緩衝區(GraphicBuffer);
  • SurfaceFlinger進程收到VSYNC信號,取圖形緩存區內容進行合成;
  • 顯示設備刷新,螢幕最終顯示相應畫面;

跨平台Web Canvas渲染引擎架構的設計與思考

值得注意的是,默認情況下App與SurfaceFlinger同時收到VSYNC信號,App生產第N幀,而SurfaceFlinger合成第N-1幀畫面,也即App第N幀產生的數據在第N+1次VSYNC到來時才會顯示到螢幕。VSYNC+雙緩衝的模型保證了幀率的穩定,但是會導致輸出延遲,且並不能解決卡頓、丟幀等問題,當UI線程有耗時操作、渲染場景過於複雜、App記憶體占用高等等場景就會導致丟幀。丟幀從系統層面上看原因主要是由於CPU/GPU不能在規定的時間內生產幀數據導致SurfaceFlinger只能使用前一幀的數據去合成,Android通過引入VSYNC
offset、Triple Buffer等策略進行了一定程度的優化,不過要想幀率流暢主要還是得靠開發者分場景去做針對性的優化。

跨平台Web Canvas渲染引擎架構的設計與思考

與原生的渲染流程類似,Canvas渲染引擎的繪製流程也是由VSYNC驅動的,在Android平台上可以通過 Choreographer註冊VSYNC Callback,當VSYNC信號到來時,就可以執行一次Canvas
2D/WebGL的繪製。以WebGL單線程模型為例,一次繪製過程如下:

  • 在JS線程,遊戲引擎調用Canvas WebGLContext執行WebGL Binding調用;
  • 在Android UI線程,Canvas收到平台VSYNC信號;
  • 通過消息隊列調度到JS線程,在JS線程遍歷Canvas實例,找到所有WebGL渲染上下文;
  • 對每個需要執行渲染(dirty)的WebGL上下文執行SwapBuffer;

這里其實還涉及到一個問題,如果當前Canvas渲染的內容未發生變化,是否還需要監聽VSYNC信號? 這就是所謂的OnDemand Rendering和Continuously Rendering模型。在 OnDemand 模型下,應用層調用了Canvas
API就會標記狀態為dirty同時向系統請求VSYNC,下一次收到VSYNC callback時執行繪製,而在Continuously 模型下,會一直向系統請求下一次VSYNC,在VSYNC Callback時再去判斷是否需要繪製。理論上OnDemand模型更為合理,避免了不必要的通信,功耗更低,
不過Continuously模型實現上更為簡單。Android與Flutter均採用了OnDemand模型,而我們則同時支持兩種模式。

以上僅僅考慮了Canvas自身的渲染流程,在上文窗體環境搭建中,Android平台我們最終選擇了TextureView作為Canvas的Render Target,那麼在引入了TextureView之後,從作業系統的角度看,宏觀的渲染流程又是怎樣的呢?
我畫了這張圖,為簡單起見,這里以TextureView Thread代表Canvas的渲染線程。

跨平台Web Canvas渲染引擎架構的設計與思考

TextureView基於SurfaceTexture,由於沒有獨立Surface,渲染合成依賴於Android HWUI,TextureView生產完一幀的數據後,還需觸發一次view
invalidate,再走一次ViewRootImpl#doTraversal流程,因此整體流水線更長,從圖上可知,在沒有丟幀的情況下,顯示也會延遲,第N幀的繪製在第N+2幀才會顯示到螢幕上。

同時,TextureView下卡頓、丟幀的情況也更為複雜,有時即使FPS很高但是依然感覺卡頓,下面是常見的兩種丟幀情況。

第一種丟幀情況是第N幀TextureView線程渲染超時,導致錯過了N+1幀UI線程的繪製。

跨平台Web Canvas渲染引擎架構的設計與思考

第二種丟幀情況是UI線程卡頓而TextureView線程渲染較快,導致第N+1幀時UI線程上傳的是TextureView第N+1幀的紋理,而第N幀的紋理被忽略掉了。

跨平台Web Canvas渲染引擎架構的設計與思考

以上可見,在遊戲等重渲染場景,SurfaceView是比TextureView更好的選擇,另外,分析卡頓往往需要對整個系統的底層機制有較深瞭解才能順利解決問題,這對開發者也提出了更高的要求。

調試

最後討論下調試的話題。對於Canvas渲染引擎,傳統的調試方法如日誌、斷點調試、systrace對於問題診斷依然十分有用。不過由於引擎會用到Java/OC/C++/JS等語言,調試的鏈路大大延長,開發者需要根據經驗或者對問題的分析進行針對性的調試,有一定的難度。除了使用上面幾種方式調試之外,還可以使用一些GPU調試工具輔助,下面簡要介紹下。

▐ Gapid(Graphic API Debugger)

Gapid是Android平台提供的GPU調試工具,功能十分強大,它可以Inspect 任意Android應用的OpenGLES/Vulkan調用,無論是系統的GL上下文(如hwui/skia等)還是應用自己創建的GL上下文都能追蹤到,細化到每一幀的話,可以查看該幀所有的Draw
Call、GL狀態機的運行狀態、FrameBuffer內容、創建的Texture、Shader、Program等等。通過這個工具除了可以驗證渲染正確性之外,還可以輔助性能調優(如頻繁的上下文切換、大紋理的分配等等)、診斷可能發生的GPU記憶體泄露等等。

跨平台Web Canvas渲染引擎架構的設計與思考

▐ Snapdragon Profiler

Snapdragon Profiler是高通開發一款GPU調試工具,使用了高通晶片的設備應該都能使用。這個工具也提供了類似的GPU
Profiler的工具,可以抓幀分析,不過個人覺得沒有gapid好用。除此之外,snapdragon還提供了實時性能分析的功能,可以查看CPU、GPU、網絡、FPS、電量等等全方位的性能數據,比Android Studio更強大。有興趣的同學可以研究下。

跨平台Web Canvas渲染引擎架構的設計與思考

跨平台Web Canvas渲染引擎架構的設計與思考

總結

以上基本講清楚了如何實現一個跨平台Canvas引擎,然而這還只是第一步,還有更多的挑戰在前面,比如Canvas與容器層的研發鏈路、生產鏈路如何協同?
如何保障線上功能的穩定性?如何管控記憶體使用?如何優化啟動速度等等。另外,對於複雜遊戲來說,遊戲引擎的使用必不可少,遊戲引擎使用Canvas作為渲染接口並不是性能最佳的方案,如果可以將遊戲引擎中的通用邏輯下沉,提供更高階API,勢必會對性能帶來更大的提升。

來源:kknews跨平台Web Canvas渲染引擎架構的設計與思考