14 KiB
STEP-086 Particle 连续渲染唤醒 MapLibre 整图成本调查 v1
状态:INVESTIGATION / SOURCE_EVIDENCE_CONFIRMED / RUNTIME_PROFILE_PENDING
日期:2026-09-21
1. 调查目标
本调查针对 NavSea 完整场景中出现的以下现象:
- Particle 独立 Host / NavSea 最小 Particle Host 中,Particle 本体性能基本正常。
- 完整 NavSea 在 Particle OFF、camera 静止时能够正常 idle。
- 完整 NavSea 在 Particle ON 后,RenderThread、JS、main CPU 明显高于最小 Particle Host。
- 已排除 NavSea 侧两个已确认的 JS 问题:NavigationEngine renderPulse 导致整树重渲染,以及 JsSchedulerProbe 常驻 RAF/16ms timer。
核心问题是验证:
Particle 连续动画是否通过 MapLibre 的全局 repaint/update/render 生命周期,把本来可 idle 的 PBF/vector/style/map scene 持续唤醒。
本调查优先检查 Shared Core / Renderer / Android frame scheduling,不修改 NavSea ↔ Particle C-001 公共责任边界。
2. 调查基线
Integration 仓库已同步最新 main:
- repo:
/root/sourceserver/navsea-particle-integration - branch:
main - HEAD:
39ad8415fbdb76c1822460bb09f3a3f35fe141e0 - Contract:
C-001Particle 源码调查基线: - repo:
/mnt/sda1/MapLibreParticleLab - branch:
origin/particle/main - HEAD:
70f112ea27acaaa4da0a307563b7868937f69691 - commit:
fix: close STEP-085 V4 renderer efficiency - 当前该 HEAD 中 package/version 字段仍为
13.4.1-particle.7。
注意:用户当前运行环境称为 .8,而本次可直接审计的最新远端源码 HEAD 仍显示 .7 版本字段。
因此本文的源码结论针对 70f112ea2 的 MapLibre/Particle 调度架构;后续如 .8 对应精确发布提交不同,应再做一次 commit-level 对照。
不过本文确认的全局 repaint/render 机制位于 MapLibre Renderer 主链,不依赖 NavSea 业务代码。
3. 当前实测背景
用户提供的 Android 模拟环境参考量级:
最小 Particle Host
- RenderThread:约 38–40%
- JS
mqt_v_js:约 4–5% - main:约 11%
完整 NavSea,Particle OFF
- JS:7.08%
- RenderThread:1.31%
- main:3.34%
说明完整 NavSea 的静止地图、PBF、UI 和业务逻辑本身能够进入低成本 idle。
完整 NavSea,Particle ON,且已排除两个 NavSea JS 问题
- JS:29.07%
- RenderThread:80.13%
- main:15.51%
相对于最小 Particle Host,大约额外:
- JS:+24 percentage points
- RenderThread:+40 percentage points
- main:+4~5 percentage points
4. 源码确认的核心结论
核心假设成立:
当前 Particle 没有独立的“只绘制 Particle Layer”的动画循环。Particle 的连续动画通过 MapLibre 全局
needsRepaint/onUpdate/Renderer::render链维持,因此一次合法的 Particle animation frame 会进入完整 MapLibre frame orchestration。
主链如下:
RenderRasterParticleLayer::needsRepaint()
↓
RenderOrchestrator::hasTransitions()
↓
RenderTreeParameters::needsRepaint = true
↓
Renderer::Impl::render() 完成当前整帧
↓
Map::Impl::onDidFinishRenderingFrame()
↓
if (needsRepaint) onUpdate()
↓
重新构造完整 UpdateParameters
↓
AndroidRendererFrontend::update()
↓
MapRenderer.requestRender()
↓
Renderer::render()
↓
RenderOrchestrator::createRenderTree()
↓
完整 Map frame
因此 needsRepaint() 的语义不是“仅让 Particle Layer 再绘制一次”,而是“MapLibre 需要下一张完整地图 frame”。
5. Particle 每帧如何请求下一帧
RenderRasterParticleLayer::needsRepaint() 在 Particle active、visible、field-ready 且 cadence 到期时返回 true。
RenderOrchestrator::hasTransitions() 会遍历全部 RenderLayer:
hasTransition()needsRepaint()
任一 layer 返回 true,整个 RenderTree 即被标记为 needsRepaint。
帧结束后,Map::Impl::onDidFinishRenderingFrame() 在 Continuous 模式下执行:
if (needsRepaint || transform.inTransition()) {
onUpdate();
}
而 Map::triggerRepaint() 本身也只是:
impl->onUpdate();
Android 显式 FPS_30/FPS_60 还存在一层 platform wake:
- FPS_30:Handler 约每 34ms 调用一次
map.triggerRepaint() - FPS_60:Handler 约每 17ms 调用一次
map.triggerRepaint() - AUTO:不使用该 Handler,但 Particle 的
needsRepaint()仍可使连续 frame 自行串联。
结论:当前没有 layer-scoped repaint 调度语义。
6. 一次 Particle repaint 会重新进入哪些 MapLibre 工作
Map::Impl::onUpdate() 每次都会重新生成完整 UpdateParameters,其中包括:
- transform state
- style loaded state
- light
- images
- sources
- layers
- glyph/font/sprite 状态
- annotation manager
- tile LOD 参数等
随后 Renderer::render(updateParameters) 会重新调用:
RenderOrchestrator::createRenderTree()renderTree->prepare()Renderer::Impl::render()
因此不是复用一张静态 RenderTree 后只执行 Particle pass。
7. camera 静止时仍重复执行的非 Particle 工作
在当前源码中,即使 camera 完全静止,连续 frame 仍会重复:
- 遍历全部 source。
- 对每个 source 遍历 ordered layers,计算 source/layer 关系、visibility、zoom fit、renderability。
- 对启用 source 调用
source->update()。 - Vector/PBF source 进入
RenderTileSetSource::update()→TilePyramid::update()。 - TilePyramid 会重新处理 wrap、zoom、covering tiles、retain 等逻辑。
- 对全部 enabled source 执行
prepare()。 - 对全部 renderable layer 执行
prepare()。 - symbol layer 进入 cross-tile symbol bookkeeping。
- 根据 placement freshness 条件周期性重新执行 placement。
- Renderer upload pass 遍历 source/layer RenderItem。
updateLayers()、layer-group tweaker、layer-group upload 继续执行。- opaque pass 重新遍历/绘制普通 layer groups。
- translucent pass 重新遍历/绘制普通 layer groups。
- 重新 present 整个默认 framebuffer。
因此“大量 PBF/vector/fill/line/symbol 在静止 camera 下由 Particle frame 唤醒”从源码结构上成立。
8. 需要精确区分:不是所有 Style 工作都每帧完整重算
当前代码并非每帧无条件重新 evaluate 所有 style property。
普通 RenderLayer 的 evaluate() 通常受以下条件约束:
- layer added/changed
- zoom changed 且该 layer 依赖 zoom
- crossfade
- active transition
所以静止 camera、style 不变时,很多 property evaluation 可以跳过。
同样,完整 symbol placement 也不是严格 60 次/秒。
PlacementController::placementIsRecent() 使用 placement update period 控制重新 placement;默认逻辑与约 300ms 的 transition/update period 相关。
但这些优化不改变主结论:
即使 property evaluation 和完整 placement 并非每帧执行,整张地图仍持续进入 source/layer traversal、source update/prepare、layer prepare、command encoding、普通 layer draw 与 framebuffer present。
这足以解释复杂 Style 与简单 Test Host 之间的大幅 RenderThread 差异。
9. Particle 是否每帧制造不必要的全局状态变化
本次静态审计没有发现 Particle 每个 simulation frame 都在修改以下全局状态的直接证据:
- camera state
- 普通 vector/PBF source data
- 全局 Style revision
- 普通 layer property
- sprite/image revision
STEP-085 已针对 Particle-local 的不必要成本做过修复,包括:
- V4 WeatherColor 静态状态不再因为没有 legacy timeline 而永久
needsRepaint()。 - V4 Particle 增加 Source-owned renderer cadence。
- V4 snapshot 改用 immutable shared pointer,减少稳定 window 的大数组深复制。
- WeatherColor 尽量复用 drawable,仅 wrap 变化时重建。
- 增加 renderer debug counters。
所以当前问题与 STEP-085 并不矛盾。
STEP-085 解决的是:
Particle 自己是否产生了多余 repaint / copy / drawable rebuild
本调查发现的是更外层问题:
即使是“合法的 Particle frame”,当前仍等价于一次完整 MapLibre frame
10. 为什么简单 Particle Host 不能证明完整 NavSea 成本正常
简单 Host 的 Style 很小,因此:
Particle simulation/render
+ 少量普通 MapLibre layer 成本
≈ 当前约 40% RenderThread
完整 NavSea Style 包含大量:
- PBF/vector source
- fill
- line
- symbol
- text/icon placement
- navigation marks
- 其他 MapLibre layers
当 Particle 让 MapLibre 持续出帧后:
Particle 自身成本
+ 大型静态 map scene 的重复 orchestration / prepare / draw
≈ 当前约 80% RenderThread
因此必须使用复杂真实 Style 做性能判断,不能只使用 Particle Test Host。
11. JsSchedulerProbe 联动现象的当前解释边界
用户实测:
- Particle OFF:Probe OFF 7.08%,Probe ON 8.93%,约 +1.85pp JS。
- Particle ON:Probe OFF 约 27~29%,Probe ON 96.47%,约 +69pp JS。
当前 Native 源码审计没有发现 Particle 每帧主动通过 RN bridge 回调 JS 的证据。
更合理的当前解释是:
- Particle 让 MapLibre/RenderThread 持续处于 frame pressure。
- FPS_30/60 时 Android Main Looper 还有 cadence wake。
- RN RAF / Choreographer / timer / JS scheduler 在不再 idle 的系统中发生严重放大或竞争。
但这部分尚不能仅靠 Native 静态源码判定根因。 需要 RN scheduler trace / perfetto 才能进一步归因。 由于 JsSchedulerProbe 将退出正常运行路径,它不应阻塞当前 Native Renderer 主问题。
12. 下一阶段必须做的运行时 Profile
需要固定 camera,并至少构造以下矩阵:
| 场景 | Style | Particle |
|---|---|---|
| A0 | 简单 Style | OFF |
| A1 | 简单 Style | ON |
| B0 | 大量 PBF/vector/symbol/line/fill | OFF |
| B1 | 与 B0 完全相同 | ON |
B1 还应分别验证:
- AUTO
- 60fps
- 30fps
建议记录以下 Native 阶段的调用次数与 CPU 时间:
Map::Impl::onUpdateRenderOrchestrator::createRenderTree- source × layer traversal
- VectorSource / TilePyramid update
- source prepare
- layer prepare
- cross-tile symbol bookkeeping
- full placement
updateLayers- layer-group tweaker
- upload pass
- opaque pass
- translucent pass
- present
- Particle simulation advance
- Particle render/composite
同时记录 MapLibre 已有 gfx::RenderingStats:
- encodingTime
- renderingTime
- numDrawCalls
- texture/buffer/uniform updates
并结合 STEP-085 的 Particle debug counters,区分 Particle 自身和非 Particle 成本。 预期验证特征:
B0 + stable camera
→ frame/update 很快降到接近 idle
B1 + stable camera
→ onUpdate/createRenderTree 与 Particle frame 数量高度相关
→ Vector/PBF source update/prepare 被持续调用
→ 普通非 Particle draw calls 持续存在
→ B1 non-Particle CPU 显著高于 A1
如果该特征成立,则“continuous repaint 唤醒完整 scene”的运行时证据闭环完成。
13. 优化方向分层
第一层:低风险缓解
优先验证 30fps / adaptive fps。
原因:在当前 full-map repaint 架构下,降低 Particle frame 数不仅减少 Particle 本身成本,也会近似同比减少被唤醒的整个 NavSea map scene 成本。
第二层:Renderer animation-only fast path
值得优先调查是否可在 MapLibre Native 内部实现:
- 复用稳定 map state / RenderTree
- Particle 下一帧不重新进入完整
Map::Impl::onUpdate - 跳过普通 source update、TilePyramid update、部分 prepare 和 placement
- 仍允许必要的 framebuffer redraw
这属于内部性能实现优化,原则上不需要改变 C-001。
第三层:静态地图 framebuffer cache + Particle composition
若目标是让静态 fill/line/symbol/PBF draw call 在 Particle 动画期间也消失,则需要 retained/offscreen composition。
这比 animation-only fast path 复杂得多,需要处理:
- layer ordering
- transparent layers
- depth/stencil
- 3D
- world wrap
- custom layer/render target
- camera/style/source invalidation 如果最终要求 Particle 固定作为静态地图之上的独立 overlay,架构可显著简化;但若这种限制成为 Host 可观察的公共语义,则必须重新进入 Integration RFC/Contract 评审。
14. 当前结论
源码层面可以确认:
- Particle 当前通过 MapLibre 全局
needsRepaint维持动画。 - Particle 没有独立 layer-only render loop。
- 一次 Particle repaint 会进入完整
Renderer::render。 - camera 静止时仍会重复大量普通 source/layer update、prepare、render 工作。
- 普通 fill/line/symbol/PBF draw 会在连续 frame 中再次提交。
- 并非所有 style property 和 placement 都每帧完整重算,但不足以让复杂地图回到 idle。
- 当前没有证据表明主要原因是 Particle 每帧修改全局 Style/Source/Camera revision。
- STEP-085 已降低 Particle-local 的多余 repaint/copy/rebuild,但没有改变“合法 Particle frame = 完整 MapLibre frame”的架构。
- Test Host Style 太简单,不能用于排除真实大型 Style 下的 full-scene amplification。
- 下一步应先做 A0/A1/B0/B1 定量 profile,再决定 animation-only fast path 或 framebuffer composition。
- 现阶段不需要修改 Particle 公共 API,也不需要修改 NavSea ↔ Particle C-001。
15. 建议的下一任务
建议下一任务只做 Renderer observability / performance profile,不先做架构重构。
目标是把完整 frame 拆成 Particle 与 non-Particle 阶段,定量证明:
- 每秒进入多少次完整 map update/render;
- 大型 Style 中额外 CPU 具体落在哪些 source/layer/placement/draw 阶段;
- 30fps 对完整 NavSea 的实际收益;
- 哪些阶段可以通过复用 RenderTree 或 animation-only fast path 安全跳过。
完成该 profile 后,再决定是否进入具体 Renderer 优化实现。