深入解析Flutter下一代渲染引擎Impeller
Impeller项目启动背景
2022 年 6 月在 Flutter 3.0 版本中 Google 官方正式将渲染器 Impeller 从独立仓库中合入 Flutter Engine 主干进行迭代,这是 2021 年 Flutter 团队推动重新实现 Flutter 渲染后端以来,首次正式明确了 Impeller 未来代替 Skia 作为 Flutter 主渲染方案的定位。Impeller 的出现是 Flutter 团队用以彻底解决 SkSL(Skia 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 低很多。
列表滑动过程中随机触发的着色器编译,耗时~28ms
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 :NIR,NIR 结合当前 GPU 的硬件信息被处理为真正的可执行文件。不同的 IR 用来执行不同粒度的优化操作,通常底层 IR 更面向可执行文件的生成,而上层 IR 可以进行诸如 dead code elimination 等粗粒度优化。常见的高级语言(如 Swift )的编译过程也存在 High-Level IR (Swift IL) 到 Low-Level IR (LLVM IR)的转换。
在 Metal 2 中, Apple 首次为开发者引入了手动控制着色器缓存的能力:Metal Binary Archive。Metal 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 的能力:
矢量渲染基础概念
高阶贝塞尔通过起始点和额外的控制点来定义一条曲线, 在将这样的抽象曲线交付给后端进行渲染前,我们需要首先要对贝塞尔曲线做插值来近似模拟这条曲线,这个操作通常称为 Flatten , GPU真实渲染的是一由组离散的点来近似模拟的曲线。根据 Path 定义的差异, 这一组离散的点会构成不同种类的多边形,对 Path 的处理简化为了对多边形的处理,我们以一个简单的凹多边形为例来了解 Path 的描边和填充操作是如何实现的:
基于模版掩码的填充(NanoVG)
基于三角剖分的填充(Skia)
对于任意一个多边形 p 而言, 如果存在一条直线 l, l 的垂线与 p 相交的部分都在 p 的内部, 那么称多边形 p 是相对于 l 的单调多边形。单调多边形的单调性是相对于某一特定方向而言,针对上图的示例我们可以很容易找到一个方向的直线作为反例。利用单调多边形在 l 方向上的左右两个极点可以把多边形进一步分拆为上下两条边,每条边上的顶点在 l 方向上会确保是有序的,这个特性可以用来实现剖分算法。
以下图中的凹多边形为例子,复杂多边形的完整处理思路是:首先使用 Tesselation 算法将其拆分为若干个单调多边形(下图中两个蓝色区域),通常会在多边形的凹点进行拆分,得到一组单调多边形的集合后, 再分别对每一个单调多边形进行三角化,单调多边形的 Triangulation 算法比较著名的有 *EarCut*, 也有一些实现如 libtess2 可以同时对复杂多边形进行 Tesselation / Triangulation 两步操作, libtess2 使用 Delaunay 算法来对单调多边形实现剖分, Delaunay 算法可以避免剖分出现过于狭长的三角形。无论使用何种方案,最终的产物都是能够直接交付给 GPU 进行渲染的三角形 Mesh 集合。
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 相关的代码位于https://github.com/flutter/engine/tree/main/display_list 中,
DisplayList 作为 Recoder 的过程和使用 SkPicture 差别不大,核心是在 canvas.cc 中进行了切换:
// https://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();
}
// https://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 的添加。
// https://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;
}
首先通过调用 BeginRecording 创建 DisplayListCanvasRecoder (继承自 SkCanvasNoDraw) 之后创建核心类 DisplayListBuilder 并返回 Canvas 给应用层; 应用层通过 Canvas 调用如 drawRect 方法,将会被以 DrawRectOp 记录在 DisplayListBuilder 的 storage_ 中; 最后调用 endRecording 将 DisplayListBuilder 的 storage_ 转移到 DisplayList 中,后面在 SceneBuilder 阶段,DisplayList 会被封装到 DisplayListLayer 中;
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 中最关键的组成部分是 Contents。Contents 中持有了着色器的编译产物, 被用来实际控制当前 Entity 的绘制效果, Contents 有多种子类,来承接填充/纹理着色等不同的绘制任务。Renderer 层可以理解为与具体渲染 api 沟通的桥梁,Renderer 会将 Entity 中的信息(包含Contents 中保存的着色器句柄)转换为 *Metal/*OpenGL 等渲染后端的具体 api 调用。
Impeller 绘制流程
Metal 在上层为设备的 GPU 硬件抽象了 CommandQueue 的概念,CommandQueue 与 GPU 数量一一对应,CommandQueue 中可包含一个或者多个 CommandBuffer。CommandBuffer 是实际绘制指令 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-V。shaderc 的编译过程使用了 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 Backend(MSLCompiler / 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 是 Flutter 为了治理 SkSL 编译耗时引入的的性能问题所做的重要尝试,Skia 的渲染机制需要在运行时动态创建 SkSL,导致着色器编译的时间后移, Impeller 通过在编译期完成 GLSL 至 MSL 的转换,在 iOS 平台上可以直接使用 MetalLib 构建着色器机器码,并且引入确定性的缓存策略来提升渲染性能表现。随着今年 WWDC 中 Apple 补齐了离线构建 Metal Binary Archive 的能力, Metal 3 已经具备了全场景下高性能渲染的能力。Impeller 作为 Flutter 独占的渲染方案, 没有 Skia 的历史负担, 更容易充分利用 Apple 的技术优化,这意味着 Impeller 的性能表现还有进一步提升的可能。
Impeller 目前使用了基于 libtess2 的三角剖分方案, 根据社区的 RoadMap,Impeller 还会继续探索 GPU 剖分等高阶的三角化方案用来替换陈旧的 libtess2 实现。Impeller 总体是一个移动优先的渲染解决方案,目前已经具备 GL 和 Metal 两个完整的渲染后端实现, Vulkan 的支持目前正在进行中,官方目前没有支持 CPU 软绘的计划。Impeller 短期内不会也没有可能作为 Skia 的替代品, 不过其优秀的架构设计使其依然有潜力剥离出 Flutter 成为一个独立的渲染解决方案, 未来可能会对基于 Skia 的自绘方案形成挑战, 我们对 Impeller 后续的发展也会持续保持关注。
作者信息
魏国梁:字节 Flutter Infra 工程师, Flutter Member,长期专注 Flutter 引擎技术 袁 欣:字节 Flutter Infra 工程师, 长期关注渲染技术发展 谢昊辰:字节 Flutter Infra 工程师,Impeller Contributor