深入解析Flutter下一代渲染引擎Impeller

作者

  • 魏國梁:位元組 Flutter Infra 工程師, Flutter Member,長期專註 Flutter 引擎技術
  • 袁    欣:位元組 Flutter Infra 工程師, 長期關注渲染技術發展
  • 謝昊辰:位元組 Flutter Infra 工程師,Impeller Contributor

Impeller項目啟動背景

2022 年 6 月在 Flutter 3.0 版本中 Google 官方正式將渲染器 Impeller 從獨立倉庫中合入 Flutter Engine 主幹進行迭代,這是 2021 年 Flutter 團隊推動重新實現 Flutter 渲染後端以來,首次正式明確了 Impeller 未來代替 Skia 作為 Flutter 主渲染方案的定位。Impeller 的出現是 Flutter 團隊用以徹底解決 SkSLSkia Shading Language) 引入的 Jank 問題所做的重要嘗試。官方首次注意到 Flutter 的 Jank 問題是在 2015 年,當時推出的最重要的優化是對 Dart 程式碼使用 AOT 編譯優化執行效率。在 Impeller出現之前,Flutter 對渲染性能的優化大多停留在 Skia 上層,如渲染執行緒優先順序的提升,在著色器編譯過久的情況下切換 CPU 繪製等策略性優化。

Jank 類型分為兩種:首次運行卡頓(Early-onset Jank)和非首次運行卡頓, Early-onset Jank 的本質是運行時著色器的編譯行為阻塞了 Flutter Raster 執行緒對渲染指令的提交。在 Native 應用中,開發者通常會基於 UIkit 等系統級別的 UI 框架開發應用,極少需要自定義著色器,Core Animation 等 framework 使用的著色器在 OS 啟動階段就可以完成編譯,著色器編譯產物對所有的 app 而言全局共享,所以 Native 應用極少出現著色器編譯引起的性能問題 更常見的是用戶邏輯對 UI 執行緒過度佔用 官方為了優化 Early-onset Jank ,推出了SkSL 的 Warmup 方案,Warmup 本質是將部分性能敏感的 SkSL 生成時間前置到編譯期,仍然需要在運行時將 SkSL 轉換為 MSL 才能在 GPU 上執行。Warmup 方案需要在開發期間在真實設備上捕獲 SkSL 導出配置文件 在應用打包時通過編譯參數可以將部分 SkSL 預置在應用中。此外由於 SkSL 創建過程中捕獲了用戶設備特定的參數,不同設備 Warmup 配置文件不能相互通用,這種方案帶來的性能提升非常有限。

在 2019 年 Apple 宣布在其生態中廢棄 OpenGL 後, Flutter 迅速完成了渲染層對 Metal 的適配。與預期不符的是, Metal 的切換使得 Early-onset Jank 的情況更加惡化,Warmup 方案的實現需要依賴 Skia 團隊對 Metal 的預編譯做支援,由於 Skia 團隊的排期問題,一度導致 Warmup 方案在 Metal 後端上不可用。與此同時社區中對 iOS 平台 Jank 問題的回饋更加強烈,社區中一度出現屏蔽 Metal 的 Flutter Engine Build,回退到 GL 後端雖然能一定程度改善首幀性能但是在 iOS 平台上會出現視覺效果的退化,與之相對的是,由於 Android 平台上擁有 iOS 缺失的著色器機器碼的快取能力, Android 平台出現 Jank 的概率比 iOS 低很多。

除了社區中出現的通用問題外,Flutter infra 團隊也經常收到位元組內部業務方遇到的 Jank 問題的回饋,回饋較集中的有轉場動畫首次卡頓、列表滾動過程中隨機卡頓等場景:

圖片

轉場動畫觸發的著色器編譯,耗時~100ms

圖片

列表滑動過程中隨機觸發的著色器編譯,耗時~28ms

在這篇文章中,我們嘗試從 Metal 著色器編譯方案,矢量渲染器原理和 Flutter Engine 渲染層的介面設計三個維度去探究 Impeller 想要解決的問題和渲染器背後的相關技術。

Metal Shader Compilation演進

一般而言,不同的渲染後端會使用獨立的著色器語言,與 JavaScript 等常見腳本語言的執行過程類似,不同語言編寫的著色器程式為了能在 GPU 硬體上執行,需要經歷完整的 lexical analysis / syntax analysis /  Abstrat Syntax Tree (抽象語法樹,下文簡稱 AST)構建,IR 優化,binary generation 的過程。著色器的編譯處理是在廠商提供的驅動中實現,其中具體的實現對上層開發者並不可見。Mesa 是一個在 MIT 許可證下開源的三維電腦圖形庫,以開源形式實現了 OpenGL 的 api 介面。通過 Mesa 中對 GLSL 的處理可以觀察到完整的著色器處理流水線。如下圖所示,上層提供的 GLSL 源文件被 Mesa 處理為 AST 後首先會被編譯為 GLSL IR, 這是一種 High-Level  IR,經過優化後會生成另一種 Low-Level  IR :NIRNIR 結合當前 GPU 的硬體資訊被處理為真正的可執行文件。不同的 IR 用來執行不同粒度的優化操作,通常底層 IR 更面向可執行文件的生成,而上層 IR 可以進行諸如 dead code elimination 等粗粒度優化。常見的高級語言(如 Swift )的編譯過程也存在 High-Level  IR (Swift IL) 到 Low-Level IR (LLVM IR)的轉換。

圖片

隨著 Vulkan 的發展, OpenGL 4.6 標準中引入了對 SPIR-V 格式的支援。SPIR-VStandard Portable Intermediate Representation)是一種標準化的 IR,統一了圖形著色器語言與並行計算(GPGPU 應用)領域。它允許不同的著色器語言轉化為標準化的中間表示,以便優化或轉化為其他高級語言,或直接傳給VulkanOpenGL 或 OpenCL 驅動執行。SPIR-V 消除了設備驅動程式中對高級語言前端編譯器的需求,大大降低了驅動程式的複雜性,使廣泛的語言和框架前端能夠在不同的硬體架構上運行。Mesa 中使用 SPIR-V 格式的著色器程式可以在編譯時直接對接到 NIR 層,縮短著色器機器碼編譯的開銷, 有助於系統渲染性能的提升。

圖片

在 Metal 應用中, 使用 Metal Shading Language(以下簡稱 MSL )編寫的著色器源碼首先被處理為 AIR (Apple IR)  格式的中間表示。如果著色器源碼是以字元形式在工程中引用,這一步會在運行時在用戶設備上進行,如果著色器被添加為工程的Target,著色器源碼會在編譯期在 Xcode 中跟隨項目構建生成 MetalLib:  一種設計用來存放 AIR 的容器格式。隨後 AIR 會在運行時,根據當前設備 GPU 的硬體資訊,被 Metal Compiler Service 用 JIT 編譯為可供執行的機器碼。相比源碼形式,將著色器源碼打包為 MetalLib 有助於降低運行時生著色器機器碼的開銷。著色器機器碼的編譯會在每一次渲染管線狀態對象(P ipeline S tate O bject,下文簡稱 PSO)創建時發生,一個 PSO 持有當前渲染管線關聯的所有狀態,包含光柵化各階段的著色器機器碼,顏色混合狀態,深度資訊,模版掩碼狀態,多重取樣資訊等等。PSO 通常被設計為一個 imutable object(不可變對象),如果需要更改 PSO 中的狀態需要創建一個新的 PSO 拷貝。

圖片

由於 PSO 可能在應用生命周期中多次創建, 為了防止著色器的重複編譯開銷,所有編譯過的著色器機器碼會被 Metal 快取用來加速後續 PSO 的創建過程,這個快取稱為 Metal Shader Cache ,完全由 Metal 內部管理,不受開發者控制。應用通常會在啟動階段一次性創建大量 PSO 對象,由於此時 Metal 中沒有任何著色器的編譯快取,PSO 的創建會觸發所有的著色器完整執行從 AIR 到機器碼的編譯過程,整個集中編譯階段是一個 CPU 密集型操作。在遊戲中通常在玩家進入新關卡前利用 Loading Screen 準備好下一場景所需的 PSO,然而常規 app 中用戶的預期是能夠即點即用,一旦著色器編譯時間超過 16 ms,用戶就會感受到明顯的卡頓和掉幀。

圖片

在 Metal 2 中, Apple 首次為開發者引入了手動控制著色器快取的能力:Metal Binary ArchiveMetal Binary Archive 的快取層次位於 Metal Shader Cache 之上, 這意味著 Metal Binary Archive 中的快取在 PSO 創建時會被優先使用 運行時,開發者可以通過 Metal Pipeline Manager 手動將性能敏感的著色器函數添加至 Metal Binary Archive 對象中並序列化至磁碟中。應用再次冷啟後,此時創建相同的 PSO 即是一個輕量化操作,沒有任何著色器編譯開銷。快取的 Binary Archive 甚至可以二次分發給相同設備的用戶,如果本地 Binary Archive 中快取的機器碼與當前設備的硬體資訊不匹配,Metal 會回落至完整的編譯流水線,確保應用的正常執行。遊戲堡壘之夜「Fortnite」 在啟動階段需要創建多達 1700 個 PSO 對象,通過使用 Metal Binary Archive  來加速 PSO 創建,啟動耗時從 1m26s 優化為 3s 速度提升28倍

Metal Binary Archive 通過記憶體映射的方式供 GPU 直接訪問文件系統中的著色器快取,因此打開 Metal Binary Archive 時會佔用設備寶貴的虛擬記憶體地址空間。與快取所有的著色器函數相比,更明智的做法是根據具體的業務場景將快取分層,在頁面退出後及時關閉對應的快取 釋放不必要的虛擬記憶體空間。Metal Shader Cache 的黑盒管理機制無法保證著色器在使用時不會出現二次編譯 而 Metal Binary Archive 可以確保其中的快取的著色器函數在應用生命周期內始終可用。Metal Binary Archive 雖然允許開發者手動管理著色器快取,卻依然需要通過在運行時搜集機器碼來構建,無法保證應用初次安裝時的使用體驗。在 2022 年 WWDC 中,Metal 3 終於彌補了這個遺留的缺陷,為開發者帶來了在離線構建 Metal Binary Archive 的能力:

圖片

構建離線 Metal Binary Archive 需要使用一種全新的配置文件 Pipeline Script,Pipeline Script 其實是 Pipeline State Descriptor 的一種 JSON 表示,其中配置了 PSO 創建所需的各種狀態資訊,開發者可以直接編輯生成,也可以在運行時捕獲 PSO 獲得。給定 Pipeline Script 和 MetalLib,通過 Metal 工具鏈提供的 metal 命令即可離線構建出包含著色器機器碼的 Metal Binary ArchiveMetal Binary Archive  中的機器碼可能會包含多種 GPU 架構 由於 Metal Binary Archive 需要內置在應用中提交市場 開發者可以綜合考慮包體積的因素剔除不必要的架構支援。

圖片

通過離線構建 Metal Binary Archive,著色器編譯的開銷只存在於編譯階段,應用啟動階段 PSO 的創建開銷大大降低。Metal Binary Archive 不止可以優化應用的首屏性能, 真實的業務場景下,一些 PSO 對象會遲滯到具體頁面才會被創建,觸發新的著色器編譯流程。一旦編譯耗時過長,就會影響當前 RunLoop 下 Metal 繪製指令的提交, Metal Binary Archive 可以確保在應用的生命周期內, 核心交互路徑下的著色器快取始終為可用狀態,將節省的 CPU 時間片用來處理與用戶交互強相關的邏輯, 大大提升應用的響應性和使用體驗。

矢量渲染基礎概念

矢量渲染泛指在平面坐標系內通過組裝幾何圖元來生成影像資訊的手段,通過定義一套完整的繪製指令,可以在不同的終端上還原出不失真的圖形, 任何前端的視窗都可以被看作一個 2D 平面的矢量渲染畫布,Chrome 與 Android 渲染系統就是基於 Google 的 2D 圖形庫 Skia 構建。對應用開發而言,矢量渲染技術也扮演重要角色,如文本 / 圖表 / 地圖 / SVG / Lottie 等都依賴矢量渲染能力來提供高品質的視覺效果。

圖片

矢量渲染的基礎單元是 Path(路徑),Path 可以包含單個或多個 Contour(輪廓),Contour在一些渲染器中也稱為 SubPathContour 由連續的 Segment(直線/高階貝塞爾曲線)組成,標準的幾何構型(圓形/矩形)均可被視為一種特殊的 Path,一些特殊的 Path 可以包含坑洞或者自交叉(如五角星⭐️),這類 Path 的處理需要一些特殊的方案。圍繞 Path 可以構造出各種複雜的圖形,著名的老虎🐯SVG一共包含480條 Path ,通過對其中不同 Path 的描邊和填充,可以呈現出極富表現力的視覺效果:

圖片

高階貝塞爾通過起始點和額外的控制點來定義一條曲線, 在將這樣的抽象曲線交付給後端進行渲染前,我們需要首先要對貝塞爾曲線做插值來近似模擬這條曲線,這個操作通常稱為 Flatten , GPU真實渲染的是一由組離散的點來近似模擬的曲線。根據 Path 定義的差異, 這一組離散的點會構成不同種類的多邊形,對 Path 的處理簡化為了對多邊形的處理,我們以一個簡單的凹多邊形為例來了解 Path 的描邊和填充操作是如何實現的:

圖片

圖片

多邊形的描邊操作,由於描邊寬度的存在,描邊的真實著色區域會有一半落在 Path 定義的區域之外。遍歷多邊形的外邊緣的每條邊,根據每條邊兩側的頂點,描邊寬度以及邊緣的斜率可以組裝出一組模擬描邊行為的三角形圖元,如上圖所示:一個方向上的描邊是由兩個相結合的三角形構成。針對不同的 Line Join 風格,結合處有可能需要做不同的處理, 但是原理類似。將描邊的三角形提交 GPU 可以渲染得到正確的描邊效果,除了純色的描邊,結合不同的著色器可以實現漸變和紋理的填充效果。多邊形的填充方法相比描邊更加複雜,目前主流的矢量渲染器有兩種不同的實現思路:

基於模版掩碼的填充( NanoVG

基於模版掩碼的填充是在 OpenGL 紅寶書中所描述的一種填充多邊形的經典方法。Skia 在簡單的場景下也會使用這種方法做多邊形的填充。這種繪製方法分為兩步:首先利用 StencilBuffer 來記錄實際繪製區域,這一步只寫入 StencilBuffer,不操作 Color Attachment,然後再進行一次繪製,通過StencilBuffer 記錄的模版掩碼,只向特定的像素位置寫入顏色資訊。通過圖例可以更直觀的了解這個過程:第一步,打開 StencilBuffer 的寫入開關,使用 GL_TRIANGLE_FAN 形式繪製所有的頂點, GL會自動根據頂點索引組裝兩組三角形基元 0 -> 1 -> 2 和 0 -> 2 -> 3GL 中通常指定逆時針方向為三角形片元的正面, 0 -> 1 -> 2 三角形所包圍的區域在 StencilBuffer 中做 +1 操作, 由於頂點3是多邊形的凹點, 0 -> 2 -> 3 三角形的環繞數被翻轉為了順時針,我們可以在 StencilBuffer 中對順時針包裹的區域做 -1 操作, 此時 StencilBuffer 中所有標記為 1 的像素就是我們所需要的繪製區域,再次提交相同的頂點進行繪製,打開顏色寫入,就可以得到正確的繪製結果。這種方法巧妙的利用了凹多邊形會改變局部三角形環繞方向的特性。

圖片

模版掩碼可以正確處理複雜的多邊形, 但是由於需要進行兩段式的繪製, 對於複雜的多邊形性能繪製性能瓶頸較明顯, 此外 StencilBuffer 等操作都是由 GL 驅動層所實現,幾乎不可能進行任何的性能優化, 這種繪製方法常在一些追求小尺寸的矢量渲染器中使用(NanoVG), 在一些文章中通常也被稱為 Stencil & Cover 。

基於三角剖分的填充( Skia

Skia 中對多邊形的渲染是由 Tesselation 和  Triangulation 兩步構成,Tesselation 原意指在多邊形中新增頂點來構造更加細分的幾何圖元,Triangulation 是指連接多邊形自身的頂點構造可以填充滿自身的若干三角圖元(不增加頂點的情況下) , Triangulation 可以認為是 Tessellation 的一種特例,在 Skia 中描述的 Tessellation 其實是指一種對複雜多邊形的拆分操作,了解多邊形的 Triangulation 首先我們需要引入單調多邊形的概念:

圖片

對於任意一個多邊形 p 而言, 如果存在一條直線 l, l 的垂線與 p 相交的部分都在 p 的內部, 那麼稱多邊形 p 是相對於 l 的單調多邊形。單調多邊形的單調性是相對於某一特定方向而言,針對上圖的示例我們可以很容易找到一個方向的直線作為反例。利用單調多邊形在 l 方向上的左右兩個極點可以把多邊形進一步分拆為上下兩條邊,每條邊上的頂點在 l 方向上會確保是有序的,這個特性可以用來實現剖分演算法。

以下圖中的凹多邊形為例子,複雜多邊形的完整處理思路是:首先使用 Tesselation 演算法將其拆分為若干個單調多邊形(下圖中兩個藍色區域),通常會在多邊形的凹點進行拆分,得到一組單調多邊形的集合後,  再分別對每一個單調多邊形進行三角化,單調多邊形的 Triangulation 演算法比較著名的有 EarCut, 也有一些實現如 libtess2 可以同時對複雜多邊形進行 Tesselation / Triangulation 兩步操作, libtess2 使用 Delaunay 演算法來對單調多邊形實現剖分, Delaunay 演算法可以避免剖分出現過於狹長的三角形。無論使用何種方案,最終的產物都是能夠直接交付給 GPU 進行渲染的三角形 Mesh 集合。

圖片

圖片

針對上文中的凹多邊形, 剖分後的產物會是如上圖所示的兩個三角形, 三角形可以被認為是一種最簡單的單調多邊形, 提交這兩個三角形即可實現此凹多邊形的正確填充。基於三角剖分的填充方案, 最大的瓶頸是拆分單調多邊形單調多邊形三角化兩個步驟的的演算法選擇, 由於這兩步完全由上層實現, 因此對後期優化更加友好, 目前業界最新的方案已經可以實現利用 GPU 或者深度學習的方法實現剖分的加速。

Flutter DisplayList

DisplayList 出現之前,Skia 使用 SkPicture 來搜集每一幀的繪製指令,隨後在 Raster 執行緒回放完成當前幀的繪製。gl 函數在進入 GPU 執行前,仍然會有一部分邏輯如 PSO 狀態檢測 / 指令封裝等操作在 CPU 上執行,錄製回放能力可以避免繪製操作佔用寶貴的主執行緒時間片。DisplayList 和 SkPicture 的作用類似,那麼為什麼還需要將 SkPicture 向 DisplayList 做遷移 ?Skia 對 Flutter 來說屬於第三方依賴,涉及到 SkPicture 的優化一般需要由 Skia 團隊支援,對 Skia 團隊而言 SkPicture 的能力不只服務於 Flutter 業務,Flutter 團隊如果修改 SkPicture 的源碼會對 Skia 的程式碼有比較大的入侵, 而為了解決長期遺留的 Jank 問題, Flutter 團隊又不得不考慮在 SkPicture 這一層進行優化 。2020 年 3 月,liyuqian 創建一個 flutter issue 中首次提出了 DisplayList 的設想,預期相較於 SkPicture 會有如下三個方面的優勢:

  • DisplayList 相比 SkPicture 有更高的可操作性去優化光柵化時期產生的快取;
  • DisplayList 有助於實現更好的著色器預熱方案;
  • DisplayList 相比 SkPicture可以更好的對每一幀進行性能分析;

在 Flutter RoadMap 明確了 Impeller 的替換目標後,DisplayList 能更好的實現 Flutter Engine 層對渲染器的解耦,從而保障後續渲染層能無縫的從 Skia 遷移到 Impeller 中。在最新的 Flutter 3.0 程式碼, DisplayList 相關的程式碼位於github.com/flutter/eng…

DisplayList 作為 Recoder 的過程和使用 SkPicture 差別不大,核心是在 canvas.cc 中進行了切換:

// //github.com/flutter/engine/blob/main/lib/ui/painting/canvas.cc#L260
// lib/ui/painting/canvas.cc
void Canvas::drawRect(double left,
                      double top,
                      double right,
                      double bottom,
                      const Paint& paint,
                      const PaintData& paint_data) {
  if (display_list_recorder_) {
    paint.sync_to(builder(), kDrawRectFlags);
    builder()->drawRect(SkRect::MakeLTRB(left, top, right, bottom));
  }
  // 3.0 因為默認開啟了 DisplayList 作為 Recorder 所以下面的已經刪除 
  // else if (canvas_) {
  //  SkPaint sk_paint;
  //  canvas_->drawRect(SkRect::MakeLTRB(left, top, right, bottom),
  //                  *paint.paint(sk_paint));
  // }
}

// lib/ui/painting/canvas.h
DisplayListBuilder* builder() {
  return display_list_recorder_->builder().get();
}
複製程式碼

從上面的程式碼可以看出,是在 Canvas 的 DrawOp 中進行了 DisplayList 還是 SkPicture 的選擇,一次DrawOp 的錄製過程如下圖所示:

圖片

DisplayList Record DrawOp 過程圖中 Push 的操作,DrawRectOp 定義在 display_list_ops.h 中:

// //github.com/flutter/engine/blob/main/display_list/display_list_ops.h#L554
// display_list/display_list_ops.h
#define DEFINE_DRAW_1ARG_OP(op_name, arg_type, arg_name)                  \
  struct Draw##op_name##Op final : DLOp {                                 \
    static const auto kType = DisplayListOpType::kDraw##op_name;          \
                                                                          \
    explicit Draw##op_name##Op(arg_type arg_name) : arg_name(arg_name) {} \
                                                                          \
    const arg_type arg_name;                                              \
                                                                          \
    void dispatch(Dispatcher& dispatcher) const {                         \
      dispatcher.draw##op_name(arg_name);                                 \
    }                                                                     \
  };
DEFINE_DRAW_1ARG_OP(Rect, SkRect, rect)
DEFINE_DRAW_1ARG_OP(Oval, SkRect, oval)
DEFINE_DRAW_1ARG_OP(RRect, SkRRect, rrect)
#undef DEFINE_DRAW_1ARG_OP
複製程式碼

將宏定義展開可以看到如下定義, 這裡 DrawRectOp 是一種單參數 DLOp, DrawRectOp 中的 dispatch 方法會將 drawRect 操作派發給 dispatcher 來實際執行

struct DrawRectOp final :DLOp {
    static const auto kType = DisplayListOpType::kDrawRect;
    explicit DrawRectOp(arg_type arg_name) : rect(rect) {}
    const SkRect rect;  
    void dispatch(Dispatcher& dispatcher) const {                         
      dispatcher.drawRect(arg_name);                                 
    } 
}
複製程式碼

在 LLDB 中可以列印出 DrawRectOp 的相關資訊:

圖片

Push 中的Push 函數的實現如下,storage_  是一個一維數組,同來存儲 DrawOp,在添加元素前會先進行容量的判斷,是否需要擴容,隨後創建 DrawRectOp 並對 Type 和 參數 rect 進行賦值,並累加 op_count_,完成 DrawOp 的添加。

// //github.com/flutter/engine/blob/main/display_list/display_list_builder.cc#L27
// display_list/display_list_builder.cc
void* DisplayListBuilder::Push(size_t pod, int op_inc, Args&&... args) {
  size_t size = SkAlignPtr(sizeof(T) + pod);
  // 擴容
  if (used_ + size > allocated_) {
    // Next greater multiple of DL_BUILDER_PAGE.
    allocated_ = (used_ + size + DL_BUILDER_PAGE) & ~(DL_BUILDER_PAGE - 1);
    storage_.realloc(allocated_);
    FML_DCHECK(storage_.get());
    memset(storage_.get() + used_, 0, allocated_ - used_);
  }
  FML_DCHECK(used_ + size <= allocated_);
  // 如 new DrawRectOp
  auto op = reinterpret_cast<T*>(storage_.get() + used_);
  used_ += size;
  new (op) T{std::forward<Args>(args)...};
  op->type = T::kType;
  op->size = size;
  op_count_ += op_inc;
  return op + 1;
}
複製程式碼

DisplayList 記錄 DrawOp 的流程如下:

  • 首先通過調用 BeginRecording 創建 DisplayListCanvasRecoder (繼承自 SkCanvasNoDraw) 之後創建核心類  DisplayListBuilder 並返回 Canvas 給應用層;
  • 應用層通過 Canvas 調用如 drawRect 方法,將會被以 DrawRectOp 記錄在 DisplayListBuilder 的  storage_  中;
  • 最後調用 endRecording 將 DisplayListBuilder 的  storage_  轉移到 DisplayList 中,後面在 SceneBuilder 階段,DisplayList 會被封裝到 DisplayListLayer 中;

DisplayList 中的幾個核心概念:DisplayListCanvasRecorder 作為命令記錄的載體,其中包含了 DisplayListBuilderDisplayListBuilder 的 storage 是真實記錄 DLOp 的載體,DisplayList 將會記錄 DisplayListBuilder 的 storage,並最終被包裹在 DisplayListLayer 中,作為記錄 DLOp 的載體。DisplayListCanvasDispatcher 作為最後派發至 SkCanvas 或者 Impeller 的 Wrapper 層。

Impeller 渲染流程和架構設計

Impeller 概覽

圖片

Impeller 的目標是為 Flutter 提供具備 predictable performance 的渲染支援,Skia 的渲染機制需要應用在啟動過程中動態生成 SkSL, 這一部分著色器需要在運行時轉換為 MSL,才能進一步被編譯為可執行的機器碼,整個編譯過程會對 Raster 執行緒形成阻塞。Impeller 放棄了使用 SkSL 轉而使用 GLSL 4.6 作為上層的著色器語言,通過 Impeller 內置的 ImpellerC 編譯器,在編譯期即可將所有的著色器轉換為 Metal Shading language, 並使用 MetalLib 格式打包為 AIR 位元組碼內置在應用中。Impeller 的另一個優勢是大量使用 Modern Graphics APIs ,Metal 的設計可以充分利用 CPU 多核優勢並行提交渲染指令, 大幅減少了驅動層對 PSO 的狀態校驗, 相對於 GL 後端僅僅將上層渲染介面的調用切換為 Metal 就可以為應用帶來約 ~10%  的渲染性能提升。

在一個 Flutter 應用中,RenderObject 的 Paint 操作最終會轉換為 Canvas 的 draw options,繪製操作在 Engine 層組裝成 DisplayList 之後通過 DisplayListDispatcher 分發到不同的渲染器來執行具體的渲染操作。Impeller 中實現了DisplayListDispatcher 介面,這意味著 Impeller 可以消費上層傳遞的 DisplayList 數據。Aiks 層維護了 Canvas,Paint 等繪製對象的句柄。Entity 可以理解為 Impeller 中的一個原子繪製行為,如 drawRect 操作,其中保存了執行一次繪製所有的狀態資訊,Canvas 會通過 Entity 中保存的狀態設置畫布的 Transform,BlendMode 等屬性。Entity 中最關鍵的組成部分是 ContentsContents 中持有了著色器的編譯產物, 被用來實際控制當前 Entity 的繪製效果, Contents 有多種子類,來承接填充/紋理著色等不同的繪製任務。Renderer 層可以理解為與具體渲染 api 溝通的橋樑,Renderer 會將 Entity 中的資訊(包含Contents 中保存的著色器句柄)轉換為 *Metal/*OpenGL 等渲染後端的具體 api 調用。

Impeller 繪製流程

圖片

Flutter Engine 層的 LayerTree 在被 Impeller 繪製前需要首先被轉換為 EntityPassTree UI 執行緒在接收到 v-sync 訊號後會將  LayerTree 從UI 執行緒提交到 Raster 執行緒,在 Raster 執行緒中會遍歷 LayerTree 的每個節點並通過 DisplayListRecorder 記錄各個節點的繪製資訊以及 saveLayer 操作, LayerTree 中可以做可以 Raster Cache 的子樹其繪製結果會被快取為點陣圖, DisplayListRecorder 會將對應子樹的繪製操作轉換為 drawImage 操作,加速後續渲染速度。DisplayListRecorder 完成指令錄製後,就可以提交當前幀。DisplayListRecorder 中的指令快取會被用來創建 DisplayList 對象DisplayList 被DisplayListDispatcher 的實現者(Skia / Impeller)消費,回放  DisplayList 其中所有的 DisplayListOptions 可以將繪製操作轉換為 EntityPassTree。

圖片

完成 EntityPassTree 的構建之後,需要把 EntityPassTree 中的指令解析出來執行。EntityPassTree 繪製操作以 Entity 對象為單位,Impeller 中使用 Vector 來管理一個繪製上下文中多個不同的 Entity 對象。 通常 Canvas 在執行複雜繪製操作時會使用 SaveLayer 開闢一個新的繪製上下文,在 iOS 上習慣稱為離屏渲染, SaveLayer 操作在 Impeller 中會被標記為創建一個新的 EntityPass,用於記錄獨立上下文中的 Entity,新的 EntityPass 會被記錄到父節點的 EntityPass 列表中, EntityPass 的創建流程如上圖所示。

圖片

Metal 在上層為設備的 GPU 硬體抽象了 CommandQueue 的概念,CommandQueue 與 GPU 數量一一對應,CommandQueue 中可包含一個或者多個 CommandBufferCommandBuffer 是實際繪製指令 RenderCommand 存放的隊列,簡單的應用可以只包含一個 CommandBuffer, 不同的執行緒可以通過持有不同CommandBuffer 來加速 RenderCommand 的提交。RenderCommand 由 RenderCommandEncoder 的 Encode 操作產生,RenderCommandEncoder 定義了此次繪製結果的保存方式 繪製結果的像素格式以及繪製開始或結束時 Framebuffer attachmement 所需要做的操作(clear / store),RenderCommand  包含了最終交付給 Metal 的真實 drawcall 操作。

Entity 中的 Command 轉化為真正的 MTLRenderCommand 時, 還攜帶了一個重要的資訊:PSO*。Entity* 從 DisplayList 中繼承的繪製狀態最終會變為 MTLRenderCommand 關聯的 PSO ,MTLRenderCommand 被消費時 Metal 驅動層會首先讀取 PSO 調整渲染管線狀態,再執行著色器進行繪製,完成當前的繪製操作

ImpellerC 編譯器設計

圖片

ImpellerC 是 Impeller 內置的著色器編譯解決方案,源碼位於 Impeller 的 compiler 目錄下 ,它能夠在編譯期將 Impeller 上層編寫的 glsl 源文件轉化為兩個產物:1.   目標平台對應的著色器文件;2.   根據著色器 uniform 資訊生成的反射文件,其中包含了著色器 uniform 的 struct 布局等資訊。反射文件中的 struct 類型作為 model 層,使得上層使用無需關心具體後端的 uniform 賦值方式,極大地增強了 Impeller 的跨平台屬性,為編寫不同平台的著色器程式碼提供了便利。

在編譯 Flutter Engine 工程中 Impeller 部分時,gn 會首先將 compiler 目錄下的文件編譯出為 ImpellerC 可執行文件,再使用 ImpellerC 對 entity/content/shaders 目錄下的所有著色器進行預處理。GL 後端會將著色器源碼處理為 hex 格式並整合到一個頭文件中, 而 Metal 後端會在 GLSL 完成 MSL 的轉譯後進一步處理為 MetalLib。

ImpellerC 在處理 glsl 源文件時,會調用 shaderc對 glsl 文件進行編譯。shaderc是 Google 維護的著色器編譯器,可以 glsl 源碼編譯為 SPIR-Vshaderc 的編譯過程使用了 glslang 和 SPIRV-Tools  兩個開源工具:glslang 是 glsl 的編譯前端 負責將  glsl 處理為 AST , SPIRV-Tools 可以接管剩下的工作將 AST 進一步編譯為 SPIR-V, 在這一步的編譯過程中,為了能得到正確的反射資訊,ImpellerC 會對 shaderc 限制優化等級。

隨後 ImpellerC 會調用 SPIR-V Cross 對上一步驟得到的 SPIR-V 進行反彙編,得到 SPIR-V IR, 這是一種 SPIR-V Cross 內部使用的數據結構,SPIR-V Cross 會在其之上進行進一步優化。ImpellerC 隨後會調用 SPIR-V Cross 創建目標平台的 Compiler BackendMSLCompiler / GLSLCompiler / SKSLCompiler), Compiler Backend  中封裝了目標平台著色器語言的具體轉譯邏輯 。同時 SPIR-V Cross 會從 SPIR-V IR 中提取 Uniform 數量,變數類型和偏移值等反射資訊,

   struct ShaderStructMemberMetadata {
     ShaderType type; // the data type (bool, int, float, etc.)
     std::string name; // the uniform member name "frame_info.mvp"
     size_t offset;
     size_t size;
   };
複製程式碼

Reflector 在得到這些資訊後,會對內置的  .h 與  .cc 模版進行填充,得到可供 Impeller 引用的  .h 與.cc 文件,上層可以反射文件的類型方便的生成數據 memcpy 到對應的 buffer 中實現與著色器的通訊。對於Metal 和 GLES3 來說,由於原生支援 UBO,最終會通過對應後端提供的 UBO 介面來實現 傳值,對於不支援 UBO 的 GLES2 來說,對 UBO 的賦值需要轉換為 glUniform*  系列 api 對 Uniform 中每個欄位的單獨賦值,在 shader program link 後,Impeller 在運行時通過 glGetUniformLocation 得到所有欄位在 buffer 中的位置,與反射文件中提取出的偏移值結合,Impeller 就可以得到每個 Uniform 欄位的位置資訊,這個過程會在 Imepller Context 創建時生成一次,隨後 Impeller 會維護 Uniform 欄位的資訊。對於上層來說,不管是 GLES2 還是其他後端, 通過 Reflector 與著色器的通訊過程都是一樣的。

完成著色器轉譯和反射文件提取後,就可以實際執行 uniform 數據的綁定,Entity 在觸發繪製操作時會首先調用 Content 的 Render 函數, 其中會創建一個供 Metal 消費的 Command 對象,Command 會提交到 RenderPass 中等待調度, uniform 數據的綁定發生在 Command 創建這一步。如下圖所示:VS::FrameInfo 和 FS::GradientInfo 是反射生成的兩個 Struct 類型, 初始化 VS::FrameInfo 和 FS::GradientInfo 的實例並賦值後,通過 VS::BindFrameInfo 和 FS::BindGradientInfo 函數即可實現數據和 uniform 的綁定。

VS::FrameInfo frame_info;
frame_info.mvp = Matrix::MakeOrthographic(pass.GetRenderTargetSize()) * entity.GetTransformation();

FS::GradientInfo gradient_info;
gradient_info.start_point = start_point_;
gradient_info.end_point = end_point_;
gradient_info.start_color = colors_[0].Premultiply();
gradient_info.end_color = colors_[1].Premultiply();

Command cmd;
cmd.label = "LinearGradientFill";
cmd.pipeline = renderer.GetGradientFillPipeline(OptionsFromPassAndEntity(pass, entity));
cmd.stencil_reference = entity.GetStencilDepth();
cmd.BindVertices(vertices_builder.CreateVertexBuffer(pass.GetTransientsBuffer()));
cmd.primitive_type = PrimitiveType::kTriangle;
FS::BindGradientInfo(cmd, pass.GetTransientsBuffer().EmplaceUniform(gradient_info));
VS::BindFrameInfo(cmd, pass.GetTransientsBuffer().EmplaceUniform(frame_info));
return pass.AddCommand(std::move(cmd));
複製程式碼

LinearGradientContents Render函數實現

Impeller 完整的著色器處理流水線如下圖所示:

圖片

總結

Impeller 是 Flutter 為了治理 SkSL 編譯耗時引入的的性能問題所做的重要嘗試,Skia 的渲染機制需要在運行時動態創建 SkSL, 導致著色器編譯的時間後移, Impeller 通過在編譯期完成 GLSL 至 MSL 的轉換,在 iOS 平台上可以直接使用 MetalLib 構建著色器機器碼,並且引入確定性的快取策略來提升渲染性能表現。隨著今年 WWDC 中 Apple 補齊了離線構建 Metal Binary Archive 的能力, Metal 3 已經具備了全場景下高性能渲染的能力。Impeller 作為 Flutter 獨佔的渲染方案  沒有 Skia 的歷史負擔  更容易充分利用 Apple 的技術優化,這意味著 Impeller 的性能表現還有進一步提升的可能。

Impeller 目前使用了基於 libtess2 的三角剖分方案, 根據社區的 RoadMapImpeller 還會繼續探索 GPU 剖分等高階的三角化方案用來替換陳舊的 libtess2 實現。Impeller 總體是一個移動優先的渲染解決方案,目前已經具備 GL 和 Metal 兩個完整的渲染後端實現  Vulkan 的支援目前正在進行中,官方目前沒有支援 CPU 軟繪的計劃。Impeller 短期內不會也沒有可能作為 Skia 的替代品, 不過其優秀的架構設計使其依然有潛力剝離出 Flutter 成為一個獨立的渲染解決方案, 未來可能會對基於 Skia 的自繪方案形成挑戰, 我們對 Impeller 後續的發展也會持續保持關注。