feat(step102): isolate placement-free retained segments

This commit is contained in:
tei
2026-10-08 09:59:58 +09:00
parent 6df21dac38
commit 0ce551ad37
14 changed files with 922 additions and 27 deletions

View File

@@ -0,0 +1,262 @@
# STEP-102 RenderSegment 无 Placement 段局部复用 Codex 任务 v1
## 1. Step 编号和目的
STEP-102:修复 full CPU 多 Retained Segment 下 placement aggregate gate 的不必要扩散,使**结构上不具备 placement 能力**的 Retained Segment 能在其他 placement-bearing Segment fallback/live 时继续独立 composite/reuse。
本 Step 的设计依据:
`docs/20_调查/RenderSegment多Retained段全局Placement门槛_粒子管理员审查说明_v3.md`。
## 2. 当前已冻结基线
- 仓库:`/Users/tei/dev/MapLibreParticleLab-step102`
- branch:`step102-placement-free-retained-locality`
- start HEAD:`6df21dac38f072c5ad35b8dfc7ec5217509602bc`
- upstream base:MapLibre Native core 既有冻结基线,不升级
- V19/V20 release worktree:冻结,只读,不修改
- STEP-100 source-scoped locality:必须保持
- STEP-101 teardown 修复:必须保持
- Fast Path:本 Step 不修改
- C-001:不升级
## 3. 本 Step 目标
必须实现:
1. 增加结构性 placement-capable range 查询;
2. full CPU 每个 Retained Segment 独立计算 placement gate;
3. placement-free Retained 不再被其他 Segment 的 placementChanged/symbolFadeChange 纯 aggregate gate 拖入 fallback;
4. placement-bearing Retained 继续使用现有保守 placement gate;
5. 从 full CPU `globalLiveFallback` 移除纯 aggregate placement 条件;
6. 保持真正全局 fail-closed 条件;
7. diagnostics 增加结构 placement、per-segment gate、实际 composite/live 与全局 main-buffer execution 信息;
8. RN diagnostics TS 类型做 additive 扩展;
9. 增加对应 C++ 测试源码与 Android Validation 场景源码;
10. 不改变 Style Z-order、mixed executor、C-001 和 RN method surface。
## 4. 非目标 / 禁止事项
禁止:
- placement-bearing Segment 之间的局部碰撞缓存;
- per-segment collision graph / symbol fade / placement fingerprint;
- Fast Path 改造;
- multi-continuous Fast Path;
- 重写 `visitRenderSegmentExecution()`;
- 修改 NavSea;
- 新增 NavSea 私有 Native API;
- 修改 C-001;
- 修改 V19/V20 release worktree/tag;
- 升级 MapLibre、NDK、Gradle、AGP、Java、Node 或其他工具链;
- 为通过测试降低 fail-closed 门槛;
- Android Gradle/build、APK/AAR build、ADB、设备操作;
- Jest。用户未要求 Jest,本任务不得新增或执行 Jest。
## 5. 允许修改范围
生产代码优先限制在:
- `src/mbgl/renderer/render_orchestrator.hpp`
- `src/mbgl/renderer/render_orchestrator.cpp`
- `src/mbgl/renderer/renderer_impl.cpp`
- `include/mbgl/renderer/render_segment.hpp`
- `src/mbgl/renderer/retained_underlay_runtime_state.hpp`
- `src/mbgl/renderer/render_segment_json.hpp`
- `packages/maplibre-particle-rn/src/types.ts`
测试/Validation 允许:
- `test/renderer/retained_underlay_cache.test.cpp`
- 与现有 Particle Validation 场景直接相关的最小 Android Test App 文件
文档允许:
- 本任务书
- `docs/50_Step记录/STEP-102_RenderSegment无Placement段局部复用_v1.md`
- `docs/报告/STEP-102_RenderSegment无Placement段局部复用_Codex完成报告_v1.md`
- `粒子开发记录.md`
- v3 审查说明只读,除非发现明确事实错误;若需改,停止并先报告
如果实现必须扩大生产代码范围,先停止并报告原因,不得自行扩张架构。
## 6. 禁止修改范围
- NavSea 仓库
- `navsea-particle-integration` contract/RFC/ADR
- Fast Path 逻辑、`tryRenderParticleFastFrame()`、`singleContinuousSegment()` 语义
- `retainedRangeUsesPlacement()` 现有动态语义
- mixed executor 算法
- Source `isLoaded()` 定义
- STEP-100 source revision/locality 语义
- STEP-101 teardown 生命周期语义
- renderer/backend 架构
- release/version/tag/publish 坐标
## 7. 实施要求
### 7.1 结构性 placement-capable 查询
新增 full CPU retained 专用查询,必须基于 layer 结构,而不是当前帧 placementData。
最小保守语义:
- range 中存在 `symbol` layer → placement-capable
- 无 `symbol` layer → placement-free
- custom/custom-drawable 继续由 unsupported gate 处理
不得用 `needsPlacement()==false` 证明 placement-free。
### 7.2 per-segment placement gate
每个 Retained Segment 至少形成两个明确事实:
- structural placement capability
- current full-CPU placement gate result
placement-free Segment 不受纯 global placement gate 阻塞。
placement-bearing Segment 本 Step 继续沿用旧保守 gate,不承诺稳定后 reuse。
变量/字段命名不得把 `symbolFadeChange==0` 错称为严格 placement stable。
### 7.3 full CPU mixed execution
`segmentSupported` 改为使用对应 Segment placement gate。
`globalLiveFallback` 仅移除纯 aggregate placement 条件,其他真正 global 条件继续保留。
不得重写 `visitRenderSegmentExecution()`。
### 7.4 fallback reason
`static-placement` 必须按 Segment 自身 placement capability/gate 判定,不能从 aggregate reason 无差别传播。
### 7.5 diagnostics
每个 Segment 最少新增等价语义:
- `placementCapable`
- `placementGatePassed`
- `actualExecution = composite | live`
全局最少新增等价语义:
- `placementChanged`
- `symbolFadeChange`
- `placementTransitionActive`
- `crossSourceCollisions`
- `mainBufferExecutionMode = mixed | global-live`
- `globalLiveFallbackReason`
`actualExecution` 必须反映本帧 main-buffer 最终实际执行,不能根据 cache finalDecision 推导。
Diagnostics 查询必须无副作用、不得推进 revision。
### 7.6 RN 类型
只扩展现有 diagnostics 返回类型,不新增 method,不改变 `setRenderSegmentPlan()` 输入 contract。
Android/iOS Shared Core JSON 语义保持一致。
## 8. 构建要求
Codex 本阶段**不得执行任何 Android 编译/Gradle/APK/AAR build**,也不得启动长时间 CMake/Ninja/Bazel 构建。
Codex 完成代码后只需提供精确验证命令,由后续 RDC/用户按规则执行。其中 Android 编译由用户手工执行。
Codex 可执行的机械静态检查仅限:
- `git diff --check`
- 只读 grep/rg/git diff/status
- 不产生构建产物的格式/静态检查(若仓库已有且无需安装依赖)
不得安装或升级系统依赖来制造测试 PASS。
## 9. 测试要求
Codex 必须新增/补强测试源码,但本阶段不执行大型测试。
至少覆盖:
1. placement-free R0 + placement-bearing R1:R0 可 composite,R1 live;
2. 含 symbol 但当前 placementData 为空:仍判定 placementCapable=true;
3. 两个 placement-free Retained:持续 reuse/composite;
4. source-local fading/mutation 回归不误伤 R0;
5. placement-bearing 在 transition disabled / `symbolFadeChange=1` 等状态仍不被本 Step 放宽;
6. 真正 global gate 继续 global-live/fail-closed;
7. topology 变化释放旧 resource;
8. cache decision=reuse 但 global-live 时,diagnostics 的 actualExecution 必须为 live;
9. diagnostics JSON/TS 字段语义一致且查询无副作用。
Android Validation 场景应能在后续手工构建后明确输出 STEP-102 验收所需字段,不依赖 NavSea。
## 10. 验收标准
代码审计门槛:
- [ ] 新增结构性 placement-capable 查询
- [ ] 未改变 `retainedRangeUsesPlacement()` 旧语义
- [ ] full CPU per-segment placement gate 生效
- [ ] placement-bearing 继续保守
- [ ] `globalLiveFallback` 只移除纯 aggregate placement 条件
- [ ] 真正 global gate 保留
- [ ] mixed executor 未重写
- [ ] Fast Path 无语义修改
- [ ] per-segment fallback reason 正确
- [ ] diagnostics 有 actualExecution 与 main-buffer mode/fallback reason
- [ ] RN method surface 不变
- [ ] C-001 不变
- [ ] 测试源码覆盖设计矩阵
- [ ] `git diff --check` PASS
运行态 PASS 不在 Codex 阶段宣称。Android 手工编译与真机验证未完成前,STEP-102 状态只能写“代码完成 / 等待验证”或同等非 PASS 状态。
## 11. Git 要求
- 工作分支:`step102-placement-free-retained-locality`
- 禁止修改 particle/main 工作区
- 禁止 `git add -A`、`git add .`、`git reset --hard`、`git clean`
- 只 scoped staging 本任务文件
- 不提交无关 dirty/untracked 文件
- Codex 可在实现完成、静态检查通过后创建 scoped implementation commit,但**不得 merge 到 particle/main、不得 push release 分支/tag、不得发布制品**
- 是否 push/merge 由后续独立验收决定
## 12. 必做输出
Codex 完成后必须交付:
1. 生产代码修改;
2. C++ 测试源码;
3. Android Validation STEP-102 场景源码/日志字段;
4. `docs/50_Step记录/STEP-102_RenderSegment无Placement段局部复用_v1.md`;
5. `docs/报告/STEP-102_RenderSegment无Placement段局部复用_Codex完成报告_v1.md`;
6. 追加 `粒子开发记录.md`;
7. 精确列出后续验证命令;
8. 明确列出所有 NOT RUN / BLOCKED 项;
9. Git diff/status/commit(若创建)事实。
## 13. 执行报告完整文件名
固定:
`docs/报告/STEP-102_RenderSegment无Placement段局部复用_Codex完成报告_v1.md`
同主题后续报告只递增 `_v2/_v3`,不得另造近义文件名。
## 14. 执行报告固定内容
至少包含:
1. 执行结论:只能按实际写代码完成/等待验证、PARTIAL、BLOCKED 或 FAIL;未运行 Android 不得写运行态 PASS
2. Git 基线:branch/start HEAD/end HEAD/status
3. 实际修改文件
4. 新增文件
5. 实现内容逐条对照任务书
6. 与 v3 设计差异
7. Fast Path / mixed executor / C-001 未修改审计
8. diagnostics 新字段与语义
9. 测试源码覆盖矩阵
10. 实际执行的静态检查
11. 未执行的 build/test/Android/设备项目
12. 用户手工 Android 构建命令
13. 后续 RDC/真机验收命令与硬门槛
14. 已知风险与未完成事项
15. Git commit 信息(若有)
## 15. 阻塞与停止条件
遇到以下任一情况必须停止扩大修改并报告:
- 结构性 placement capability 不能仅靠既有 layer type 安全判定,需要新增更大公共抽象;
- 必须修改 Fast Path 才能完成本任务;
- 必须重写 mixed executor;
- 必须改变 collision/Placement 核心算法;
- 必须修改 C-001 / RN method surface;
- 必须修改 NavSea;
- 必须扩大到 renderer/backend 架构重构;
- 发现 global revision 仍有未预期的 placement 推进,需要新设计;
- 需要升级工具链/依赖;
- 需要删除或弱化既有测试/安全门槛。
若实现范围保持在 v3 冻结边界内,可直接完成代码并停在“等待手工构建/运行验证”。

View File

@@ -0,0 +1,56 @@
# STEP-102 RenderSegment 无 Placement 段局部复用记录 v1
状态:**进行中(代码完成,等待独立构建与运行验证)**
日期:2026-10-08
## 目标与基线
- 工作区:`/Users/tei/dev/MapLibreParticleLab-step102`
- 分支:`step102-placement-free-retained-locality`
- 起点 HEAD:`6df21dac38f072c5ad35b8dfc7ec5217509602bc`
- 工作合同:`docs/40_任务/STEP-102_RenderSegment无Placement段局部复用_Codex任务_v1.md`
- 设计依据:`docs/20_调查/RenderSegment多Retained段全局Placement门槛_粒子管理员审查说明_v3.md`
- 发布坐标:V19/V20 工作区与发行内容冻结;本 Step 不创建版本。
目标是让结构上没有 symbol layer 的 Retained Segment 在其他段 placement 活跃时继续独立缓存并经 main-buffer composite。
## 必做项
- [x] 基于 style layer type 查询结构性 placement capability;不使用当前帧 `needsPlacement()` 证明 placement-free。
- [x] full CPU 每段计算 placement gate;含 symbol 的段仍受 changed、fade 与 transition 保守门槛约束。
- [x] `segmentSupported` 和 fallback reason 按段生成;full CPU global-live 只移除纯 aggregate placement 条件。
- [x] 保留 3D、debug、MapMode、sprite/image、light 等真实全局门槛和既有 revision/key 失效。
- [x] diagnostics 增加结构、逐段 gate、实际 main-buffer 执行及全局 placement/execution 字段;RN TS 只增加诊断字段。
- [x] 新增 C++ 源码测试与 Android STEP-102 Validation fixture/日志场景。
- [x] 静态核对 Fast Path、mixed executor、C-001、RN method surface 未修改。
- [ ] C++ 编译与定向测试、Android 用户手工构建、设备场景、iOS/Metal 验证:尚未执行。
## 允许与禁止范围
代码修改仅在任务书允许的 renderer、RN diagnostics 类型、`retained_underlay_cache.test.cpp` 和 Android Particle Validation 文件。未修改 `visitRenderSegmentExecution()`、`tryRenderParticleFastFrame()`、`retainedRangeUsesPlacement()`、Placement 算法、NavSea、C-001、发布文件或总则。既有未跟踪 v2/v3 审查说明及任务书保持原样。
## 构建、测试与当前结果
只读源码/diff 审计及 `git diff --check` 已执行,后者通过;新增未跟踪文件另用 `git diff --no-index --check` 检查,无空白错误(差异本身使退出码为 1)。CMake/Ninja/Bazel、GTest、Android Gradle、APK/AAR、ADB、设备、Jest、NavSea、发布全部 **NOT RUN**。静态检查不构成运行态 PASS。
## 必做输出
- STEP-102 生产代码、C++ 测试源码、Android Validation 场景源码。
- `docs/报告/STEP-102_RenderSegment无Placement段局部复用_Codex完成报告_v1.md`。
- 本记录与 `粒子开发记录.md` 追加。
- 报告中列出精确后续验证命令、硬门槛、NOT RUN 与剩余风险。
## 阻塞与下一步
目前没有已观察到的编译或运行失败;真正结果仍未知。由 RDC/用户按报告完成 Native focused tests、用户手工 Android APK 构建和 T1/T2 设备场景,核对 R0/R1 的 `placementCapable`、`placementGatePassed`、`actualExecution` 与 build/reuse/invalidation。构建或设备失败时保留首个真实错误与日志,再按任务边界修复;本 Step 未进入后续发行阶段。
## RDC 独立验证补记(2026-10-08)
- 从现有工作树、任务书、v3 审查依据和项目总则恢复审查;原 Codex 阶段的 NOT RUN 记录保留,以下为新增 RDC 事实。
- `git diff --check`:退出码 0,PASS;无关的 `platform/windows/vendor/vcpkg` 子模块状态变化不纳入本任务。
- 以 AppleClang 对 `test/renderer/retained_underlay_cache.test.cpp` 执行 `-std=c++20 -fsyntax-only`:退出码 0,语法检查 PASS;不是 GTest 运行结果。
- 本地临时独立 C++ helper smoke 测试:结构性 symbol 判定、placement gate、全局 live reason 共 10 个断言,10/10 PASS;测试文件位于 `/tmp/step102-placement-smoke.cpp`,不是正式 GTest。
- Mac CMake `cmake --preset macos-metal`:配置退出码 1,阻塞于缺少 `libuv`;未进入 GTest 编译或运行,未安装/升级依赖。日志 `/tmp/step102-cmake-config.log`。
- 额外审查并修复 Android STEP-102 Validation 切换入口:进入该场景时停止 STEP-093 诊断轮询、清除 STEP-093/097 标志,避免旧场景轮询污染 STEP-102 证据。
- Metal Bazel Core 构建 `bazel build -c opt --//:renderer=metal //:mbgl-core` 已完成:退出码 0,642/642 actions,耗时 875.118s,产物 `bazel-bin/libmbgl-core.a`,构建 PASS;日志 `/tmp/step102-bazel-core.log`。该 PASS 仅表示 Native Core 编译,不代表 GTest/Android/运行态 PASS。
- Android Gradle/APK/ADB 与设备验证依任务规则均未执行;STEP-102 继续保持“进行中 / 待 Android 实际验收”,不得标记 CLOSED / PASS。

View File

@@ -0,0 +1,121 @@
# STEP-102 RenderSegment 无 Placement 段局部复用 Codex 完成报告 v1
## 1. 执行结论
**代码完成,等待独立构建与运行验证;STEP-102 尚非 PASS。** 已按任务书实现 full CPU placement-free Retained Segment 局部复用、逐段 fallback reason 和实际 main-buffer 执行诊断。C++ 与 Android 测试均只交付源码,未运行。没有编译、设备或视觉结果,不能据此宣称无 stale、Z-order 错乱或 crash。
## 2. Git 基线与工作树
- 工作区:`/Users/tei/dev/MapLibreParticleLab-step102`
- 当前机器:macOS 15.7.7 / Darwin x86_64;未连接或操作设备。
- 分支:`step102-placement-free-retained-locality`
- start HEAD:`6df21dac38f072c5ad35b8dfc7ec5217509602bc`
- end HEAD:仍为 `6df21dac38f072c5ad35b8dfc7ec5217509602bc`;本阶段未 commit、push、merge。
- 子模块:`git submodule status` 均以 `-` 开头,本 worktree 未初始化;未更改。
- 工作树:仅下列任务文件有本阶段差异;任务开始时已经未跟踪的 v2/v3 审查说明及 STEP-102 任务书未修改、未暂存。
- V19/V20 release worktree、`particle/main`、tag 与发布坐标未修改;无新 artifact。
## 3. 实际修改与新增文件
生产代码:
1. `include/mbgl/renderer/render_segment.hpp`:结构查询、placement gate、全局 live reason 辅助函数及 diagnostics 字段。
2. `src/mbgl/renderer/render_orchestrator.hpp`、`src/mbgl/renderer/render_orchestrator.cpp`:对 full CPU 提供结构查询和 placement transition 查询;旧动态查询保持原样。
3. `src/mbgl/renderer/renderer_impl.cpp`:逐段 gate/fallback、全局 live reason、实际执行记录。
4. `src/mbgl/renderer/retained_underlay_runtime_state.hpp`、`src/mbgl/renderer/render_segment_json.hpp`:只读快照与 JSON 字段。
5. `packages/maplibre-particle-rn/src/types.ts`:现有 diagnostics 返回类型 additive 扩展。
测试/Validation:
6. `test/renderer/retained_underlay_cache.test.cpp`:新增 `RenderSegmentPlacementFree.*` C++ 测试源码。
7. `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt`:STEP-102 场景菜单、Intent 入口、轮询及审计日志。
8. 新增 `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step102PlacementFixture.kt`:离线 R-C-R symbol/placement-free 两组 fixture。
文档:新增 `docs/50_Step记录/STEP-102_RenderSegment无Placement段局部复用_v1.md`、本报告;追加 `粒子开发记录.md`。
## 4. 实现与任务书逐项对照
- **结构能力**:扫描同一 style-layer 元数据中的 `layerType == "symbol"`;越界 range 返回 placement-capable 以 fail-closed。`retainedRangeUsesPlacement()` 的 `needsPlacement()` 动态语义未变。custom/custom-drawable 仍由原 unsupported gate 处理。
- **逐段 gate**:placement-free 返回通过;placement-bearing 要求 `!placementChanged && symbolFadeChange == 0 && !placementTransitionActive`。`symbolFadeChange==0` 只是沿用保守 gate 的条件,不命名为严格 stable。transition disabled 且 fade=1 仍不能让含 symbol 段复用。
- **逐段 fallback**:优先明确 source、global resource、placement、layer transition、legacy/unsupported 等本段理由。`static-placement` 只会由本段 gate 失败产生;不再拷贝 aggregate placement/transition 理由给邻段。
- **full CPU main-buffer**:全局 live 条件只删掉 aggregate `!retainedPlacementStable`,其他既有 3D、debug、非 Continuous MapMode、sprite、global resources、light transition 条件保留。原 `visitRenderSegmentExecution()` 和 Style 顺序不变。camera/viewport/pixelRatio、topology、content/global revision、resource/compositor failure 的原有失效路径未放宽。
- **兼容/安全边界**:aggregate `retainedStaticUsesPlacement`、`retainedPlacementStable`、prepared-tree/Fast Path eligibility 与 revision 观察继续原用;含 symbol 的 retained 仍 fail-closed;C-001、RN method surface、`setRenderSegmentPlan()` 输入未变。
与 v3 审查设计无架构差异。逐段 gate 额外显式查询当前 placement transition,属于对 placement-bearing 的更保守判定;原 `retainedSegmentIsStable()` 内仍保留既有 transition 检查。测试和 Android 场景均不修改 Placement 核心算法。
## 5. diagnostics 字段与真实执行语义
每段新增 `placementCapable`、`placementGatePassed`、`actualExecution`。前者来自 style-layer 结构,第二项表示本帧 full CPU placement cache gate;Continuous 段的 gate 为非阻塞值。`actualExecution` 在 main-buffer 全局 live 分支或 mixed executor 的 composite 回调实际发生后记录。即使 `cache.finalDecision=reuse`,全局门槛触发时仍记录 `actualExecution=live`。
全局新增 `placementChanged`、`symbolFadeChange`、`placementTransitionActive`、`crossSourceCollisions`、`mainBufferExecutionMode`、`globalLiveFallbackReason`。`mixed` 表示进入 Style 顺序的 mixed executor,可以包含某些 Retained live;`global-live` 表示整帧 live 分支。尚未渲染时 `mainBufferExecutionMode/actualExecution=not-rendered`。查询只拷贝已记录快照,不推进 revision;Android/iOS Shared Core 使用同一 JSON writer,RN TS 只补类型。
## 6. 测试源码覆盖矩阵
| 合同场景 | 新增/现有测试源码 | 尚待运行证明 |
| --- | --- | --- |
| A:R0 free、C live、R1 symbol live;Style 顺序 | `RenderSegmentPlacementFree.MixedExecutionKeepsFreeRangesInStyleOrder` | Android T1 真实 GPU composite/live 与视觉 Z-order |
| B:symbol 当前 placementData 为空仍 capable | `StructuralCapabilityAndConservativeGate` 直接输入 symbol style 结构,不依赖 placementData | Android 空 bucket/placementData 条件仍需设备审计 |
| C:两个 free Retained 复用 | `MixedExecutionKeepsFreeRangesInStyleOrder` 中 R0/R2;Android T2 | 60 秒 build/reuse/invalidation 窗口 |
| D:source-local unloaded/fading/mutation | 既有 `RenderSegmentLocality.ReadinessAndFadeFollowOnlyDependencies` 与本轮两 free cache 断言;Android T1 动态 symbol source | 设备 R0 计数不误增 |
| E:bearing 保守 gate | `StructuralCapabilityAndConservativeGate` changed/fade=1/transition 活跃组合 | transition disabled 场景的 GPU 行为 |
| F:真实全局门槛 | `RealGlobalGatesStillForceGlobalLive`;既有 revision/cache tests | style/context/image/glyph/light 等设备失效 |
| G:拓扑与 key | `TopologyAndCameraChangesCannotReuseOldRetainedKey`;既有 `OldCacheReleaseCannotChargeReplacementAtSameIndex` | 真正 GPU resource 释放及无 stale texture |
| H:reuse 但 global-live | `DiagnosticsRecordActualMainBufferAndDoNotMutateOnRead` | 设备日志中 cache/actual 分离 |
测试源码只验证可隔离的策略、cache 决策、diagnostics 和 mixed traversal;无法单凭这些单元测试替代真实 renderer/GPU 场景。
## 7. Android Validation 场景
Test App 的 STEP-093 菜单新增 `STEP-102 PLACEMENT LOCALITY`,也可用 Intent `step102_scenario` 启动:
- `T1_FREE_SYMBOL`:R0 background/fill(无 symbol)→ C0 持续粒子 → R1 symbol;每 500ms 更新 R1 GeoJSON point,让 placement/source 活跃。
- `T2_TWO_FREE`:相同 R0/C0,R1 改为 fill;两个 Retained 均结构上 placement-free。
每个 `STEP102_SAMPLE` 记录固定 camera、placement 四个全局字段、main-buffer mode/reason、每段 capability/gate/actual、cache decision、build/reuse/invalidation/fallback。tick 8/130 打窗口标记。日志字段用于验收,不在 App 中自行宣告 PASS。
## 8. 实际检查与 NOT RUN
- 已执行:`git status --short`、`git diff`/`rg` 只读审计;`git diff --check` **PASS**。三个新增未跟踪文件另以 `git diff --no-index --check /dev/null <文件>` 检查,无空白错误;该命令因有差异正常退出 1。
- **NOT RUN**:CMake/Ninja/Bazel configure/build/link、C++ GTest、Android Native TestRunner、Android Gradle、APK/AAR、codegen、Jest、ADB、设备/截图/logcat、iOS/Metal、NavSea、发布、commit、push。
- 无 APK/AAR、大小、SHA-256、运行日志或最终性能结果。没有编译失败或测试失败的事实;缺少编译/运行证据。
## 9. 后续精确验证命令与硬门槛
先由 RDC 在本工作区运行 host 定向测试;若当前 Mac `libuv` 环境仍阻塞,应记录首个真实 configure 错误,而非改动依赖:
```bash
cmake --preset macos-metal
cmake --build build-macos-metal --target mbgl-test-runner --parallel 3
build-macos-metal/mbgl-test-runner --gtest_filter='RenderSegmentPlacementFree.*:RenderSegmentLocality.*:RenderSegmentDiagnostics.*'
```
**Android Validation APK 只由用户手工构建**,使用项目固定入口:
```bash
cd /Users/tei/dev/MapLibreParticleLab-step102
./tools/particle_android_validation/build_validation_apk.sh
```
构建成功后由 RDC/用户核对 APK ZIP、ABI、`libmaplibre.so`、SHA-256、branch/commit/dirty 状态,再在选定设备上独立运行。下列 `<SERIAL>` 需替换为实际 `adb devices` 返回的 serial;每场景单独清日志、force-stop、启动并留存完整 logcat:
```bash
adb -s <SERIAL> install -r artifacts/maplibre-particle/android-validation/MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk
adb -s <SERIAL> logcat -c
adb -s <SERIAL> shell am force-stop org.maplibre.android.testapp
adb -s <SERIAL> shell am start -n org.maplibre.android.testapp/.activity.particle.ParticleCoreLayerValidationActivity --es step102_scenario T1_FREE_SYMBOL
adb -s <SERIAL> logcat -v threadtime -d > /tmp/step102-t1-logcat.txt
rg 'STEP102_(SCENARIO_START|STYLE_READY|SAMPLE|WINDOW_MARKER)|Fatal signal|ANR' /tmp/step102-t1-logcat.txt
```
对 `T2_TWO_FREE` 重复 `logcat -c`、force-stop、启动、保存到 `/tmp/step102-t2-logcat.txt`。每组至少等待 tick 130(约 65 秒)后取最终日志。T1 硬门槛:固定 camera、`mainBufferExecutionMode=mixed` 的稳定窗口中 R0 `placementCapable=false`、`actualExecution=composite`、buildDelta=0、invalidationDelta=0、reuseDelta>0;R1 `placementCapable=true`,placement gate 失败期间 `actualExecution=live`,C0 live,Style Z-order 正确,无 stale/crash/ANR。T2 两个 Retained 均无 symbol,build 一次后持续 reuse/composite。若窗口内出现真实 global mutation,应结合 `globalLiveFallbackReason` 与 lastMutation 重新选择稳定窗口,不把合法 global invalidation 算作误伤。
还需独立做 STEP-100 source locality 与 STEP-101 teardown 回归,以及 global light/style/context/camera、topology 变动资源释放验证。iOS/Metal 只可在相同源码提交的 Mac/iOS 验收后声明通过。本阶段均未执行。
## 10. 风险、未完成事项与交接
- Android symbol glyph asset 与 placement 活跃窗口尚未由构建/设备核实;T1 若未出现 placement gate 失败,不构成主场景验收。
- 现有 C++ topology 测试验证 key/revision 无旧 cache reuse,不能证明真实 GPU 资源已释放;需设备/后端证据。
- `placementChanged` 与 `symbolFadeChange` 在 Fast Path 帧来自既有 prepared render tree;本 Step 没有改变 Fast Path 判定或语义。主验收只使用 `fullCpuFrame` 增长的 full CPU 窗口。
- 构建或运行失败应保留现场、回传首个真实错误及必要上下文;不得放宽测试或安全门槛。没有触及任务书要求 BLOCKED 的架构扩张条件。
本阶段未创建 Git commit,故无 implementation commit SHA;也未执行 merge、push 或远端 HEAD 回读。后续独立验收决定 scoped commit/merge 与发行流程。

View File

@@ -70,6 +70,47 @@ inline RenderSegmentDependencies renderSegmentDependencies(const std::vector<Sty
return result;
}
// Placement capability is a property of the style layer, not of this frame's
// buckets or placementData. Invalid ranges fail closed.
inline bool renderSegmentHasPlacementCapableLayer(const std::vector<StyleLayerDiagnostics>& layers,
int32_t begin,
int32_t endExclusive) {
if (begin < 0 || endExclusive < begin || endExclusive > static_cast<int32_t>(layers.size())) return true;
for (int32_t i = begin; i < endExclusive; ++i) {
if (layers[static_cast<std::size_t>(i)].layerType == "symbol") return true;
}
return false;
}
inline bool renderSegmentPlacementGatePassed(bool placementCapable,
bool placementChanged,
float symbolFadeChange,
bool placementTransitionActive) noexcept {
return !placementCapable ||
(!placementChanged && symbolFadeChange == 0.0f && !placementTransitionActive);
}
struct RenderSegmentGlobalLiveGates {
bool retainedMode = false;
bool has3D = false;
bool debugOptions = false;
bool continuousMode = false;
bool spriteLoaded = false;
bool globalResourcesReady = false;
bool globalLightTransition = false;
};
inline const char* renderSegmentGlobalLiveFallbackReason(const RenderSegmentGlobalLiveGates& gates) noexcept {
if (!gates.retainedMode) return "no-boundary";
if (gates.has3D) return "3d";
if (gates.debugOptions) return "debug-options";
if (!gates.continuousMode) return "map-mode";
if (!gates.spriteLoaded) return "sprite-not-loaded";
if (!gates.globalResourcesReady) return "global-resource-not-ready";
if (gates.globalLightTransition) return "global-light-transition";
return "none";
}
class RenderSegmentRevisionBook {
public:
void advanceGlobal() noexcept { ++globalEpoch; }
@@ -190,6 +231,9 @@ struct RenderSegmentDiagnosticsEntry {
std::vector<StyleLayerDiagnostics> layers;
std::vector<std::string> dependentSourceIds;
std::vector<std::string> unknownDependencies;
bool placementCapable = false;
bool placementGatePassed = false;
std::string actualExecution{"not-rendered"};
std::optional<RenderSegmentCacheDiagnostics> cache;
};
@@ -214,6 +258,12 @@ struct RenderSegmentDiagnostics {
uint64_t fastPathHit = 0;
uint64_t fastPathMiss = 0;
uint64_t fullCpuFrame = 0;
bool placementChanged = false;
float symbolFadeChange = 0.0f;
bool placementTransitionActive = false;
bool crossSourceCollisions = true;
std::string mainBufferExecutionMode{"not-rendered"};
std::string globalLiveFallbackReason{"none"};
bool styleLoaded = false;
bool imageManagerLoaded = false;
bool renderOrchestratorLoaded = false;

View File

@@ -91,6 +91,9 @@ export type RetainedRenderSegmentDiagnostics = Readonly<{
layers: readonly RenderSegmentLayerDiagnostics[];
dependentSourceIds: readonly string[];
unknownDependencies: readonly string[];
placementCapable: boolean;
placementGatePassed: boolean;
actualExecution: "composite" | "live" | "not-rendered";
cache: Readonly<{
cacheState: string;
contentRevision: number;
@@ -122,6 +125,9 @@ export type ContinuousRenderSegmentDiagnostics = Readonly<{
layers: readonly RenderSegmentLayerDiagnostics[];
dependentSourceIds: readonly string[];
unknownDependencies: readonly string[];
placementCapable: boolean;
placementGatePassed: boolean;
actualExecution: "composite" | "live" | "not-rendered";
cache: null;
}>;
export type RenderSourceReadinessDiagnostics = Readonly<{
@@ -170,6 +176,12 @@ export type RenderSegmentDiagnostics = Readonly<{
fastPathHit: number;
fastPathMiss: number;
fullCpuFrame: number;
placementChanged: boolean;
symbolFadeChange: number;
placementTransitionActive: boolean;
crossSourceCollisions: boolean;
mainBufferExecutionMode: "mixed" | "global-live" | "not-rendered";
globalLiveFallbackReason: string;
styleLoaded: boolean;
imageManagerLoaded: boolean;
renderOrchestratorLoaded: boolean;

View File

@@ -63,6 +63,56 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var step097Topology: Step097StencilFixture.Topology? = null
private var step099Scenario: Step099ReadinessFixture.Scenario? = null
private var step101Scenario: String? = null
private var step102Scenario: Step102PlacementFixture.Scenario? = null
private var step102Tick = 0L
private val step102Handler = Handler(Looper.getMainLooper())
private val step102Poll = object : Runnable {
override fun run() {
val scenario = step102Scenario ?: return
if (this@ParticleCoreLayerValidationActivity::maplibreMap.isInitialized && styleReady) {
++step102Tick
if (scenario == Step102PlacementFixture.Scenario.T1_FREE_SYMBOL) {
val source = maplibreMap.style?.getSourceAs<GeoJsonSource>(Step102PlacementFixture.SYMBOL_SOURCE)
if (source != null) {
val offset = (step102Tick % 20L) * 0.08
source.setGeoJson(Feature.fromGeometry(Point.fromLngLat(135.0 + offset, 28.0)))
}
}
val snapshot = JSONObject(maplibreMap.getRenderSegmentDiagnostics())
val segments = snapshot.optJSONArray("segments")
val ranges = buildList {
for (index in 0 until (segments?.length() ?: 0)) {
val segment = segments?.optJSONObject(index) ?: continue
val cache = segment.optJSONObject("cache")
add("$index:${segment.optString("strategy")}" +
" capable=${segment.optBoolean("placementCapable")}" +
" gate=${segment.optBoolean("placementGatePassed")}" +
" actual=${segment.optString("actualExecution")}" +
" decision=${cache?.optString("finalDecision") ?: "-"}" +
" build=${cache?.optLong("build") ?: 0}" +
" reuse=${cache?.optLong("reuse") ?: 0}" +
" invalidation=${cache?.optLong("invalidation") ?: 0}" +
" fallback=${cache?.optString("lastFallbackReason") ?: "-"}")
}
}
val message = "scenario=$scenario tick=$step102Tick camera=fixed " +
"placementChanged=${snapshot.optBoolean("placementChanged")} " +
"symbolFadeChange=${snapshot.optDouble("symbolFadeChange")} " +
"placementTransitionActive=${snapshot.optBoolean("placementTransitionActive")} " +
"crossSourceCollisions=${snapshot.optBoolean("crossSourceCollisions")} " +
"mainBufferExecutionMode=${snapshot.optString("mainBufferExecutionMode")} " +
"globalLiveFallbackReason=${snapshot.optString("globalLiveFallbackReason")} " +
"fullCpuFrame=${snapshot.optLong("fullCpuFrame")} ranges=${ranges.joinToString(";")}"
diagnostics.text = "STEP-102 $message"
Log.i(TAG, "STEP102_SAMPLE $message")
if (step102Tick == 8L || step102Tick == 130L) {
Log.i(TAG, "STEP102_WINDOW_MARKER phase=${if (step102Tick == 8L) "baseline" else "final"} " +
"scenario=$scenario tick=$step102Tick")
}
}
if (step102Scenario != null) step102Handler.postDelayed(this, 500L)
}
}
private var step101Completed = 0
private var step101DynamicTick = 0L
private var step101ReadySinceMs = 0L
@@ -268,6 +318,9 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
intent.getStringExtra("step101_scenario")?.takeIf { it in STEP101_SCENARIOS }
}
step101Completed = savedInstanceState?.getInt(STATE_STEP101_COMPLETED, 0) ?: 0
step102Scenario = (savedInstanceState?.getString(STATE_STEP102_SCENARIO)
?: intent.getStringExtra("step102_scenario"))
?.let { value -> Step102PlacementFixture.Scenario.values().firstOrNull { it.name == value } }
step093Topology = savedInstanceState?.getString(STATE_STEP093_TOPOLOGY)
?.let { value -> Step093Topology.values().firstOrNull { it.name == value } }
?: Step093Topology.BOTTOM_CONTINUOUS_TOP
@@ -306,7 +359,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
updateNavSeaPbfButtonState()
updatePbfModeButtonState()
updateRetainedCacheButtonState()
if (step101Scenario != null) loadStep101Style()
if (step102Scenario != null) loadStep102Style()
else if (step101Scenario != null) loadStep101Style()
else if (step097Topology != null) loadStep097StencilStyle()
else if (step093Mode) loadStep093Style(configureBeforeStyle = false)
else loadValidationStyle(active = active, enabled = enabled, reason = "INITIAL")
@@ -613,6 +667,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
menu.add(0, 16, 16, "STEP-097 SAME SOURCE STENCIL")
menu.add(0, 17, 17, "STEP-099 READINESS")
menu.add(0, 18, 18, "STEP-101 TEARDOWN")
menu.add(0, 19, 19, "STEP-102 PLACEMENT LOCALITY")
setOnMenuItemClickListener { item ->
when (item.itemId) {
in Step093Topology.values().indices -> applyStep093Scene(Step093Topology.values()[item.itemId])
@@ -625,6 +680,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
16 -> showStep097StencilMenu(anchor)
17 -> showStep099Menu(anchor)
18 -> showStep101Menu(anchor)
19 -> showStep102Menu(anchor)
}
true
}
@@ -685,8 +741,57 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
}
private fun showStep102Menu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
Step102PlacementFixture.Scenario.values().forEachIndexed { index, scenario ->
menu.add(0, index, index, scenario.name)
}
setOnMenuItemClickListener { item ->
val scenario = Step102PlacementFixture.Scenario.values().getOrNull(item.itemId)
?: return@setOnMenuItemClickListener false
step102Scenario = scenario
loadStep102Style()
true
}
show()
}
}
private fun loadStep102Style() {
val scenario = step102Scenario ?: return
stopStep093DiagnosticsPolling()
step093Mode = false
step097Topology = null
step101Scenario = null
step101Handler.removeCallbacksAndMessages(null)
stopStep099Scenario()
step102Handler.removeCallbacksAndMessages(null)
step102Tick = 0L
retainedCacheEnabled = true
maplibreMap.setRetainedUnderlayEnabled(true)
updateRetainedCacheButtonState()
val accepted = maplibreMap.setRenderSegmentPlan(Step102PlacementFixture.planJson())
styleReady = false
val epoch = ++styleLoadEpoch
Log.i(TAG, "STEP102_SCENARIO_START scenario=$scenario planAccepted=$accepted")
maplibreMap.setStyle(Style.Builder().fromJson(Step102PlacementFixture.styleJson(scenario))) {
if (step102Scenario != scenario || epoch != styleLoadEpoch) return@setStyle
styleReady = true
maplibreMap.moveCamera(CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder().target(LatLng(28.0, 135.0)).zoom(3.0).build()))
Log.i(TAG, "STEP102_STYLE_READY scenario=$scenario")
step102Handler.post(step102Poll)
}
}
private fun stopStep102Scenario() {
step102Scenario = null
step102Handler.removeCallbacksAndMessages(null)
}
private fun loadStep101Style() {
val scenario = step101Scenario ?: return
stopStep102Scenario()
stopStep099Scenario()
step101Handler.removeCallbacksAndMessages(null)
step101ReadySinceMs = 0L
@@ -711,6 +816,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun applyStep099Scenario(scenario: Step099ReadinessFixture.Scenario) {
stopStep102Scenario()
stopStep093DiagnosticsPolling()
step093Mode = false
step097Topology = null
@@ -834,6 +940,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun applyStep097StencilScene(topology: Step097StencilFixture.Topology) {
stopStep102Scenario()
stopStep099Scenario()
leaveRealWeatherMode()
step097Topology = topology
@@ -973,6 +1080,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
variant: Step093Variant = Step093Variant.DEFAULT,
configureBeforeStyle: Boolean = false
) {
stopStep102Scenario()
stopStep099Scenario()
leaveRealWeatherMode()
step093Mode = true
@@ -1493,6 +1601,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun loadValidationStyle(active: Boolean, enabled: Boolean, reason: String = "STYLE_CHANGE") {
stopStep102Scenario()
if (step099Scenario != null) stopStep099Scenario()
step097V4GoldenReady = false
if (pureParticleMode) {
@@ -2279,6 +2388,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
super.onStop()
}
override fun onDestroy() {
step102Handler.removeCallbacksAndMessages(null)
step101Handler.removeCallbacksAndMessages(null)
if (step101Scenario != null) Log.i(TAG, "STEP101_MAPVIEW_DESTROY_ENTER scenario=$step101Scenario completed=$step101Completed")
step093Handler.removeCallbacks(step093DiagnosticsPoll)
@@ -2317,6 +2427,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
outState.putString(STATE_STEP097_TOPOLOGY, step097Topology?.name)
outState.putString(STATE_STEP101_SCENARIO, step101Scenario)
outState.putInt(STATE_STEP101_COMPLETED, step101Completed)
outState.putString(STATE_STEP102_SCENARIO, step102Scenario?.name)
outState.putString(STATE_STEP093_TOPOLOGY, step093Topology.name)
outState.putString(STATE_STEP093_VARIANT, step093Variant.name)
super.onSaveInstanceState(outState)
@@ -2356,6 +2467,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private const val STATE_STEP097_TOPOLOGY = "particle_validation.step097_topology"
private const val STATE_STEP101_SCENARIO = "particle_validation.step101_scenario"
private const val STATE_STEP101_COMPLETED = "particle_validation.step101_completed"
private const val STATE_STEP102_SCENARIO = "particle_validation.step102_scenario"
private val STEP101_SCENARIOS = listOf(
"T1_RESET_ONCE", "T2_RESET_LOOP_20", "T3_RESET_WITH_DYNAMIC_GEOJSON", "T4_RESET_WITH_DIAGNOSTICS"
)

View File

@@ -0,0 +1,65 @@
package org.maplibre.android.testapp.activity.particle
import org.json.JSONArray
import org.json.JSONObject
/** Offline STEP-102 R-C-R fixture; only the upper retained range changes placement capability. */
internal object Step102PlacementFixture {
enum class Scenario { T1_FREE_SYMBOL, T2_TWO_FREE }
const val STATIC_SOURCE = "step102-static"
const val SYMBOL_SOURCE = "step102-symbol"
private const val PARTICLE_SOURCE = "step102-field"
private const val PARTICLE_LAYER = "step102-particle"
private const val UPPER_LAYER = "step102-upper"
fun styleJson(scenario: Scenario): String {
val sources = JSONObject()
.put(STATIC_SOURCE, geoJsonSource(
"""{"type":"Polygon","coordinates":[[[112,0],[158,0],[158,56],[112,56],[112,0]]]}"""))
.put(PARTICLE_SOURCE, JSONObject()
.put("type", "raster-array")
.put("fixture", "uniform-east")
.put("metadata", "asset://integration/particle-fixtures/uniform-east/metadata.json")
.put("field", "asset://integration/particle-fixtures/uniform-east/field.f32"))
if (scenario == Scenario.T1_FREE_SYMBOL) {
sources.put(SYMBOL_SOURCE, geoJsonSource("""{"type":"Point","coordinates":[135.0,28.0]}"""))
}
val layers = JSONArray()
.put(JSONObject().put("id", "step102-background").put("type", "background")
.put("paint", JSONObject().put("background-color", "#0b2942")))
.put(JSONObject().put("id", "step102-static-fill").put("type", "fill")
.put("source", STATIC_SOURCE)
.put("paint", JSONObject().put("fill-color", "#176b87").put("fill-opacity", 0.58)))
.put(JSONObject().put("id", PARTICLE_LAYER).put("type", "raster-particle")
.put("source", PARTICLE_SOURCE)
.put("paint", JSONObject().put("raster-particle-active", true)
.put("raster-particle-particle-count", 1024)))
if (scenario == Scenario.T1_FREE_SYMBOL) {
layers.put(JSONObject().put("id", UPPER_LAYER).put("type", "symbol")
.put("source", SYMBOL_SOURCE)
.put("layout", JSONObject().put("text-field", "STEP-102")
.put("text-font", JSONArray().put("DIN Offc Pro Regular").put("Arial Unicode MS Regular"))
.put("text-size", 20))
.put("paint", JSONObject().put("text-color", "#ffcc00")))
} else {
layers.put(JSONObject().put("id", UPPER_LAYER).put("type", "fill")
.put("source", STATIC_SOURCE)
.put("paint", JSONObject().put("fill-color", "#d3a640").put("fill-opacity", 0.38)))
}
return JSONObject().put("version", 8).put("name", "STEP-102 Placement Locality")
.put("glyphs", "asset://fonts/{fontstack}/{range}.pbf")
.put("sources", sources).put("layers", layers).toString()
}
fun planJson(): String = JSONObject().put("schemaVersion", 1)
.put("segments", JSONArray()
.put(JSONObject().put("strategy", "RETAINED"))
.put(JSONObject().put("strategy", "CONTINUOUS").put("startLayerId", PARTICLE_LAYER))
.put(JSONObject().put("strategy", "RETAINED").put("startLayerId", UPPER_LAYER)))
.toString()
private fun geoJsonSource(geometryJson: String): JSONObject = JSONObject().put("type", "geojson")
.put("data", JSONObject().put("type", "Feature").put("properties", JSONObject())
.put("geometry", JSONObject(geometryJson)))
}

View File

@@ -1140,6 +1140,14 @@ bool RenderOrchestrator::retainedRangeUsesPlacement(int32_t begin, int32_t endEx
return false;
}
bool RenderOrchestrator::retainedRangeHasPlacementCapableLayer(int32_t begin, int32_t endExclusive) const {
return renderSegmentHasPlacementCapableLayer(retainedStyleLayers, begin, endExclusive);
}
bool RenderOrchestrator::retainedPlacementTransitionActive(TimePoint timePoint) const {
return placementController.hasTransitions(timePoint);
}
bool RenderOrchestrator::retainedUnderlayIsStable(TimePoint timePoint, int32_t boundary) const {
return retainedRangeIsStable(timePoint, 0, boundary);
}

View File

@@ -127,6 +127,8 @@ public:
bool retainedUnderlayIsStable(TimePoint, int32_t boundary) const;
bool retainedUnderlayHasUnsupportedLayer(int32_t boundary) const;
bool retainedRangeUsesPlacement(int32_t begin, int32_t endExclusive) const;
bool retainedRangeHasPlacementCapableLayer(int32_t begin, int32_t endExclusive) const;
bool retainedPlacementTransitionActive(TimePoint timePoint) const;
bool retainedRangeIsStable(TimePoint, int32_t begin, int32_t endExclusive) const;
struct RetainedSegmentSourceState {
uint64_t revision = 0;

View File

@@ -131,6 +131,12 @@ inline std::string renderSegmentDiagnosticsToJSON(const RenderSegmentDiagnostics
writer.Key("fastPathHit"); writer.Uint64(snapshot.fastPathHit);
writer.Key("fastPathMiss"); writer.Uint64(snapshot.fastPathMiss);
writer.Key("fullCpuFrame"); writer.Uint64(snapshot.fullCpuFrame);
writer.Key("placementChanged"); writer.Bool(snapshot.placementChanged);
writer.Key("symbolFadeChange"); writer.Double(snapshot.symbolFadeChange);
writer.Key("placementTransitionActive"); writer.Bool(snapshot.placementTransitionActive);
writer.Key("crossSourceCollisions"); writer.Bool(snapshot.crossSourceCollisions);
writer.Key("mainBufferExecutionMode"); writer.String(snapshot.mainBufferExecutionMode.c_str());
writer.Key("globalLiveFallbackReason"); writer.String(snapshot.globalLiveFallbackReason.c_str());
writer.Key("styleLoaded"); writer.Bool(snapshot.styleLoaded);
writer.Key("imageManagerLoaded"); writer.Bool(snapshot.imageManagerLoaded);
writer.Key("renderOrchestratorLoaded"); writer.Bool(snapshot.renderOrchestratorLoaded);
@@ -187,6 +193,9 @@ inline std::string renderSegmentDiagnosticsToJSON(const RenderSegmentDiagnostics
writer.EndArray();
writeStringArray(writer, "dependentSourceIds", segment.dependentSourceIds);
writeStringArray(writer, "unknownDependencies", segment.unknownDependencies);
writer.Key("placementCapable"); writer.Bool(segment.placementCapable);
writer.Key("placementGatePassed"); writer.Bool(segment.placementGatePassed);
writer.Key("actualExecution"); writer.String(segment.actualExecution.c_str());
writer.Key("cache");
if (!segment.cache) {
writer.Null();

View File

@@ -30,6 +30,7 @@
#include <mbgl/renderer/render_target.hpp>
#include <mbgl/renderer/retained_underlay_cache.hpp>
#include <mbgl/particle/renderer_debug_counters.hpp>
#include <string_view>
#if MLN_RENDER_BACKEND_METAL
#include <mbgl/mtl/renderer_backend.hpp>
@@ -579,6 +580,8 @@ void Renderer::Impl::render(const RenderTree& renderTree,
const bool retainedPlacementStable =
!retainedStaticUsesPlacement ||
(!renderTreeParameters.placementChanged && renderTreeParameters.symbolFadeChange == 0.0f);
const bool placementTransitionActive = orchestrator.retainedPlacementTransitionActive(
renderTreeParameters.timePoint);
const auto retainedContentRevision = orchestrator.getRetainedUnderlayContentRevision();
const auto retainedContentReason = orchestrator.getRetainedUnderlayContentReason();
const RetainedUnderlayRevisionState retainedRevisionState{
@@ -615,29 +618,12 @@ void Renderer::Impl::render(const RenderTree& renderTree,
if (retainedRuntimeState) retainedRuntimeState->recordFastPathEligibility(
retainedSupported && fastContinuousSupported);
if (!particleFastFrame) particlePreparedSceneSupported = retainedSupported && fastContinuousSupported;
const char* retainedFallbackReason = nullptr;
if (!retainedMode) {
retainedFallbackReason = "no-boundary";
} else if (!renderTreeParameters.loaded) {
retainedFallbackReason = "style-not-loaded";
} else if (renderTreeParameters.has3D) {
retainedFallbackReason = "3d";
} else if (renderTreeParameters.debugOptions != MapDebugOptions::NoDebug) {
retainedFallbackReason = "debug-options";
} else if (!retainedStaticStable) {
retainedFallbackReason = "static-transition";
} else if (!retainedPlacementStable) {
retainedFallbackReason = "static-placement";
} else if (legacyInRetainedRange) {
retainedFallbackReason = "legacy-ordering";
} else if (retainedUnsupportedStaticLayer) {
retainedFallbackReason = "unsupported";
}
const auto wantsResources = [](RetainedUnderlayDecision decision) {
return decision == RetainedUnderlayDecision::Build || decision == RetainedUnderlayDecision::Reuse;
};
std::vector<RenderOrchestrator::RetainedSegmentSourceState> segmentSourceStates(segmentPlan.segments.size());
std::vector<bool> segmentSupportsCache(segmentPlan.segments.size(), false);
std::vector<bool> segmentPlacementGates(segmentPlan.segments.size(), true);
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy != RenderSegmentStrategy::Retained) continue;
auto& cache = retainedSegmentCaches[segment.segmentIndex];
@@ -656,18 +642,32 @@ void Renderer::Impl::render(const RenderTree& renderTree,
static_cast<uint64_t>(updateParameters->pixelRatio * 1000000.0f);
const bool segmentStable = sourceState.stable && orchestrator.retainedSegmentIsStable(
renderTreeParameters.timePoint, segment.beginIndex, segment.endExclusive);
const bool placementCapable = orchestrator.retainedRangeHasPlacementCapableLayer(
segment.beginIndex, segment.endExclusive);
const bool segmentPlacementGatePassed = renderSegmentPlacementGatePassed(
placementCapable, renderTreeParameters.placementChanged,
renderTreeParameters.symbolFadeChange, placementTransitionActive);
segmentPlacementGates[segment.segmentIndex] = segmentPlacementGatePassed;
const bool segmentSupported = retainedMode && updateParameters->styleLoaded &&
updateParameters->spriteLoaded && orchestrator.retainedGlobalResourcesReady() &&
updateParameters->mode == MapMode::Continuous && !renderTreeParameters.has3D &&
renderTreeParameters.debugOptions == MapDebugOptions::NoDebug && segmentStable &&
retainedPlacementStable && !legacyInSegment[segment.segmentIndex] &&
segmentPlacementGatePassed && !legacyInSegment[segment.segmentIndex] &&
!orchestrator.retainedRangeHasUnsupportedLayer(segment.beginIndex, segment.endExclusive);
segmentSupportsCache[segment.segmentIndex] = segmentSupported;
const char* segmentFallbackReason = !sourceState.ready ? sourceState.reason
: !sourceState.stable ? sourceState.reason
: !updateParameters->styleLoaded || !updateParameters->spriteLoaded ||
!orchestrator.retainedGlobalResourcesReady() ? "style-not-loaded"
: !segmentStable ? "static-transition" : retainedFallbackReason;
: !segmentPlacementGatePassed ? "static-placement"
: !segmentStable ? "static-transition"
: legacyInSegment[segment.segmentIndex] ? "legacy-ordering"
: orchestrator.retainedRangeHasUnsupportedLayer(segment.beginIndex, segment.endExclusive) ? "unsupported"
: !retainedMode ? "no-boundary"
: renderTreeParameters.has3D ? "3d"
: renderTreeParameters.debugOptions != MapDebugOptions::NoDebug ? "debug-options"
: updateParameters->mode != MapMode::Continuous ? "map-mode"
: nullptr;
decision = cache->evaluate(segmentKeys[segment.segmentIndex],
segmentContentRevision,
segmentSupported,
@@ -987,23 +987,37 @@ void Renderer::Impl::render(const RenderTree& renderTree,
}
commonClearPass();
context.bindGlobalUniformBuffers(*parameters.renderPass);
const bool globalLiveFallback = !retainedMode || renderTreeParameters.has3D ||
renderTreeParameters.debugOptions != MapDebugOptions::NoDebug ||
updateParameters->mode != MapMode::Continuous || !updateParameters->spriteLoaded ||
!orchestrator.retainedGlobalResourcesReady() || !retainedPlacementStable ||
orchestrator.retainedGlobalLightTransition();
const char* globalLiveFallbackReason = renderSegmentGlobalLiveFallbackReason({
retainedMode,
renderTreeParameters.has3D,
renderTreeParameters.debugOptions != MapDebugOptions::NoDebug,
updateParameters->mode == MapMode::Continuous,
updateParameters->spriteLoaded,
orchestrator.retainedGlobalResourcesReady(),
orchestrator.retainedGlobalLightTransition(),
});
const bool globalLiveFallback = std::string_view(globalLiveFallbackReason) != "none";
std::vector<std::string> actualExecutions(segmentPlan.segments.size(), "live");
if (globalLiveFallback) {
drawableOpaquePass(0, segmentPlan.layerCount);
drawableTranslucentPass(0, segmentPlan.layerCount);
} else {
visitRenderSegmentExecution(segmentPlan,
[&](uint32_t index) { return wantsResources(segmentDecisions[index]); },
[&](uint32_t index) { retainedSegmentCaches[index]->composite(orchestrator, parameters); },
[&](uint32_t index) {
retainedSegmentCaches[index]->composite(orchestrator, parameters);
actualExecutions[index] = "composite";
},
[&](int32_t begin, int32_t endExclusive) {
drawableOpaquePass(begin, endExclusive);
drawableTranslucentPass(begin, endExclusive);
});
}
if (retainedRuntimeState) retainedRuntimeState->recordMainBufferExecution(
segmentSnapshot.topologyRevision, renderTreeParameters.placementChanged,
renderTreeParameters.symbolFadeChange, placementTransitionActive,
updateParameters->crossSourceCollisions, globalLiveFallback ? "global-live" : "mixed",
globalLiveFallbackReason, segmentPlacementGates, actualExecutions);
drawableDebugOverlays();
// Give the layers a chance to do cleanup

View File

@@ -242,6 +242,8 @@ public:
entry.dependentSourceIds = dependencies.sourceIds;
entry.unknownDependencies = dependencies.globalPrerequisites;
if (dependencies.unknown) entry.unknownDependencies.push_back("unknown/source-or-custom");
entry.placementCapable = renderSegmentHasPlacementCapableLayer(
next.styleLayers, segment.beginIndex, segment.endExclusive);
if (segment.strategy == RenderSegmentStrategy::Retained) {
++next.retainedSegmentCount;
entry.cache.emplace();
@@ -325,6 +327,31 @@ public:
if (std::string_view(decision) == "fallback") ++value.fallbackCount;
});
}
void recordMainBufferExecution(uint64_t topologyRevision,
bool placementChanged,
float symbolFadeChange,
bool placementTransitionActive,
bool crossSourceCollisions,
const char* mode,
const char* globalLiveFallbackReason,
const std::vector<bool>& placementGates,
const std::vector<std::string>& actualExecutions) {
std::lock_guard<std::mutex> lock(rangeMutex);
if (topologyRevision != renderSegmentTopologyRevision ||
placementGates.size() != renderSegmentDiagnostics.segments.size() ||
actualExecutions.size() != renderSegmentDiagnostics.segments.size()) return;
auto& diagnostics = renderSegmentDiagnostics;
diagnostics.placementChanged = placementChanged;
diagnostics.symbolFadeChange = symbolFadeChange;
diagnostics.placementTransitionActive = placementTransitionActive;
diagnostics.crossSourceCollisions = crossSourceCollisions;
diagnostics.mainBufferExecutionMode = mode;
diagnostics.globalLiveFallbackReason = globalLiveFallbackReason;
for (std::size_t i = 0; i < diagnostics.segments.size(); ++i) {
diagnostics.segments[i].placementGatePassed = placementGates[i];
diagnostics.segments[i].actualExecution = actualExecutions[i];
}
}
void recordSegmentBuild(uint32_t index) { updateSegmentCache(index, [](auto& value) { ++value.build; }); }
void recordSegmentReuse(uint32_t index) { updateSegmentCache(index, [](auto& value) { ++value.reuse; }); }
void recordSegmentInvalidation(uint32_t index, RetainedUnderlayReason reason) {

View File

@@ -1157,6 +1157,149 @@ TEST(RenderSegmentLocality, FailedBuildCannotReuseOldTexture) {
EXPECT_EQ(cache.evaluate(viewportKey, 8, true), RetainedUnderlayDecision::Build);
}
TEST(RenderSegmentPlacementFree, StructuralCapabilityAndConservativeGate) {
const std::vector<StyleLayerDiagnostics> layers{
{0, "background", "background", true, std::nullopt, std::nullopt},
{1, "particle", "raster-particle", true, "field", std::nullopt},
{2, "label", "symbol", true, "chart", std::nullopt}};
EXPECT_FALSE(renderSegmentHasPlacementCapableLayer(layers, 0, 1));
EXPECT_TRUE(renderSegmentHasPlacementCapableLayer(layers, 2, 3));
// The symbol's current placementData may be empty; style structure still wins.
EXPECT_TRUE(renderSegmentHasPlacementCapableLayer(layers, 2, 3));
EXPECT_TRUE(renderSegmentHasPlacementCapableLayer(layers, -1, 1));
EXPECT_TRUE(renderSegmentPlacementGatePassed(false, true, 1.0f, true));
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, true, 0.0f, false));
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, false, 1.0f, false));
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, false, 0.0f, true));
EXPECT_TRUE(renderSegmentPlacementGatePassed(true, false, 0.0f, false));
}
TEST(RenderSegmentPlacementFree, MixedExecutionKeepsFreeRangesInStyleOrder) {
ResolvedRenderSegmentPlan plan;
plan.valid = true;
plan.layerCount = 5;
plan.segments = {{0, RenderSegmentStrategy::Retained, std::nullopt, 0, 1},
{1, RenderSegmentStrategy::Continuous, std::nullopt, 1, 2},
{2, RenderSegmentStrategy::Retained, std::nullopt, 2, 3},
{3, RenderSegmentStrategy::Continuous, std::nullopt, 3, 4},
{4, RenderSegmentStrategy::Retained, std::nullopt, 4, 5}};
RetainedUnderlayCache r0, r1, r2;
const auto k0 = key({640, 480}, 1);
const auto k1 = key({640, 480}, 3);
const auto k2 = key({640, 480}, 5);
EXPECT_EQ(r0.evaluate(k0, 1, true), RetainedUnderlayDecision::Build);
EXPECT_EQ(r1.evaluate(k1, 1, true), RetainedUnderlayDecision::Build);
EXPECT_EQ(r2.evaluate(k2, 1, true), RetainedUnderlayDecision::Build);
r0.markBuildSucceeded(); r1.markBuildSucceeded(); r2.markBuildSucceeded();
const bool freeGate = renderSegmentPlacementGatePassed(false, true, 1.0f, true);
const bool symbolGate = renderSegmentPlacementGatePassed(true, true, 1.0f, true);
EXPECT_EQ(r0.evaluate(k0, 1, freeGate), RetainedUnderlayDecision::Reuse);
EXPECT_EQ(r1.evaluate(k1, 1, symbolGate, "static-placement"), RetainedUnderlayDecision::Fallback);
EXPECT_EQ(r1.getLastInvalidationReason(), "static-placement");
EXPECT_EQ(r2.evaluate(k2, 1, freeGate), RetainedUnderlayDecision::Reuse);
EXPECT_NE(r0.getLastInvalidationReason(), "static-placement");
EXPECT_NE(r2.getLastInvalidationReason(), "static-placement");
std::vector<std::string> events;
visitRenderSegmentExecution(plan,
[](uint32_t index) { return index == 0 || index == 4; },
[&](uint32_t index) { events.push_back("composite:" + std::to_string(index)); },
[&](int32_t begin, int32_t end) {
for (auto i = begin; i < end; ++i) events.push_back("live:" + std::to_string(i));
});
EXPECT_EQ(events, (std::vector<std::string>{"composite:0", "live:1", "live:2",
"live:3", "composite:4"}));
// Source-local mutation/fade in R1 leaves both independent retained caches reusable.
EXPECT_EQ(r0.evaluate(k0, 1, freeGate), RetainedUnderlayDecision::Reuse);
EXPECT_EQ(r2.evaluate(k2, 1, freeGate), RetainedUnderlayDecision::Reuse);
}
TEST(RenderSegmentPlacementFree, RealGlobalGatesStillForceGlobalLive) {
RenderSegmentGlobalLiveGates gates{true, false, false, true, true, true, false};
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "none");
gates.has3D = true;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "3d");
gates.has3D = false; gates.debugOptions = true;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "debug-options");
gates.debugOptions = false; gates.continuousMode = false;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "map-mode");
gates.continuousMode = true; gates.spriteLoaded = false;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "sprite-not-loaded");
gates.spriteLoaded = true; gates.globalResourcesReady = false;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "global-resource-not-ready");
gates.globalResourcesReady = true; gates.globalLightTransition = true;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "global-light-transition");
gates.globalLightTransition = false; gates.retainedMode = false;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "no-boundary");
}
TEST(RenderSegmentPlacementFree, TopologyAndCameraChangesCannotReuseOldRetainedKey) {
RetainedUnderlayTopologyTracker tracker;
RetainedUnderlayTopologySnapshot topology;
topology.staticPrefix = {{0, "RETAINED:1", 0}, {1, "CONTINUOUS:2", 1},
{2, "RETAINED:3", 0}, {3, "CONTINUOUS:4", 1}};
const auto firstRevision = tracker.observe(topology);
RetainedUnderlayCache cache;
const auto firstKey = key({640, 480}, 3);
EXPECT_EQ(cache.evaluate(firstKey, firstRevision, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(firstKey, firstRevision, true), RetainedUnderlayDecision::Reuse);
topology.staticPrefix.resize(2); // R-C-R-C becomes R-C.
EXPECT_GT(tracker.observe(topology), firstRevision);
EXPECT_EQ(cache.evaluate(firstKey, tracker.current(), true, nullptr, "layer-group-mutation"),
RetainedUnderlayDecision::Build);
EXPECT_FALSE(cache.isValid());
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(key({640, 480}, 4), tracker.current(), true), RetainedUnderlayDecision::Build);
EXPECT_FALSE(cache.isValid());
}
TEST(RenderSegmentPlacementFree, DiagnosticsRecordActualMainBufferAndDoNotMutateOnRead) {
RetainedUnderlayRuntimeState state;
RenderSegmentPlan configured;
configured.segments = {{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "particle"},
{RenderSegmentStrategy::Retained, "label"}};
ASSERT_TRUE(state.setRenderSegmentPlan(configured));
const std::vector<std::string> ids{"background", "particle", "label"};
const auto plan = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), ids, true);
ASSERT_TRUE(plan.valid);
state.recordRenderSegmentResolution(plan, {
{0, "background", "background", true, std::nullopt, std::nullopt},
{1, "particle", "raster-particle", true, "field", std::nullopt},
{2, "label", "symbol", true, "chart", std::nullopt}});
auto snapshot = state.renderSegmentSnapshot();
ASSERT_EQ(snapshot.segments.size(), 3u);
EXPECT_FALSE(snapshot.segments[0].placementCapable);
EXPECT_TRUE(snapshot.segments[2].placementCapable);
state.recordSegmentExecution(0, true, true, true, 7, "reuse", nullptr);
state.recordMainBufferExecution(snapshot.topologyRevision, true, 1.0f, true, true,
"global-live", "global-light-transition",
{true, true, false}, {"live", "live", "live"});
snapshot = state.renderSegmentSnapshot();
ASSERT_TRUE(snapshot.segments[0].cache);
EXPECT_EQ(snapshot.segments[0].cache->finalDecision, "reuse");
EXPECT_EQ(snapshot.segments[0].actualExecution, "live");
EXPECT_EQ(snapshot.mainBufferExecutionMode, "global-live");
EXPECT_EQ(snapshot.globalLiveFallbackReason, "global-light-transition");
EXPECT_TRUE(snapshot.placementChanged);
EXPECT_FLOAT_EQ(snapshot.symbolFadeChange, 1.0f);
EXPECT_TRUE(snapshot.placementTransitionActive);
EXPECT_TRUE(snapshot.crossSourceCollisions);
state.recordMainBufferExecution(snapshot.topologyRevision, true, 1.0f, true, true,
"mixed", "none", {true, true, false},
{"composite", "live", "live"});
const auto mixed = state.renderSegmentSnapshot();
EXPECT_EQ(mixed.segments[0].actualExecution, "composite");
EXPECT_EQ(mixed.segments[2].actualExecution, "live");
const auto json = renderSegmentDiagnosticsToJSON(mixed);
EXPECT_EQ(renderSegmentDiagnosticsToJSON(state.renderSegmentSnapshot()), json);
EXPECT_NE(json.find("\"placementCapable\":true"), std::string::npos);
EXPECT_NE(json.find("\"placementGatePassed\":false"), std::string::npos);
EXPECT_NE(json.find("\"actualExecution\":\"composite\""), std::string::npos);
EXPECT_NE(json.find("\"mainBufferExecutionMode\":\"mixed\""), std::string::npos);
EXPECT_EQ(state.renderSegmentSnapshot().topologyRevision, mixed.topologyRevision);
}
TEST(RenderSegmentPlan, StrictJSONRejectsUnknownFieldsStrategyAndInvalidFirstBoundary) {
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED","unexpected":true}]})"));

View File

@@ -1067,3 +1067,17 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2`
- Mac host GTest 仍受本机 libuv pkg-config 环境阻塞,未安装系统依赖、未虚报 PASS。Jest NOT RUN。
- STEP-101 允许关闭;下一步进入 V19 candidate → NavSea 冷启动 Gate → 正式发布。
- STEP-101 implementation commit `64cf8f44350eb8ec4b45360db4d3f9505ac4ac0b` 已推送 `origin/particle/main`,远端回读一致;后续只做 docs closeout 与 V19 发行。
### 2026-10-08 STEP-102 RenderSegment 无 Placement 段局部复用(代码完成,待验证)
- 基于 `6df21dac38f072c5ad35b8dfc7ec5217509602bc` 的独立 `step102-placement-free-retained-locality` worktree,实现结构性 symbol placement capability、full CPU per-segment gate、逐段 fallback reason,以及仅从全局 live 条件移除纯 aggregate placement gate。旧动态 `retainedRangeUsesPlacement()`、Fast Path、mixed executor、C-001 与 RN method surface 保持原语义。
- diagnostics 新增 `placementCapable`、`placementGatePassed`、`actualExecution`、全局 placement 状态、`mainBufferExecutionMode` 与 `globalLiveFallbackReason`;actual 在 main-buffer 分支执行后记录,允许 cache=reuse 但实际 live 的事实被看见。C++ 测试源码和 Android T1 symbol/T2 two-free 离线场景已交付。
- 只做只读审计与 `git diff --check` 静态检查;CMake/Ninja、C++ 测试、Android Gradle/APK、ADB/设备、Jest、iOS/Metal、NavSea、发布、commit/push 均未执行。STEP-102 不记运行态 PASS;用户手工 Android 构建与后续独立验收命令见 `docs/报告/STEP-102_RenderSegment无Placement段局部复用_Codex完成报告_v1.md`。
### 2026-10-08 STEP-102 RDC 独立验证补记
- 在独立工作树 `step102-placement-free-retained-locality` 核查了结构性 placement 判断、per-segment gate、实际 main-buffer diagnostics 与 RN 类型边界,未改 Fast Path、C-001、V20 发布源码。
- AppleClang 对新增 C++ GTest 源码 `-fsyntax-only` PASS;独立头文件 smoke 测试 10/10 PASS;`git diff --check` PASS。
- Metal `bazel build -c opt --//:renderer=metal //:mbgl-core` 构建 PASS(642/642,875.118s,产物 `bazel-bin/libmbgl-core.a`)。
- Mac CMake/GTest 配置遇缺失 `libuv`,未安装依赖,正式 GTest NOT RUN。
- 修复 STEP-102 Android Validation 入口切换时残留 STEP-093/097 状态与轮询的问题。Android APK 编译、T1/T2 真机及 STEP-100/101 回归仍待手工构建与运行;STEP-102 维持“进行中”,尚非 CLOSED/PASS。