Files
MapLibreParticleLab/docs/20_调查/STEP-086_Particle连续渲染唤醒MapLibre整图成本调查_v1.md
OpenAI Codex c0bad3df6e
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
docs: record STEP-086 continuous repaint investigation
2026-09-21 06:20:05 +08:00

14 KiB
Raw Blame History

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-001 Particle 源码调查基线:
  • 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 仍会重复:

  1. 遍历全部 source。
  2. 对每个 source 遍历 ordered layers,计算 source/layer 关系、visibility、zoom fit、renderability。
  3. 对启用 source 调用 source->update()。
  4. Vector/PBF source 进入 RenderTileSetSource::update() → TilePyramid::update()。
  5. TilePyramid 会重新处理 wrap、zoom、covering tiles、retain 等逻辑。
  6. 对全部 enabled source 执行 prepare()。
  7. 对全部 renderable layer 执行 prepare()。
  8. symbol layer 进入 cross-tile symbol bookkeeping。
  9. 根据 placement freshness 条件周期性重新执行 placement。
  10. Renderer upload pass 遍历 source/layer RenderItem。
  11. updateLayers()、layer-group tweaker、layer-group upload 继续执行。
  12. opaque pass 重新遍历/绘制普通 layer groups。
  13. translucent pass 重新遍历/绘制普通 layer groups。
  14. 重新 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::onUpdate
  • RenderOrchestrator::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. 当前结论

源码层面可以确认:

  1. Particle 当前通过 MapLibre 全局 needsRepaint 维持动画。
  2. Particle 没有独立 layer-only render loop。
  3. 一次 Particle repaint 会进入完整 Renderer::render。
  4. camera 静止时仍会重复大量普通 source/layer update、prepare、render 工作。
  5. 普通 fill/line/symbol/PBF draw 会在连续 frame 中再次提交。
  6. 并非所有 style property 和 placement 都每帧完整重算,但不足以让复杂地图回到 idle。
  7. 当前没有证据表明主要原因是 Particle 每帧修改全局 Style/Source/Camera revision。
  8. STEP-085 已降低 Particle-local 的多余 repaint/copy/rebuild,但没有改变“合法 Particle frame = 完整 MapLibre frame”的架构。
  9. Test Host Style 太简单,不能用于排除真实大型 Style 下的 full-scene amplification。
  10. 下一步应先做 A0/A1/B0/B1 定量 profile,再决定 animation-only fast path 或 framebuffer composition。
  11. 现阶段不需要修改 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 优化实现。