把 Impeller 理解成「更快的 Skia」会在打开开关之后立刻失效。它要消灭的不是平均填充率,而是 第一次画某种效果时,驱动现场编译着色器那几十毫秒。Skia 的能力面是开放的:没见过的 Path + 模糊 + 高级混合,可以在运行时特化出一条新 GPU 程序。移动驱动上,这一特化就是一次掉帧,甚至一次连续掉帧。

读完你应该能独立回答三个问题:为什么同一动画「第一次卡、第二次顺」、Impeller 把编译挪到哪一个生命周期、打开 Impeller 之后 GPU 条仍然高应该去减什么而不是怀疑着色器缓存。

1. 先画边界:Impeller 不管 Dart 对象

Impeller 活在 Raster Task Runner 上。它吃的是 UI 线程交过来的 DisplayList / Layer 树,吐的是 Metal、Vulkan(或 GLES 回退)的 command buffer。它 不 跑 build(),不 持有 Element,不 解引用 Dart 堆上的 Widget。

dart:ui 的 Canvas.drawPath 在 UI 线程只是往 Picture 记账。记账便宜;贵的是 Raster 侧把这条 path 变成覆盖率:三角化、stencil-then-cover、再采样。所以 GPU 掉帧和 Dart 掉帧是两种病。FrameTiming.buildDuration 对前者不敏感。

不变量:着色器程序在打包时就该存在;运行时原则上不再让驱动编译源码。 违反这条,Impeller 就退回 Skia 的失败模式。

2. Skia 在移动端到底卡在哪

Skia 是通用 2D 引擎。遇到新的绘制组合,它生成 SKSL,再交给后端编成 Metal/GLSL。桌面驱动往往有磁盘缓存,一次编译终身受益。移动端的现实是:

  • GPU 驱动编译器慢,且和厂商强相关。
  • 缓存键包含设备、驱动版本、绘制组合。换机、升级系统、换一个没录到的效果,缓存失效。
  • 动画中途第一次出现模糊、虚线、高级 blend,编译发生在 当前帧的 Raster 线程。这一帧的 rasterDuration 从 8ms 变成 40ms,用户只看到「卡一下」。

Skia warmup 包试图提前录一遍动画,把当时用到的 shader 存下来。它是缓存策略,不是架构。没录到的组合、没覆盖的 GPU,warmup 帮不上。

3. 封闭图元:把 2D 收敛成有限操作

Impeller 的第一刀是缩小问题域。圆、矩形、RRect、路径填充/描边、纹理采样、文字(字形图集)、渐变、模糊、阴影、裁剪、混合,被收成一套 封闭的 Contents / Geometry。

封闭的含义是:任意 Flutter 2D API,最后都要落到这套图元上。多一个开放的「用户运行时特化」,就多一个必须现场编译的变体。少一个图元,某个 Canvas API 就要用更贵的通用路径模拟(例如用离屏 + 模糊近似阴影)。

路径是最危险的开放面。Impeller 对复杂 Path 常用 stencil-then-cover:先把覆盖率打进模板,再一次性上色。它避免为每个新形状编一条专用 fragment shader,代价是模板缓冲带宽。简单矩形走更短的几何体,不走这条。

4. impellerc 与 PSO:编译发生在构建期

构建期 impellerc 把 Impeller 的着色器源编成目标后端的二进制:Metal 的 AIR/metallib、Vulkan 的 SPIR-V。这些产物打进 Engine 或应用包,随 AOT 快照一起分发。

运行时要的是 管线状态对象(PSO):顶点格式、混合模式、深度/模板、采样数、render pass 格式。着色器已经是二进制,创建 PSO 仍然有驱动开销,但比编译源码低一个数量级,而且可以在首帧前、空闲时预热。

// Dart 侧看不到 PSO。你能控制的是「画出哪种封闭操作」。
canvas.drawRRect(rrect, paint..color = color); // 走矩形/圆角短路径
canvas.saveLayer(bounds, Paint()..imageFilter = blur); // 强制一次离屏 pass

同一视觉,第一条往往是一条实体;第二条是「先画到中间纹理,再采样」。Impeller 消灭的是编译,消灭不了 pass 的数量。

和 Skia warmup 的本质差别:warmup 是「把见过的编好存着」;Impeller 是「没见过的也不现场编」。换 GPU 只要后端二进制还在包里,语义不变。

5. 从 DisplayList 到 Entity 的翻译

UI 线程交出的是 DisplayList(一组绘制命令 + 变换 + clip)。Raster 线程上 Impeller 把它译成 Entity 图:每个 Entity 带变换、混合、Clip 栈和一份 Contents。

翻译是一次遍历,不是着色器编译。然后按 render pass 组织:主屏 pass、离屏 pass(saveLayer、Backdrop、高级 blend)、字形/图集更新。Command buffer 在 Metal/Vulkan 上可以并行录制,不再有 OpenGL 那种全局状态机——这也是 Impeller 坚持现代 API 的原因。

UI 不碰 GPU 上下文;Raster 不跑 Dart。 DisplayList 跨线程移交是所有权转移,不是共享可变命令流。你在下一帧 setState 改 Canvas 内容,碰不到上一帧正在录的 buffer。

6. 线程、内存、显存各记各的账

几块互不混淆的资源:

  • Dart 堆:Picture 的 Dart 句柄、ui.Image 句柄。GC 管句柄,不管像素。
  • 原生堆:DisplayList 的指令、path 的点、glyph 缓存的 CPU 侧。
  • GPU 显存:纹理、MSAA、离屏 color/stencil、字形图集、PSO 池。

图片:压缩文件在 IO Runner 解码,像素上传常在 Raster。解码尺寸是宽 × 高 × 4,和 JPEG KB 无关。4000×3000 是 48MB 量级,Impeller 不会因为「没编译着色器」就让这张图变便宜。

字形图集涨到上限会重建。第一次画某语言、某 emoji 彩色字体,可能伴随一次图集扩容和上传。这表现为「首帧或第一次出中文时 Raster 尖刺」,不是 shader jank,工具上看是纹理上传。

7. Impeller 仍然贵的操作

打开 Impeller 之后掉帧,优先查这些,而不是怀疑「是不是没预编译全」:

操作为什么贵和着色器编译的关系
saveLayer / 模糊 / 阴影额外离屏 pass + 带宽无关
BackdropFilter读回 framebuffer,打断流水线无关
全屏 Opacity 动画整层离屏再混合无关
复杂 Path / 虚线stencil 或细分无关
超大图 / 未解码到位图上传和采样无关
Platform View打破「一条 Raster 画完」无关

文字测量仍在 UI 线程(LibTxt / HarfBuzz)。Impeller 只画已经 shaping 好的 glyph。build 里反复 TextPainter.layout 是 UI 病,换渲染器无效。

8. 平台边界:谁在用、谁还不是

iOS 上 Impeller 已成为默认,Metal 路径最完整。Android 上 Vulkan 是主路径,部分设备回退 GLES;回退不是「又变成 Skia」,但能力与驱动质量参差。桌面逐步切;Web 仍是 CanvasKit / Skwasm 那套 Skia 语义,不能拿移动端 Impeller 的结论去优 Web。

软件渲染、部分测试、旧 Embedder,仍可能走 Skia。混合 App 里旧 Engine 和新 Engine 并存时,不要假设「工程开了 Impeller 就全进程都是 Impeller」。

极端自定义着色器(FragmentProgram)有 Impeller 的运行时着色器路径,但那是显式出口,不是 Skia 式的隐式特化。用它等于你自己把「封闭图元」打开一个口,帧时间可预测性要你自己证明。

确认当前进程是不是 Impeller,不要靠感觉。iOS 上 Engine 日志和 debugPrint 抓不到 GPU 后端;Android 可用 Impeller 开关、--enable-impeller / --no-enable-impeller,以及 GPU 捕获里是否出现 impeller:: 的 pass 名。混合工程里旧 FlutterEngine 和新 Engine 并存时,一页 Impeller、一页 Skia 完全可能。把 Skia 的 warmup 包留在 Impeller 默认的产品里,是在治已经不存在的病。

首帧仍然可能尖刺,只是原因换了:PSO 第一次绑定、glyph atlas 从空扩到能装 CJK、第一张大纹理上传、swapchain 第一次建立。这些是资源生命周期,不是源码编译。预热手段也换了:让首个路由的静态页先画一遍常见 RRect/文字/图片,而不是录一份 SkSL warmup。预热发生在 Raster 空闲或启动动画之前,不要放在滑动热路径上。

MSAA、模板缓冲、离屏 color attachment 才是 Impeller 帧内的稳定成本。路径走 stencil-then-cover 时,模板附件的带宽按分辨率线性涨;模糊按半径采样邻域。把 3x 模糊从全屏收到 200×200 的卡片上,比纠结「要不要再编一条 shader」有效得多。

DisplayList 上的一条 drawPaint 全屏色,翻译成 Impeller 可能是一次 CoverGeometry + SolidColorContents,几乎是最便宜的 pass。同一视觉用一张 1×1 纹理拉伸、再套 ColorFilter,会多一次采样和可能的离屏。Framework 的 ColorFiltered、ShaderMask、BackdropFilter 在 Impeller 里各自对应不同的 Contents 和 clip 覆盖策略:ShaderMask 通常要把子树先画到中间纹理再 mask;ColorFiltered 有时能折叠进混合。看 Widget 名猜 GPU 成本会错,要看它最终有没有迫使 saveLayer 语义。

文字不是「一条 drawText shader」。LibTxt 在 UI 线程 shaping,Raster 侧把 glyph 送进 atlas。atlas 满了要扩容或淘汰,扩容是一次 CPU 上传。Emoji 彩色字体、可变字体、fallback 到第二张 face,都可能让 atlas 布局失效重建。这表现为「第一次出现某个字卡一下」,和 Skia 的 shader jank 症状相同、机理完全不同。用「预编译」去治字形图集,不会好。

混合模式里,SrcOver 是快路径;高级 blend(ColorBurn、Hue 等)往往要读 dst,等于局部 saveLayer。设计师随手一个「叠加」,可能把本可以合批的矩形拆成逐个离屏。能用 SrcOver + 预乘颜色解决的,不要进高级 blend。

Impeller 的 PSO 池按「着色器 + 混合 + 采样数 + 附件格式」做键。同一条 drawRRect,在不透明和半透明、有无 MSAA 下不是同一个 PSO。第一次走进半透明圆角,仍可能付一次创建成本,只是没有 GLSL 编译那几十毫秒。把主题从全不透明改成全局 0.99 opacity,等于让整页走另一组管线,首屏尖刺会回来。这是封闭集合内部的组合爆炸,仍然有限,但不是零。

调试时 GPU 捕获应能看到 pass 名和纹理尺寸。若一帧里出现多张与屏幕一样大的中间纹理,问题就是离屏图,不是「Impeller 没开好」。若捕获里根本没有 impeller 命名空间,你在错误的后端上优化。

包体积是离线编译的显性账单:Metal 的 metallib、Vulkan 的 SPIR-V 变体要打进 Engine。这是「所有设备预付」换「运行时不编译」。裁掉用不到的后端(纯 iOS 不必带一份给 GLES 的幻想)是 Embedder/构建的事,不是业务 Dart 的事。业务侧能做的仍然是少制造离屏,而不是少 import 某个 Widget 来减 shader。

9. 设计取舍(为什么是现在这样)

  • 封闭图元 + 离线编译:帧时间分布可预测。代价是包体积增加,以及「Skia 能随便特化、Impeller 还没做的效果」要用更贵的通用 pass 模拟。
  • 现代 API、无全局状态机:多线程录制、更清晰的资源生命周期。代价是 GLES/旧设备要单独回退,Embedder 更厚。
  • 不在 Dart 里暴露 PSO:业务代码稳定。代价是性能问题经常要同时看 Dart 火焰图和 GPU 捕获(Xcode GPU Frame Debugger / Android GPU Inspector)。
  • 保留 DisplayList 作为 UI↔Raster 契约:换 Skia/Impeller 不必改 Framework。代价是翻译层本身也有 CPU 开销,极端简单场景未必比老路径短。

这些取舍决定了「什么优化有意义」。给 Skia 写 warmup 包,在 Impeller 默认的平台上基本是在治已经不存在的病;减 saveLayer,两种后端都有效。

10. 怎么把原理用到排障上

遇到「开了 Impeller 还是卡」,按层提问:

  1. 尖刺只出现一次,之后再也不见? 更像 PSO 首次绑定、字形图集扩容、纹理首次上传,而不是旧式 shader 编译。看它是否可预热、是否发生在真机而不是模拟器。
  2. 每帧 Raster 都高? 减离屏、减模糊、减 clip、减图素。Impeller 把「偶尔几十毫秒」变成了「稳定的填充率」,后者才是可优化的。
  3. 只有某机型? 先确认后端是 Vulkan 还是 GLES,再查驱动;不要在 Dart 里微优化。
  4. Web 或软件渲染? 你不在 Impeller 世界里。
  5. UI 条和 GPU 条谁高? 谁高优化谁。渲染器换了,这条分流没换。

能回答这五问,Impeller 就不再是开关,而是一张「编译已付清、pass 还要付」的账单。

11. 小结

Impeller 用封闭图元和构建期管线,换掉 Skia 在移动端「开放特化 + 运行时编译」的历史包袱。它不让第一帧 magically 变快,它让第一帧和第一百帧走同一类 GPU 成本。成本仍然是像素、离屏、带宽。下一篇回到 UI 线程,看 Layout 协议如何保证这些像素的几何只被算一次。