diff --git a/docs/20_调查/STEP-086_Particle连续渲染唤醒MapLibre整图成本调查_v1.md b/docs/20_调查/STEP-086_Particle连续渲染唤醒MapLibre整图成本调查_v1.md new file mode 100644 index 000000000..b014bf92f --- /dev/null +++ b/docs/20_调查/STEP-086_Particle连续渲染唤醒MapLibre整图成本调查_v1.md @@ -0,0 +1,370 @@ +# 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。 + +主链如下: + +```text +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 模式下执行: + +```text +if (needsRepaint || transform.inTransition()) { + onUpdate(); +} +``` + +而 `Map::triggerRepaint()` 本身也只是: +```text +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 解决的是: +```text +Particle 自己是否产生了多余 repaint / copy / drawable rebuild +``` + +本调查发现的是更外层问题: +```text +即使是“合法的 Particle frame”,当前仍等价于一次完整 MapLibre frame +``` + +## 10. 为什么简单 Particle Host 不能证明完整 NavSea 成本正常 +简单 Host 的 Style 很小,因此: +```text +Particle simulation/render ++ 少量普通 MapLibre layer 成本 +≈ 当前约 40% RenderThread +``` + +完整 NavSea Style 包含大量: +- PBF/vector source +- fill +- line +- symbol +- text/icon placement +- navigation marks +- 其他 MapLibre layers + +当 Particle 让 MapLibre 持续出帧后: +```text +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 成本。 +预期验证特征: + +```text +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 优化实现。