评论

收藏

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

游戏开发 游戏开发 发布于:2022-09-07 10:02 | 阅读数:326 | 评论:0

作者
魏国梁:字节 Flutter Infra 工程师, Flutter Member,长期专注 Flutter 引擎技术 
  袁  欣:字节 Flutter Infra 工程师, 长期关注渲染技术发展 
  谢昊辰:字节 Flutter Infra 工程师,Impeller Contributor
Impeller项目启动背景
20226 月在 Flutter 3.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-onset Jank 的本质是运行时着色器的编译行为阻塞了 Flutter Raster 线程对渲染指令的提交。在 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-onset Jank 的情况更加恶化,Warmup 方案的实现需要依赖 Skia 团队对 Metal 的预编译做支持,由于 Skia 团队的排期问题,一度导致 Warmup 方案在 Metal 后端上不可用。与此同时社区中对 iOS 平台 Jank 问题的反馈更加强烈,社区中一度出现屏蔽 MetalFlutter 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 许可证下开源的三维计算机图形库,以开源形式实现了 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 (Swift IL) 到 Low-Level IR (LLVM IR)的转换。

随着 Vulkan 的发展, OpenGL 4.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 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 虽然允许开发者手动管理着色器缓存,却依然需要通过在运行时搜集机器码来构建,无法保证应用初次安装时的使用体验。在 2022WWDC 中,Metal 3 终于弥补了这个遗留的缺陷,为开发者带来了在离线构建 Metal Binary Archive 的能力:

构建离线 Metal Binary Archive 需要使用一种全新的配置文件 Pipeline Script,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 可以构造出各种复杂的图形,著名的老虎