From bcec6ce5e0f4d1cfc35fd0b4dddadfaf6e37e0fb Mon Sep 17 00:00:00 2001 From: tei Date: Tue, 22 Sep 2026 21:01:01 +0900 Subject: [PATCH] feat: add retained underlay revision invalidation --- ...²染Revision与Invalidation基础层_Codex任务_v1.md | 144 ++++++++++++ ...9_静态渲染Revision与Invalidation基础层_v1.md | 117 ++++++++++ ...Revision与Invalidation基础层_Codex完成报告_v1.md | 60 +++++ src/mbgl/renderer/render_orchestrator.cpp | 54 +++++ src/mbgl/renderer/render_orchestrator.hpp | 17 +- src/mbgl/renderer/renderer_impl.cpp | 48 +++- src/mbgl/renderer/renderer_impl.hpp | 2 + src/mbgl/renderer/retained_underlay_cache.cpp | 94 +++++++- src/mbgl/renderer/retained_underlay_cache.hpp | 68 +++++- .../retained_underlay_runtime_state.hpp | 20 ++ .../renderer/retained_underlay_cache.test.cpp | 217 +++++++++++++++++- 11 files changed, 817 insertions(+), 24 deletions(-) create mode 100644 docs/40_任务/STEP-089_静态渲染Revision与Invalidation基础层_Codex任务_v1.md create mode 100644 docs/50_Step记录/STEP-089_静态渲染Revision与Invalidation基础层_v1.md create mode 100644 docs/报告/STEP-089_静态渲染Revision与Invalidation基础层_Codex完成报告_v1.md diff --git a/docs/40_任务/STEP-089_静态渲染Revision与Invalidation基础层_Codex任务_v1.md b/docs/40_任务/STEP-089_静态渲染Revision与Invalidation基础层_Codex任务_v1.md new file mode 100644 index 000000000..8f1fd67b5 --- /dev/null +++ b/docs/40_任务/STEP-089_静态渲染Revision与Invalidation基础层_Codex任务_v1.md @@ -0,0 +1,144 @@ +# STEP-089 静态渲染 Revision 与 Invalidation 基础层 Codex 任务 v1 + +## 目标 + +为 STEP-090 Static CPU Fast Path 建立可靠、可验证的静态状态失效判据。 + +STEP-089 不负责跳过 static source/tile/render-tree/prepare,也不负责降低 CPU;只建立“静态前缀当前是否仍可安全复用”的 revision / invalidation 基础,确保后续 fast path 不会复用过期静态状态。 + +当前基线: + +- branch: `particle/main` +- baseline HEAD: `07a17b4c701cd22c8e8b516ad84f4044f291f9fa` +- STEP-088 retained underlay 已完成: + - GPU static draw cache + - map-scoped runtime Cache ON/OFF + - Release 最小 diagnostics + - Android/iOS/RN runtime API + - Android NavSea 真实 PBF 性能场景 +- 实测证明主要剩余成本是 Particle continuous repaint 下 PBF/vector/static renderer pipeline 被重复 processing。 +- STEP-090 才负责 CPU fast path。 + +## 必做项 + +- [ ] 引入单调递增的 static/retained revision 机制,能表达“静态前缀相关输入已变化”。 +- [ ] retained cache build 时记录所依据的 revision。 +- [ ] reuse 前必须确认当前 revision 与 build revision 一致。 +- [ ] 下列变化必须使 revision 前进或明确 invalidate: + - [ ] camera / transform + - [ ] viewport / size / pixel ratio + - [ ] style reload / style mutation + - [ ] layer add/remove/reorder/property mutation + - [ ] source add/remove/change + - [ ] tile / drawable 内容更新 + - [ ] image / sprite / glyph 变化 + - [ ] placement / symbol fade 状态变化 + - [ ] render context / surface / backend resource reset + - [ ] debug render options + - [ ] terrain / 3D / custom layer 等当前不能安全证明静态的场景 +- [ ] symbol fade / placement transition 活跃时不得复用 stale static cache。 +- [ ] unsupported / uncertain 情况继续 fail closed 到 full renderer。 +- [ ] Cache OFF/ON runtime 行为保持不变。 +- [ ] Release diagnostics 保持低成本;允许新增只读 revision 字段,但禁止 per-frame Release 日志。 +- [ ] Debug 日志可增加 revision / invalidation reason,便于 RDC 验证。 +- [ ] 原有 LayerGroup ordinal、dynamic boundary、transparent underlay、legacy ordering 语义不得回退。 +- [ ] 不实现 STEP-090 的 static CPU processing skip。 +- [ ] 不新增 React 管理的 renderer cache。 +- [ ] 不把 provider/network/NavSea Store 逻辑引入 MapLibre Core。 +- [ ] 不使用 Jest。 + +## Revision 设计约束 + +优先采用单调 `uint64_t` revision / generation: + +- revision 只增不减; +- 不依赖 wall clock; +- 不要求每种事件都维护独立缓存副本; +- 可以保留 reason/category 作为诊断,但 correctness 依赖 revision,而不是字符串 reason; +- revision wraparound 可按现实上不可达处理,但类型必须明确。 + +建议最少区分: + +- 当前 static revision; +- retained underlay build revision; + +如现有 Renderer/RenderOrchestrator 的生命周期更适合其他命名,可调整,但不要把模型复杂化。 + +## Invalidation 范围原则 + +STEP-089 首选“安全偏保守”。 + +如果某类状态变化是否会影响 static prefix 无法可靠判断: + +- 直接 bump revision / invalidate; +- 不做复杂局部 dependency graph; +- 不为了提高 hit rate 提前实现 STEP-090 级别的细粒度 source/layer dependency tracking。 + +目标是让 STEP-090 有一个可信的 fast-path gate,而不是在 089 追求最高 cache reuse。 + +## Placement / Symbol 特别规则 + +已有 Android 真机证据: + +- Particle 放到全部 63 层上方时,symbol-heavy prefix 会出现持续 `static-placement` / `static-transition`。 +- BEFORE_SYMBOL_CLUSTER(fill/line 在下,symbol cluster 在上)是目前最稳定边界。 + +因此: + +- placement/fade active 必须阻止旧 static state 被错误复用; +- fade/placement 稳定后允许重新 build 并记录新 revision; +- 不得简单忽略 symbol placement 来提高 TOP 场景 reuse。 + +## Runtime API / Diagnostics + +保持现有: + +- `setRetainedUnderlayEnabled` +- `getRetainedUnderlayDiagnostics` + +允许在 diagnostics 增加: + +- `staticRevision` +- `buildRevision` + +如增加,Android/iOS/RN 三端需一致;如果为了控制 STEP-089 范围决定暂不公开,也至少在 Debug Native 日志中可观察。 + +不要新增第二套 runtime module / map registry。 + +## 测试要求 + +新增/扩展 Native directed tests,至少覆盖: + +1. revision 单调递增; +2. build revision 与 current revision 相等时允许 reuse; +3. camera/viewport/style/source 变化后 revision 改变并禁止旧 cache reuse; +4. placement/fade active 时不 reuse stale cache; +5. Cache OFF -> invalidate;重新 ON -> 下一次重新 build; +6. unsupported / resource failure 保持安全 fallback; +7. 原有 RetainedDynamicBoundary / LayerGroupRange / RetainedUnderlayCache 测试继续通过。 + +不要求 Jest。 + +## Codex 执行规则 + +- 先完整阅读本任务、AGENTS.md、STEP-088 v3 Step/报告,以及 retained underlay 当前实现。 +- 不需要提交实施计划。 +- 直接实现。 +- 不运行 Gradle / Android build / ADB / Xcode。 +- 不运行大型 noisy build/test;编译和最终验证由 RDC 负责。 +- 可以做必要的静态代码检查,但不要替代 RDC 编译。 +- 不 commit/push,等 RDC 验证后统一收尾。 +- 不触碰用户既有无关 dirty/untracked。 +- 不使用 worktree。 +- 无法完成的项明确说明,不得静默跳过。 + +## 完成时必须报告 + +逐项按“已完成 / 未完成 / 无法验证”报告上述必做项,并列出: + +- 修改文件; +- revision 的实际所有者与生命周期; +- 每类 invalidation 在哪里触发; +- 新增测试名; +- RDC 应执行的精确 OpenGL / Metal / Android Validation 命令; +- 明确说明没有实现 STEP-090 fast path。 diff --git a/docs/50_Step记录/STEP-089_静态渲染Revision与Invalidation基础层_v1.md b/docs/50_Step记录/STEP-089_静态渲染Revision与Invalidation基础层_v1.md new file mode 100644 index 000000000..52ee2805c --- /dev/null +++ b/docs/50_Step记录/STEP-089_静态渲染Revision与Invalidation基础层_v1.md @@ -0,0 +1,117 @@ +# STEP-089 静态渲染 Revision 与 Invalidation 基础层 v1 + +## 状态 + +**CLOSED / PASS** + +STEP-089 已完成 retained underlay 的静态状态 revision / invalidation 基础层,为 STEP-090 Static CPU Fast Path 提供可靠的安全门控。 + +## 核心实现 + +- 新增单调递增的静态 revision / generation。 +- retained cache build 时记录 buildRevision。 +- reuse 前要求 buildRevision 与当前 staticRevision 一致。 +- 新增稳定 topology snapshot: + - dynamic boundary; + - static prefix 中每个 LayerGroup 的 layerIndex; + - groupName; + - groupType。 +- 真正的 LayerGroup 增删、排序、index 变化会推进 topologyRevision。 +- Renderer 内部 transient drawable churn 不再直接推进 static revision。 +- 等价的 stencil tile set 不因 shared_ptr identity 变化而误失效。 +- 支持并记录以下语义失效来源: + - camera / viewport / pixel ratio; + - style / layer / source mutation; + - tile content change; + - image / sprite / glyph; + - placement / fade; + - context reset; + - debug options; + - 3D / unsupported custom/static layer。 +- unsupported / uncertain 场景继续 fail-closed。 +- STEP-088 runtime Cache ON/OFF 行为保持不变。 +- 未实现 STEP-090 CPU fast path。 + +## Android 实机关键验证 + +测试场景: +- Android 13 / x86_64; +- NavSea 正式 63 层 PBF Style; +- Particle = AFTER_CLIP; +- 固定 Camera:Karatsu; +- Cache ON。 + +### 稳态 + +修复前曾出现: +- drawable-mutation 每帧失效; +- layer-group-mutation 每帧失效; +- staticRevision 每帧递增; +- build 每帧递增; +- reuse=0。 + +最终版本: + +```text +frame=480 build=2 reuse=302 staticRevision=21 buildRevision=21 +frame=600 build=2 reuse=422 staticRevision=21 buildRevision=21 +frame=720 build=2 reuse=542 staticRevision=21 buildRevision=21 +``` + +确认: +- staticRevision 稳定; +- buildRevision == staticRevision; +- build 不再增长; +- reuse 持续增长。 + +### Camera 变化 + +相机变化后: +- staticRevision: 41 -> 44; +- build: 18 -> 19; +- buildRevision = 44; + +随后稳定: + +```text +build=19 +reuse=3519 -> 3639 -> 3759 -> 3879 +staticRevision=44 +buildRevision=44 +``` + +确认真实变化会使 revision 前进并重建一次,稳定后恢复 reuse。 + +## Native 测试 + +Retained directed tests:12/12 PASS。 + +覆盖: +- dynamic boundary; +- layer group range; +- cache build/reuse/invalidation; +- runtime Cache ON/OFF; +- revision 单调; +- 无语义变化时 revision 不前进; +- 等价 intra-frame remove/add 不推进 topology; +- 持久 topology 变化只推进一次; +- ordering/index 变化推进 topology; +- buildRevision 匹配才允许 reuse; +- placement/fade 活跃时拒绝 stale cache。 + +## 构建验证 + +- Mac OpenGL mbgl-test-runner:PASS。 +- Metal/iOS mbgl-core:BUILD SUCCEEDED。 +- Android x86_64 stripped Debug Validation APK:BUILD SUCCESSFUL,约 57 MB。 +- Android 实机稳态 reuse:PASS。 +- Android Camera invalidation -> rebuild -> reuse:PASS。 +- git diff --check:PASS。 + +## 结论 + +STEP-089 已建立可信的 static revision gate。后续 STEP-090 可以在该 gate 为稳定状态时尝试跳过静态 PBF/vector CPU processing;089 本身不做 CPU fast path。 + +## 下一步 + +进入 STEP-090:Static CPU Fast Path。 diff --git a/docs/报告/STEP-089_静态渲染Revision与Invalidation基础层_Codex完成报告_v1.md b/docs/报告/STEP-089_静态渲染Revision与Invalidation基础层_Codex完成报告_v1.md new file mode 100644 index 000000000..576ffbb5b --- /dev/null +++ b/docs/报告/STEP-089_静态渲染Revision与Invalidation基础层_Codex完成报告_v1.md @@ -0,0 +1,60 @@ +# STEP-089 静态渲染 Revision 与 Invalidation 基础层 Codex 完成报告 v1 + +## 最终状态 + +**CLOSED / PASS** + +## 完成项 + +- 已完成 static revision / buildRevision 门控。 +- 已完成 topology snapshot / topologyRevision。 +- 已覆盖 camera、viewport、pixel ratio、style、layer、source、tile、image、glyph、placement/fade、context、debug、3D/custom fail-closed。 +- 已移除 transient drawable churn 对 static revision 的误触发。 +- 已修复 equivalent stencil tile set 因 shared_ptr identity 导致的误失效。 +- 已保留真正 LayerGroup topology 变化的失效能力。 +- 已保持 STEP-088 Cache ON/OFF 运行时能力。 +- 未实现 STEP-090 fast path。 + +## 实机回归中发现并修复的问题 + +1. 首版把 LayerGroup drawable maintenance 当成 semantic mutation,导致 staticRevision 每帧递增。 +2. stencil tile shared_ptr identity 在正常帧中变化,导致 false positive。 +3. LayerGroup transient churn 继续导致 topology false positive。 +4. 最终改为 semantic content revision + stable topology snapshot,稳态复用恢复。 + +## 验证 + +- OpenGL:PASS。 +- Retained directed tests:12/12 PASS。 +- Metal/iOS mbgl-core:BUILD SUCCEEDED。 +- Android APK:BUILD SUCCESSFUL。 +- Android 稳态: + - build 固定; + - reuse 持续增长; + - staticRevision == buildRevision 且保持稳定。 +- Camera 变化: + - revision 前进; + - cache rebuild 一次; + - 随后恢复稳定 reuse。 +- git diff --check:PASS。 + +## Android 代表日志 + +```text +frame=480 build=2 reuse=302 staticRevision=21 buildRevision=21 +frame=600 build=2 reuse=422 staticRevision=21 buildRevision=21 +frame=720 build=2 reuse=542 staticRevision=21 buildRevision=21 +``` + +相机变化后: + +```text +build=19 reuse=3519 staticRevision=44 buildRevision=44 +build=19 reuse=3639 staticRevision=44 buildRevision=44 +build=19 reuse=3759 staticRevision=44 buildRevision=44 +build=19 reuse=3879 staticRevision=44 buildRevision=44 +``` + +## Git + +将在 scoped commit/push 后补充最终 commit hash。 diff --git a/src/mbgl/renderer/render_orchestrator.cpp b/src/mbgl/renderer/render_orchestrator.cpp index 7a2db2413..3206b9df7 100644 --- a/src/mbgl/renderer/render_orchestrator.cpp +++ b/src/mbgl/renderer/render_orchestrator.cpp @@ -31,6 +31,7 @@ #include #include +#include namespace mbgl { @@ -151,6 +152,16 @@ RenderOrchestrator::~RenderOrchestrator() { threadPool.waitForEmpty(); } +void RenderOrchestrator::advanceRetainedUnderlayContentRevision(RetainedUnderlayReason reason) noexcept { + retainedUnderlayContentReason.store(reason, std::memory_order_release); + retainedUnderlayContentRevision.fetch_add(1, std::memory_order_acq_rel); +} + +void RenderOrchestrator::markContextLost() { + contextLost = true; + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::ContextReset); +} + void RenderOrchestrator::setObserver(RendererObserver* observer_) { observer = observer_ ? observer_ : &nullObserver(); } @@ -161,6 +172,20 @@ std::unique_ptr RenderOrchestrator::createRenderTree( const auto startTime = util::MonotonicTimer::now().count(); + const auto fontFacesIdentity = static_cast(updateParameters->fontFaces.get()); + if (retainedInputStateInitialized && + (retainedGlyphURL != updateParameters->glyphURL || + retainedFontFacesIdentity != fontFacesIdentity)) { + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::GlyphMutation); + } + if (retainedInputStateInitialized && retainedSpriteLoaded != updateParameters->spriteLoaded) { + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::ImageMutation); + } + retainedInputStateInitialized = true; + retainedGlyphURL = updateParameters->glyphURL; + retainedFontFacesIdentity = fontFacesIdentity; + retainedSpriteLoaded = updateParameters->spriteLoaded; + const bool isMapModeContinuous = updateParameters->mode == MapMode::Continuous; if (!isMapModeContinuous) { // Reset zoom history state. @@ -211,6 +236,7 @@ std::unique_ptr RenderOrchestrator::createRenderTree( const bool lightChanged = renderLight.impl != updateParameters->light; if (lightChanged) { + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::StyleMutation); renderLight.impl = updateParameters->light; renderLight.transition(transitionParameters); } @@ -220,6 +246,9 @@ std::unique_ptr RenderOrchestrator::createRenderTree( } const ImageDifference imageDiff = diffImages(imageImpls, updateParameters->images); + if (!imageDiff.added.empty() || !imageDiff.removed.empty() || !imageDiff.changed.empty()) { + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::ImageMutation); + } imageImpls = updateParameters->images; // Only trigger tile reparse for changed images. Changed images only need a @@ -249,6 +278,9 @@ std::unique_ptr RenderOrchestrator::createRenderTree( imageManager->setLoaded(updateParameters->spriteLoaded); const LayerDifference layerDiff = diffLayers(layerImpls, updateParameters->layers); + if (!layerDiff.added.empty() || !layerDiff.removed.empty() || !layerDiff.changed.empty()) { + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::LayerMutation); + } layerImpls = updateParameters->layers; const bool layersAddedOrRemoved = !layerDiff.added.empty() || !layerDiff.removed.empty(); @@ -327,6 +359,9 @@ std::unique_ptr RenderOrchestrator::createRenderTree( } const SourceDifference sourceDiff = diffSources(sourceImpls, updateParameters->sources); + if (!sourceDiff.added.empty() || !sourceDiff.removed.empty() || !sourceDiff.changed.empty()) { + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::SourceMutation); + } sourceImpls = updateParameters->sources; // Remove render layers for removed sources. @@ -751,6 +786,7 @@ void RenderOrchestrator::setFeatureState(const std::string& sourceID, if (RenderSource* renderSource = getRenderSource(sourceID)) { renderSource->setFeatureState(sourceLayerID, featureID, state); + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::SourceMutation); } } @@ -773,6 +809,7 @@ void RenderOrchestrator::removeFeatureState(const std::string& sourceID, if (RenderSource* renderSource = getRenderSource(sourceID)) { renderSource->removeFeatureState(sourceLayerID, featureID, stateKey); + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::SourceMutation); } } @@ -882,6 +919,16 @@ bool RenderOrchestrator::retainedUnderlayIsStable(TimePoint timePoint, int32_t b return true; } +bool RenderOrchestrator::retainedUnderlayHasUnsupportedLayer(int32_t boundary) const { + const auto staticLayerCount = std::min( + boundary > 0 ? static_cast(boundary) : 0, orderedLayers.size()); + for (std::size_t index = 0; index < staticLayerCount; ++index) { + const std::string_view type = orderedLayers[index].get().baseImpl->getTypeInfo()->type; + if (type == "custom" || type == "custom-drawable") return true; + } + return false; +} + bool RenderOrchestrator::isLoaded() const { MLN_TRACE_FUNC(); @@ -924,6 +971,7 @@ void RenderOrchestrator::clearData() { imageManager->clear(); glyphManager->evict(fontStacks(*layerImpls)); + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::StyleMutation); } void RenderOrchestrator::addChanges(UniqueChangeRequestVec& changes) { @@ -1076,6 +1124,7 @@ void RenderOrchestrator::updateDebugLayerGroups(const RenderTree& renderTree, Pa } void RenderOrchestrator::onGlyphsLoaded(const FontStack& fontStack, const GlyphRange& range) { + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::GlyphMutation); observer->onGlyphsLoaded(fontStack, range); } @@ -1113,6 +1162,7 @@ void RenderOrchestrator::onTileError(RenderSource& source, const OverscaledTileI void RenderOrchestrator::onTileChanged(RenderSource&, const OverscaledTileID&) { MLN_TRACE_FUNC(); + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::TileMutation); observer->onInvalidate(); } @@ -1126,12 +1176,16 @@ void RenderOrchestrator::onTileAction(RenderSource&, void RenderOrchestrator::onStyleImageMissing(const std::string& id, const std::function& done) { MLN_TRACE_FUNC(); + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::ImageMutation); observer->onStyleImageMissing(id, done); } void RenderOrchestrator::onRemoveUnusedStyleImages(const std::vector& unusedImageIDs) { MLN_TRACE_FUNC(); + if (!unusedImageIDs.empty()) { + advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::ImageMutation); + } observer->onRemoveUnusedStyleImages(unusedImageIDs); } diff --git a/src/mbgl/renderer/render_orchestrator.hpp b/src/mbgl/renderer/render_orchestrator.hpp index 5395b5b8c..5089af6c5 100644 --- a/src/mbgl/renderer/render_orchestrator.hpp +++ b/src/mbgl/renderer/render_orchestrator.hpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -64,7 +65,7 @@ public: void enableAndroidEmulatorGoldfishMitigation(bool enable) { androidGoldfishMitigationEnabled = enable; } #endif - void markContextLost() { contextLost = true; }; + void markContextLost(); // TODO: Introduce RenderOrchestratorObserver. void setObserver(RendererObserver*); @@ -111,6 +112,13 @@ public: std::optional getRetainedDynamicBoundary() const noexcept { return retainedDynamicBoundary; } bool retainedUnderlayUsesPlacement(int32_t boundary) const; bool retainedUnderlayIsStable(TimePoint, int32_t boundary) const; + bool retainedUnderlayHasUnsupportedLayer(int32_t boundary) const; + uint64_t getRetainedUnderlayContentRevision() const noexcept { + return retainedUnderlayContentRevision.load(std::memory_order_acquire); + } + RetainedUnderlayReason getRetainedUnderlayContentReason() const noexcept { + return retainedUnderlayContentReason.load(std::memory_order_acquire); + } void updateLayerIndex(LayerGroupBasePtr, int32_t newIndex); template @@ -269,6 +277,7 @@ private: /// Move changes into the pending set, clearing the provided collection void addChanges(UniqueChangeRequestVec&); + void advanceRetainedUnderlayContentRevision(RetainedUnderlayReason) noexcept; RendererObserver* observer; @@ -295,6 +304,12 @@ private: bool contextLost = false; bool placedSymbolDataCollected = false; bool tileCacheEnabled = true; + bool retainedInputStateInitialized = false; + bool retainedSpriteLoaded = false; + std::string retainedGlyphURL; + const void* retainedFontFacesIdentity = nullptr; + std::atomic retainedUnderlayContentRevision{1}; + std::atomic retainedUnderlayContentReason{RetainedUnderlayReason::None}; #if MLN_RENDER_BACKEND_OPENGL bool androidGoldfishMitigationEnabled{false}; diff --git a/src/mbgl/renderer/renderer_impl.cpp b/src/mbgl/renderer/renderer_impl.cpp index a80aac51e..df1ad8ea0 100644 --- a/src/mbgl/renderer/renderer_impl.cpp +++ b/src/mbgl/renderer/renderer_impl.cpp @@ -75,6 +75,8 @@ void logRetainedRendererCounters(const RetainedUnderlayCache& cache) { << " fallback=" << retained.fallback << " invalidation=" << retained.invalidation << " reason=" << retained.lastReason + << " staticRevision=" << cache.getStaticRevision() + << " buildRevision=" << cache.getBuildRevision() << " staticDraw=" << detailed.retainedUnderlayStaticGroupDraw << " dynamicDraw=" << detailed.retainedDynamicGroupDraw << " particleAdvance=" << detailed.particleSimulationAdvance; @@ -301,14 +303,19 @@ void Renderer::Impl::render(const RenderTree& renderTree, const std::shared_ptr< std::optional retainedKey; const auto retainedBoundary = orchestrator.getRetainedDynamicBoundary(); - size_t retainedStaticGroupCount = 0; + RetainedUnderlayTopologySnapshot retainedTopology{.boundary = retainedBoundary, .staticPrefix = {}}; + mat4 retainedCameraMatrix; + state.getProjMatrix(retainedCameraMatrix); if (retainedBoundary) { - orchestrator.visitLayerGroupsBefore(*retainedBoundary, - [&](LayerGroupBase&, size_t) { ++retainedStaticGroupCount; }); - mat4 cameraMatrix; - state.getProjMatrix(cameraMatrix); - retainedKey = RetainedUnderlayKey{size, *retainedBoundary, cameraMatrix}; + orchestrator.visitLayerGroupsBefore(*retainedBoundary, [&](LayerGroupBase& layerGroup, size_t) { + retainedTopology.staticPrefix.push_back({.layerIndex = layerGroup.getLayerIndex(), + .groupName = layerGroup.getName(), + .groupType = static_cast(layerGroup.getType())}); + }); + retainedKey = RetainedUnderlayKey{size, *retainedBoundary, retainedCameraMatrix}; } + const auto retainedTopologyRevision = retainedUnderlayTopologyTracker.observe(retainedTopology); + const auto retainedStaticGroupCount = retainedTopology.staticPrefix.size(); bool legacyBelowBoundary = false; if (retainedBoundary) { for (const auto& item : layerRenderItems) { @@ -325,11 +332,33 @@ void Renderer::Impl::render(const RenderTree& renderTree, const std::shared_ptr< const bool retainedPlacementStable = !retainedStaticUsesPlacement || (!renderTreeParameters.placementChanged && renderTreeParameters.symbolFadeChange == 0.0f); + const bool retainedUnsupportedStaticLayer = + retainedBoundary && orchestrator.retainedUnderlayHasUnsupportedLayer(*retainedBoundary); + const auto retainedContentRevision = orchestrator.getRetainedUnderlayContentRevision(); + const auto retainedContentReason = orchestrator.getRetainedUnderlayContentReason(); + const RetainedUnderlayRevisionState retainedRevisionState{ + .viewport = size, + .cameraMatrix = retainedCameraMatrix, + .boundary = retainedBoundary, + .pixelRatio = updateParameters->pixelRatio, + .contentRevision = retainedContentRevision, + .topologyRevision = retainedTopologyRevision, + .mapMode = static_cast(updateParameters->mode), + .debugOptions = static_cast(renderTreeParameters.debugOptions), + .styleLoaded = updateParameters->styleLoaded, + .renderTreeLoaded = renderTreeParameters.loaded, + .staticStable = retainedStaticStable, + .placementStable = retainedPlacementStable, + .has3D = renderTreeParameters.has3D, + .unsupportedStaticLayer = retainedUnsupportedStaticLayer, + }; + const auto retainedStaticRevision = retainedUnderlayRevisionTracker.observe( + retainedRevisionState, retainedUnderlayReasonText(retainedContentReason)); const bool retainedSupported = retainedBoundary && retainedStaticGroupCount > 0 && renderTreeParameters.loaded && !renderTreeParameters.has3D && renderTreeParameters.debugOptions == MapDebugOptions::NoDebug && retainedStaticStable && retainedPlacementStable && - !legacyBelowBoundary; + !legacyBelowBoundary && !retainedUnsupportedStaticLayer; const char* retainedFallbackReason = !retainedBoundary ? "no-boundary" : retainedStaticGroupCount == 0 ? "empty-static-prefix" : !renderTreeParameters.loaded ? "style-not-loaded" @@ -341,10 +370,13 @@ void Renderer::Impl::render(const RenderTree& renderTree, const std::shared_ptr< : !retainedPlacementStable ? "static-placement" : legacyBelowBoundary ? "legacy-ordering" + : retainedUnsupportedStaticLayer ? "unsupported" : nullptr; auto retainedDecision = retainedUnderlayCache->evaluate(retainedKey, + retainedStaticRevision, retainedSupported, - retainedFallbackReason); + retainedFallbackReason, + retainedUnderlayRevisionTracker.reason().c_str()); if ((retainedDecision == RetainedUnderlayDecision::Build || retainedDecision == RetainedUnderlayDecision::Reuse) && (!staticData->shaders || !retainedUnderlayCache->prepareResources(context, *staticData->shaders, *retainedKey))) { diff --git a/src/mbgl/renderer/renderer_impl.hpp b/src/mbgl/renderer/renderer_impl.hpp index 30ee7e3ca..c7153057e 100644 --- a/src/mbgl/renderer/renderer_impl.hpp +++ b/src/mbgl/renderer/renderer_impl.hpp @@ -57,6 +57,8 @@ private: const float pixelRatio; std::unique_ptr staticData; std::unique_ptr retainedUnderlayCache; + RetainedUnderlayRevisionTracker retainedUnderlayRevisionTracker; + RetainedUnderlayTopologyTracker retainedUnderlayTopologyTracker; gfx::DynamicTextureAtlasPtr dynamicTextureAtlas; bool styleLoaded = false; diff --git a/src/mbgl/renderer/retained_underlay_cache.cpp b/src/mbgl/renderer/retained_underlay_cache.cpp index f19ecc09b..38d6e681c 100644 --- a/src/mbgl/renderer/retained_underlay_cache.cpp +++ b/src/mbgl/renderer/retained_underlay_cache.cpp @@ -39,11 +39,73 @@ RetainedUnderlayReason retainedReason(const char* reason) noexcept { if (value == "static-placement") return RetainedUnderlayReason::StaticPlacement; if (value == "legacy-ordering") return RetainedUnderlayReason::LegacyOrdering; if (value == "resource-or-compositor") return RetainedUnderlayReason::ResourceOrCompositor; + if (value == "style-mutation") return RetainedUnderlayReason::StyleMutation; + if (value == "layer-mutation") return RetainedUnderlayReason::LayerMutation; + if (value == "source-mutation") return RetainedUnderlayReason::SourceMutation; + if (value == "tile-mutation") return RetainedUnderlayReason::TileMutation; + if (value == "image-mutation") return RetainedUnderlayReason::ImageMutation; + if (value == "glyph-mutation") return RetainedUnderlayReason::GlyphMutation; + if (value == "layer-group-mutation") return RetainedUnderlayReason::LayerGroupMutation; + if (value == "context-reset") return RetainedUnderlayReason::ContextReset; + if (value == "pixel-ratio") return RetainedUnderlayReason::PixelRatio; + if (value == "static-revision") return RetainedUnderlayReason::StaticRevision; if (value == "unsupported") return RetainedUnderlayReason::Unsupported; return RetainedUnderlayReason::Unknown; } } +uint64_t RetainedUnderlayTopologyTracker::observe(const RetainedUnderlayTopologySnapshot& next) { + if (!snapshot) { + snapshot = next; + return revision; + } + if (*snapshot == next) return revision; + snapshot = next; + return ++revision; +} + +uint64_t RetainedUnderlayRevisionTracker::observe(const RetainedUnderlayRevisionState& next, + const char* contentReason) { + if (!state) { + state = next; + return revision; + } + if (*state == next) return revision; + + const auto& previous = *state; + if (previous.viewport != next.viewport) { + lastReason = "viewport"; + } else if (previous.pixelRatio != next.pixelRatio) { + lastReason = "pixel-ratio"; + } else if (previous.cameraMatrix != next.cameraMatrix || previous.boundary != next.boundary) { + lastReason = "camera-or-boundary"; + } else if (previous.topologyRevision != next.topologyRevision) { + lastReason = "layer-group-mutation"; + } else if (previous.contentRevision != next.contentRevision) { + lastReason = contentReason ? contentReason : "static-revision"; + } else if (previous.styleLoaded != next.styleLoaded) { + lastReason = "style-mutation"; + } else if (previous.renderTreeLoaded != next.renderTreeLoaded) { + lastReason = "tile-mutation"; + } else if (previous.staticStable != next.staticStable) { + lastReason = "static-transition"; + } else if (previous.placementStable != next.placementStable) { + lastReason = "static-placement"; + } else if (previous.debugOptions != next.debugOptions) { + lastReason = "debug-options"; + } else if (previous.has3D != next.has3D) { + lastReason = "3d"; + } else if (previous.unsupportedStaticLayer != next.unsupportedStaticLayer || + previous.mapMode != next.mapMode) { + lastReason = "unsupported"; + } else { + lastReason = "static-revision"; + } + state = next; + ++revision; + return revision; +} + RetainedUnderlayCache::RetainedUnderlayCache() : runtimeState(std::make_shared()) {} RetainedUnderlayCache::~RetainedUnderlayCache() = default; @@ -69,9 +131,19 @@ void RetainedUnderlayCache::synchronizeRuntimeState( } RetainedUnderlayDecision RetainedUnderlayCache::evaluate(const std::optional& nextKey, + uint64_t staticRevision, bool supported, - const char* fallbackReason) { - if (!nextKey) return RetainedUnderlayDecision::Disabled; + const char* fallbackReason, + const char* revisionReason) { + const bool revisionChanged = observedStaticRevision != 0 && observedStaticRevision != staticRevision; + observedStaticRevision = staticRevision; + if (!nextKey) { + if (valid || key) { + invalidate(fallbackReason ? fallbackReason : "no-boundary"); + releaseResources(); + } + return RetainedUnderlayDecision::Disabled; + } if (runtimeState && !runtimeState->isEnabled()) { ++fallbackCount; runtimeState->recordFallback(); @@ -83,13 +155,26 @@ RetainedUnderlayDecision RetainedUnderlayCache::evaluate(const std::optionalrecordFallback(); particle::incrementRendererDebugCounter(particle::RendererDebugCounter::RetainedUnderlayFallback); invalidate(fallbackReason ? fallbackReason : "unsupported"); + if (revisionChanged) releaseResources(); return RetainedUnderlayDecision::Fallback; } - if (!key || *key != *nextKey) { + if (revisionChanged) { + invalidate(revisionReason ? revisionReason : "static-revision"); + // A content or backend generation change may have made any retained GPU object stale. + // Releasing here is deliberately conservative; these events are not the continuous + // Particle repaint itself and therefore do not affect the steady-state reuse path. + releaseResources(); + key = nextKey; + } else if (!key || *key != *nextKey) { const char* reason = !key ? "first-frame" : (key->viewport != nextKey->viewport ? "viewport" : "camera-or-boundary"); invalidate(reason); key = nextKey; } + if (valid && buildRevision != observedStaticRevision) { + invalidate("static-revision"); + releaseResources(); + key = nextKey; + } if (valid) { ++reuseCount; runtimeState->recordReuse(); @@ -181,6 +266,7 @@ void RetainedUnderlayCache::composite(RenderOrchestrator& orchestrator, PaintPar void RetainedUnderlayCache::markBuildSucceeded() { valid = true; + buildRevision = observedStaticRevision; ++buildCount; runtimeState->recordBuild(); particle::incrementRendererDebugCounter(particle::RendererDebugCounter::RetainedUnderlayBuild); @@ -191,6 +277,7 @@ void RetainedUnderlayCache::markBuildFailed(const char* reason) { runtimeState->recordFallback(); particle::incrementRendererDebugCounter(particle::RendererDebugCounter::RetainedUnderlayFallback); invalidate(reason); + releaseResources(); } void RetainedUnderlayCache::invalidate(const char* reason) { @@ -202,6 +289,7 @@ void RetainedUnderlayCache::invalidate(const char* reason) { void RetainedUnderlayCache::releaseResources() { valid = false; + buildRevision = 0; key.reset(); compositeLayerGroup.reset(); offscreenTexture.reset(); diff --git a/src/mbgl/renderer/retained_underlay_cache.hpp b/src/mbgl/renderer/retained_underlay_cache.hpp index 83f09e71e..fdff71899 100644 --- a/src/mbgl/renderer/retained_underlay_cache.hpp +++ b/src/mbgl/renderer/retained_underlay_cache.hpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace mbgl { @@ -30,6 +31,62 @@ struct RetainedUnderlayKey { bool operator==(const RetainedUnderlayKey&) const = default; }; +struct RetainedUnderlayTopologyEntry { + int32_t layerIndex = 0; + std::string groupName; + uint8_t groupType = 0; + + bool operator==(const RetainedUnderlayTopologyEntry&) const = default; +}; + +struct RetainedUnderlayTopologySnapshot { + std::optional boundary; + std::vector staticPrefix; + + bool operator==(const RetainedUnderlayTopologySnapshot&) const = default; +}; + +class RetainedUnderlayTopologyTracker { +public: + uint64_t observe(const RetainedUnderlayTopologySnapshot&); + uint64_t current() const noexcept { return revision; } + +private: + std::optional snapshot; + uint64_t revision = 1; +}; + +struct RetainedUnderlayRevisionState { + Size viewport; + mat4 cameraMatrix{}; + std::optional boundary; + float pixelRatio = 1.0f; + uint64_t contentRevision = 0; + uint64_t topologyRevision = 0; + uint8_t mapMode = 0; + uint32_t debugOptions = 0; + bool styleLoaded = false; + bool renderTreeLoaded = false; + bool staticStable = false; + bool placementStable = false; + bool has3D = false; + bool unsupportedStaticLayer = false; + + bool operator==(const RetainedUnderlayRevisionState&) const = default; +}; + +class RetainedUnderlayRevisionTracker { +public: + uint64_t observe(const RetainedUnderlayRevisionState&, const char* contentReason = nullptr); + uint64_t current() const noexcept { return revision; } + const std::string& reason() const noexcept { return lastReason; } + +private: + std::optional state; + uint64_t revision = 1; + std::string lastReason{"first-frame"}; +}; + enum class RetainedUnderlayDecision : uint8_t { Disabled, Build, Reuse, Fallback }; class RetainedDynamicBoundaryTracker { @@ -53,8 +110,11 @@ public: RetainedUnderlayCache(); ~RetainedUnderlayCache(); - RetainedUnderlayDecision evaluate(const std::optional&, bool supported, - const char* fallbackReason = nullptr); + RetainedUnderlayDecision evaluate(const std::optional&, + uint64_t staticRevision, + bool supported, + const char* fallbackReason = nullptr, + const char* revisionReason = nullptr); void synchronizeRuntimeState(const std::shared_ptr&); bool prepareResources(gfx::Context&, gfx::ShaderRegistry&, const RetainedUnderlayKey&); void upload(gfx::UploadPass&); @@ -69,6 +129,8 @@ public: uint64_t getBuildCount() const noexcept { return buildCount; } uint64_t getReuseCount() const noexcept { return reuseCount; } uint64_t getFallbackCount() const noexcept { return fallbackCount; } + uint64_t getStaticRevision() const noexcept { return observedStaticRevision; } + uint64_t getBuildRevision() const noexcept { return buildRevision; } const std::string& getLastInvalidationReason() const noexcept { return lastInvalidationReason; } RetainedUnderlayDiagnostics getDiagnostics() const { return runtimeState->snapshot(); } @@ -83,6 +145,8 @@ private: uint64_t buildCount = 0; uint64_t reuseCount = 0; uint64_t fallbackCount = 0; + uint64_t observedStaticRevision = 0; + uint64_t buildRevision = 0; std::string lastInvalidationReason; std::shared_ptr runtimeState; uint64_t observedControlRevision = 0; diff --git a/src/mbgl/renderer/retained_underlay_runtime_state.hpp b/src/mbgl/renderer/retained_underlay_runtime_state.hpp index b0c0cc464..5978a7c1d 100644 --- a/src/mbgl/renderer/retained_underlay_runtime_state.hpp +++ b/src/mbgl/renderer/retained_underlay_runtime_state.hpp @@ -21,6 +21,16 @@ enum class RetainedUnderlayReason : uint8_t { StaticPlacement, LegacyOrdering, ResourceOrCompositor, + StyleMutation, + LayerMutation, + SourceMutation, + TileMutation, + ImageMutation, + GlyphMutation, + LayerGroupMutation, + ContextReset, + PixelRatio, + StaticRevision, Unsupported, Unknown, }; @@ -41,6 +51,16 @@ inline const char* retainedUnderlayReasonText(RetainedUnderlayReason reason) noe case RetainedUnderlayReason::StaticPlacement: return "static-placement"; case RetainedUnderlayReason::LegacyOrdering: return "legacy-ordering"; case RetainedUnderlayReason::ResourceOrCompositor: return "resource-or-compositor"; + case RetainedUnderlayReason::StyleMutation: return "style-mutation"; + case RetainedUnderlayReason::LayerMutation: return "layer-mutation"; + case RetainedUnderlayReason::SourceMutation: return "source-mutation"; + case RetainedUnderlayReason::TileMutation: return "tile-mutation"; + case RetainedUnderlayReason::ImageMutation: return "image-mutation"; + case RetainedUnderlayReason::GlyphMutation: return "glyph-mutation"; + case RetainedUnderlayReason::LayerGroupMutation: return "layer-group-mutation"; + case RetainedUnderlayReason::ContextReset: return "context-reset"; + case RetainedUnderlayReason::PixelRatio: return "pixel-ratio"; + case RetainedUnderlayReason::StaticRevision: return "static-revision"; case RetainedUnderlayReason::Unsupported: return "unsupported"; case RetainedUnderlayReason::Unknown: return "unknown"; } diff --git a/test/renderer/retained_underlay_cache.test.cpp b/test/renderer/retained_underlay_cache.test.cpp index 41290e89d..169ae8ed1 100644 --- a/test/renderer/retained_underlay_cache.test.cpp +++ b/test/renderer/retained_underlay_cache.test.cpp @@ -2,6 +2,7 @@ #include +#include #include using namespace mbgl; @@ -15,6 +16,21 @@ RetainedUnderlayKey key(Size size = {640, 480}, int32_t boundary = 4) { result.cameraMatrix[15] = 1.0; return result; } + +RetainedUnderlayRevisionState revisionState(Size size = {640, 480}, int32_t boundary = 4) { + RetainedUnderlayRevisionState result; + result.viewport = size; + result.boundary = boundary; + result.pixelRatio = 2.0f; + result.contentRevision = 1; + result.topologyRevision = 1; + result.styleLoaded = true; + result.renderTreeLoaded = true; + result.staticStable = true; + result.placementStable = true; + result.cameraMatrix = key(size, boundary).cameraMatrix; + return result; +} } // namespace TEST(RetainedDynamicBoundary, SelectsFirstCandidate) { @@ -64,25 +80,206 @@ TEST(RetainedLayerGroupRange, PreservesForwardReverseOriginalOrdinals) { TEST(RetainedUnderlayCache, BuildReuseAndInvalidation) { RetainedUnderlayCache cache; - EXPECT_EQ(cache.evaluate(std::nullopt, false), RetainedUnderlayDecision::Disabled); - EXPECT_EQ(cache.evaluate(key(), true), RetainedUnderlayDecision::Build); + EXPECT_EQ(cache.evaluate(std::nullopt, 1, false), RetainedUnderlayDecision::Disabled); + EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Build); cache.markBuildSucceeded(); - EXPECT_EQ(cache.evaluate(key(), true), RetainedUnderlayDecision::Reuse); + EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Reuse); + EXPECT_EQ(cache.getBuildRevision(), 1u); - EXPECT_EQ(cache.evaluate(key({800, 600}), true), RetainedUnderlayDecision::Build); + EXPECT_EQ(cache.evaluate(key({800, 600}), 2, true, nullptr, "viewport"), RetainedUnderlayDecision::Build); EXPECT_EQ(cache.getLastInvalidationReason(), "viewport"); cache.markBuildSucceeded(); - EXPECT_EQ(cache.evaluate(key({800, 600}, 6), true), RetainedUnderlayDecision::Build); + EXPECT_EQ(cache.evaluate(key({800, 600}, 6), 3, true, nullptr, "camera-or-boundary"), + RetainedUnderlayDecision::Build); EXPECT_EQ(cache.getLastInvalidationReason(), "camera-or-boundary"); } +TEST(RetainedUnderlayRevision, MonotonicAcrossCameraViewportStyleAndSourceChanges) { + RetainedUnderlayRevisionTracker tracker; + RetainedUnderlayCache cache; + auto state = revisionState(); + const auto currentKey = [&] { + return RetainedUnderlayKey{state.viewport, *state.boundary, state.cameraMatrix}; + }; + EXPECT_EQ(tracker.observe(state), 1u); + EXPECT_EQ(tracker.observe(state), 1u); + EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true), RetainedUnderlayDecision::Build); + cache.markBuildSucceeded(); + + state.cameraMatrix[12] = 4.0; + EXPECT_EQ(tracker.observe(state), 2u); + EXPECT_EQ(tracker.reason(), "camera-or-boundary"); + EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()), + RetainedUnderlayDecision::Build); + cache.markBuildSucceeded(); + + state.viewport = {800, 600}; + EXPECT_EQ(tracker.observe(state), 3u); + EXPECT_EQ(tracker.reason(), "viewport"); + EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()), + RetainedUnderlayDecision::Build); + cache.markBuildSucceeded(); + + state.pixelRatio = 3.0f; + EXPECT_EQ(tracker.observe(state), 4u); + EXPECT_EQ(tracker.reason(), "pixel-ratio"); + EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()), + RetainedUnderlayDecision::Build); + cache.markBuildSucceeded(); + + ++state.contentRevision; + EXPECT_EQ(tracker.observe(state, "style-mutation"), 5u); + EXPECT_EQ(tracker.reason(), "style-mutation"); + EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()), + RetainedUnderlayDecision::Build); + cache.markBuildSucceeded(); + + ++state.contentRevision; + EXPECT_EQ(tracker.observe(state, "layer-mutation"), 6u); + EXPECT_EQ(tracker.reason(), "layer-mutation"); + EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()), + RetainedUnderlayDecision::Build); + cache.markBuildSucceeded(); + + ++state.contentRevision; + EXPECT_EQ(tracker.observe(state, "source-mutation"), 7u); + EXPECT_EQ(tracker.reason(), "source-mutation"); + EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()), + RetainedUnderlayDecision::Build); + cache.markBuildSucceeded(); + + ++state.contentRevision; + EXPECT_EQ(tracker.observe(state, "tile-mutation"), 8u); + EXPECT_EQ(tracker.reason(), "tile-mutation"); + EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()), + RetainedUnderlayDecision::Build); + cache.markBuildSucceeded(); + + ++state.topologyRevision; + EXPECT_EQ(tracker.observe(state), 9u); + EXPECT_EQ(tracker.reason(), "layer-group-mutation"); + EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()), + RetainedUnderlayDecision::Build); +} + +TEST(RetainedUnderlayRevision, RepeatedFramesWithoutSemanticMutationDoNotAdvanceRevision) { + RetainedUnderlayRevisionTracker tracker; + auto state = revisionState(); + const auto settledRevision = tracker.observe(state); + + // Transient drawable add/remove/rebuild and equivalent stencil assignment are + // deliberately not inputs to the semantic revision state. Stable frames must + // therefore keep the revision used by the retained build. + for (int frame = 0; frame < 240; ++frame) { + EXPECT_EQ(tracker.observe(state), settledRevision); + } + + ++state.contentRevision; + EXPECT_GT(tracker.observe(state, "tile-mutation"), settledRevision); + EXPECT_EQ(tracker.reason(), "tile-mutation"); +} + +TEST(RetainedUnderlayRevision, EquivalentIntraFrameRemoveAddDoesNotAdvanceTopology) { + RetainedUnderlayTopologyTracker tracker; + const RetainedUnderlayTopologySnapshot initial{ + .boundary = 32, + .staticPrefix = {{3, "sea-area-fill", 1}, {31, "clip-outline", 1}}, + }; + const auto settledRevision = tracker.observe(initial); + + // The request processor may temporarily remove a group and add an equivalent + // replacement. Only the final post-processChanges snapshot is observed. + auto finalTopology = initial; + finalTopology.staticPrefix.erase(finalTopology.staticPrefix.begin()); + finalTopology.staticPrefix.insert(finalTopology.staticPrefix.begin(), {3, "sea-area-fill", 1}); + EXPECT_EQ(finalTopology, initial); + EXPECT_EQ(tracker.observe(finalTopology), settledRevision); +} + +TEST(RetainedUnderlayRevision, PersistentTopologyChangeAdvancesOnce) { + RetainedUnderlayTopologyTracker tracker; + const RetainedUnderlayTopologySnapshot initial{ + .boundary = 32, + .staticPrefix = {{3, "sea-area-fill", 1}, {31, "clip-outline", 1}}, + }; + const auto initialRevision = tracker.observe(initial); + + auto changed = initial; + changed.staticPrefix.pop_back(); + const auto changedRevision = tracker.observe(changed); + EXPECT_GT(changedRevision, initialRevision); + EXPECT_EQ(tracker.observe(changed), changedRevision); +} + +TEST(RetainedUnderlayRevision, OrderingAndIndexChangesAdvanceTopology) { + RetainedUnderlayTopologyTracker tracker; + RetainedUnderlayTopologySnapshot topology{ + .boundary = 32, + .staticPrefix = {{3, "sea-area-fill", 1}, {31, "clip-outline", 1}}, + }; + const auto initialRevision = tracker.observe(topology); + + std::swap(topology.staticPrefix[0], topology.staticPrefix[1]); + const auto reorderedRevision = tracker.observe(topology); + EXPECT_GT(reorderedRevision, initialRevision); + + topology.staticPrefix[0].layerIndex = 30; + const auto reindexedRevision = tracker.observe(topology); + EXPECT_GT(reindexedRevision, reorderedRevision); + EXPECT_EQ(tracker.observe(topology), reindexedRevision); +} + +TEST(RetainedUnderlayRevision, MatchingBuildRevisionAllowsReuseAndMismatchRebuilds) { + RetainedUnderlayCache cache; + EXPECT_EQ(cache.evaluate(key(), 7, true), RetainedUnderlayDecision::Build); + cache.markBuildSucceeded(); + EXPECT_EQ(cache.getBuildRevision(), 7u); + EXPECT_EQ(cache.evaluate(key(), 7, true), RetainedUnderlayDecision::Reuse); + + EXPECT_EQ(cache.evaluate(key(), 8, true, nullptr, "tile-mutation"), RetainedUnderlayDecision::Build); + EXPECT_FALSE(cache.isValid()); + EXPECT_EQ(cache.getLastInvalidationReason(), "tile-mutation"); +} + +TEST(RetainedUnderlayRevision, PlacementAndFadeActivityRejectsStaleCacheUntilStable) { + RetainedUnderlayRevisionTracker tracker; + RetainedUnderlayCache cache; + auto state = revisionState(); + auto revision = tracker.observe(state); + EXPECT_EQ(cache.evaluate(key(), revision, true), RetainedUnderlayDecision::Build); + cache.markBuildSucceeded(); + + state.placementStable = false; + revision = tracker.observe(state); + EXPECT_EQ(cache.evaluate(key(), revision, false, "static-placement", tracker.reason().c_str()), + RetainedUnderlayDecision::Fallback); + EXPECT_FALSE(cache.isValid()); + + state.placementStable = true; + revision = tracker.observe(state); + EXPECT_EQ(cache.evaluate(key(), revision, true, nullptr, tracker.reason().c_str()), + RetainedUnderlayDecision::Build); + cache.markBuildSucceeded(); + + state.staticStable = false; + revision = tracker.observe(state); + EXPECT_EQ(cache.evaluate(key(), revision, false, "static-transition", tracker.reason().c_str()), + RetainedUnderlayDecision::Fallback); + EXPECT_FALSE(cache.isValid()); + + state.staticStable = true; + revision = tracker.observe(state); + EXPECT_EQ(cache.evaluate(key(), revision, true, nullptr, tracker.reason().c_str()), + RetainedUnderlayDecision::Build); +} + TEST(RetainedUnderlayCache, UnsupportedAndResourceFailureFallBackSafely) { RetainedUnderlayCache cache; - EXPECT_EQ(cache.evaluate(key(), false, "3d"), RetainedUnderlayDecision::Fallback); + EXPECT_EQ(cache.evaluate(key(), 1, false, "3d"), RetainedUnderlayDecision::Fallback); EXPECT_EQ(cache.getLastInvalidationReason(), "3d"); EXPECT_FALSE(cache.isValid()); - EXPECT_EQ(cache.evaluate(key(), true), RetainedUnderlayDecision::Build); + EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Build); cache.markBuildFailed("resource-or-compositor"); EXPECT_FALSE(cache.isValid()); EXPECT_EQ(cache.getFallbackCount(), 2u); @@ -93,14 +290,14 @@ TEST(RetainedUnderlayCache, RuntimeControlDisablesAndReenablesMapScopedCache) { RetainedUnderlayCache cache; cache.synchronizeRuntimeState(state); - EXPECT_EQ(cache.evaluate(key(), true), RetainedUnderlayDecision::Build); + EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Build); cache.markBuildSucceeded(); EXPECT_TRUE(cache.isValid()); EXPECT_TRUE(state->setEnabled(false)); cache.synchronizeRuntimeState(state); EXPECT_FALSE(cache.isValid()); - EXPECT_EQ(cache.evaluate(key(), true), RetainedUnderlayDecision::Fallback); + EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Fallback); auto disabled = state->snapshot(); EXPECT_FALSE(disabled.enabled); EXPECT_EQ(disabled.build, 1u); @@ -110,6 +307,6 @@ TEST(RetainedUnderlayCache, RuntimeControlDisablesAndReenablesMapScopedCache) { EXPECT_TRUE(state->setEnabled(true)); cache.synchronizeRuntimeState(state); - EXPECT_EQ(cache.evaluate(key(), true), RetainedUnderlayDecision::Build); + EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Build); EXPECT_TRUE(state->snapshot().enabled); }