把 Skia 的 shader warmup 和 Impeller 的预编译当成同一件事,优化会做反。一个是 把已知变体的编译挪到启动或动画开始前,一个是 取消运行时变体生成。前者是缓存策略,后者是着色器架构。缓存会失效;封闭图元集合在构建期编完,原则上不会在动画中间冒出几十毫秒的驱动编译。
读完你应该能独立回答三个问题:为什么换机后 warmup 包失效、Impeller 为什么还能首帧尖刺、saveLayer 和 shader compile 在 FrameTiming 上如何区分。
1. 先画边界:卡顿的两种完全不同的尖刺
移动 GPU 驱动编译一条着色器,常见是几毫秒到几十毫秒,极端上百。它发生在 Raster 线程(或驱动线程,结果仍阻塞提交)。UI Isolate 的 build 火焰图是干净的。用户感觉是「第一次滑到这个效果时顿一下,以后顺了」。
另一种尖刺是填充率、带宽、离屏:模糊、大图、每帧 saveLayer。它 每次 都贵,不会「熟了就好」。用 warmup 解决第二种,是在错误的病上开药。
| Shader compile jank | Fill-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. 排障
- 只第一次卡、以后顺: 先假设 shader compile 或图集/路径缓存冷。Timeline 看 raster 线程上的 compile /
GrGL/impeller事件。开 Impeller 后若仍如此,更可能是纹理、字形、PSO bind。 - 每次都卡: 不是 compile。查
saveLayer、blur、大图、checkerboard 离屏。Performance overlay 的 GPU 条持续高。 - 开了 Impeller 更卡: 个别路径 Impeller 尚未与 Skia 同等优化,或 GLES 后端。用 Impeller 的调试 overlay / frame capture(Metal Frame Capture / RenderDoc)看 pass 数,不要立刻关回去直到你看见 pass 爆炸。
- warmup 包越来越大仍漏: 正常。停用「录全站」,改 Impeller 或减少绘制组合。
- 换机复现、自家不复现: Skia 缓存和驱动差异。实验室必须覆盖目标 GPU。
- 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 变大。
本地验证:
- Profile 真机,进会模糊/阴影的页,看 Timeline 第一次 vs 第二次 raster。
- 第一次有几十 ms 的 compile 事件 → 你不在 Impeller,或在 GLES,或用了 RuntimeEffect。
- 第一次只有几 ms 的 bind/upload → 正常冷缓存。
- 每次都有几十 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 和图片的问题,换渲染器解决不了。看尖刺是否只出现一次,就能决定你在打哪一场仗。