天天看點

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

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

作者 | 楚奕

來源 | 阿裡技術公衆号

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

一 設計目标

  • 标準化:Web Canvas标準主要指的是W3C的Canvas2D[1]和WebGL[2]。标準化的好處一方面是學習成本低,另一方面上層的遊戲引擎也可以以很低的适配成本得到複用;
  • 跨平台:跨平台主要目的是為了擴寬使用場景、提升研發效率、降低維護成本;
  • 跨容器:由于業務形态的不同,Canvas需要能夠跑在多種異構容器上,如小程式、小遊戲、小部件、Weex等等;
  • 高性能:正所謂「勿在浮沙築高台」,上層業務的性能很大程度取決于Canvas的實作;
  • 可擴充:從下文的Canvas分層設計上可以看到,每一層的技術選型都是多樣化的,不同場景可能會選擇不同的實作方案,是以架構上需要有一定的可擴充性,最好能夠做到關鍵子產品可插拔、可替換。

二 Canvas渲染引擎原理概覽

1 工作原理

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

舉個例子,以下是淘寶小程式容器Canvas元件的渲染流程,省略了「億」點點細節。Canvas在Android上其實是一個SurfaceView/TextureView,通過同層渲染的方式嵌入到UCWebView中。開發者調用Canvas JS接口,最終會生成一系列的渲染指令送到GPU,渲染結果寫入圖形緩沖區,在合适時機通過SwapBuffer交換緩沖區,然後作業系統進行圖層合成和送顯。

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

Canvas渲染過程示意圖(Android)

2 分層架構

從業務形态上看,不管是小程式、小遊戲還是其他容器,實作上都是相似的。如下圖所示,通過JSBinding實作标準Canvas接口,開發者可以通過适配在上面跑web遊戲引擎(laya、egret、threejs...),下邊是JS引擎,這一層可以有不同的技術選型,如老牌的V8、JSC,後起之秀quickjs、hermes等等,在這之下就是Canvas核心實作了,這一層需要分别提供WebGL、Canvas2D的能力。

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

分層架構

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++實作。

三 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渲染引擎架構的設計與思考

小程式JSWorker JSEngine插件注入示意圖(Android)

關于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的渲染流程,簡單來說分為如下幾個步驟(硬體加速場景):

  1. 通過SurfaceView申請的Surface建立EGL環境;
  2. Surface通過dequeueBuffer向SurfaceFlinger請求一塊GraphicBuffer(可了解為一塊記憶體,用于存儲繪圖資料),随後所有繪制内容都會寫到這塊Buffer上;
  3. 當調用EGL swapBuffer之後,會将GraphicBuffer入隊到BufferQueue;
  4. SurfaceFlinger在下一個VSYNC信号到來時,取GraphicBuffer,進行合成上屏;
深度 | 跨平台Web Canvas渲染引擎架構的設計與思考

SurfaceView渲染流程

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

  1. 通過TextureView綁定的SurfaceTexture建立EGL環境;
  2. 生産端(Surface)通過dequeueBuffer從SurfaceTexture管理的BufferQueue中獲得一塊GraphicBuffer,後續所有繪制内容都會寫到這塊Buffer上;
  3. 當調用EGL swapBuffer之後,會将GraphicBuffer入隊到SurfaceTexture内部的BufferQueue;
  4. 随後TextureView觸發frameAvailable,通知系統進行重繪(view#invalidate);
  5. 系統在下次VSYNC信号到來的時候進行重繪,在UI線程生成DisplayList,然後驅動渲染線程進行真正渲染;
  6. 渲染線程會将步驟2中的GraphicBuffer作為一張特殊的紋理(GL_TEXTURE_EXTERNAL_OES)上傳,與View Hierarchy上其他視圖一起通過SurfaceFlinger進行合成;
深度 | 跨平台Web Canvas渲染引擎架構的設計與思考

TextureView渲染流程

由以上兩者的渲染流程對比可發現,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的渲染管線技術選型及具體的方案設計。

1 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[9]的實作,這裡就不展開了。

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

  1. 開發者建立Canvas對象,并通過Canvas.getContext('2d')擷取2D上下文;
  2. 通過2D上下文調用Canvas Binding API,内部實際上通過SkCanvas調用Skia的繪圖API,不過此時并沒有繪制,而是将繪圖指令記錄下來;
  3. 當平台層收到Vsync信号時,會排程到JS線程通知到Canvas;
  4. Canvas收到信号後,停止記錄指令,生成SkPicture對象(其實就是個DisplayList),封裝成PictureLayer,添加到LayerTree,發送到GPU線程;
  5. GPU線程Rasterizer子產品收到LayerTree之後,會拿到Picture對象,交給目前Window Surface關聯的SkCanvas;
  6. 這個SkCanvas先通過Picture回放渲染指令,再根據目前backend選擇vulkan、GL或者metal圖形API将渲染指令送出到GPU。
深度 | 跨平台Web Canvas渲染引擎架構的設計與思考

基于Skia的Canvas2D渲染管線

文字渲染

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

目前字型的事實标準是OpenType和TrueType,它們通過使用貝塞爾曲線的方式定義字型的形狀,這樣可以保證字型與分辨率無關,可以輸出任意大小的文字而不會變形或者模糊。衆所周知,OpenGL并沒有提供直接的方式用于繪制文字,最容易想到的方式是先在CPU上加載字型檔案,光栅化到記憶體,然後作為GL紋理上傳到GPU,目前業界用的最廣泛的是 Freetype 庫,它可以用來加載字型檔案、處理字形,生成光栅化的位圖資料。如果每個文字對應一張紋理顯然代價非常高,主流的做法是使用 texture atlas 的方式将所有可能用到的文字全部寫到一張紋理上,進行緩存,然後根據uv坐标選擇正确的文字,有點類似雪碧圖。

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

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

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

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

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

位圖渲染

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

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

位圖渲染流程

2 WebGL Rendering Context

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

單線程模型

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

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

WebGL單線程模型

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

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

WebGL JSBinding優化之Command Buffer

這個方案的思路是這樣的,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

14

,思路是這樣的,JS線程收到Binding調用後,并不直接送出,而是先encode到Command Buffer(通常使用Ring buffer資料結構)緩存起來,随後在渲染線程中通路CommandBuffer,進行Decode,調用真正的GL指令,雙線程模型實作要複雜的多,需要考慮Lock Free&WaitFree、同步、參數拷貝等問題,寫的不好可能性能還不如單線程模型。

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

3 離屏渲染

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

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

  • PBuffer[15]:需要通過PBuffer建立新的GL Context,每次渲染都需要切換GL上下文;
  • FBO[16]: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加速下)大緻會經過如下幾個階段:

  1. HWC産生VSYNC事件,分别發給SurfaceFlinger合成程序與App程序;
  2. App UI線程(通過Choreographer)收到VSYNC信号後,處理使用者輸入(input)、動畫、視圖更新等事件,然後将繪圖指令更新到DisplayList中,随後驅動渲染線程執行繪制;
  3. 渲染線程解析DisplayList,調用hwui/skia繪圖子產品将渲染指令發給GPU;
  4. GPU進行繪制,繪制結果寫入圖形緩沖區(GraphicBuffer);
  5. SurfaceFlinger程序收到VSYNC信号,取圖形緩存區内容進行合成;
  6. 顯示裝置重新整理,螢幕最終顯示相應畫面。
深度 | 跨平台Web Canvas渲染引擎架構的設計與思考

Android基于vsync驅動的渲染模型-理想情況

值得注意的是,預設情況下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渲染引擎架構的設計與思考

Android基于vsync驅動的渲染模型(雙緩沖)-丢幀情況

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

  1. 在JS線程,遊戲引擎調用Canvas WebGLContext執行WebGL Binding調用;
  2. 在Android UI線程,Canvas收到平台VSYNC信号;
  3. 通過消息隊列排程到JS線程,在JS線程周遊Canvas執行個體,找到所有WebGL渲染上下文;
  4. 對每個需要執行渲染(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渲染引擎架構的設計與思考

Android基于vsync驅動的渲染模型-TextureView

TextureView基于SurfaceTexture,由于沒有獨立Surface,渲染合成依賴于Android HWUI,TextureView生産完一幀的資料後,還需觸發一次view invalidate,再走一次ViewRootImpl#doTraversal[17]流程,是以整體流水線更長,從圖上可知,在沒有丢幀的情況下,顯示也會延遲,第N幀的繪制在第N+2幀才會顯示到螢幕上。

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

第一種丢幀情況是第N幀TextureView線程渲染逾時,導緻錯過了N+1幀UI線程的繪制。

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

TextureView丢幀情況1:TextureView線程渲染耗時

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

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

TextureView丢幀情況2:UI線程卡頓導緻吞幀

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

七 調試

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

1 Gapid(Graphic API Debugger)

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

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

2 Snapdragon Profiler

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

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

八 總結

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

相關連結

[1]

https://www.w3.org/TR/2dcontext/ [2] https://www.khronos.org/registry/webgl/specs/latest/1.0/ [3] https://www.cairographics.org/ [4] https://skia.org/ [5] https://github.com/alibaba/GCanvas [6] https://blend2d.com/ [7] https://github.com/memononen/nanovg [8] https://github.com/phoboslab/Ejecta [9] https://chromium.googlesource.com/chromium/blink/+/master/Source/modules/canvas2d/CanvasRenderingContext2D.h [10] https://www.freetype.org/ [11] https://harfbuzz.github.io/ [12] https://android.googlesource.com/platform/frameworks/minikin/ [13] https://github.com/flutter/engine/blob/master/third_party/txt [14] https://www.chromium.org/developers/design-documents/gpu-command-buffer [15] https://www.khronos.org/registry/EGL/sdk/docs/man/html/eglCreatePbufferSurface.xhtml [16] https://en.wikipedia.org/wiki/Framebuffer_object [17] https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/ViewRootImpl.java#1939 [18] https://gapid.dev/about/ [19] https://developer.qualcomm.com/software/snapdragon-profiler

免費領取電子書

《開源與雲Elasticsearch應用剖析》

雲生态讓開源技術更具開放性與創造性。在【Elasticsearch生态&技術】峰會上,阿裡巴巴集團副總裁賈揚清、Elastic創始人&CEO Shay Bannon等10位資深大咖探讨了當下熱門的Elasticsearch技術與雲生态下開源共生之路。本書對演講内容收集整理,幫助大家更好地了解Elasticsearch開源體系、雲原生和數字化轉型。

掃碼加阿裡妹好友,回複“開源”擷取吧~(若掃碼無效,可直接添加alimei4、alimei5、alimei6、alimei7)

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