把 Skia 的 shader warmup 和 Impeller 的预编译当成同一件事,优化会做反。一个是 把已知变体的编译挪到启动或动画开始前,一个是 取消运行时变体生成。前者是缓存策略,后者是着色器架构。缓存会失效;封闭图元集合在构建期编完,原则上不会在动画中间冒出几十毫秒的驱动编译。

读完你应该能独立回答三个问题:为什么换机后 warmup 包失效、Impeller 为什么还能首帧尖刺、saveLayer 和 shader compile 在 FrameTiming 上如何区分。

1. 先画边界:卡顿的两种完全不同的尖刺

移动 GPU 驱动编译一条着色器,常见是几毫秒到几十毫秒,极端上百。它发生在 Raster 线程(或驱动线程,结果仍阻塞提交)。UI Isolate 的 build 火焰图是干净的。用户感觉是「第一次滑到这个效果时顿一下,以后顺了」。

另一种尖刺是填充率、带宽、离屏:模糊、大图、每帧 saveLayer。它 每次 都贵,不会「熟了就好」。用 warmup 解决第二种,是在错误的病上开药。

Shader compile jankFill-rate / 离屏 jank
何时第一次见到某绘制组合每一帧
线程Raster / 驱动Raster / GPU
以后会否消失会(缓存在)不会
Skia warmup 有用吗有,若录到了无
Impeller 消灭它吗原则上消灭运行时编译否

FrameTiming.rasterDuration 两者都会变长。要区分,看是否「同路径第一次」,以及 Timeline 里有没有 shader compile 事件。

2. Skia 路径:开放特化 + 运行时编译

Skia 的 Ganesh(以及部分 Graphite 路径)按绘制状态特化着色器:混合模式、覆盖类型、颜色空间、是否有纹理、是否有圆角……状态空间是组合爆炸。第一次用到某个组合,Skia 生成 SKSL / GLSL / MSL,交给驱动编译,再放进 持久化缓存(Android 上常和 GL 程序缓存有关)。

推论:

  • 变体空间大,你不可能在 warmup 里录全 App。
  • 缓存键和 GPU、驱动、有时还和系统版本绑定。换机、OTA、清缓存,预热包失效。
  • 没录到的效果,第一次出现仍会编译。产品上就是「冷门页面第一次进才卡」。
  • Debug/Profile 和 Release 的着色器路径不完全一样,不能拿 Debug 判断 compile jank 消失了。

Warmup 的做法:启动或闪屏阶段,用 Skia 把首屏真实动画里出现的绘制跑一遍(或播放一份录制的 warmup.zip / shader bundle)。编译发生在用户以为「还在进 App」的时候。它 不改变 架构,只改变编译的时间点。

局限是结构性的:录制覆盖率、设备绑定、新效果。把 warmup 当「性能方案」会随着功能迭代不断漏。

3. Impeller 路径:封闭图元 + 构建期管线

Impeller 的核心不变量是:运行时不再根据「没见过的画法」生成新着色器。 画圆、矩形、路径、模糊、渐变、图像、裁剪,收敛到有限绘制操作。对应的 Metal/SPIR-V/GLES 程序在构建期由 impellerc 编进包。运行时绑定已经存在的 PSO(管线状态对象),改的是 uniform、顶点缓冲、纹理绑定。

所以「第一次画模糊」不是「编译 blur shader」,是「走已经编译好的 blur 管线 + 不同的 radius uniform」。没有「新形状 → 新 shader」这条边。

和 Skia warmup 的本质差别:

  • Warmup:缓存 已知 变体。未知变体仍会编译。
  • Impeller:变体集合在构建期闭合。未知画法要么走现有管线组合,要么根本画不出来(要扩展 Impeller 本身)。

换 GPU 不会让「着色器没编译过」——包里已经是设备 ISA 或中间表示(Metal library / SPIR-V,GLES 上仍有驱动的余地,这是 Impeller 在老 GLES 设备上不完美的原因之一)。

代价:

  • 包体积增加。预编译库按后端切片,仍不是零。
  • 自定义着色器不能再指望 Skia 的无限运行时特化,要走 Impeller 的扩展 / runtime effect 路径,且能力面更窄。
  • 构建变慢(impellerc)。CI 要缓存这份产物。

4. Impeller 仍然会卡:不要把所有 GPU 问题算到 shader 上

关掉 compile jank 之后,剩下的才是可以按帧优化的:

  • PSO 第一次绑定: 管线已编译,但驱动可能在首次 bind 时做验证、分配。通常比编译小,仍可能在首帧可见。和 warmup 不同,这更像「缓存冷启动」,且集合有限。
  • 纹理上传: 解码后的 RGBA 上 GPU。大图、列表预加载会打 Raster。见图片缓存那篇。
  • MSAA / 离屏: saveLayer、模糊、复杂 clip、Opacity 动画。Impeller 同样要分配离屏纹理、做两次绘制。
  • 路径 tessellation: 复杂 Path 第一次拆三角形有 CPU 成本,发生在 Raster。之后可能被缓存。这不是 shader compile,但「第一次」的体感像。
  • 字形图集扩容: 第一次见到某 CJK 字,图集扩、上传。UI 线程 shaping + Raster 上传。

iOS 上 Impeller 已默认较久;Android 随版本和设备回退。回退到 Skia 的设备,warmup 仍有意义。不要在「我开了 Impeller」的假设下给所有机型关掉 warmup,先看实际后端。

5. 线程、内存、生命周期

着色器编译和 PSO 创建发生在 Raster,绝不要想在 Dart 里「预编译一条 shader」。Dart 只能通过真正画一次(Skia warmup)或换渲染器(Impeller)影响它。

内存:

  • Skia 程序缓存:原生堆,按变体涨,可被系统清。
  • Impeller 着色器库:映射进内存的包内二进制,基线成本,不随页面线性涨。
  • 离屏纹理:两者都有,跟 Layer 树走,跟 RepaintBoundary 走。

生命周期:Engine 销毁会丢部分 GL 缓存;FlutterEngineGroup 共享 GPU 设备时,第二份 Engine 少付「设备初始化」,不一定共享全部程序缓存。第一页画过的效果,第二页仍可能有一次轻量 bind,但不应再出现几十毫秒编译——前提是真的在用 Impeller 或 Skia 缓存还在。

6. 设计取舍

  • Skia 开放特化: 画什么都能出一种优化过的 shader。代价是编译时间不可预测。桌面和能力强的 GPU 上这很划算;中低端移动上是 jank 源。
  • Impeller 封闭集合: 帧时间可预测。代价是包体、构建、以及「奇葩画法」要改引擎。
  • Warmup 把编译藏进启动: 不改架构就能改善首动画。代价是启动变慢、覆盖率游戏、设备绑定。
  • 默认 Impeller: 团队选择「可预测优于极限特化」。这和 Flutter「跨设备一致」的目标一致。

因此:能开 Impeller 就开。还停在 Skia 的设备,warmup 只覆盖 首屏真实会播放的动画,不要幻想录全 App。新页面的第一次特效,接受它或改画法减少新变体。

7. 排障

  1. 只第一次卡、以后顺: 先假设 shader compile 或图集/路径缓存冷。Timeline 看 raster 线程上的 compile / GrGL / impeller 事件。开 Impeller 后若仍如此,更可能是纹理、字形、PSO bind。
  2. 每次都卡: 不是 compile。查 saveLayer、blur、大图、checkerboard 离屏。Performance overlay 的 GPU 条持续高。
  3. 开了 Impeller 更卡: 个别路径 Impeller 尚未与 Skia 同等优化,或 GLES 后端。用 Impeller 的调试 overlay / frame capture(Metal Frame Capture / RenderDoc)看 pass 数,不要立刻关回去直到你看见 pass 爆炸。
  4. warmup 包越来越大仍漏: 正常。停用「录全站」,改 Impeller 或减少绘制组合。
  5. 换机复现、自家不复现: Skia 缓存和驱动差异。实验室必须覆盖目标 GPU。
  6. Debug 流畅 Release 卡或相反: 关闭 checkerboard、关闭 debugDisableClipLayers 这类调试开关再比。Debug 本身有 JIT 和额外校验。
WidgetsBinding.instance.addTimingsCallback((timings) {
  for (final t in timings) {
    final rasterMs = t.rasterDuration.inMicroseconds / 1000;
    final buildMs = t.buildDuration.inMicroseconds / 1000;
    if (rasterMs > 16 && buildMs < 8) {
      // 病在 GPU/Raster,不是 Dart。再拆「首次 vs 持续」。
    }
  }
});

8. 和 Layer 树的关系

Impeller 消灭编译,不消灭 Layer。Opacity 包整页、ImageFilter.blur 全屏、多层 clip,仍是多次离屏 pass。很多人开了 Impeller 期待「所有卡顿消失」,然后把责任推回 Framework。正确顺序:后端确认 Impeller → 仍卡则当填充率问题治 → 用 checkerboard 和 layer dump 减离屏。

RepaintBoundary 在 Impeller 下同样分配纹理。缓存命中省的是记录和 tessellation,不是着色器编译。

9. 构建期你实际要保证什么

  • CI 打的包确认 Impeller 着色器库在(iOS impeller.framework 一类产物、Android 的 blob)。
  • 不要在 Release 用 --no-enable-impeller 做「以防万一」除非有设备黑名单。
  • Skia 设备名单维护的是 GPU/驱动,不是机型营销名。
  • 自定义 FragmentProgram / ImageFilter 要在目标后端上测第一次出现的帧,不要只测稳态。

10. 后端差异:Metal、Vulkan、GLES 不是同一份「Impeller」

Impeller 的不变量是「封闭图元 + 离线着色器」,落地到后端仍分叉:

  • Metal(iOS / macOS): 最成熟。impellerc 产出 Metal library,驱动编译点基本被挪到构建期。PSO 首次 bind 仍可能有一帧尖刺,幅度通常小于 Skia 的 shader compile。
  • Vulkan(Android 较新设备): 同样走 SPIR-V / 预编译管线。驱动质量参差。个别 OEM 的 pipeline cache 行为会让「理论上不编译」变成「第一次仍卡一下」。这是驱动,不是你 Dart 写错。黑名单和 Impeller 的设备回退存在的理由在这里。
  • GLES: Impeller 也能出 GLES 后端,但 GLES 没有 PSO 这套对象模型,驱动仍可能在链接程序时干活。老设备上回退 Skia 往往更可预测。把「开了 Impeller」当成全机型免编译,会被 GLES 打脸。

Skia 的持久化缓存位置随平台变(Android 上和 GL_PROGRAM_BINARY、应用 cache 目录有关)。用户清缓存、换机、系统升级都会丢。warmup 包若按 GPU 指纹存,要有淘汰;否则 cache 目录会被无用 blob 塞满。

RuntimeEffect / FragmentProgram.fromAsset 是运行时着色器的逃生口。Impeller 和 Skia 都能跑一份 GLSL-like。它会把「封闭集合」打开一个口。 第一帧编译/转译成本回来了。用在滤镜实验室可以,用在列表滚动的每一项等于自己把 compile jank 请回来。能用内置 blur/color matrix 就不要 RuntimeEffect。

Impeller 的实体(entity)pass:每个需要离屏的效果一次 render pass。Frame capture 里数 pass 比猜 Widget 有用。全屏 blur 至少:画内容到离屏、模糊水平、模糊垂直、合成回主缓冲。半径变大带宽变大,和是否 Impeller 无关。

11. 构建、开关、以及和第一帧的关系

iOS 上 Impeller 默认开。Android 用 metadata / ImpellerBackend / 命令行 --enable-impeller。以你目标 Flutter 版本的官方表为准,不要抄两年旧文。Play 分发多 ABI 时,着色器 blob 按 ABI 打进 so 或 asset,包体增量要进 APK analyzer,不要到 16KB 页对齐的设备上才发现 so 变大。

本地验证:

  1. Profile 真机,进会模糊/阴影的页,看 Timeline 第一次 vs 第二次 raster。
  2. 第一次有几十 ms 的 compile 事件 → 你不在 Impeller,或在 GLES,或用了 RuntimeEffect。
  3. 第一次只有几 ms 的 bind/upload → 正常冷缓存。
  4. 每次都有几十 ms → 不是 shader,是离屏或解码。

impellerc 失败会让构建失败,这是好事。Skia warmup 录制失败是静默覆盖率下降,这是坏事。CI 应该:Impeller 构建必须绿;若仍支持 Skia 设备,warmup 只作为额外产物,缺了不能当 Ignored。

宽色域、MSAA、HDR 会加 pass 或改格式。第一次切到 HDR 屏幕可能触发管线切换。这不是「又回到 Skia 编译」,但是用户可感知的第一帧尖刺。在 didChangeMetrics 后的几帧不要同时再解 4K 图。

和启动路径叠在一起:冷启动第一帧本来就要绑管线、shape 字体、解图。把 Impeller 的 PSO 冷绑定、CJK fallback、首图解码叠在同一帧,P95 会很难看。预热策略是 错峰:闪屏阶段先触发字体和首屏 PSO(真正画一次首屏离屏或低优先级帧),再进可交互帧。Group 的第二页可以吃到第一页留下的设备级缓存,这是 Group 和 Impeller 的交叉收益。

变体空间:Skia 为什么录不全

Skia 特化键大致包括:后端、是否有纹理、纹理格式、覆盖几何(rect/rrect/path)、混合、颜色空间、是否 AA、是否有 mask filter、是否有 color filter。两两组合就是指数。业务每加一种「卡片上的渐变描边 + 模糊」,就可能是新键。warmup 录制是在特定导航脚本下跑的,脚本没进的设置页、深色模式、动态字体,全是漏网。深色模式单独一套颜色空间/混合,常常让「白天录的包」晚上失效。

所以 warmup 的正确范围是 启动后 3 秒内用户必见的动画(闪屏到首页、第一个 hero)。其余接受第一次尖刺,或靠 Impeller 关掉这类事件。

Impeller 的封闭集合如何扩展

新画法要进 Impeller,得加 entity、加 contents、加预编译着色器、加黄金测试。这是引擎开发,不是 App 开发。App 侧能做的组合是:用现有图元表达,而不是求新 shader。例如虚线边框用 path effect(若后端支持)或预先画好的九宫格图,而不是 RuntimeEffect 写虚线 SDF。

Canvas.drawVertices / drawAtlas 在 Impeller 上是一等公民,适合合批。循环 drawCircle 一万次,即使没有 compile jank,也有 CPU 记录和 draw call 问题。自定义 RenderObject 那篇的合批,在 Impeller 下同样成立,且更值得:后端可预测后,fill rate 和 draw call 成为唯一账单。

如何向非图形同事解释这次卡顿

一句话分类:

  • 「只第一次,以后顺」→ 编译或冷缓存,换 Impeller / 错峰预热。
  • 「每次经过都卡」→ 像素或离屏,减层减图。
  • 「只有某台 Android」→ 驱动或 GLES 回退,查后端开关和 OEM。
  • 「开了 Impeller 更卡」→ 数 pass,可能是某效果的实现回归,抓帧,不要立刻全面回退。

把这四句写进性能值班文档,比贴一篇 Impeller 介绍有用。

DisplayList 与「为什么 Dart 里看不见 shader」

UI 线程 paint 产出的是 DisplayList(或 Impeller 的等价记录),不是 GL 调用。记录里是「画圆、颜色、模糊半径」这类高级操作。Raster 线程把高级操作映射到后端命令。所以你在 Dart 里搜 shader 找不到;它不在 Framework。Canvas.drawPath 的贵贱,要到 Raster 才揭晓:简单矩形走 fast path,复杂自交 path 要 tessellate。同一段 Dart,不同 path 数据,rasterDuration 可以差一个数量级。优化要 dump 实际 path 复杂度,不要只看调用次数。

saveLayer 在记录里就是一个明确的离屏边界。debugDisableClipLayers 等调试开关能帮你确认是不是它。生产代码里用 Paint.saveLayer 等价物(Opacity、ImageFilter)要知道自己在插入这个边界。

预编译覆盖不到的「第一次」

即使 Impeller,下列第一次仍可能尖刺:

  • 该设备上第一次创建某种像素格式的纹理(HDR、wide gamut)
  • 第一次分配超大离屏(全屏 blur)
  • 第一次把 CJK 某段 fallback 字体上传图集(图集扩容可能拷整张)
  • 第一次从 iOS 后台回前台,GPU 上下文丢了要重建(系统回收)

这些不是「Impeller 没做好」,是 GPU 资源寿命。后台回前台的第一帧单独统计,不要和滚动 P95 混在一起,否则永远解释不清。

包体与多后端

同时带 Metal、Vulkan、GLES 的预编译库,包体会加。按 ABI / 平台切:iOS 不带 Vulkan blob。Android App Bundle 按 ABI 分包。不要为了「包体减 200KB」关掉 Impeller 再把 compile jank 迎回来——那是用用户的帧换你的下载转化,要明确做产品取舍,而不是当技术优化默默做。

Skia persistent cache 的运维

Android 上程序二进制缓存可被用户「清数据」清掉。清数据后第一次进动画的 compile jank 回来。这不是回归,是缓存空了。Impeller 设备上用户清数据不应引回 compile jank——用来做 A/B 验证:清数据后若仍几十 ms 尖刺,你可能没在 Impeller,或尖刺根本不是 compile。

warmup 包放 asset,启动时加载。它增大包体,且和 GPU 绑定。策略:只对仍走 Skia 的 ABI/机型下载或内置,Impeller 机型不要带。否则双份成本。

模糊半径与带宽公式

一次全屏 blur 的大致带宽:宽 × 高 × 4 × pass 次数。两次可分离高斯再合成,pass ≥ 3。1080×2340 × 4 × 3 ≈ 30MB 每帧读或写。60fps 就是极高带宽。半径从 8 改到 64,采样更远,cache miss 更多。Impeller 不会把 30MB 变成 3MB。产品上:小区域 blur、降分辨率离屏再 blit 回去、动画结束用静态模糊图替换实时 blur。这些才是数量级优化。

与 saveLayer 等价的 Widget 列表

给设计/开发一份黑名单:整页 Opacity 动画、BackdropFilter、ColorFiltered 包列表、ShaderMask、Clip.antiAliasWithSaveLayer、多层重叠半透明视频。出现在滚动热路径上就当 raster 超预算的第一嫌疑。不是永远不用,是不要用在每帧全屏。

把渲染后端当成 设备能力矩阵 的一列,和 CPU 档、RAM 放在一起。列值:Impeller Metal / Impeller Vulkan / Impeller GLES / Skia GL。启动时打点,性能指标按这列分维。否则你会平均掉真实问题:Vulkan 设备 P95 很好,GLES 设备很差,平均看起来「还能接受」。分维后才能决定黑名单。

开发流程:设计师出的「全屏毛玻璃 + 列表滚动」在评审就要按带宽公式挡回去,而不是做完再优化。Impeller 让这种效果的 第一次 不再惨,但 每一次 仍然惨。把「第一次」和「每一次」写进评审语言。能让非图形工程师加入讨论,性能才不会只留在图形专家脑子里。

自定义着色器走 RuntimeEffect 要登记:哪些页面、是否滚动路径、有无降级方案(切回 ImageFilter 或静态图)。未登记的 RuntimeEffect 当 bug。封闭集合被打开的口,必须可清点。否则两年后你又在做 Skia warmup 那套覆盖率游戏,只是对象从隐式变体换成了项目里散落的 .frag 文件。

GPU 抓帧应成为和看 Dart 火焰图同等的技能。iOS Frame Capture、Android GPU Inspector、至少会数 pass、看纹理尺寸。不会抓帧就不要结论「Impeller 不行」。结论必须带 pass 数和纹理分辨率。这是把图形问题从信仰拉回工程的最后一步。

着色器问题的沟通模板:给产品看的是「这类卡顿会否随使用消失」。会消失 → 编译/冷缓存,排期可以靠 Impeller 覆盖率;不消失 → 每帧成本,必须改设计。模板减少「再加一层毛玻璃吧,Impeller 不是修好了吗」这类对话成本。

研发侧维护一张效果表:模糊、阴影、圆角、渐变、blend、RuntimeEffect,各行注明 Impeller 路径是否成熟、是否离屏、滚动中是否允许。表比口头知识可继承。新效果先查表再进视觉稿。

设备实验室至少一台 GLES 回退机、一台 Vulkan Impeller、一台 iOS Metal。三台都跑同一动画脚本,看第一次 raster。缺 GLES 那台,你会在发版后被老设备打脸。实验室名单和分维指标用同一套设备 id。

不要在 Dart 里根据 defaultTargetPlatform 猜后端。用引擎提供的报告或自己在原生读开关。猜错比不开开关更糟:你以为没 compile jank,实际在 Skia 上录了一堆无用日志。

若必须同时支持 Skia 与 Impeller,绘制代码按「封闭图元」来写,避免依赖 Skia 独有的运行时特化。这样两条后端的视觉差最小,warmup 需求也最小。视觉差要黄金截图分后端存,允许微小 AA 差,不允许一边有阴影一边没有。发现缺失时,是效果不该用,不是再写一套 if。

团队里指定一个「图形值班」角色,负责 Impeller 发行说明跟踪和设备黑名单。后端演进快,两年旧文会害人。值班不是全职,是发版前看一遍 release note 的 shader/impeller 段。比出事故后读源码便宜。

图形问题还要和电量、发热放在同一张表里。持续高 raster 等于持续高 GPU 功耗,降频后 raster 更长,进入正反馈。实验室短测看不出。外场 20 分钟滚动才能看见。性能优化若只看 30 秒脚本,会把「能跑但会烫」的效果放出去。FrameTiming 的持续超帧率和皮肤温度相关, correlating 需要外场。至少在夏天用真机跑一遍主 Feed。这不是小题大做,是中低端 Android 的真实约束。

Impeller 的收益在这种正反馈里特别明显:没有 compile 尖刺,平均负载更平滑,更不容易瞬间把 GPU 拉到降频点。这是「可预测」的另一层含义:不只是用户少感觉一次 hitch,还是热管理更稳。讲给硬件同事听,他们会点头;讲给只看 FPS 的人听,要补这一句。

若发现某 OEM 上 Impeller 画面错(缺字、花屏),黑名单要按 GPU 和驱动版本,不要按手机营销名。同名机可能换过 GPU。上报带 GL_RENDERER / Metal 设备名。黑名单进远端配置,不必发版就能扩。本地写死名单会在新品上滞后。这是图形问题的运维面。原理文章也要写运维面,否则原理停在开发者电脑上。电脑是 Pixel 和 iPhone,用户是长尾 OEM。长尾才是 shader 历史的重复。Impeller 减少了重复,没有消灭长尾。长尾用黑名单和分维指标收口。

着色器故事的结局不是「永远不卡」,是「卡的种类变了」。从不可预测的编译,变成可预测的填充。可预测才能排期,才能挡设计,才能写预算。Impeller 的价值在这一变。Warmup 仍服务被留下的 Skia 设备,范围收在首屏。两套手段,两种病。以后再看见「第一次卡」,先问会不会消失。会消失的,看后端;不消失的,数像素。问句比工具先到。工具用来回答问句,不是用来代替问句。

回到开篇那三个问题:warmup 换机失效,因为缓存键绑 GPU;Impeller 仍可能首帧尖刺,因为 bind、上传、字形图集不是编译;saveLayer 和 compile 用「是否每次出现」区分。三个答案现在都有了机制和流程。机制在 Raster,流程在分维指标和设备实验室。没有实验室,机制只在文档里。有实验室,机制才能变成黑名单和预算。预算挡住全屏毛玻璃,黑名单挡住花屏 OEM,分维防止平均值撒谎。三件套比再读一遍 impellerc 帮助文档更接近生产。生产要的不是图形学完整性,是帧时间可预测。可预测是 Impeller 的设计目标,也是你选后端、写效果、做评审时唯一需要对齐的目标。对齐了,shader 故事结束。结束之后,去减层。

减层的手法已经在 Layer 树那篇:少 Opacity 整页、少 blur 全屏、少 saveLayer clip。本文只负责告诉你:这些手法在 Impeller 下仍然有效,而且更值得做,因为编译尖刺不再掩盖它们。掩盖消失后,填充率成为唯一的 GPU 语言。学会这门语言,比学会 warmup 包格式重要。格式会过时。填充率不会。像素×pass×帧率,永远是账单。账单在,效果就要买得起。买不起,就换设计。换设计是合法优化。合法,因为物理在。物理不吃 Impeller 的面子。

像素×pass×帧率买不起时,换设计是第一优化,换后端是第二。第一更便宜。便宜的先做。做完再问还要不要黑名单。黑名单是兜底,不是日常。日常是预算。预算是物理。物理读完就可以去改稿。改稿比改引擎快。快,是你真正拥有的杠杆。引擎在上游。稿在手里。手里的先动。

12. 小结

Warmup 治标:把 Skia 的运行时编译挪到用户不注意的时间点。Impeller 治本:构建期闭合着色器集合,运行时原则上不再编译。两者都能让「第一次」变好,但只有后者让帧时间分布可预测。剩下的 GPU 账单是离屏、填充、上传,那是 Layer 和图片的问题,换渲染器解决不了。看尖刺是否只出现一次,就能决定你在打哪一场仗。