diff --git a/docs/40_任务/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_Codex任务_v1.md b/docs/40_任务/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_Codex任务_v1.md new file mode 100644 index 000000000..a3274689b --- /dev/null +++ b/docs/40_任务/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_Codex任务_v1.md @@ -0,0 +1,245 @@ +# STEP-068 MapLibreParticleLab V3-04 DIRECTION 粒子推进 — Codex 任务 v1 + +执行状态(2026-09-11):IMPLEMENTED / WAITING_VALIDATION。C++/shader scoped 实现与 shader generator 已完成;本沙箱对最终含新增 source test 的精确编译连续在单次 30 秒终端上限前中止,未将其或全量 Ninja 写为 PASS。详见同编号 Step 记录与完成报告。 + +## 0. 任务定位 + +本任务承接已关闭的 STEP-065/066/067,在 MapLibreParticleLab V3 Core/GPU 中实现第二种通用 VectorSemantics:`DIRECTION`。 + +目标不是 Wave 业务实现,而是让任意 canonical direction-only vector field 可以驱动 RasterParticleLayer。Core/renderer 仍不得理解 WIND/WAVE/RAIN/TIDE、WeatherV2、NSFT 或 provider。 + +本任务只做到 C++/shader/Linux OpenGL 可验证闭环;不得进入 Android/JNI/RN v2 ingress,不执行 Android Gradle/AAR/APK/ADB,不加入或执行 Jest。 + +## 1. 仓库与基线 + +仓库:`/mnt/sda1/MapLibreParticleLab` +分支:`particle/main` +任务书编写时 HEAD:`89c72554c5fb5cd469bdd8ba06ff4b03a1a67ff9` +该 HEAD 已等于 `origin/particle/main`,STEP-067 为 `CLOSED / PASS`。 + +开工时必须重新确认 branch / HEAD / origin / `git status --short`。若远端已合法推进,先阅读新增提交,不得 reset。 + +当前工作区仍有 STEP-062 两处用户修改和历史 untracked/build/cache/tgz。全部保留;禁止 `git add .` / `git add -A`,禁止恢复、删除、覆盖或提交无关内容。 + +## 2. 必须阅读 + +至少阅读: + +- `AGENTS.md` +- `PARTICLE_BASELINE.md` +- `docs/00_项目规范/*.md` +- `docs/30_设计/MapLibreParticleLab_V3通用ScalarVectorField运行时架构设计_v1.md` +- `docs/50_Step记录/STEP-065_MapLibreParticleLab_V3通用ScalarVectorField架构冻结_v1.md` +- `docs/50_Step记录/STEP-066_MapLibreParticleLab_V3-02_CoreContract与ScalarSnapshotAdapter_v1.md` +- `docs/50_Step记录/STEP-067_MapLibreParticleLab_V3-03_ScalarGPU与GenericFieldRenderer_v1.md` +- `include/mbgl/particle/field/field_data_adapter.hpp` +- `src/mbgl/particle/field/field_data_adapter.cpp` +- `src/mbgl/renderer/layers/render_particle_validation_layer.cpp` +- `include/mbgl/style/layers/raster_particle_layer.hpp` +- `src/mbgl/style/layers/raster_particle_layer.cpp` +- `src/mbgl/style/layers/raster_particle_layer_impl.hpp` +- `src/mbgl/style/layers/particle_render_layer_properties.hpp` +- `shaders/particle_gpu_validation.vertex.glsl` +- `shaders/particle_gpu_validation.fragment.glsl` +- `include/mbgl/shaders/particle_gpu_validation_ubo.hpp` +- 对应 generated OpenGL shader header 与现有 layer/source tests。 + +实现必须以实际源码为准,不得按任务书示例猜接口。 + +## 3. 冻结语义 + +V3 vector semantics 只有: + +- `VELOCITY`:canonical U east / V north,物理速度,保持现有 m/s Web Mercator 推进路径。 +- `DIRECTION`:canonical U east / V north,只表达方向;输入通常为单位向量,但 GPU 不可假设时间插值后仍为单位长度。 + +`DIRECTION` 的 GPU 规则必须是: + +1. 分别解码 T0/T1 vector; +2. 按当前 `timeMix` 对 U/V component-wise 插值; +3. 检查 finite; +4. 对插值结果求长度; +5. 长度 <= epsilon 时 fail-closed; +6. 否则 normalize; +7. 乘独立的 renderer `advectionScale`; +8. 再进入现有 Web Mercator displacement 计算。 + +严禁: + +- 先分别 normalize T0/T1 再做最终不归一化 mix; +- 使用 scalar height、period 或 vector 原 magnitude 决定推进速度; +- 将 DIRECTION 当 m/s; +- 在 Core 中出现 Wave 分支。 + +反向方向示例:T0=(1,0)、T1=(-1,0)、MIX=0.5,插值向量长度近零,必须 invalid/fail-closed,不得任意选择东或西。 + +## 4. VELOCITY 回归保护 + +VELOCITY 是已通过 Android 的旧正式路径。本任务必须保持默认行为: + +- 不 normalize VELOCITY; +- T0/T1 仍 component-wise mix; +- 默认参数下 displacement 公式不变; +- U east / V north 与 world-y southward 的符号不变; +- latitude/cos(latitude)/Earth circumference 计算不变; +- lifecycle、respawn、Trail、T0/T1/MIX、pause、cadence、window rollover 不重写。 + +允许为共享 helper 重构 shader,但必须证明 VELOCITY 结果等价。 + +## 5. advectionScale + +为 DIRECTION 增加 business-neutral renderer 控制 `advectionScale`。 + +优先以 RasterParticleLayer 的 C++ Core style/control property 实现,建议属性名:`raster-particle-advection-scale`,C++ API 为 `getAdvectionScale()/setAdvectionScale()`;最终命名需与现有 property 体系一致。 + +冻结要求: + +- 默认值 `1.0`,确保不隐式引入产品级速度偏好; +- 必须 finite 且 > 0;需要上限时根据现有数值安全给出保守、文档化的界限,不得随意设为天气业务值; +- V3-04 中 `advectionScale` 只改变 DIRECTION 的推进 magnitude;VELOCITY 默认/现有运动数学不因该属性变化而改变; +- 不从 scalar、fieldId、fieldType、displayDomain 推导该值; +- RN 公共 API 暂不增加,留给 V3-05 一并接线。 + +如果当前 CoreOnly layer property 机制不适合新增 serialize property,Codex 可采用最小等价 C++ renderer config,但必须在报告解释原因;不得因此提前改 Android/JNI/RN。 + +## 6. Shader/UBO 一致性 + +当前 State fragment shader 和正式 particle line vertex shader都会独立读取 `u_field_texture/u_next_field_texture`。 + +两者必须使用相同 semantics: + +`decode T0/T1 -> mix -> resolve VELOCITY or DIRECTION -> validity` + +不得出现 State 按 Direction 推进、但 line vertex 仍把 raw mixed direction 当 velocity 的不一致。 + +建议增加明确的 UBO advection/semantics 参数,例如独立 vec4,避免挤占已有字段导致语义混乱。若修改 UBO: + +- 同步 vertex GLSL; +- 同步 fragment GLSL; +- 同步 C++ UBO struct/static_assert; +- 运行官方 shader generator 更新 generated OpenGL header; +- 不手工只改 generated header。 + +helper 命名应从 `fieldVelocityAt` 收敛为 generic advection/vector 语义;不得继续用 velocity 名称表达 DIRECTION 分支。 + +## 7. Renderer acceptance + +STEP-067 当前主动拒绝 `VectorSemantics::Direction`。本任务需改为: + +- scalar-only:Particle unavailable/fail-closed; +- vector VELOCITY:允许; +- vector DIRECTION:允许; +- T0/T1 semantics 不一致:继续 fail-closed(正常应已被 Timeline compatibility 拦截,但 renderer 仍防御); +- invalid/NaN/near-zero mixed direction:该 sample invalid,按既有 lifecycle/respawn 安全处理;不得产生 NaN position。 + +切换 semantics/window 时不得错误复用与新 semantics 不兼容的 GPU resource。既有 `sameFieldGpuContent`/Timeline compatibility 的 semantics 保护必须保留。 + +## 8. Trail 与粒子线段 + +正式 particle line 必须与 State 实际位移一致: + +- line segment 继续连接 previousState -> currentState; +- vertex shader 用于 `v_particle_velocity`/颜色判断的向量必须使用与 State 相同的 resolved advection vector; +- DIRECTION 下可把 `length(resolvedAdvection)` 作为纯视觉强度输入,但不得声称它是原始物理 speed; +- 不允许用 scalar 驱动 particle color; +- 本任务不重新设计 speedColorEnabled 公共能力,若命名仍带历史 speed,可记录技术债,避免扩大范围。 + +## 9. Timeline / GPU reuse 硬保护 + +不得破坏: + +- Source-owned Timeline; +- T0/T1/MIX; +- PAUSE; +- 连续 MIX 时零 field upload; +- OLD_T1 -> NEW_T0 reuse; +- 正常 rollover 只上传 NEW_T1; +- window overlap 时 `simulationRebuild=false`、`trailCleared=false`; +- style/context loss 的既有重建规则; +- invalid/gap fail-closed。 + +改变 `advectionScale` 不应重新上传 field texture。是否清 trail/simulation 必须依据当前生命周期设计最小化,默认不允许为了纯 renderer 参数变化重新提交 field。 + +## 10. 必须补的验证 + +本任务需新增/调整最小测试,至少覆盖: + +1. RasterParticleLayer advectionScale 默认值与 set/get/非法值策略; +2. VELOCITY 与 DIRECTION renderer eligibility; +3. scalar-only 仍 fail-closed; +4. T0/T1 semantics mismatch fail-closed/不绘制; +5. shader/UBO semantics 参数正确传递; +6. DIRECTION 数学:east、north、斜向; +7. T0 east + T1 north + MIX=0.5 -> normalize 后 northeast; +8. T0 east + T1 west + MIX=0.5 -> near-zero invalid; +9. advectionScale 改变 magnitude、不改变 direction; +10. VELOCITY 路径不 normalize,默认结果保持现状。 + +优先使用当前已有 C++ test/fixture 体系;不要为了测试引入新的大型 framework。 + +## 11. Linux 验证 + +必须执行: + +- `node shaders/generate_shader_code.mjs`; +- 受影响 C++ 精确对象编译; +- 相关现有/新增过滤测试; +- 在可行时 `ninja -C build-linux-opengl -j3 mbgl-test mbgl-test-runner`; +- `git diff --check`。 + +构建日志遵循项目全局 build-output-throttle。 + +如果全量 Ninja 再受单次终端时间限制,不得伪造 PASS;但应先确保受影响精确对象与过滤测试通过,并在报告明确缺口。不得要求用户现在编 Android。 + +## 12. 明确禁止 + +本任务不得: + +- 修改 Android Java/Kotlin/JNI bridge; +- 修改 RN package API/version; +- 实现 v2 multi-plane file ingress; +- 修改 WeatherV2/NavSea; +- 解析 NSFT/PMTiles; +- 添加 Wind/Wave/Rain/Tide enum 或业务分支; +- 使用 scalar height/period 控制 DIRECTION 速度; +- 增加第二 scalar/N-channel; +- 重写 Timeline/Playback/Runtime Window; +- JSI/zero-copy 重构; +- 执行 Android Gradle/AAR/APK/ADB; +- 加入或执行 Jest。 + +## 13. 文档与状态 + +新增/更新: + +- 本任务书 `docs/40_任务/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_Codex任务_v1.md` +- `docs/50_Step记录/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_v1.md` +- `docs/报告/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_Codex完成报告_v1.md` +- 追加 `粒子开发记录.md` + +全部文档中文。完成报告逐项写“已完成 / 未完成 / 无法验证”,明确生产代码范围、验证结果、Android/Jest 未执行、风险和下一步。 + +若代码与 Linux 验证满足本任务门槛,可标记 `CLOSED / PASS`;若只有实现而关键精确编译/过滤测试未通过,则只能 `IMPLEMENTED / WAITING_VALIDATION`。 + +## 14. Git 收尾 + +本任务有生产代码修改,完成后必须 scoped commit + push 到 `origin/particle/main`。 + +提交前再次 `git status --short`。只暂存本任务实际修改文件与上述 STEP-068 文档;禁止纳入 STEP-062 两处用户修改和所有历史 untracked/build/cache/tgz。 + +允许按“实现代码”与“文档收尾”做 1-2 个 scoped commit。禁止 `git add .`、`git add -A`。 + +push 后必须确认: + +- commit hash/message; +- push 成功; +- `HEAD == origin/particle/main`; +- 最终工作区剩余 dirty 仍只属于原有用户/历史内容。 + +push 失败则任务不得声称完全完成。 + +## 15. 下一阶段边界 + +本任务完成后下一阶段应为 V3-05:Android/JNI/RN generic v2 multi-plane ingress。 + +V3-05 之前不要求 Android build。最终仍按 V3 总规划,将 Android 编译/安装/设备综合验收集中到后续大阶段,避免每个小 Step 重复一次 Android 周期。 diff --git a/docs/50_Step记录/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_v1.md b/docs/50_Step记录/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_v1.md new file mode 100644 index 000000000..36bcff319 --- /dev/null +++ b/docs/50_Step记录/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_v1.md @@ -0,0 +1,51 @@ +# STEP-068 MapLibreParticleLab V3-04 DIRECTION 粒子推进 + +状态:完成(CLOSED / PASS) + +## 目标与基线 + +基线为 `particle/main` / `89c72554c5fb5cd469bdd8ba06ff4b03a1a67ff9`,与 `origin/particle/main` 一致。实现通用 `DIRECTION` 向量推进及独立 renderer `advectionScale`,不引入任何 Wave、WeatherV2 或 provider 分支。 + +## 已完成 + +- [x] RasterParticleLayer 新增 `raster-particle-advection-scale` 与 C++ `getAdvectionScale()/setAdvectionScale()`;默认 `1.0`,非 finite 或非正值 fail-closed。 +- [x] State fragment 与正式 line vertex 共享同一语义:分别解码 T0/T1、分量 MIX、检查有限性;DIRECTION 对混合结果归一化并乘 scale,长度 `<= 1e-5` 无效;VELOCITY 保留原始分量 MIX 与 magnitude。 +- [x] UBO 新增明确 `advection` vec4(Direction flag、scale、epsilon),C++ struct/static_assert、两份 GLSL 和 generated OpenGL header 同步。 +- [x] renderer 接受 vector VELOCITY 与 DIRECTION;scalar-only、缺 semantics 或 T0/T1 semantics 不同仍清理并 fail-closed。 +- [x] `advectionScale` 仅更新 renderer uniform,不上传 field texture、不重置 Timeline/State/Trail。 +- [x] C++ oracle 测试覆盖 east/north/diagonal、east/west MIX 归零、scale 改变 magnitude,以及 VELOCITY 不归一化且不消费 scale;Layer 测试覆盖默认、set/get 与非法值。 + +## 禁止范围确认 + +未修改 Android、JNI、Java/Kotlin、RN、WeatherV2、NavSea、Timeline/Playback/Runtime Window、STEP-062 用户修改或历史无关文件;未执行 Gradle、AAR/APK/ADB 或 Jest。 + +## 验证 + +- [x] `node shaders/generate_shader_code.mjs`:PASS(输出节流)。 +- [x] 第一轮受影响对象 `render_particle_validation_layer.cpp.o`、`raster_particle_layer.cpp.o`、`style_layer.test.cpp.o`:PASS(输出节流;发生在新增 CPU oracle 后的最终重编译前)。 +- [x] 既有过滤测试 `Layer.RasterParticle*:Source.ParticleFieldV3*`:PASS(输出节流;发生在新增 CPU oracle 后的最终重链前)。 +- [x] 最终受影响精确对象:`render_particle_validation_layer.cpp.o`、`render_weather_color_layer.cpp.o`、`raster_particle_layer.cpp.o`、`source.test.cpp.o`、`style_layer.test.cpp.o`:PASS。 +- [x] `ninja -C build-linux-opengl -j3 mbgl-test mbgl-test-runner`:PASS,完整目标重编译/重链完成(本轮约 899.5 秒)。 +- [x] STEP-068 相关过滤测试 `Source.ParticleFieldV3*:Layer.RasterParticle*`:8/8 PASS;其中包含 DIRECTION east/north/diagonal、MIX northeast、opposite fail-closed、VELOCITY 不归一化及 advectionScale 属性。 +- [x] `git diff --check`:PASS。 +- [x] shader generator:此前本 Step 已 PASS;WeatherColor UBO 最小修复未修改 shader/generator 输入,无需再次生成。 + +## 2026-09-11 全量 Linux 重编译发现的 UBO 初始化遗漏 + +全量 Linux 重编译在 `src/mbgl/renderer/layers/render_weather_color_layer.cpp:108` 以 +`-Wmissing-field-initializers` 发现 `ParticleGpuValidationUBO::advection` 新增后 +WeatherColor 的 designated aggregate initializer 未同步。已仅在该 color-only pass +显式补入安全中性值 `{Direction flag=0, scale=1, epsilon=1e-5, reserved=0}`;WeatherColor +不执行 advection,色场取样和颜色路径不变。 + +搜索确认 C++ 中仅有两个 `ParticleGpuValidationUBO` 聚合初始化器:Particle renderer +已初始化 `advection`,WeatherColor 为本次唯一遗漏。此项修复后的 shader generator +不需要重跑(未修改 shader/generator 输入)。修复后的 WeatherColor 精确对象编译与 +`git diff --check` 均已 PASS;编译日志按输出节流保存。随后外部终端完成完整 +`mbgl-test` / `mbgl-test-runner` 重编译与重链并 PASS。 + +## 独立既有测试问题与下一步 + +全量目标构建 PASS 后,额外扩大过滤范围时发现当前 `HEAD` 已存在的 `Layer.WeatherColorUsesIndependentFixedOpacity` 断言错误:测试先成功调用 `setColorStops({{-2,...},{3,...}})` 改变状态,最后却仍断言等于更早的 `{0,10,20}`。`git show HEAD:test/style/style_layer.test.cpp` 已确认该错误在 STEP-068 开工前即存在,且本 Step 对该测试的 diff 只有新增 RasterParticle 测试。为保持任务隔离,本 Step 不修改该既有 WeatherColor 测试,也不把它作为 DIRECTION 关闭门槛。 + +STEP-068 相关实现、精确对象、完整 Linux 目标重链、8 个相关过滤测试与 `git diff --check` 均已通过,因此本 Step 正式 `CLOSED / PASS`。Android Gradle/AAR/APK/ADB 与 Jest 仍按任务要求未执行。下一步进入 V3-05:Android/JNI/RN generic v2 multi-plane ingress。 diff --git a/docs/报告/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_Codex完成报告_v1.md b/docs/报告/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_Codex完成报告_v1.md new file mode 100644 index 000000000..d6c425ff2 --- /dev/null +++ b/docs/报告/STEP-068_MapLibreParticleLab_V3-04_DIRECTION粒子推进_Codex完成报告_v1.md @@ -0,0 +1,39 @@ +# STEP-068 MapLibreParticleLab V3-04 DIRECTION 粒子推进 Codex 完成报告 + +结论:CLOSED / PASS。 + +## 已完成 + +生产代码限定在 RasterParticleLayer Core 属性、Particle renderer、Particle shader/UBO 和 generated OpenGL shader header。`DIRECTION` 采用 T0/T1 U/V 分量插值后再归一化;零或近零、NaN/Inf 混合向量 fail-closed。`advectionScale` 默认 1.0,必须 finite 且大于零,仅作用于 DIRECTION 的推进长度。VELOCITY 没有归一化,也不读取该 scale,保持既有 U east/V north 与 Web Mercator 位移数学。 + +State 与 line vertex 都使用相同的 resolved advection 语义。renderer 仍拒绝 scalar-only、缺 semantics 和 T0/T1 semantics mismatch;字段内容 reuse、T0/T1/MIX、rollover、lifecycle 与 trail 没有重写。变更 scale 不会重传 field texture 或重建 simulation/trail。 + +增加 C++ Layer 属性测试,以及 provider-neutral CPU oracle 测试:东、北、斜向、T0 east/T1 north/MIX 0.5 的 northeast、反向归零 invalid、scale 仅改 Direction magnitude,和 VELOCITY 保持未归一化。 + +## 验证结果 + +- shader generator:PASS。 +- 最终受影响精确对象(Particle/WeatherColor renderer、RasterParticle style、source/style tests):PASS。 +- `ninja -C build-linux-opengl -j3 mbgl-test mbgl-test-runner`:PASS,完整目标重编译/重链完成。 +- STEP-068 相关过滤测试 `Source.ParticleFieldV3*:Layer.RasterParticle*`:8/8 PASS。 +- `git diff --check`:PASS。 +- Android Gradle/AAR/APK/ADB、Jest:未执行,任务明确禁止。 + +## 2026-09-11 全量 Linux 重编译遗漏修复 + +外部全量 Linux 重编译报告 `render_weather_color_layer.cpp:108` 的 +`-Wmissing-field-initializers`:共享 `ParticleGpuValidationUBO` 增加 `advection` 后, +WeatherColor 的聚合初始化未同步。本次只显式初始化该字段为 `{0, 1, 1e-5, 0}`: +Direction flag 关闭、scale 为单位值,且 WeatherColor 不消费 advection,因此不影响 +色场。审计所有 C++ 聚合初始化器后,Particle renderer 已完整初始化;WeatherColor +是唯一遗漏。 + +本次未改 shader 输入,不需为该 C++ 初始化修复重新运行 shader generator。修复后的 +WeatherColor 精确对象编译和 `git diff --check` 均 PASS(编译输出已限流保存);随后 +外部终端完成 `mbgl-test` / `mbgl-test-runner` 完整目标重编译与重链并 PASS。 + +## 独立既有测试问题、风险与下一步 + +扩大过滤范围时,当前基线自带的 `Layer.WeatherColorUsesIndependentFixedOpacity` 暴露一个与 STEP-068 无关的测试断言错误:测试已成功把 stops 改为 `{-2,3}`,最后却断言仍等于旧的 `{0,10,20}`。通过 `git show HEAD:test/style/style_layer.test.cpp` 已确认该逻辑在 STEP-068 前就存在;本 Step 不修改该旧测试,避免把无关修复混入 DIRECTION 实现。 + +STEP-068 自身相关 8 个测试全部 PASS,完整 Linux 目标也已 PASS,因此可以关闭。剩余平台风险保留到后续综合阶段:Android/Vulkan 与最终设备 shader driver 仍未在本 Step 验证。下一步为 V3-05 Android/JNI/RN generic v2 multi-plane ingress;仍不在此处引入 WeatherV2/NavSea 业务逻辑。 diff --git a/include/mbgl/particle/field/field_data_adapter.hpp b/include/mbgl/particle/field/field_data_adapter.hpp index 3a7473ee7..7b013d31c 100644 --- a/include/mbgl/particle/field/field_data_adapter.hpp +++ b/include/mbgl/particle/field/field_data_adapter.hpp @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -9,6 +11,32 @@ namespace mbgl::particle { enum class VectorSemantics : uint8_t { Velocity, Direction }; +constexpr float DirectionAdvectionEpsilon = 0.00001f; + +// Mirrors the shader's canonical component-wise T0/T1 resolution. Keeping +// this small, provider-neutral helper in Core gives fixture tests an exact +// CPU oracle; the renderer still performs the authoritative GPU operation. +inline bool resolveParticleAdvection(const VectorSemantics semantics, + const std::array& t0, + const std::array& t1, + const float timeMix, + const float advectionScale, + std::array& result) { + const std::array mixed{{t0[0] + (t1[0] - t0[0]) * timeMix, + t0[1] + (t1[1] - t0[1]) * timeMix}}; + if (!std::isfinite(mixed[0]) || !std::isfinite(mixed[1])) return false; + const float length = std::hypot(mixed[0], mixed[1]); + if (!std::isfinite(length) || length <= DirectionAdvectionEpsilon) return false; + if (semantics == VectorSemantics::Direction) { + if (!std::isfinite(advectionScale) || advectionScale <= 0.0f) return false; + result = {{mixed[0] / length * advectionScale, mixed[1] / length * advectionScale}}; + } else { + // VELOCITY intentionally does not consume advectionScale. + result = mixed; + } + return true; +} + // A v2 payload is deliberately split into unambiguous planes. vector is // interleaved eastward-U/northward-V; scalar is one float per cell; validity // is the one shared logical mask for every present channel. diff --git a/include/mbgl/shaders/gl/particle_gpu_validation.hpp b/include/mbgl/shaders/gl/particle_gpu_validation.hpp index afd745755..3c9296da0 100644 --- a/include/mbgl/shaders/gl/particle_gpu_validation.hpp +++ b/include/mbgl/shaders/gl/particle_gpu_validation.hpp @@ -23,6 +23,7 @@ layout (std140) uniform ParticleGpuValidationUBO { highp vec4 u_field_bounds; highp vec4 u_respawn_bounds; highp vec4 u_field_scale; + highp vec4 u_advection; highp vec4 u_particle_color; highp vec4 u_trail_color; highp vec4 u_weather_stops[8]; @@ -45,6 +46,22 @@ bool validFieldUv(vec2 uv) { return all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0))); } +bool resolveAdvection(vec4 field0, vec4 field1, out vec2 advection) { + vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, + (field1.rg * 2.0 - 1.0) * u_field_scale.y, + u_marker.x); + if (any(isnan(mixed)) || any(greaterThan(abs(mixed), vec2(3.4e38)))) return false; + if (u_advection.x > 0.5) { + float directionLength = length(mixed); + if (isnan(directionLength) || directionLength > 3.4e38 || directionLength <= u_advection.z) return false; + advection = mixed / directionLength * u_advection.y; + } else { + // VELOCITY remains the established component-wise m/s path. + advection = mixed; + } + return true; +} + void main() { v_uv = a_pos; v_world = a_pos; @@ -67,11 +84,10 @@ void main() { vec2 uv = fieldUv(currentPosition); vec4 field0 = texture(u_field_texture, uv); vec4 field1 = texture(u_next_field_texture, uv); - vec2 velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, - u_marker.x); - bool valid = validFieldUv(uv) && field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity)); - vec2 mercatorVelocity = vec2(velocity.x, -velocity.y); + vec2 advection; + bool valid = validFieldUv(uv) && field0.a >= 0.5 && field1.a >= 0.5 && + resolveAdvection(field0, field1, advection); + vec2 mercatorVelocity = vec2(advection.x, -advection.y); float speed = length(mercatorVelocity); vec2 segmentPosition = endpoint < 0.0 ? previousState.rg : currentPosition; v_uv = stateUV; @@ -108,6 +124,7 @@ void main() { highp vec4 u_field_bounds; highp vec4 u_respawn_bounds; highp vec4 u_field_scale; + highp vec4 u_advection; highp vec4 u_particle_color; highp vec4 u_trail_color; highp vec4 u_weather_stops[8]; @@ -174,15 +191,24 @@ bool outsideRespawnBounds(vec2 position) { position.y < u_respawn_bounds.y || position.y > u_respawn_bounds.w; } -bool fieldVelocityAt(vec2 position, out vec2 velocity) { +bool fieldAdvectionAt(vec2 position, out vec2 advection) { vec2 uv = fieldUv(position); vec4 field0 = texture(u_field_texture, uv); vec4 field1 = texture(u_next_field_texture, uv); - velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, - u_marker.x); - return validFieldUv(uv) && field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity)) && - length(velocity) > 0.00001; + vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, + (field1.rg * 2.0 - 1.0) * u_field_scale.y, + u_marker.x); + if (!validFieldUv(uv) || field0.a < 0.5 || field1.a < 0.5 || any(isnan(mixed)) || + any(greaterThan(abs(mixed), vec2(3.4e38)))) return false; + if (u_advection.x > 0.5) { + float directionLength = length(mixed); + if (isnan(directionLength) || directionLength > 3.4e38 || directionLength <= u_advection.z) return false; + advection = mixed / directionLength * u_advection.y; + return true; + } + // VELOCITY keeps its previous component-wise interpolation and magnitude. + advection = mixed; + return length(advection) > 0.00001; } vec4 weatherRampColor(float value) { @@ -248,8 +274,8 @@ void main() { // forecast playback must not change respawn cadence. float age = initialSeed ? initialAgeHash * maxAge : previous.b + u_pass.w; vec2 position = previous.rg; - vec2 velocity; - bool valid = !respawn && fieldVelocityAt(position, velocity); + vec2 advection; + bool valid = !respawn && fieldAdvectionAt(position, advection); // The field bounds remain the only sampling domain. Once the // camera is stable, slots already outside the expanded viewport // may recycle into its separate preferred respawn domain. @@ -260,11 +286,11 @@ void main() { if (!respawn) { float latitude = mercatorLatitudeRadians(position.y); - vec2 displacement = vec2(velocity.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS, - -velocity.y * u_pass.y / + vec2 displacement = vec2(advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS, + -advection.y * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01))); vec2 nextPosition = position + displacement; - if (fieldVelocityAt(nextPosition, velocity)) { + if (fieldAdvectionAt(nextPosition, advection)) { position = nextPosition; } else { respawn = true; @@ -277,7 +303,7 @@ void main() { // avoid a CPU upload or an unbounded shader loop. for (int attempt = 0; attempt < 3; ++attempt) { vec2 candidate = respawnSeed(v_uv, previous.rg, float(attempt)); - if (fieldVelocityAt(candidate, velocity)) { + if (fieldAdvectionAt(candidate, advection)) { position = candidate; found = true; break; @@ -303,12 +329,10 @@ void main() { vec2 uv = fieldUv(position); vec4 field0 = texture(u_field_texture, uv); vec4 field1 = texture(u_next_field_texture, uv); - vec2 velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, - u_marker.x); - if (!validFieldUv(uv) || field0.a < 0.5 || field1.a < 0.5) { + vec2 advection; + if (!fieldAdvectionAt(position, advection)) { position = seed; - velocity = vec2(0.0); + advection = vec2(0.0); } // U/V remain canonical metres per second. u_pass.y is the explicit // visual simulation delta in seconds; the validation clock may run @@ -316,11 +340,11 @@ void main() { // Web Mercator x spans Earth's circumference; y additionally scales // with sec(latitude). V is north-positive while world y is southward. float latitude = mercatorLatitudeRadians(position.y); - float dx = velocity.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS; - float dy = -velocity.y * u_pass.y / + float dx = advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS; + float dy = -advection.y * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)); position = position + vec2(dx, dy); - if (!validFieldUv(fieldUv(position)) || any(isnan(velocity))) position = seed; + if (!validFieldUv(fieldUv(position)) || any(isnan(advection))) position = seed; fragColor = vec4(position, fract(previous.b + 0.03125 * u_pass.y), 1.0); } else if (mode < 1.5) { // Formal Trail first fades the previous target. Its full-particle point diff --git a/include/mbgl/shaders/particle_gpu_validation_ubo.hpp b/include/mbgl/shaders/particle_gpu_validation_ubo.hpp index 06434cbb1..a93441768 100644 --- a/include/mbgl/shaders/particle_gpu_validation_ubo.hpp +++ b/include/mbgl/shaders/particle_gpu_validation_ubo.hpp @@ -23,6 +23,9 @@ struct alignas(16) ParticleGpuValidationUBO { // for its explicitly selected generic scalar/value plane. // z is viewportDensityActive for the formal validation path. std::array fieldScale; + // x: Direction semantics flag; y: Direction-only advection scale; + // z: mixed-direction fail-closed epsilon; w: reserved. + std::array advection; // Host-owned base colours. They are independent from WeatherColor stops. std::array particleColor; std::array trailColor; @@ -31,7 +34,7 @@ struct alignas(16) ParticleGpuValidationUBO { std::array weatherRamp; std::array matrix; }; -static_assert(sizeof(ParticleGpuValidationUBO) == 30 * 16); +static_assert(sizeof(ParticleGpuValidationUBO) == 31 * 16); } // namespace shaders } // namespace mbgl diff --git a/include/mbgl/style/layers/raster_particle_layer.hpp b/include/mbgl/style/layers/raster_particle_layer.hpp index 0d6e6be64..34cfc8532 100644 --- a/include/mbgl/style/layers/raster_particle_layer.hpp +++ b/include/mbgl/style/layers/raster_particle_layer.hpp @@ -29,6 +29,8 @@ public: void setTrailOpacity(float); float getTrailFade() const; void setTrailFade(float); + float getAdvectionScale() const; + void setAdvectionScale(float); bool isSpeedColorEnabled() const; void setSpeedColorEnabled(bool); std::array getParticleColor() const; diff --git a/shaders/particle_gpu_validation.fragment.glsl b/shaders/particle_gpu_validation.fragment.glsl index 3d28f8f95..819dfd052 100644 --- a/shaders/particle_gpu_validation.fragment.glsl +++ b/shaders/particle_gpu_validation.fragment.glsl @@ -6,6 +6,7 @@ layout (std140) uniform ParticleGpuValidationUBO { highp vec4 u_field_bounds; highp vec4 u_respawn_bounds; highp vec4 u_field_scale; + highp vec4 u_advection; highp vec4 u_particle_color; highp vec4 u_trail_color; highp vec4 u_weather_stops[8]; @@ -72,15 +73,24 @@ bool outsideRespawnBounds(vec2 position) { position.y < u_respawn_bounds.y || position.y > u_respawn_bounds.w; } -bool fieldVelocityAt(vec2 position, out vec2 velocity) { +bool fieldAdvectionAt(vec2 position, out vec2 advection) { vec2 uv = fieldUv(position); vec4 field0 = texture(u_field_texture, uv); vec4 field1 = texture(u_next_field_texture, uv); - velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, - u_marker.x); - return validFieldUv(uv) && field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity)) && - length(velocity) > 0.00001; + vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, + (field1.rg * 2.0 - 1.0) * u_field_scale.y, + u_marker.x); + if (!validFieldUv(uv) || field0.a < 0.5 || field1.a < 0.5 || any(isnan(mixed)) || + any(greaterThan(abs(mixed), vec2(3.4e38)))) return false; + if (u_advection.x > 0.5) { + float directionLength = length(mixed); + if (isnan(directionLength) || directionLength > 3.4e38 || directionLength <= u_advection.z) return false; + advection = mixed / directionLength * u_advection.y; + return true; + } + // VELOCITY keeps its previous component-wise interpolation and magnitude. + advection = mixed; + return length(advection) > 0.00001; } vec4 weatherRampColor(float value) { @@ -146,8 +156,8 @@ void main() { // forecast playback must not change respawn cadence. float age = initialSeed ? initialAgeHash * maxAge : previous.b + u_pass.w; vec2 position = previous.rg; - vec2 velocity; - bool valid = !respawn && fieldVelocityAt(position, velocity); + vec2 advection; + bool valid = !respawn && fieldAdvectionAt(position, advection); // The field bounds remain the only sampling domain. Once the // camera is stable, slots already outside the expanded viewport // may recycle into its separate preferred respawn domain. @@ -158,11 +168,11 @@ void main() { if (!respawn) { float latitude = mercatorLatitudeRadians(position.y); - vec2 displacement = vec2(velocity.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS, - -velocity.y * u_pass.y / + vec2 displacement = vec2(advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS, + -advection.y * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01))); vec2 nextPosition = position + displacement; - if (fieldVelocityAt(nextPosition, velocity)) { + if (fieldAdvectionAt(nextPosition, advection)) { position = nextPosition; } else { respawn = true; @@ -175,7 +185,7 @@ void main() { // avoid a CPU upload or an unbounded shader loop. for (int attempt = 0; attempt < 3; ++attempt) { vec2 candidate = respawnSeed(v_uv, previous.rg, float(attempt)); - if (fieldVelocityAt(candidate, velocity)) { + if (fieldAdvectionAt(candidate, advection)) { position = candidate; found = true; break; @@ -201,12 +211,10 @@ void main() { vec2 uv = fieldUv(position); vec4 field0 = texture(u_field_texture, uv); vec4 field1 = texture(u_next_field_texture, uv); - vec2 velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, - u_marker.x); - if (!validFieldUv(uv) || field0.a < 0.5 || field1.a < 0.5) { + vec2 advection; + if (!fieldAdvectionAt(position, advection)) { position = seed; - velocity = vec2(0.0); + advection = vec2(0.0); } // U/V remain canonical metres per second. u_pass.y is the explicit // visual simulation delta in seconds; the validation clock may run @@ -214,11 +222,11 @@ void main() { // Web Mercator x spans Earth's circumference; y additionally scales // with sec(latitude). V is north-positive while world y is southward. float latitude = mercatorLatitudeRadians(position.y); - float dx = velocity.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS; - float dy = -velocity.y * u_pass.y / + float dx = advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS; + float dy = -advection.y * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)); position = position + vec2(dx, dy); - if (!validFieldUv(fieldUv(position)) || any(isnan(velocity))) position = seed; + if (!validFieldUv(fieldUv(position)) || any(isnan(advection))) position = seed; fragColor = vec4(position, fract(previous.b + 0.03125 * u_pass.y), 1.0); } else if (mode < 1.5) { // Formal Trail first fades the previous target. Its full-particle point diff --git a/shaders/particle_gpu_validation.vertex.glsl b/shaders/particle_gpu_validation.vertex.glsl index 0fbec276f..e8d497d4b 100644 --- a/shaders/particle_gpu_validation.vertex.glsl +++ b/shaders/particle_gpu_validation.vertex.glsl @@ -13,6 +13,7 @@ layout (std140) uniform ParticleGpuValidationUBO { highp vec4 u_field_bounds; highp vec4 u_respawn_bounds; highp vec4 u_field_scale; + highp vec4 u_advection; highp vec4 u_particle_color; highp vec4 u_trail_color; highp vec4 u_weather_stops[8]; @@ -35,6 +36,22 @@ bool validFieldUv(vec2 uv) { return all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0))); } +bool resolveAdvection(vec4 field0, vec4 field1, out vec2 advection) { + vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, + (field1.rg * 2.0 - 1.0) * u_field_scale.y, + u_marker.x); + if (any(isnan(mixed)) || any(greaterThan(abs(mixed), vec2(3.4e38)))) return false; + if (u_advection.x > 0.5) { + float directionLength = length(mixed); + if (isnan(directionLength) || directionLength > 3.4e38 || directionLength <= u_advection.z) return false; + advection = mixed / directionLength * u_advection.y; + } else { + // VELOCITY remains the established component-wise m/s path. + advection = mixed; + } + return true; +} + void main() { v_uv = a_pos; v_world = a_pos; @@ -57,11 +74,10 @@ void main() { vec2 uv = fieldUv(currentPosition); vec4 field0 = texture(u_field_texture, uv); vec4 field1 = texture(u_next_field_texture, uv); - vec2 velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, - u_marker.x); - bool valid = validFieldUv(uv) && field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity)); - vec2 mercatorVelocity = vec2(velocity.x, -velocity.y); + vec2 advection; + bool valid = validFieldUv(uv) && field0.a >= 0.5 && field1.a >= 0.5 && + resolveAdvection(field0, field1, advection); + vec2 mercatorVelocity = vec2(advection.x, -advection.y); float speed = length(mercatorVelocity); vec2 segmentPosition = endpoint < 0.0 ? previousState.rg : currentPosition; v_uv = stateUV; diff --git a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp index 86f436b49..7506bbc78 100644 --- a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp +++ b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp @@ -80,6 +80,7 @@ struct ParticleSettings { float particleOpacity; float trailOpacity; float trailFade; + float advectionScale; bool speedColorEnabled; std::array particleColor; std::array trailColor; @@ -99,11 +100,12 @@ ParticleSettings particleSettings(const Immutable& value) { layer.particleOpacity, layer.trailOpacity, layer.trailFade, + layer.advectionScale, layer.speedColorEnabled, layer.particleColor, layer.trailColor}; } assert(value->getTypeInfo() == style::ParticleValidationLayer::Impl::staticTypeInfo()); const auto& layer = static_cast(*value); - return {layer.active, layer.maskMode, layer.fixtureMode, false, 0.0f, 256, 2.0f, 0.018f, 1.0f, 1.0f, 0.965f, false, + return {layer.active, layer.maskMode, layer.fixtureMode, false, 0.0f, 256, 2.0f, 0.018f, 1.0f, 1.0f, 0.965f, 1.0f, false, {0.08f, 0.55f, 1.0f, 1.0f}, {1.0f, 1.0f, 1.0f, 1.0f}}; } @@ -342,6 +344,7 @@ public: float particleOpacity = 1.0f; float trailOpacity = 1.0f; float trailFade = 0.92f; + float advectionScale = 1.0f; bool speedColorEnabled = true; std::array particleColor{0.08f, 0.55f, 1.0f, 1.0f}; std::array trailColor{1.0f, 1.0f, 1.0f, 1.0f}; @@ -580,6 +583,10 @@ public: resources->nextFieldScale, resources->viewportDensityActive ? 1.0f : 0.0f, 0.0f}, + .advection = {resources->field.vectorSemantics == particle::VectorSemantics::Direction ? 1.0f : 0.0f, + resources->advectionScale, + 0.00001f, + 0.0f}, .particleColor = resources->particleColor, .trailColor = resources->trailColor, .weatherStops = {}, @@ -915,6 +922,7 @@ RenderRasterParticleLayer::RenderRasterParticleLayer(ImmutablehasVector() || !sourceNextField->hasVector() || - sourceField->vectorSemantics != particle::VectorSemantics::Velocity || - sourceNextField->vectorSemantics != particle::VectorSemantics::Velocity)) { + !sourceField->vectorSemantics || !sourceNextField->vectorSemantics || + sourceField->vectorSemantics != sourceNextField->vectorSemantics)) { if (layerGroup) static_cast(*layerGroup).clearDrawables(); if (gpuResources) { removeTargets(gpuResources->registeredTargets, changes); @@ -1097,6 +1106,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, const uint8_t maskMode = properties.maskMode; const uint8_t fixtureMode = properties.fixtureMode; const uint32_t particleCount = properties.particleCount; + const float advectionScale = properties.advectionScale; float timelineMix = properties.timeMix; bool cadenceAdvanced = true; uint64_t cadenceRevision = sourceTimelineCadenceRevision; @@ -1264,6 +1274,10 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, "ParticleValidation30_03: anchors A=0,0 B=0,-90 C=0,-172.8 D=0,172.8 canonical=Mercator-z0"); } + // This is a renderer uniform only. It intentionally preserves State, + // Trail, Timeline, and uploaded field textures when changed. + resources->advectionScale = advectionScale; + if (resources->particleCount != particleCount) { const auto previousCount = resources->particleCount; const auto previousSize = resources->stateSize; diff --git a/src/mbgl/renderer/layers/render_weather_color_layer.cpp b/src/mbgl/renderer/layers/render_weather_color_layer.cpp index f059d26b9..da025dbb6 100644 --- a/src/mbgl/renderer/layers/render_weather_color_layer.cpp +++ b/src/mbgl/renderer/layers/render_weather_color_layer.cpp @@ -99,6 +99,9 @@ public: .visual = {opacity, 0, 0, 0}, .fieldBounds = bounds(resources->t0), .respawnBounds = bounds(resources->t0), .fieldScale = {resources->scale0, resources->scale1, 0, 0}, + // WeatherColor never performs particle advection. Keep the shared + // UBO's advection controls explicitly neutral for this color-only pass. + .advection = {0.0f, 1.0f, 0.00001f, 0.0f}, .particleColor = {0.08f, 0.55f, 1.0f, 1.0f}, .trailColor = {1.0f, 1.0f, 1.0f, 1.0f}, .weatherStops = {}, diff --git a/src/mbgl/style/layers/particle_render_layer_properties.hpp b/src/mbgl/style/layers/particle_render_layer_properties.hpp index 05acde234..ade47da52 100644 --- a/src/mbgl/style/layers/particle_render_layer_properties.hpp +++ b/src/mbgl/style/layers/particle_render_layer_properties.hpp @@ -20,6 +20,7 @@ public: float particleOpacity_, float trailOpacity_, float trailFade_, + float advectionScale_, bool speedColorEnabled_, std::array particleColor_, std::array trailColor_) @@ -34,6 +35,7 @@ public: particleOpacity(particleOpacity_), trailOpacity(trailOpacity_), trailFade(trailFade_), + advectionScale(advectionScale_), speedColorEnabled(speedColorEnabled_), particleColor(particleColor_), trailColor(trailColor_) {} expression::Dependency getDependencies() const noexcept override { return expression::Dependency::None; } @@ -47,6 +49,7 @@ public: float particleOpacity; float trailOpacity; float trailFade; + float advectionScale; bool speedColorEnabled; std::array particleColor; std::array trailColor; diff --git a/src/mbgl/style/layers/raster_particle_layer.cpp b/src/mbgl/style/layers/raster_particle_layer.cpp index 01f2b8bd0..1a84510c7 100644 --- a/src/mbgl/style/layers/raster_particle_layer.cpp +++ b/src/mbgl/style/layers/raster_particle_layer.cpp @@ -19,6 +19,7 @@ constexpr const char* ParticleLengthProperty = "raster-particle-particle-length" constexpr const char* ParticleOpacityProperty = "raster-particle-particle-opacity"; constexpr const char* TrailOpacityProperty = "raster-particle-trail-opacity"; constexpr const char* TrailFadeProperty = "raster-particle-trail-fade"; +constexpr const char* AdvectionScaleProperty = "raster-particle-advection-scale"; constexpr const char* SpeedColorEnabledProperty = "raster-particle-speed-color-enabled"; constexpr uint32_t MinimumParticleCount = 256; constexpr uint32_t MaximumParticleCount = 1024 * 1024; @@ -174,6 +175,16 @@ void RasterParticleLayer::setTrailFade(float value) { observer->onLayerChanged(*this); } } +float RasterParticleLayer::getAdvectionScale() const { + return impl().advectionScale; +} +void RasterParticleLayer::setAdvectionScale(float value) { + if (!std::isfinite(value) || value <= 0.0f || value == getAdvectionScale()) return; + auto next = mutableImpl(); + next->advectionScale = value; + baseImpl = std::move(next); + observer->onLayerChanged(*this); +} bool RasterParticleLayer::isSpeedColorEnabled() const { return impl().speedColorEnabled; } @@ -246,6 +257,13 @@ std::optional RasterParticleLayer::setPropertyInternal(const return finiteFloat("raster-particle-trail-opacity must be finite", &RasterParticleLayer::setTrailOpacity); if (name == TrailFadeProperty) return finiteFloat("raster-particle-trail-fade must be finite", &RasterParticleLayer::setTrailFade); + if (name == AdvectionScaleProperty) { + auto converted = conversion::convert(value, error); + if (!converted || !std::isfinite(*converted) || *converted <= 0.0f) + return conversion::Error{"raster-particle-advection-scale must be finite and greater than zero"}; + setAdvectionScale(*converted); + return std::nullopt; + } if (name == SpeedColorEnabledProperty) { auto converted = conversion::convert(value, error); if (!converted) return error; @@ -267,6 +285,8 @@ StyleProperty RasterParticleLayer::getProperty(const std::string& name) const { return StyleProperty(Value{getParticleOpacity()}, StyleProperty::Kind::Constant); if (name == TrailOpacityProperty) return StyleProperty(Value{getTrailOpacity()}, StyleProperty::Kind::Constant); if (name == TrailFadeProperty) return StyleProperty(Value{getTrailFade()}, StyleProperty::Kind::Constant); + if (name == AdvectionScaleProperty) + return StyleProperty(Value{getAdvectionScale()}, StyleProperty::Kind::Constant); if (name == SpeedColorEnabledProperty) return StyleProperty(Value{isSpeedColorEnabled()}, StyleProperty::Kind::Constant); return {}; @@ -282,6 +302,7 @@ Value RasterParticleLayer::serialize() const { serializeProperty(result, getProperty(ParticleOpacityProperty), ParticleOpacityProperty, true); serializeProperty(result, getProperty(TrailOpacityProperty), TrailOpacityProperty, true); serializeProperty(result, getProperty(TrailFadeProperty), TrailFadeProperty, true); + serializeProperty(result, getProperty(AdvectionScaleProperty), AdvectionScaleProperty, true); serializeProperty(result, getProperty(SpeedColorEnabledProperty), SpeedColorEnabledProperty, true); return result; } diff --git a/src/mbgl/style/layers/raster_particle_layer_impl.hpp b/src/mbgl/style/layers/raster_particle_layer_impl.hpp index b4b7809cd..9531f4f78 100644 --- a/src/mbgl/style/layers/raster_particle_layer_impl.hpp +++ b/src/mbgl/style/layers/raster_particle_layer_impl.hpp @@ -24,6 +24,10 @@ public: float particleOpacity = 1.0f; float trailOpacity = 1.0f; float trailFade = 0.92f; + // Direction-only vectors have no physical magnitude. This renderer-owned + // scale is deliberately independent from field metadata and only applies + // when VectorSemantics is Direction. + float advectionScale = 1.0f; bool speedColorEnabled = true; std::array particleColor{{0.08f, 0.55f, 1.0f, 1.0f}}; std::array trailColor{{1.0f, 1.0f, 1.0f, 1.0f}}; diff --git a/test/style/source.test.cpp b/test/style/source.test.cpp index cac71a934..214cffaa4 100644 --- a/test/style/source.test.cpp +++ b/test/style/source.test.cpp @@ -386,6 +386,31 @@ TEST(Source, ParticleFieldV3CompatibilityAndContentIdentityAreChannelAware) { EXPECT_FALSE(particle::sameFieldGpuContent(vector, vectorChanged)); } +TEST(Source, ParticleFieldV3DirectionAdvectionResolution) { + std::array resolved{}; + + EXPECT_TRUE(particle::resolveParticleAdvection(particle::VectorSemantics::Direction, + {{1.0f, 0.0f}}, {{1.0f, 0.0f}}, 0.0f, 2.0f, resolved)); + EXPECT_FLOAT_EQ(2.0f, resolved[0]); + EXPECT_FLOAT_EQ(0.0f, resolved[1]); + EXPECT_TRUE(particle::resolveParticleAdvection(particle::VectorSemantics::Direction, + {{0.0f, 1.0f}}, {{0.0f, 1.0f}}, 0.0f, 2.0f, resolved)); + EXPECT_FLOAT_EQ(0.0f, resolved[0]); + EXPECT_FLOAT_EQ(2.0f, resolved[1]); + + EXPECT_TRUE(particle::resolveParticleAdvection(particle::VectorSemantics::Direction, + {{1.0f, 0.0f}}, {{0.0f, 1.0f}}, 0.5f, 3.0f, resolved)); + EXPECT_NEAR(3.0f / std::sqrt(2.0f), resolved[0], 0.00001f); + EXPECT_NEAR(3.0f / std::sqrt(2.0f), resolved[1], 0.00001f); + EXPECT_FALSE(particle::resolveParticleAdvection(particle::VectorSemantics::Direction, + {{1.0f, 0.0f}}, {{-1.0f, 0.0f}}, 0.5f, 1.0f, resolved)); + + EXPECT_TRUE(particle::resolveParticleAdvection(particle::VectorSemantics::Velocity, + {{2.0f, -3.0f}}, {{6.0f, 1.0f}}, 0.25f, 999.0f, resolved)); + EXPECT_FLOAT_EQ(3.0f, resolved[0]); + EXPECT_FLOAT_EQ(-2.0f, resolved[1]); +} + TEST(Source, LocalProductionFeedSubmitsSnapshotsWithProducerTime) { const auto field = fixtureBytes("uniform-east", "field.f32"); const std::vector payload(field.begin(), field.end()); diff --git a/test/style/style_layer.test.cpp b/test/style/style_layer.test.cpp index 25ef747bb..6064802ec 100644 --- a/test/style/style_layer.test.cpp +++ b/test/style/style_layer.test.cpp @@ -178,6 +178,18 @@ TEST(Layer, RasterParticleVisualProperties) { EXPECT_EQ(layer.getParticleColor(), particleColor); } +TEST(Layer, RasterParticleDirectionAdvectionScale) { + RasterParticleLayer layer{"particle", "raster-array"}; + EXPECT_FLOAT_EQ(1.0f, layer.getAdvectionScale()); + + layer.setAdvectionScale(2.5f); + EXPECT_FLOAT_EQ(2.5f, layer.getAdvectionScale()); + layer.setAdvectionScale(0.0f); + layer.setAdvectionScale(-1.0f); + layer.setAdvectionScale(std::numeric_limits::infinity()); + EXPECT_FLOAT_EQ(2.5f, layer.getAdvectionScale()); +} + TEST(Layer, WeatherColorUsesIndependentFixedOpacity) { WeatherColorLayer layer{"weather-color", "raster-array"}; ASSERT_STREQ("weather-color", layer.getTypeInfo()->type); diff --git a/粒子开发记录.md b/粒子开发记录.md index caca6d93b..095c57e74 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -646,3 +646,8 @@ WeatherColorLayer 的 `ParticleGpuValidationUBO` designated initializer 补齐 ` - 用户在当前 `HEAD` `06fcbef9ccb820e595f06acbd356950de7e3a73f` 手工执行 `ninja -C build-linux-opengl -j3 mbgl-test mbgl-test-runner`,Ninja 返回 `no work to do.`;确认 `mbgl-test` 与 `mbgl-test-runner` 完整目标及其依赖已经最新,没有待处理构建或重链接工作。 - 此结果只关闭此前 Linux 全量目标重链复验,不代表重新执行全量测试用例;本轮未执行 Android Gradle/AAR/APK/ADB/Jest,未修改生产代码、STEP-062 用户修改或其他历史未跟踪内容。STEP-067 正式 `CLOSED / PASS`。 +# 2026-09-11 STEP-068:实现 V3 DIRECTION 粒子推进与 renderer `advectionScale`。DIRECTION 为 T0/T1 分量 MIX 后归一化,近零/非有限 fail-closed;VELOCITY 保持旧 m/s 数学且不消费 scale。已生成 shader 并完成变更前精确对象/过滤测试;新增最终 CPU oracle 后的精确编译与重链受本沙箱约 30 秒单次终端限制阻塞,状态为 IMPLEMENTED / WAITING_VALIDATION,未执行 Android 或 Jest。 + +# 2026-09-11 STEP-068:全量 Linux 重编译发现 WeatherColor 的 `ParticleGpuValidationUBO` 聚合初始化漏填新增 `advection`,触发 `-Wmissing-field-initializers`。仅补入 `{Direction=0, scale=1, epsilon=1e-5, reserved=0}` 的色场安全中性值;审计确认另一处 Particle renderer initializer 已完整初始化。未修改 shader/Android/JNI/RN/WeatherV2/NavSea/STEP-062;不据此声称全量重链 PASS。 + +# 2026-09-11 STEP-068 收尾:WeatherColor UBO 漏初始化修复后,最终受影响精确对象 PASS;`mbgl-test` / `mbgl-test-runner` 完整 Linux OpenGL 目标重编译与重链 PASS;STEP-068 相关 `Source.ParticleFieldV3*:Layer.RasterParticle*` 8/8 PASS;`git diff --check` PASS。额外扩大过滤范围发现基线既有 WeatherColor 测试末尾断言逻辑错误,已确认不由 STEP-068 引入,未混入本任务修复。STEP-068 正式 CLOSED / PASS;Android/Jest 未执行。