作者

  • 魏国梁:字节FlutterInfra工程师,Flutter Member,长期专注Flutter引擎技术
  • 袁 欣:字节FlutterInfra工程师, 长期关注渲染技术发展
  • 谢昊辰:字节FlutterInfra工程师,Impeller Contributor

Impeller项目启动背景

20226月在Flutter3.0版本中Google官方正式将渲染器Impeller从独立仓库中合入Flutter Engine主干进行迭代,这是2021Flutter团队推动重新实现Flutter渲染后端以来,首次正式明确了Impeller未来代替Skia作为Flutter主渲染方案的定位。Impeller的出现是Flutter团队用以彻底解决SkSLSkia Shading Language) 引入的Jank问题所做的重要尝试。官方首次注意到FlutterJank问题是在2015年,当时推出的最重要的优化是对Dart代码使用AOT编译优化执行效率。在Impeller出现之前,Flutter对渲染性能的优化大多停留在Skia上层,如渲染线程优先级的提升,在着色器编译过久的情况下切换CPU绘制等策略性优化。

Jank类型分为两种:首次运行卡顿(Early-onset Jank)和非首次运行卡顿, Early-onsetJank的本质是运行时着色器的编译行为阻塞了FlutterRaster线程对渲染指令的提交。在Native应用中,开发者通常会基于UIkit等系统级别的UI框架开发应用,极少需要自定义着色器,Core Animationframework使用的着色器在OS启动阶段就可以完成编译,着色器编译产物对所有的app而言全局共享,所以Native应用极少出现着色器编译引起的性能问题 更常见的是用户逻辑对UI线程过度占用 官方为了优化Early-onset Jank,推出了SkSLWarmup方案,Warmup本质是将部分性能敏感的SkSL生成时间前置到编译期,仍然需要在运行时将SkSL转换为MSL才能在GPU上执行。Warmup方案需要在开发期间在真实设备上捕获SkSL导出配置文件 在应用打包时通过编译参数可以将部分SkSL预置在应用中。此外由于SkSL创建过程中捕获了用户设备特定的参数,不同设备Warmup配置文件不能相互通用,这种方案带来的性能提升非常有限。

2019Apple宣布在其生态中废弃OpenGL后,Flutter迅速完成了渲染层对Metal的适配。与预期不符的是,Metal的切换使得Early-onsetJank的情况更加恶化,Warmup方案的实现需要依赖Skia团队对Metal的预编译做支持,由于 Skia团队的排期问题,一度导致Warmup方案在Metal后端上不可用。与此同时社区中对iOS平台Jank问题的反馈更加强烈,社区中一度出现屏蔽MetalFlutter Engine Build,回退到GL后端虽然能一定程度改善首帧性能但是在iOS平台上会出现视觉效果的退化,与之相对的是,由于Android平台上拥有iOS缺失的着色器机器码的缓存能力,Android平台出现Jank的概率比iOS低很多。

除了社区中出现的通用问题外,Flutterinfra团队也经常收到字节内部业务方遇到的Jank问题的反馈,反馈较集中的有转场动画首次卡顿、列表滚动过程中随机卡顿等场景:

转场动画触发的着色器编译,耗时~100ms

列表滑动过程中随机触发的着色器编译,耗时~28ms

在这篇文章中,我们尝试从Metal着色器编译方案,矢量渲染器原理和FlutterEngine渲染层的接口设计三个维度去探究Impeller想要解决的问题和渲染器背后的相关技术。

Metal Shader Compilation演进

一般而言,不同的渲染后端会使用独立的着色器语言,与JavaScript等常见脚本语言的执行过程类似,不同语言编写的着色器程序为了能在GPU硬件上执行,需要经历完整的lexical analysis / syntax analysis/ AbstratSyntax Tree(抽象语法树,下文简称AST)构建,IR优化,binary generation的过程。着色器的编译处理是在厂商提供的驱动中实现,其中具体的实现对上层开发者并不可见。Mesa是一个在MIT许可证下开源的三维计算机图形库,以开源形式实现了OpenGLapi接口。通过Mesa中对GLSL的处理可以观察到完整的着色器处理流水线。如下图所示,上层提供的GLSL源文件被Mesa处理为AST后首先会被编译为GLSL IR, 这是一种High-Level IR,经过优化后会生成另一种Low-Level IRNIRNIR结合当前GPU的硬件信息被处理为真正的可执行文件。不同的IR用来执行不同粒度的优化操作,通常底层IR更面向可执行文件的生成,而上层IR可以进行诸如dead code elimination等粗粒度优化。常见的高级语言(如Swift)的编译过程也存在High-Level IR(SwiftIL) 到Low-Level IR(LLVMIR)的转换。

随着Vulkan的发展,OpenGL4.6标准中引入了对SPIR-V格式的支持。SPIR-VStandard Portable Intermediate Representation)是一种标准化的IR,统一了图形着色器语言与并行计算(GPGPU应用)领域。它允许不同的着色器语言转化为标准化的中间表示,以便优化或转化为其他高级语言,或直接传给VulkanOpenGLOpenCL驱动执行。SPIR-V消除了设备驱动程序中对高级语言前端编译器的需求,大大降低了驱动程序的复杂性,使广泛的语言和框架前端能够在不同的硬件架构上运行。Mesa中使用SPIR-V格式的着色器程序可以在编译时直接对接到NIR层,缩短着色器机器码编译的开销, 有助于系统渲染性能的提升。

Metal应用中, 使用Metal Shading Language(以下简称MSL)编写的着色器源码首先被处理为AIR (Apple IR) 格式的中间表示。如果着色器源码是以字符形式在工程中引用,这一步会在运行时在用户设备上进行,如果着色器被添加为工程的Target,着色器源码会在编译期在Xcode中跟随项目构建生成MetalLib: 一种设计用来存放AIR的容器格式。随后AIR会在运行时,根据当前设备GPU的硬件信息,被Metal Compiler ServiceJIT编译为可供执行的机器码。相比源码形式,将着色器源码打包为MetalLib有助于降低运行时生着色器机器码的开销。着色器机器码的编译会在每一次渲染管线状态对象(P ipelineS tateO bject,下文简称PSO)创建时发生,一个PSO持有当前渲染管线关联的所有状态,包含光栅化各阶段的着色器机器码,颜色混合状态,深度信息,模版掩码状态,多重采样信息等等。PSO通常被设计为一个imutable object(不可变对象),如果需要更改PSO中的状态需要创建一个新的PSO拷贝。

由于PSO可能在应用生命周期中多次创建, 为了防止着色器的重复编译开销,所有编译过的着色器机器码会被Metal缓存用来加速后续PSO的创建过程,这个缓存称为Metal Shader Cache,完全由Metal内部管理,不受开发者控制。应用通常会在启动阶段一次性创建大量PSO对象,由于此时Metal中没有任何着色器的编译缓存,PSO的创建会触发所有的着色器完整执行从AIR到机器码的编译过程,整个集中编译阶段是一个CPU密集型操作。在游戏中通常在玩家进入新关卡前利用Loading Screen准备好下一场景所需的PSO,然而常规app中用户的预期是能够即点即用,一旦着色器编译时间超过16ms,用户就会感受到明显的卡顿和掉帧。

Metal 2中,Apple首次为开发者引入了手动控制着色器缓存的能力:Metal Binary ArchiveMetal Binary Archive的缓存层次位于Metal Shader Cache 之上, 这意味着Metal Binary Archive中的缓存在PSO创建时会被优先使用 运行时,开发者可以通过MetalPipelineManager手动将性能敏感的着色器函数添加至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虽然允许开发者手动管理着色器缓存,却依然需要通过在运行时搜集机器码来构建,无法保证应用初次安装时的使用体验。在2022WWDC中,Metal 3终于弥补了这个遗留的缺陷,为开发者带来了在离线构建Metal Binary Archive的能力:

构建离线Metal Binary Archive需要使用一种全新的配置文件PipelineScript,Pipeline Script其实是Pipeline State Descriptor的一种JSON表示,其中配置了PSO创建所需的各种状态信息,开发者可以直接编辑生成,也可以在运行时捕获PSO获得。给定Pipeline ScriptMetalLib,通过Metal工具链提供的metal命令即可离线构建出包含着色器机器码的Metal Binary ArchiveMetal Binary Archive中的机器码可能会包含多种GPU架构 由于Metal Binary Archive需要内置在应用中提交市场 开发者可以综合考虑包体积的因素剔除不必要的架构支持。

通过离线构建Metal Binary Archive,着色器编译的开销只存在于编译阶段,应用启动阶段PSO的创建开销大大降低。Metal Binary Archive不止可以优化应用的首屏性能, 真实的业务场景下,一些PSO对象会迟滞到具体页面才会被创建,触发新的着色器编译流程。一旦编译耗时过长,就会影响当前RunLoopMetal绘制指令的提交,Metal Binary Archive可以确保在应用的生命周期内, 核心交互路径下的着色器缓存始终为可用状态,将节省的CPU时间片用来处理与用户交互强相关的逻辑, 大大提升应用的响应性和使用体验。

矢量渲染基础概念

矢量渲染泛指在平面坐标系内通过组装几何图元来生成图像信息的手段,通过定义一套完整的绘制指令,可以在不同的终端上还原出不失真的图形, 任何前端的视窗都可以被看作一个2D平面的矢量渲染画布,ChromeAndroid渲染系统就是基于Google2D图形库Skia构建。对应用开发而言,矢量渲染技术也扮演重要角色,如文本 / 图表 / 地图 /SVG/Lottie等都依赖矢量渲染能力来提供高品质的视觉效果。

矢量渲染的基础单元是Path(路径),Path可以包含单个或多个Contour(轮廓),Contour在一些渲染器中也称为SubPathContour由连续的Segment(直线/高阶贝塞尔曲线)组成,标准的几何构型(圆形/矩形)均可被视为一种特殊的Path,一些特殊的Path可以包含坑洞或者自交叉(如五角星⭐️),这类Path的处理需要一些特殊的方案。围绕Path可以构造出各种复杂的图形,著名的老虎?SVG一共包含480条Path,通过对其中不同Path的描边和填充,可以呈现出极富表现力的视觉效果:

高阶贝塞尔通过起始点和额外的控制点来定义一条曲线, 在将这样的抽象曲线交付给后端进行渲染前,我们需要首先要对贝塞尔曲线做插值来近似模拟这条曲线,这个操作通常称为FlattenGPU真实渲染的是一由组离散的点来近似模拟的曲线。根据Path定义的差异, 这一组离散的点会构成不同种类的多边形,对Path的处理简化为了对多边形的处理,我们以一个简单的凹多边形为例来了解Path的描边和填充操作是如何实现的:

多边形的描边操作,由于描边宽度的存在,描边的真实着色区域会有一半落在Path定义的区域之外。遍历多边形的外边缘的每条边,根据每条边两侧的顶点,描边宽度以及边缘的斜率可以组装出一组模拟描边行为的三角形图元,如上图所示:一个方向上的描边是由两个相结合的三角形构成。针对不同的Line Join风格,结合处有可能需要做不同的处理, 但是原理类似。将描边的三角形提交GPU可以渲染得到正确的描边效果,除了纯色的描边,结合不同的着色器可以实现渐变和纹理的填充效果。多边形的填充方法相比描边更加复杂,目前主流的矢量渲染器有两种不同的实现思路:

基于模版掩码的填充( NanoVG

基于模版掩码的填充是在OpenGL红宝书中所描述的一种填充多边形的经典方法。Skia在简单的场景下也会使用这种方法做多边形的填充。这种绘制方法分为两步:首先利用StencilBuffer来记录实际绘制区域,这一步只写入StencilBuffer,不操作Color Attachment,然后再进行一次绘制,通过StencilBuffer记录的模版掩码,只向特定的像素位置写入颜色信息。通过图例可以更直观的了解这个过程:第一步,打开StencilBuffer的写入开关,使用GL_TRIANGLE_FAN形式绘制所有的顶点,GL会自动根据顶点索引组装两组三角形基元0 -> 1 -> 20 -> 2 -> 3GL中通常指定逆时针方向为三角形片元的正面, 0 -> 1 -> 2 三角形所包围的区域在StencilBuffer中做 +1 操作, 由于顶点3是多边形的凹点,0 -> 2 -> 3三角形的环绕数被翻转为了顺时针,我们可以在StencilBuffer中对顺时针包裹的区域做 -1 操作, 此时StencilBuffer中所有标记为 1 的像素就是我们所需要的绘制区域,再次提交相同的顶点进行绘制,打开颜色写入,就可以得到正确的绘制结果。这种方法巧妙的利用了凹多边形会改变局部三角形环绕方向的特性。

模版掩码可以正确处理复杂的多边形, 但是由于需要进行两段式的绘制, 对于复杂的多边形性能绘制性能瓶颈较明显, 此外StencilBuffer等操作都是由GL驱动层所实现,几乎不可能进行任何的性能优化, 这种绘制方法常在一些追求小尺寸的矢量渲染器中使用(NanoVG), 在一些文章中通常也被称为Stencil & Cover

基于三角剖分的填充( Skia

Skia中对多边形的渲染是由TesselationTriangulation两步构成,Tesselation原意指在多边形中新增顶点来构造更加细分的几何图元,Triangulation是指连接多边形自身的顶点构造可以填充满自身的若干三角图元(不增加顶点的情况下)Triangulation可以认为是Tessellation的一种特例,在Skia 中描述的Tessellation其实是指一种对复杂多边形的拆分操作,了解多边形的Triangulation首先我们需要引入单调多边形的概念:

对于任意一个多边形p而言, 如果存在一条直线ll的垂线与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上执行,录制回放能力可以避免绘制操作占用宝贵的主线程时间片。DisplayListSkPicture的作用类似,那么为什么还需要将SkPictureDisplayList做迁移 ?SkiaFlutter来说属于第三方依赖,涉及到SkPicture的优化一般需要由Skia团队支持,对Skia团队而言 SkPicture的能力不只服务于Flutter业务,Flutter团队如果修改SkPicture的源码会对Skia的代码有比较大的入侵, 而为了解决长期遗留的Jank问题,Flutter团队又不得不考虑在SkPicture这一层进行优化 。20203月,liyuqian创建一个flutter issue中首次提出了DisplayList的设想,预期相较于SkPicture会有如下三个方面的优势:

  • DisplayList相比SkPicture有更高的可操作性去优化光栅化时期产生的缓存;
  • DisplayList有助于实现更好的着色器预热方案;
  • DisplayList相比SkPicture可以更好的对每一帧进行性能分析;

FlutterRoadMap明确了Impeller的替换目标后,DisplayList能更好的实现Flutter Engine层对渲染器的解耦,从而保障后续渲染层能无缝的从Skia迁移到Impeller中。在最新的Flutter 3.0代码,DisplayList相关的代码位于github.com/flutter/eng…

DisplayList作为Recoder的过程和使用SkPicture差别不大,核心是在canvas.cc中进行了切换:

//https://github.com/flutter/engine/blob/main/lib/ui/painting/canvas.cc#L260//lib/ui/painting/canvas.ccvoidCanvas::drawRect(doubleleft,doubletop,doubleright,doublebottom,constPaint&paint,constPaintData&paint_data){if(display_list_recorder_){paint.sync_to(builder(),kDrawRectFlags);builder()->drawRect(SkRect::MakeLTRB(left,top,right,bottom));}//3.0因为默认开启了DisplayList作为Recorder所以下面的已经删除//elseif(canvas_){//SkPaintsk_paint;//canvas_->drawRect(SkRect::MakeLTRB(left,top,right,bottom),//*paint.paint(sk_paint));//}}//lib/ui/painting/canvas.hDisplayListBuilder*builder(){returndisplay_list_recorder_->builder().get();}复制代码

从上面的代码可以看出,是在CanvasDrawOp中进行了DisplayList还是SkPicture的选择,一次DrawOp的录制过程如下图所示:

DisplayList Record DrawOp 过程图中Push的操作,DrawRectOp定义在display_list_ops.h中:

//https://github.com/flutter/engine/blob/main/display_list/display_list_ops.h#L554//display_list/display_list_ops.h#defineDEFINE_DRAW_1ARG_OP(op_name,arg_type,arg_name)\structDraw##op_name##Opfinal:DLOp{\staticconstautokType=DisplayListOpType::kDraw##op_name;\\explicitDraw##op_name##Op(arg_typearg_name):arg_name(arg_name){}\\constarg_typearg_name;\\voiddispatch(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)#undefDEFINE_DRAW_1ARG_OP复制代码

将宏定义展开可以看到如下定义, 这里DrawRectOp是一种单参数DLOpDrawRectOp中的dispatch方法会将drawRect操作派发给dispatcher来实际执行

structDrawRectOpfinal:DLOp{staticconstautokType=DisplayListOpType::kDrawRect;explicitDrawRectOp(arg_typearg_name):rect(rect){}constSkRectrect;voiddispatch(Dispatcher&dispatcher)const{dispatcher.drawRect(arg_name);}}复制代码

LLDB中可以打印出DrawRectOp的相关信息:

Push中的Push函数的实现如下,storage_ 是一个一维数组,同来存储DrawOp,在添加元素前会先进行容量的判断,是否需要扩容,随后创建DrawRectOp并对Type和 参数rect进行赋值,并累加 op_count_,完成DrawOp的添加。

//https://github.com/flutter/engine/blob/main/display_list/display_list_builder.cc#L27//display_list/display_list_builder.ccvoid*DisplayListBuilder::Push(size_tpod,intop_inc,Args&&...args){size_tsize=SkAlignPtr(sizeof(T)+pod);//扩容if(used_+size>allocated_){//NextgreatermultipleofDL_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_);//如newDrawRectOpautoop=reinterpret_cast(storage_.get()+used_);used_+=size;new(op)T{std::forward(args)...};op->type=T::kType;op->size=size;op_count_+=op_inc;returnop+1;}复制代码

DisplayList记录DrawOp的流程如下:

  • 首先通过调用BeginRecording创建DisplayListCanvasRecoder(继承自SkCanvasNoDraw) 之后创建核心类 DisplayListBuilder并返回Canvas给应用层;
  • 应用层通过Canvas调用如drawRect方法,将会被以DrawRectOp记录在DisplayListBuilderstorage_ 中;
  • 最后调用endRecordingDisplayListBuilderstorage_ 转移到DisplayList中,后面在SceneBuilder阶段,DisplayList会被封装到DisplayListLayer中;

DisplayList中的几个核心概念:DisplayListCanvasRecorder作为命令记录的载体,其中包含了DisplayListBuilderDisplayListBuilderstorage是真实记录DLOp的载体,DisplayList将会记录DisplayListBuilderstorage,并最终被包裹在DisplayListLayer中,作为记录DLOp的载体。DisplayListCanvasDispatcher作为最后派发至SkCanvas或者ImpellerWrapper层。

Impeller 渲染流程和架构设计Impeller 概览

Impeller的目标是为Flutter提供具备predictable performance的渲染支持,Skia的渲染机制需要应用在启动过程中动态生成SkSL, 这一部分着色器需要在运行时转换为MSL,才能进一步被编译为可执行的机器码,整个编译过程会对Raster线程形成阻塞。Impeller放弃了使用SkSL转而使用GLSL4.6作为上层的着色器语言,通过Impeller内置的ImpellerC编译器,在编译期即可将所有的着色器转换为Metal Shading language, 并使用MetalLib格式打包为AIR字节码内置在应用中。Impeller的另一个优势是大量使用Modern Graphics APIsMetal的设计可以充分利用CPU多核优势并行提交渲染指令, 大幅减少了驱动层对PSO的状态校验, 相对于GL后端仅仅将上层渲染接口的调用切换为Metal就可以为应用带来约~10% 的渲染性能提升。

在一个Flutter应用中,RenderObjectPaint操作最终会转换为Canvasdraw 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绘制流程

FlutterEngine层的LayerTree在被Impeller绘制前需要首先被转换为EntityPassTree UI线程在接收到v-sync信号后会将 LayerTreeUI线程提交到Raster线程,在Raster线程中会遍历LayerTree的每个节点并通过DisplayListRecorder记录各个节点的绘制信息以及saveLayer操作,LayerTree中可以做可以Raster Cache的子树其绘制结果会被缓存为位图,DisplayListRecorder会将对应子树的绘制操作转换为drawImage操作,加速后续渲染速度。DisplayListRecorder完成指令录制后,就可以提交当前帧。DisplayListRecorder 中的指令缓存会被用来创建DisplayList 对象DisplayListDisplayListDispatcher的实现者(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的概念,CommandQueueGPU 数量一一对应,CommandQueue中可包含一个或者多个CommandBufferCommandBuffer是实际绘制指令RenderCommand存放的队列,简单的应用可以只包含一个CommandBuffer, 不同的线程可以通过持有不同CommandBuffer来加速RenderCommand的提交。RenderCommandRenderCommandEncoder 的 Encode操作产生,RenderCommandEncoder定义了此次绘制结果的保存方式 绘制结果的像素格式以及绘制开始或结束时Framebuffer attachmement所需要做的操作(clear / store),RenderCommand包含了最终交付给Metal的真实drawcall操作。

Entity中的Command转化为真正的MTLRenderCommand 时, 还携带了一个重要的信息:PSO*。Entity*从DisplayList中继承的绘制状态最终会变为MTLRenderCommand关联的PSO ,MTLRenderCommand被消费时Metal驱动层会首先读取PSO调整渲染管线状态,再执行着色器进行绘制,完成当前的绘制操作

ImpellerC 编译器设计

ImpellerCImpeller内置的着色器编译解决方案,源码位于Impellercompiler目录下 ,它能够在编译期将Impeller上层编写的glsl源文件转化为两个产物:1. 目标平台对应的着色器文件;2. 根据着色器uniform信息生成的反射文件,其中包含了着色器uniformstruct布局等信息。反射文件中的struct类型作为model层,使得上层使用无需关心具体后端的uniform赋值方式,极大地增强了Impeller的跨平台属性,为编写不同平台的着色器代码提供了便利。

在编译FlutterEngine工程中Impeller部分时,gn会首先将compiler 目录下的文件编译出为ImpellerC可执行文件,再使用ImpellerCentity/content/shaders目录下的所有着色器进行预处理。GL后端会将着色器源码处理为hex格式并整合到一个头文件中, 而Metal后端会在GLSL完成MSL的转译后进一步处理为MetalLib。

ImpellerC在处理glsl源文件时,会调用shadercglsl文件进行编译。shadercGoogle维护的着色器编译器,可以glsl源码编译为SPIR-Vshaderc的编译过程使用了glslang 和SPIRV-Tools两个开源工具:glslangglsl的编译前端 负责将 glsl处理为ASTSPIRV-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创建目标平台的CompilerBackendMSLCompiler/GLSLCompiler/SKSLCompiler),Compiler Backend中封装了目标平台着色器语言的具体转译逻辑 。同时SPIR-V Cross会从SPIR-V IR中提取Uniform数量,变量类型和偏移值等反射信息,

structShaderStructMemberMetadata{ShaderTypetype;//thedatatype(bool,int,float,etc.)std::stringname;//theuniformmembername"frame_info.mvp"size_toffset;size_tsize;};复制代码

Reflector在得到这些信息后,会对内置的 .h.cc模版进行填充,得到可供Impeller引用的 .h.cc文件,上层可以反射文件的类型方便的生成数据memcpy到对应的buffer中实现与着色器的通讯。对于MetalGLES3来说,由于原生支持UBO,最终会通过对应后端提供的UBO接口来实现 传值,对于不支持UBOGLES2来说,对UBO的赋值需要转换为glUniform* 系列apiUniform中每个字段的单独赋值,在shader program link后,Impeller在运行时通过glGetUniformLocation得到所有字段在buffer中的位置,与反射文件中提取出的偏移值结合,Impeller就可以得到每个Uniform字段的位置信息,这个过程会在Imepller Context创建时生成一次,随后Impeller会维护Uniform字段的信息。对于上层来说,不管是GLES2还是其他后端, 通过Reflector与着色器的通讯过程都是一样的。

完成着色器转译和反射文件提取后,就可以实际执行uniform数据的绑定,Entity在触发绘制操作时会首先调用ContentRender函数, 其中会创建一个供Metal消费的Command对象,Command会提交到RenderPass中等待调度,uniform数据的绑定发生在Command 创建这一步。如下图所示:VS::FrameInfoFS::GradientInfo是反射生成的两个Struct类型, 初始化VS::FrameInfoFS::GradientInfo的实例并赋值后,通过VS::BindFrameInfoFS::BindGradientInfo函数即可实现数据和uniform的绑定。

VS::FrameInfoframe_info;frame_info.mvp=Matrix::MakeOrthographic(pass.GetRenderTargetSize())*entity.GetTransformation();FS::GradientInfogradient_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();Commandcmd;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));returnpass.AddCommand(std::move(cmd));复制代码

LinearGradientContents Render函数实现

Impeller完整的着色器处理流水线如下图所示:

总结

ImpellerFlutter为了治理SkSL编译耗时引入的的性能问题所做的重要尝试,Skia的渲染机制需要在运行时动态创建SkSL, 导致着色器编译的时间后移,Impeller通过在编译期完成GLSLMSL的转换,在iOS平台上可以直接使用MetalLib构建着色器机器码,并且引入确定性的缓存策略来提升渲染性能表现。随着今年WWDCApple补齐了离线构建Metal Binary Archive的能力,Metal 3已经具备了全场景下高性能渲染的能力。Impeller作为Flutter独占的渲染方案 没有Skia的历史负担 更容易充分利用Apple的技术优化,这意味着Impeller的性能表现还有进一步提升的可能。

Impeller目前使用了基于libtess2的三角剖分方案, 根据社区的RoadMapImpeller还会继续探索GPU剖分等高阶的三角化方案用来替换陈旧的libtess2实现。Impeller总体是一个移动优先的渲染解决方案,目前已经具备GLMetal两个完整的渲染后端实现 Vulkan的支持目前正在进行中,官方目前没有支持CPU软绘的计划。Impeller短期内不会也没有可能作为Skia的替代品, 不过其优秀的架构设计使其依然有潜力剥离出Flutter成为一个独立的渲染解决方案, 未来可能会对基于Skia的自绘方案形成挑战, 我们对Impeller 后续的发展也会持续保持关注。