feat: add real WeatherV2 Android composite validation
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-05 01:24:18 +08:00
parent da71c8784e
commit 52600cd3be
26 changed files with 903 additions and 64 deletions

View File

@@ -254,6 +254,7 @@ list(APPEND INCLUDE_FILES
${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/background_layer_factory.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/background_layer_factory.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/particle_validation_layer_factory.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/particle_validation_layer_factory.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/raster_particle_layer_factory.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/raster_particle_layer_factory.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/weather_color_layer_factory.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/circle_layer_factory.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/circle_layer_factory.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/custom_layer_factory.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/custom_layer_factory.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/fill_extrusion_layer_factory.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/layermanager/fill_extrusion_layer_factory.hpp
@@ -367,6 +368,7 @@ list(APPEND INCLUDE_FILES
${PROJECT_SOURCE_DIR}/include/mbgl/style/layers/background_layer.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/style/layers/background_layer.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/style/layers/particle_validation_layer.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/style/layers/particle_validation_layer.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/style/layers/raster_particle_layer.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/style/layers/raster_particle_layer.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/style/layers/weather_color_layer.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/style/sources/raster_array_source.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/style/sources/raster_array_source.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/style/layers/circle_layer.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/style/layers/circle_layer.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/style/layers/custom_layer_host.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/style/layers/custom_layer_host.hpp
@@ -514,6 +516,7 @@ list(APPEND SRC_FILES
${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/background_layer_factory.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/background_layer_factory.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/particle_validation_layer_factory.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/particle_validation_layer_factory.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/raster_particle_layer_factory.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/raster_particle_layer_factory.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/weather_color_layer_factory.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/circle_layer_factory.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/circle_layer_factory.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/fill_extrusion_layer_factory.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/fill_extrusion_layer_factory.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/fill_layer_factory.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/layermanager/fill_layer_factory.cpp
@@ -594,6 +597,8 @@ list(APPEND SRC_FILES
${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_particle_validation_layer.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_particle_validation_layer.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_particle_validation_layer.hpp ${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_particle_validation_layer.hpp
${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_raster_particle_layer.hpp ${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_raster_particle_layer.hpp
${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_weather_color_layer.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_weather_color_layer.hpp
${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_circle_layer.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_circle_layer.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_circle_layer.hpp ${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_circle_layer.hpp
${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_fill_extrusion_layer.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/render_fill_extrusion_layer.cpp
@@ -768,6 +773,8 @@ list(APPEND SRC_FILES
${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/particle_render_layer_properties.hpp ${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/particle_render_layer_properties.hpp
${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/raster_particle_layer.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/raster_particle_layer.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/raster_particle_layer_impl.hpp ${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/raster_particle_layer_impl.hpp
${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/weather_color_layer.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/weather_color_layer_impl.hpp
${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/background_layer_impl.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/background_layer_impl.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/background_layer_impl.hpp ${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/background_layer_impl.hpp
${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/background_layer_properties.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/style/layers/background_layer_properties.cpp

View File

@@ -0,0 +1,34 @@
# STEP-055 Android 真实 WeatherV2 风场综合可视化
状态:IMPLEMENTED / WAITING_ANDROID_VALIDATION
Starting HEAD:`da71c8784ec20ed830f5d23880718647d666cf2f`
## 目标与数据边界
本阶段只复用 STEP-054 已生成的真实 WeatherV2 canonical 资产:`integration/particle-weatherv2/ecmwf-hres-wind-20260807/` 下的 2026080700、0706、0712、0718。数据保持原始 513×769 网格、`interleaved-uv/float32-le`、U=eastward、V=northward、单位 m/s 和原始 validTime;未重新生成、重采样、裁剪或伪造数据。
## 实现
- 新增独立 `weather-color` Style/Render Layer。其仅从现有 `RasterArraySource` 的 canonical T0/T1 snapshot 读字段,拥有独立的颜色 GPU texture/draw pass;不合并到 `RasterParticleLayer`。
- 颜色 shader 对同一位置的 T0/T1 先还原 canonical U/V、按 Core mix 插值,再计算 `hypot(U,V)`;色带固定为 0–30 m/s,不做自动拉伸。
- 字段 texture 以每个 snapshot 的显式 signed velocity scale 编码/还原,避免真实 m/s 数据被旧 `[-1,1]` 编码截断。
- 粒子 GPU 字段采样改为 metadata bounds 的 Web Mercator UV。字段区域外保持 fail-closed;State 初始 seed 和越界恢复均使用字段 bounds,因此真实日本区域不再被当作全球 UV。
- Android MORE 新增 `REAL WEATHER`:一次 style 初始化后,经既有 STEP-053 Host Submission Contract 依次提交真实 T0–T3;样式层级为 OSM 无 Key raster basemap → `weather-color` → `raster-particle` → Android HUD,并按 metadata bounds 自动 fit camera。
- `REAL WEATHER` 状态随 Activity 保存/恢复。重建后自动再次以相同 metadata、payload 与 validTime 提交,不要求用户按按钮恢复;粒子 Trail FBO 仍由既有 viewport resize 路径重建。
## 所有权与冻结边界
Core 仍拥有 Timeline、Playback 和 Runtime Field Window。颜色层不 tick Timeline,`RasterParticleLayer` 仍是唯一的 playback tick 消费者。未修改 Adapter contract、Snapshot、Window A/B→B/C→C/D、Duplicate NOOP、Invalid/Gap fail-closed、Renderer provider/network 边界或 WeatherColorLayer/RasterParticleLayer 分离。
正常连续边界继续依赖既有 `OLD_T1 → NEW_T0 reuse` 与 `only NEW_T1 upload`;颜色层相同地复用其旧 T1 texture,仅为新的 T1 建立新 texture。正常 playback 的粒子 `simulationRebuild=false`、`trailCleared=false` 语义不变。
## 可观察性
新增低频日志:`REAL_WEATHER_DATASET_LOAD`、`REAL_WEATHER_INITIAL_WINDOW_READY`、`WEATHER_COLOR_FIELD_READY`、`REAL_WEATHER_COMPOSITE_READY`。现有 Host submit、Timeline、Window Boundary、FIELD_REUSE、FIELD_UPLOAD 和 ParticleRender 日志继续可用。没有新增每帧 Android JNI 或 UI timer。
## 验证与待验收
Codex 未执行 Android Gradle、APK build/install、ADB、App 启动、设备/模拟器测试或 Jest。Linux/OpenGL 受影响 translation unit 精确编译与相关定向测试结果见完成报告。
Android 仍需用户一次性手工综合验收,步骤见 `docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v1.md`。已知 Validation UI 的 MIX 文本滞后以及 WAITING/INVALID 可见性仍为 NON-BLOCKING,不在本阶段单独修复。

View File

@@ -0,0 +1,76 @@
# STEP-055 Android 真实 WeatherV2 风场综合可视化人工验收
状态:等待用户手工 Android 验收。Codex 不执行本文件中的 Gradle、APK 或 ADB 操作。
## Windows 11 构建与安装
在 Windows 11 项目目录执行既有 Android OpenGL Debug 构建命令,并保留 plain 控制台输出,例如:
```powershell
./gradlew :MapLibreAndroidTestApp:assembleOpenGLDebug --console=plain
```
安装、启动和日志均显式选择设备:
```powershell
adb -s 192.168.1.146 install -r <APK_PATH>
adb -s 192.168.1.146 logcat -c
adb -s 192.168.1.146 logcat | findstr /I "REAL_WEATHER WEATHER_COLOR HOST_FIELD ParticleTimeline FIELD_REUSE FIELD_UPLOAD ParticleRender simulationRebuild trailCleared"
```
## 入口
进入 `Particle Core Layer Validation`:
1. `MORE → REAL WEATHER`。
2. 等待地图自动 fit 到 112.5–157.5E、0–55.776573N;应看到 OSM 底图、半透明风速色场及其上的运动粒子。
3. `TIME → PLAY / RESTART`。真实 validTime 保持 6 小时步长;没有额外 Android 加速机制,可使用现有 `SPEED 2x`,或用 `STEP NEXT` 检查边界。
## V1–V8 视觉与行为矩阵
| 项 | 操作 | 期望 |
| --- | --- | --- |
| V1 | `REAL WEATHER` | 出现真实日本/西北太平洋覆盖范围、OSM 底图、风速色场和粒子;无黑屏/crash。 |
| V2 | 比较色场与粒子 | 色场强弱分布与粒子移动方向同源;U=eastward、V=northward,无明显横纵交换/符号反转。 |
| V3 | PLAY | Timeline READY 后 PLAY 成功;MIX 自动推进,颜色与粒子同时连续变化。 |
| V4 | 两次自动边界或 STEP NEXT | T0/T1→T1/T2→T2/T3;日志有 `OLD_T1_TO_NEW_T0` / `FIELD_REUSE`,只出现一个 `NEW_CONTENT` upload。 |
| V5 | 连续播放观察 | 正常 boundary 为 `simulationRebuild=false`、`trailCleared=false`;不得 style reload/layer rebuild。 |
| V6 | Pan/zoom/rotate/viewport resize | 底图、色场和粒子均保持地理对齐;旋转/恢复后自动可见,不按任何恢复按钮。 |
| V7 | `MORE → HOST DUPLICATE` | 对当前 Host window 得到 Duplicate NOOP,field upload/reset count 无增量。 |
| V8 | `MORE → HOST INVALID` | Host canonical invalid 被拒,最后有效 field、色场/Renderer 持续,无黑屏/crash。 |
## 必要日志
正常加载必须可见:
```text
REAL_WEATHER_DATASET_LOAD
HOST_FIELD_SUBMIT_REQUEST / HOST_FIELD_SUBMIT_ACCEPTED
HOST_FIELD_INITIAL_WINDOW_READY
ParticleTimeline: event=READY
REAL_WEATHER_INITIAL_WINDOW_READY
WEATHER_COLOR_FIELD_READY
REAL_WEATHER_COMPOSITE_READY
```
播放/边界检查:
```text
PLAYBACK_PLAY
TIME_MIX_UPDATE or PLAYBACK_TICK
WINDOW_BOUNDARY
FIELD_REUSE from=OLD_T1 to=NEW_T0
FIELD_UPLOAD reason=NEW_CONTENT
simulationRebuild=false
trailCleared=false
```
负向检查:
```text
HOST_FIELD_SUBMIT_REJECTED
ADAPTER_REJECTED
result=NOOP
```
请保存上述日志、V1–V8 结果,以及任何色场与粒子错位、黑屏、旋转后不可恢复或 crash 的时间点。MIX 文本显示可能滞后属于既知 NON-BLOCKING observability,不应替代 Core log 判断。

View File

@@ -0,0 +1,55 @@
# STEP-055 Android 真实 WeatherV2 风场综合可视化 Codex 完成报告
## 基线与范围
- Starting HEAD:`da71c8784ec20ed830f5d23880718647d666cf2f`
- 分支:`particle/main`
- 状态:IMPLEMENTED / WAITING_ANDROID_VALIDATION
- 输入:STEP-054 已提交的真实 WeatherV2 ECMWF HRES wind canonical T0–T3(513×769,原始 validTime,U=eastward、V=northward、m/s)。本阶段没有重新生成、重采样或修改这些数据。
本任务实现 Android Validation 的真实场综合可视化,不引入 Weather Service、HTTP、Provider、下载、缓存、React Native 或 Jest。
## 实际修改
- 新增 provider-neutral、独立的 `weather-color` Style Layer、Factory 和 OpenGL RenderLayer。它从现有 `RasterArraySource` 的 canonical snapshot 读 T0/T1;不含 provider/network/business logic,也不与 `RasterParticleLayer` 合并。
- `WeatherColorLayer` 颜色逻辑为:还原 canonical U/V → Core mix 插值 → `hypot(U,V)`;采用固定 0–30 m/s 色标和固定 opacity。
- 更新粒子 GPU 字段采样:真实 metadata bounds 转为 Web Mercator UV;字段区域外 fail-closed,粒子 seed/恢复受相同 bounds 约束。为真实 m/s 引入 texture decode scale,避免旧 `[-1,1]` 编码饱和。
- Android `MORE → REAL WEATHER` 一键加载 STEP-054 的 T0–T3,通过既有 Host Submission Contract submit;使用既有 OSM 无 Key raster basemap,自动 fit real bounds,并以 basemap → color → particle → HUD 排序。
- 保存 REAL WEATHER / Host mode 状态;Activity 重建后自动以相同 metadata/payload/validTime 恢复可见内容。现有 RasterParticle viewport trail FBO resize 路径保持负责其 GPU target 重建。
- 新增 `WeatherColorUsesIndependentFixedOpacity` Core style test;新增 Android V1–V8 人工验收指令。
## 架构保护
未修改 `ParticleFieldDescriptor`、`ParticleFieldSnapshot`、`ParticleFieldDataAdapter`、Host submission contract、Timeline state machine、Playback ownership、Runtime Field Window、Duplicate NOOP、Gap/Invalid fail-closed 或 Renderer provider/network 边界。
`RasterParticleLayer` 仍是唯一 tick Timeline 的 render consumer。WeatherColorLayer 只读取同一 Source-owned frame,故不建立第二时钟/Timeline。正常 Boundary 的 `OLD_T1 → NEW_T0 reuse` 和 `only NEW_T1 upload` 继续适用;颜色层也复用旧 T1 GPU texture。
## 验证
- CMake OpenGL 配置:PASS。
- 受影响 C++ 精确编译:WeatherColor Layer、Factory、RenderLayer、RasterParticle shader-side renderer、默认 LayerManager:PASS(同一 CMake OpenGL 编译参数)。
- 使用同一 CMake OpenGL 配置的 `mbgl-core` 全量目标已启动并确认既有依赖/前序 Core 可编译;按任务“不默认跑无关全量测试”的边界,在进入与 STEP-055 无关的大量 Core translation units 后停止。它不构成本阶段的验收条件。
- 新增 `test/style/style_layer.test.cpp` 和受影响 `test/style/source.test.cpp`:以同一 CMake 参数精确编译 PASS;未构建/执行无关的完整 test runner。
- `git diff --check`:待最终文档完成后执行。
- Android Gradle / APK build / install / ADB / App launch / device test:未执行(用户手工)。
- Jest:未执行,未修改。
## Android 待验收
按 `docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v1.md` 执行一次构建/安装,重点确认:真实 T0–T3、色场/粒子地理对齐、PLAY/MIX、两次 Boundary、reuse/only NEW_T1、Duplicate/Invalid、旋转恢复以及无黑屏/crash。
真实 validTime 保持 6 小时间隔;当前没有另加 Android 时间压缩。可用既有 2x 或 STEP NEXT 观察边界。
## 非阻塞项
Validation UI MIX 文本滞后及 WAITING/INVALID 的视觉不够明显仍为 NON-BLOCKING observability,不被表述为 Core、Timeline 或 Renderer 故障。
## Git 收尾
- Commit hash:待最终验证后填写
- Commit message:待最终验证后填写
- Push result:待最终验证后填写
- Remote branch:`origin/particle/main`
- Final HEAD:待最终验证后填写
无关 untracked 文件(历史 diff、任务书、日志等)将保持未触碰、未 stage。

View File

@@ -0,0 +1,10 @@
#pragma once
#include <mbgl/layermanager/layer_factory.hpp>
namespace mbgl {
class WeatherColorLayerFactory final : public LayerFactory {
public:
const style::LayerTypeInfo* getTypeInfo() const noexcept override;
std::unique_ptr<style::Layer> createLayer(const std::string&, const style::conversion::Convertible&) noexcept override;
std::unique_ptr<RenderLayer> createRenderLayer(Immutable<style::Layer::Impl>) noexcept override;
};
} // namespace mbgl

View File

@@ -20,6 +20,8 @@ layout (std140) uniform ParticleGpuValidationUBO {
highp vec4 u_marker; highp vec4 u_marker;
highp vec4 u_mask; highp vec4 u_mask;
highp vec4 u_visual; highp vec4 u_visual;
highp vec4 u_field_bounds;
highp vec4 u_field_scale;
highp mat4 u_matrix; highp mat4 u_matrix;
}; };
@@ -27,12 +29,23 @@ uniform sampler2D u_state_texture;
uniform sampler2D u_field_texture; uniform sampler2D u_field_texture;
uniform sampler2D u_next_field_texture; uniform sampler2D u_next_field_texture;
vec2 fieldUv(vec2 world) {
return vec2((world.x - u_field_bounds.x) / (u_field_bounds.z - u_field_bounds.x),
(world.y - u_field_bounds.y) / (u_field_bounds.w - u_field_bounds.y));
}
bool validFieldUv(vec2 uv) {
return all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0)));
}
void main() { void main() {
v_uv = a_pos; v_uv = a_pos;
v_world = a_pos; v_world = a_pos;
v_particle_velocity = vec2(0.0); v_particle_velocity = vec2(0.0);
v_particle_valid = 1.0; v_particle_valid = 1.0;
if (u_pass.x < 1.5) { if (u_pass.x > 4.5) {
gl_Position = u_matrix * vec4(a_pos * 8192.0, 0.0, 1.0);
} else if (u_pass.x < 1.5) {
gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0); gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0);
} else if (u_pass.x > 3.5) { } else if (u_pass.x > 3.5) {
// 40-05: a_pos.x packs State texel X plus a static line endpoint. // 40-05: a_pos.x packs State texel X plus a static line endpoint.
@@ -43,10 +56,13 @@ void main() {
vec2 stateIndex = vec2(floor(a_pos.x * 0.5), a_pos.y); vec2 stateIndex = vec2(floor(a_pos.x * 0.5), a_pos.y);
vec2 stateUV = (stateIndex + vec2(0.5)) / u_mask.xy; vec2 stateUV = (stateIndex + vec2(0.5)) / u_mask.xy;
vec2 position = texture(u_state_texture, stateUV).rg; vec2 position = texture(u_state_texture, stateUV).rg;
vec4 field0 = texture(u_field_texture, position); vec2 uv = fieldUv(position);
vec4 field1 = texture(u_next_field_texture, position); vec4 field0 = texture(u_field_texture, uv);
vec2 velocity = mix(field0.rg * 2.0 - 1.0, field1.rg * 2.0 - 1.0, u_marker.x); vec4 field1 = texture(u_next_field_texture, uv);
bool valid = field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity)); 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 mercatorVelocity = vec2(velocity.x, -velocity.y);
float speed = length(mercatorVelocity); float speed = length(mercatorVelocity);
v_uv = position; v_uv = position;
@@ -72,6 +88,8 @@ void main() {
highp vec4 u_marker; highp vec4 u_marker;
highp vec4 u_mask; highp vec4 u_mask;
highp vec4 u_visual; highp vec4 u_visual;
highp vec4 u_field_bounds;
highp vec4 u_field_scale;
highp mat4 u_matrix; highp mat4 u_matrix;
}; };
@@ -93,31 +111,68 @@ vec3 particleSpeedColor(float speed) {
return mix(mix(slow, medium, smoothstep(0.0, 0.55, t)), fast, smoothstep(0.45, 1.0, t)); return mix(mix(slow, medium, smoothstep(0.0, 0.55, t)), fast, smoothstep(0.45, 1.0, t));
} }
vec2 fieldUv(vec2 world) {
return vec2((world.x - u_field_bounds.x) / (u_field_bounds.z - u_field_bounds.x),
(world.y - u_field_bounds.y) / (u_field_bounds.w - u_field_bounds.y));
}
bool validFieldUv(vec2 uv) {
return all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0)));
}
vec3 weatherSpeedColor(float speed) {
// Fixed 0..30 m/s scale: no per-frame or per-dataset auto stretching.
float t = clamp(speed / 30.0, 0.0, 1.0);
vec3 slow = vec3(0.08, 0.18, 0.65);
vec3 medium = vec3(0.08, 0.80, 0.78);
vec3 fast = vec3(0.98, 0.22, 0.10);
return mix(mix(slow, medium, smoothstep(0.0, 0.55, t)), fast, smoothstep(0.45, 1.0, t));
}
void main() { void main() {
vec4 previous = texture(u_previous, v_uv);
float mode = u_pass.x; float mode = u_pass.x;
if (mode > 4.5) {
vec2 uv = fieldUv(v_world);
if (!validFieldUv(uv)) discard;
vec4 field0 = texture(u_field_texture, uv);
vec4 field1 = texture(u_next_field_texture, uv);
if (field0.a < 0.5 || field1.a < 0.5) discard;
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 (any(isnan(velocity))) discard;
fragColor = vec4(weatherSpeedColor(length(velocity)), u_visual.x);
return;
}
vec4 previous = texture(u_previous, v_uv);
if (mode < 0.5) { if (mode < 0.5) {
// 40-01: State A/B samples the uploaded OfflineFixture Particle Field. // 40-01: State A/B samples the uploaded OfflineFixture Particle Field.
// RG stores field-normalized position, B stores age, and the Field // RG stores field-normalized position, B stores age, and the Field
// texture encodes canonical U-east/V-north at that geographic sample. // texture encodes canonical U-east/V-north at that geographic sample.
// u_mask.w is the explicit State lifecycle flag: new RenderTargets are // u_mask.w is the explicit State lifecycle flag: new RenderTargets are
// cleared opaque by MapLibre, so sampled alpha cannot identify a seed. // cleared opaque by MapLibre, so sampled alpha cannot identify a seed.
vec2 position = u_mask.w > 0.5 ? v_uv : previous.rg; vec2 seed = mix(u_field_bounds.xy, u_field_bounds.zw, v_uv);
vec2 position = u_mask.w > 0.5 ? seed : previous.rg;
// 40-02: decode each independent Field texture, then interpolate the // 40-02: decode each independent Field texture, then interpolate the
// velocity on GPU. u_marker.x is the clamped Layer timeMix property. // velocity on GPU. u_marker.x is the clamped Layer timeMix property.
vec4 field0 = texture(u_field_texture, position); vec2 uv = fieldUv(position);
vec4 field1 = texture(u_next_field_texture, position); vec4 field0 = texture(u_field_texture, uv);
vec2 velocity = mix(field0.rg * 2.0 - 1.0, field1.rg * 2.0 - 1.0, u_marker.x); vec4 field1 = texture(u_next_field_texture, uv);
if (field0.a < 0.5 || field1.a < 0.5) { vec2 velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x,
position = v_uv; (field1.rg * 2.0 - 1.0) * u_field_scale.y,
u_marker.x);
if (!validFieldUv(uv) || field0.a < 0.5 || field1.a < 0.5) {
position = seed;
velocity = vec2(0.0); velocity = vec2(0.0);
} }
// Field V remains north-positive. Mercator Y increases southward, so // Field V remains north-positive. Mercator Y increases southward, so
// convert only at the physical-velocity -> map-displacement boundary. // convert only at the physical-velocity -> map-displacement boundary.
vec2 mercatorVelocity = vec2(velocity.x, -velocity.y); vec2 mercatorVelocity = vec2(velocity.x, -velocity.y);
position = fract(position + mercatorVelocity * u_pass.y * 0.004); position = position + mercatorVelocity * u_pass.y * 0.00004;
if (any(isnan(velocity))) position = v_uv; if (!validFieldUv(fieldUv(position)) || any(isnan(velocity))) position = seed;
fragColor = vec4(position, fract(previous.b + 0.03125 * u_pass.y), 1.0); fragColor = vec4(position, fract(previous.b + 0.03125 * u_pass.y), 1.0);
} else if (mode < 1.5) { } else if (mode < 1.5) {
// Formal Trail first fades the previous target. Its full-particle point // Formal Trail first fades the previous target. Its full-particle point

View File

@@ -13,9 +13,14 @@ struct alignas(16) ParticleGpuValidationUBO {
// Formal RasterParticle visual controls: particle opacity, canonical // Formal RasterParticle visual controls: particle opacity, canonical
// streak length, trail opacity, and Trail A/B fade multiplier. // streak length, trail opacity, and Trail A/B fade multiplier.
std::array<float, 4> visual; std::array<float, 4> visual;
// west, north, east, south in normalized Web Mercator world coordinates.
// Field textures are regional; they must never be sampled as global UVs.
std::array<float, 4> fieldBounds;
// Per-texture signed velocity decoding scales in canonical m/s.
std::array<float, 4> fieldScale;
std::array<float, 16> matrix; std::array<float, 16> matrix;
}; };
static_assert(sizeof(ParticleGpuValidationUBO) == 8 * 16); static_assert(sizeof(ParticleGpuValidationUBO) == 10 * 16);
} // namespace shaders } // namespace shaders
} // namespace mbgl } // namespace mbgl

View File

@@ -0,0 +1,29 @@
#pragma once
#include <mbgl/style/layer.hpp>
namespace mbgl::style {
// A field-speed colour display. It deliberately remains distinct from
// RasterParticleLayer: both consume the same canonical field source but own
// separate GPU resources and draw passes.
class WeatherColorLayer final : public Layer {
public:
WeatherColorLayer(const std::string& layerID, const std::string& sourceID);
~WeatherColorLayer() override;
float getOpacity() const;
void setOpacity(float);
class Impl;
const Impl& impl() const;
Mutable<Impl> mutableImpl() const;
private:
explicit WeatherColorLayer(Immutable<Impl>);
std::optional<conversion::Error> setPropertyInternal(const std::string&, const conversion::Convertible&) override;
StyleProperty getProperty(const std::string&) const override;
Value serialize() const override;
std::unique_ptr<Layer> cloneRef(const std::string&) const override;
Mutable<Layer::Impl> mutableBaseImpl() const override;
};
} // namespace mbgl::style

View File

@@ -21,6 +21,7 @@
#include "location_indicator_layer.hpp" #include "location_indicator_layer.hpp"
#include <mbgl/layermanager/particle_validation_layer_factory.hpp> #include <mbgl/layermanager/particle_validation_layer_factory.hpp>
#include <mbgl/layermanager/raster_particle_layer_factory.hpp> #include <mbgl/layermanager/raster_particle_layer_factory.hpp>
#include <mbgl/layermanager/weather_color_layer_factory.hpp>
namespace mbgl { namespace mbgl {
@@ -30,6 +31,7 @@ LayerManagerAndroid::LayerManagerAndroid() {
// VALIDATION ONLY: JSON style access; intentionally no Android product API/JNI peer. // VALIDATION ONLY: JSON style access; intentionally no Android product API/JNI peer.
addLayerTypeCoreOnly(std::make_unique<ParticleValidationLayerFactory>()); addLayerTypeCoreOnly(std::make_unique<ParticleValidationLayerFactory>());
addLayerTypeCoreOnly(std::make_unique<RasterParticleLayerFactory>()); addLayerTypeCoreOnly(std::make_unique<RasterParticleLayerFactory>());
addLayerTypeCoreOnly(std::make_unique<WeatherColorLayerFactory>());
#if defined(MBGL_LAYER_FILL_DISABLE_RUNTIME) #if defined(MBGL_LAYER_FILL_DISABLE_RUNTIME)
addLayerTypeCoreOnly(std::make_unique<FillLayerFactory>()); addLayerTypeCoreOnly(std::make_unique<FillLayerFactory>());
#elif !defined(MBGL_LAYER_FILL_DISABLE_ALL) #elif !defined(MBGL_LAYER_FILL_DISABLE_ALL)

View File

@@ -7,7 +7,9 @@ import android.widget.PopupMenu
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
import org.maplibre.android.maps.MapView import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style import org.maplibre.android.maps.Style
@@ -34,6 +36,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var timeWindowMode = false private var timeWindowMode = false
private var productionFeedMode = false private var productionFeedMode = false
private var hostSubmissionMode = false private var hostSubmissionMode = false
private var realWeatherMode = false
private var styleLoadEpoch = 0L private var styleLoadEpoch = 0L
private var styleReady = false private var styleReady = false
// Display mirror only; Core remains the playback state authority. // Display mirror only; Core remains the playback state authority.
@@ -54,6 +57,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
trailFade = savedInstanceState?.getFloat(STATE_TRAIL_FADE, DEFAULT_TRAIL_FADE) ?: DEFAULT_TRAIL_FADE trailFade = savedInstanceState?.getFloat(STATE_TRAIL_FADE, DEFAULT_TRAIL_FADE) ?: DEFAULT_TRAIL_FADE
speedColorEnabled = savedInstanceState?.getBoolean(STATE_SPEED_COLOR, true) ?: true speedColorEnabled = savedInstanceState?.getBoolean(STATE_SPEED_COLOR, true) ?: true
timeWindowMode = savedInstanceState?.getBoolean(STATE_TIME_WINDOW, false) ?: false timeWindowMode = savedInstanceState?.getBoolean(STATE_TIME_WINDOW, false) ?: false
hostSubmissionMode = savedInstanceState?.getBoolean(STATE_HOST_SUBMISSION, false) ?: false
realWeatherMode = savedInstanceState?.getBoolean(STATE_REAL_WEATHER, false) ?: false
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.i( Log.i(
TAG, TAG,
@@ -101,6 +106,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
timeWindowMode = false timeWindowMode = false
productionFeedMode = false productionFeedMode = false
hostSubmissionMode = false hostSubmissionMode = false
realWeatherMode = false
fixtureMode = FIXTURE_UNIFORM_EAST fixtureMode = FIXTURE_UNIFORM_EAST
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "UNIFORM_EAST") if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "UNIFORM_EAST")
} }
@@ -108,6 +114,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
timeWindowMode = false timeWindowMode = false
productionFeedMode = false productionFeedMode = false
hostSubmissionMode = false hostSubmissionMode = false
realWeatherMode = false
fixtureMode = FIXTURE_UNIFORM_NORTH fixtureMode = FIXTURE_UNIFORM_NORTH
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "UNIFORM_NORTH") if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "UNIFORM_NORTH")
} }
@@ -115,6 +122,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
timeWindowMode = false timeWindowMode = false
productionFeedMode = false productionFeedMode = false
hostSubmissionMode = false hostSubmissionMode = false
realWeatherMode = false
fixtureMode = FIXTURE_VORTEX fixtureMode = FIXTURE_VORTEX
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "VORTEX") if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "VORTEX")
} }
@@ -122,6 +130,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
timeWindowMode = true timeWindowMode = true
productionFeedMode = true productionFeedMode = true
hostSubmissionMode = false hostSubmissionMode = false
realWeatherMode = false
fixtureMode = FIXTURE_UNIFORM_EAST fixtureMode = FIXTURE_UNIFORM_EAST
timeMix = 0.0f timeMix = 0.0f
timelineState = "NOT READY" timelineState = "NOT READY"
@@ -146,6 +155,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
findViewById<Button>(R.id.validationProductionInvalid).setOnClickListener { findViewById<Button>(R.id.validationProductionInvalid).setOnClickListener {
productionFeedMode = true productionFeedMode = true
hostSubmissionMode = false hostSubmissionMode = false
realWeatherMode = false
timeWindowMode = true timeWindowMode = true
timelineState = "NOT READY" timelineState = "NOT READY"
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "INVALID_PRODUCTION_METADATA") if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "INVALID_PRODUCTION_METADATA")
@@ -154,6 +164,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
timeWindowMode = true timeWindowMode = true
productionFeedMode = false productionFeedMode = false
hostSubmissionMode = true hostSubmissionMode = true
realWeatherMode = false
fixtureMode = FIXTURE_UNIFORM_EAST fixtureMode = FIXTURE_UNIFORM_EAST
timeMix = 0.0f timeMix = 0.0f
timelineState = "NOT READY" timelineState = "NOT READY"
@@ -162,6 +173,16 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
findViewById<Button>(R.id.validationHostFieldDuplicate).setOnClickListener { findViewById<Button>(R.id.validationHostFieldDuplicate).setOnClickListener {
if (this::maplibreMap.isInitialized) submitHostValidationField(3, duplicate = true) if (this::maplibreMap.isInitialized) submitHostValidationField(3, duplicate = true)
} }
findViewById<Button>(R.id.validationRealWeather).setOnClickListener {
timeWindowMode = true
productionFeedMode = false
hostSubmissionMode = true
realWeatherMode = true
fixtureMode = FIXTURE_UNIFORM_EAST
timeMix = 0.0f
timelineState = "NOT READY"
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "REAL_WEATHER")
}
findViewById<Button>(R.id.validationHostFieldInvalid).setOnClickListener { findViewById<Button>(R.id.validationHostFieldInvalid).setOnClickListener {
if (this::maplibreMap.isInitialized) submitInvalidHostValidationField() if (this::maplibreMap.isInitialized) submitInvalidHostValidationField()
} }
@@ -189,7 +210,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
findViewById<Button>(R.id.validationMenuVisual).setOnClickListener { showMenu(it, "VIS", intArrayOf(R.id.validationVisualDefault, R.id.validationVisualColor, R.id.validationVisualSizeDown, R.id.validationVisualSizeUp, R.id.validationVisualLengthDown, R.id.validationVisualLengthUp, R.id.validationVisualOpacityDown, R.id.validationVisualOpacityUp, R.id.validationVisualTrailDown, R.id.validationVisualTrailUp, R.id.validationVisualFadeDown, R.id.validationVisualFadeUp)) } findViewById<Button>(R.id.validationMenuVisual).setOnClickListener { showMenu(it, "VIS", intArrayOf(R.id.validationVisualDefault, R.id.validationVisualColor, R.id.validationVisualSizeDown, R.id.validationVisualSizeUp, R.id.validationVisualLengthDown, R.id.validationVisualLengthUp, R.id.validationVisualOpacityDown, R.id.validationVisualOpacityUp, R.id.validationVisualTrailDown, R.id.validationVisualTrailUp, R.id.validationVisualFadeDown, R.id.validationVisualFadeUp)) }
findViewById<Button>(R.id.validationMenuCount).setOnClickListener { showMenu(it, "COUNT", intArrayOf(R.id.validationParticle256, R.id.validationParticle1024, R.id.validationParticle4096, R.id.validationParticle16384, R.id.validationParticle65536, R.id.validationParticle262144, R.id.validationParticle1048576)) } findViewById<Button>(R.id.validationMenuCount).setOnClickListener { showMenu(it, "COUNT", intArrayOf(R.id.validationParticle256, R.id.validationParticle1024, R.id.validationParticle4096, R.id.validationParticle16384, R.id.validationParticle65536, R.id.validationParticle262144, R.id.validationParticle1048576)) }
findViewById<Button>(R.id.validationMenuMap).setOnClickListener { showMenu(it, "MAP", intArrayOf(R.id.validationGlobal, R.id.validationOceanA, R.id.validationOceanB, R.id.validationDateline, R.id.validationWorldWrap)) } findViewById<Button>(R.id.validationMenuMap).setOnClickListener { showMenu(it, "MAP", intArrayOf(R.id.validationGlobal, R.id.validationOceanA, R.id.validationOceanB, R.id.validationDateline, R.id.validationWorldWrap)) }
findViewById<Button>(R.id.validationMenuMore).setOnClickListener { showMenu(it, "MORE", intArrayOf(R.id.validationActive, R.id.validationStatic, R.id.validationDisable, R.id.validationEnable, R.id.validationUniformEast, R.id.validationUniformNorth, R.id.validationVortex, R.id.validationTimeWindow, R.id.validationHostFieldSubmit, R.id.validationProductionInvalid, R.id.validationHostFieldDuplicate, R.id.validationHostFieldInvalid, R.id.validationAdvanceWindow, R.id.validationDuplicateWindow)) } findViewById<Button>(R.id.validationMenuMore).setOnClickListener { showMenu(it, "MORE", intArrayOf(R.id.validationActive, R.id.validationStatic, R.id.validationDisable, R.id.validationEnable, R.id.validationUniformEast, R.id.validationUniformNorth, R.id.validationVortex, R.id.validationTimeWindow, R.id.validationHostFieldSubmit, R.id.validationRealWeather, R.id.validationProductionInvalid, R.id.validationHostFieldDuplicate, R.id.validationHostFieldInvalid, R.id.validationAdvanceWindow, R.id.validationDuplicateWindow)) }
} }
private fun showMenu(anchor: android.view.View, title: String, actions: IntArray) { private fun showMenu(anchor: android.view.View, title: String, actions: IntArray) {
@@ -239,7 +260,46 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
} }
private fun actionLabel(id: Int): String = when (id) { private fun actionLabel(id: Int): String = when (id) {
R.id.validationVisualDefault -> "VIS DEFAULT"; R.id.validationVisualSizeDown -> "SIZE -"; R.id.validationVisualSizeUp -> "SIZE +"; R.id.validationVisualLengthDown -> "LENGTH -"; R.id.validationVisualLengthUp -> "LENGTH +"; R.id.validationVisualOpacityDown -> "OPACITY -"; R.id.validationVisualOpacityUp -> "OPACITY +"; R.id.validationVisualTrailDown -> "TRAIL -"; R.id.validationVisualTrailUp -> "TRAIL +"; R.id.validationVisualFadeDown -> "FADE -"; R.id.validationVisualFadeUp -> "FADE +"; R.id.validationVisualColor -> "COLOR"; R.id.validationParticle256 -> "256"; R.id.validationParticle1024 -> "1K"; R.id.validationParticle4096 -> "4K"; R.id.validationParticle16384 -> "16K"; R.id.validationParticle65536 -> "64K"; R.id.validationParticle262144 -> "256K"; R.id.validationParticle1048576 -> "1M"; R.id.validationGlobal -> "GLOBAL"; R.id.validationOceanA -> "OCEAN A"; R.id.validationOceanB -> "OCEAN B"; R.id.validationDateline -> "DATELINE"; R.id.validationWorldWrap -> "WORLD WRAP"; R.id.validationActive -> "ACTIVE"; R.id.validationStatic -> "STATIC"; R.id.validationDisable -> "DISABLE"; R.id.validationEnable -> "ENABLE"; R.id.validationUniformEast -> "UNIFORM EAST"; R.id.validationUniformNorth -> "UNIFORM NORTH"; R.id.validationVortex -> "VORTEX"; R.id.validationTimeWindow -> "PRODUCTION FEED"; R.id.validationHostFieldSubmit -> "HOST FIELD"; R.id.validationProductionInvalid -> "INVALID PROD"; R.id.validationHostFieldDuplicate -> "HOST DUPLICATE"; R.id.validationHostFieldInvalid -> "HOST INVALID"; R.id.validationAdvanceWindow -> "ADVANCE WINDOW"; else -> "DUPLICATE T1" R.id.validationTimeWindow -> "PRODUCTION FEED"
R.id.validationHostFieldSubmit -> "HOST FIELD"
R.id.validationRealWeather -> "REAL WEATHER"
R.id.validationProductionInvalid -> "INVALID PROD"
R.id.validationHostFieldDuplicate -> "HOST DUPLICATE"
R.id.validationHostFieldInvalid -> "HOST INVALID"
R.id.validationParticle1048576 -> "1M"
R.id.validationParticle262144 -> "256K"
R.id.validationParticle65536 -> "64K"
R.id.validationParticle16384 -> "16K"
R.id.validationParticle4096 -> "4K"
R.id.validationParticle1024 -> "1K"
R.id.validationParticle256 -> "256"
R.id.validationActive -> "ACTIVE"
R.id.validationStatic -> "STATIC"
R.id.validationDisable -> "DISABLE"
R.id.validationEnable -> "ENABLE"
R.id.validationUniformEast -> "UNIFORM EAST"
R.id.validationUniformNorth -> "UNIFORM NORTH"
R.id.validationVortex -> "VORTEX"
R.id.validationAdvanceWindow -> "ADVANCE WINDOW"
R.id.validationGlobal -> "GLOBAL"
R.id.validationOceanA -> "OCEAN A"
R.id.validationOceanB -> "OCEAN B"
R.id.validationDateline -> "DATELINE"
R.id.validationWorldWrap -> "WORLD WRAP"
R.id.validationVisualDefault -> "VIS DEFAULT"
R.id.validationVisualSizeDown -> "SIZE -"
R.id.validationVisualSizeUp -> "SIZE +"
R.id.validationVisualLengthDown -> "LENGTH -"
R.id.validationVisualLengthUp -> "LENGTH +"
R.id.validationVisualOpacityDown -> "OPACITY -"
R.id.validationVisualOpacityUp -> "OPACITY +"
R.id.validationVisualTrailDown -> "TRAIL -"
R.id.validationVisualTrailUp -> "TRAIL +"
R.id.validationVisualFadeDown -> "FADE -"
R.id.validationVisualFadeUp -> "FADE +"
R.id.validationVisualColor -> "COLOR"
R.id.validationDuplicateWindow -> "DUPLICATE T1"
else -> "ACTION"
} }
private fun loadValidationStyle(active: Boolean, enabled: Boolean, reason: String = "STYLE_CHANGE") { private fun loadValidationStyle(active: Boolean, enabled: Boolean, reason: String = "STYLE_CHANGE") {
@@ -252,7 +312,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
maplibreMap.setStyle(Style.Builder().fromJson(validationStyle(active, enabled, reason))) { maplibreMap.setStyle(Style.Builder().fromJson(validationStyle(active, enabled, reason))) {
styleReady = true styleReady = true
if (hostSubmissionMode) { if (hostSubmissionMode) {
submitHostValidationDataset() if (realWeatherMode) submitRealWeatherDataset() else submitHostValidationDataset()
} else if (timeWindowMode) { } else if (timeWindowMode) {
timelineState = "READY" timelineState = "READY"
} }
@@ -408,6 +468,39 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
Log.i(TAG, "ParticleField: event=HOST_FIELD_DATASET_SUBMIT results=${results.joinToString(",")} timeline=$timelineState") Log.i(TAG, "ParticleField: event=HOST_FIELD_DATASET_SUBMIT results=${results.joinToString(",")} timeline=$timelineState")
} }
private fun submitRealWeatherDataset() {
val cycles = arrayOf("2026080700", "2026080706", "2026080712", "2026080718")
val root = "integration/particle-weatherv2/ecmwf-hres-wind-20260807"
val results = cycles.mapIndexed { index, cycle ->
try {
val metadataPath = "$root/$cycle/metadata.json"
val payloadPath = "$root/$cycle/field.f32"
val metadata = assets.open(metadataPath).bufferedReader().use { it.readText() }
val payload = assets.open(payloadPath).use { it.readBytes() }
Log.i(TAG, "ParticleField: event=REAL_WEATHER_DATASET_LOAD index=$index metadata=$metadataPath payloadBytes=${payload.size}")
maplibreMap.submitRasterArraySourceHostField(PARTICLE_SOURCE_ID, metadata, payload)
} catch (error: Exception) {
Log.e(TAG, "ParticleField: event=REAL_WEATHER_SUBMIT_REJECTED index=$index reason=ASSET_READ_FAILED", error)
MapLibreMap.HOST_FIELD_REJECTED_ADAPTER
}
}
timelineState = if (results.size == 4 && results.all { it > 0 }) "READY" else "NOT READY"
fitRealWeatherBounds()
status.text = statusText()
Log.i(TAG, "ParticleField: event=REAL_WEATHER_INITIAL_WINDOW_READY results=${results.joinToString(",")} timeline=$timelineState")
Log.i(TAG, "WeatherColorLayer: event=WEATHER_COLOR_FIELD_READY source=$PARTICLE_SOURCE_ID scale=0..30mps")
Log.i(TAG, "ParticleField: event=REAL_WEATHER_COMPOSITE_READY source=$PARTICLE_SOURCE_ID basemap=OSM color=weather-color particle=raster-particle")
}
private fun fitRealWeatherBounds() {
val bounds = LatLngBounds.Builder()
.include(LatLng(0.0, 112.5))
.include(LatLng(55.77657301866769, 157.5))
.build()
maplibreMap.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 48))
Log.i(TAG, "ParticleField: event=REAL_WEATHER_CAMERA_FIT bounds=112.5,0.0,157.5,55.77657301866769")
}
private fun submitHostValidationField(index: Int, duplicate: Boolean): Int { private fun submitHostValidationField(index: Int, duplicate: Boolean): Int {
if (!styleReady || !hostSubmissionMode) { if (!styleReady || !hostSubmissionMode) {
Log.i(TAG, "ParticleField: event=HOST_FIELD_SUBMIT_REJECTED reason=HOST_MODE_NOT_READY index=$index") Log.i(TAG, "ParticleField: event=HOST_FIELD_SUBMIT_REJECTED reason=HOST_MODE_NOT_READY index=$index")
@@ -466,7 +559,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
} else { } else {
"EAST" "EAST"
} }
val playback = if (timeWindowMode) " · ${if (hostSubmissionMode) "HOST" else if (productionFeedMode) "PRODUCTION" else "FIXTURE"} · TL $timelineState · $timelineSpeed" else "" val playback = if (timeWindowMode) " · ${if (realWeatherMode) "REAL WEATHER" else if (hostSubmissionMode) "HOST" else if (productionFeedMode) "PRODUCTION" else "FIXTURE"} · TL $timelineState · $timelineSpeed" else ""
return "$mask · $runtime · $fields$playback · MIX ${(timeMix * 100).toInt()}% · $particleCount · W ${"%.1f".format(lineWidth)} L ${"%.3f".format(particleLength)} O ${(particleOpacity * 100).toInt()} T ${(trailOpacity * 100).toInt()} F ${(trailFade * 100).toInt()} C ${if (speedColorEnabled) "ON" else "OFF"}" return "$mask · $runtime · $fields$playback · MIX ${(timeMix * 100).toInt()}% · $particleCount · W ${"%.1f".format(lineWidth)} L ${"%.3f".format(particleLength)} O ${(particleOpacity * 100).toInt()} T ${(trailOpacity * 100).toInt()} F ${(trailFade * 100).toInt()} C ${if (speedColorEnabled) "ON" else "OFF"}"
} }
@@ -487,6 +580,20 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
} else { } else {
"" ""
} }
val weatherColorLayer = if (realWeatherMode && enabled) {
"""{"id":"weather-color-real","type":"weather-color","source":"particle-fixture",
"paint":{"weather-color-opacity":0.62}},"""
} else ""
val validationDecorations = if (realWeatherMode) "" else """
{"id":"validation-below","type":"circle","source":"validation-point",
"paint":{"circle-radius":46,"circle-color":"#247ba0"}},
"""
val validationHud = if (realWeatherMode) """
{"id":"validation-real-weather-hud","type":"background","paint":{"background-opacity":0.0}}
""" else """
{"id":"validation-above","type":"circle","source":"validation-point",
"paint":{"circle-radius":16,"circle-color":"#ffe066"}}
"""
val source = if (hostSubmissionMode) { val source = if (hostSubmissionMode) {
"""{"type":"raster-array","host-field-submission":true}""" """{"type":"raster-array","host-field-submission":true}"""
} else if (productionFeedMode) { } else if (productionFeedMode) {
@@ -516,6 +623,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
"name": "Particle Core Layer Validation", "name": "Particle Core Layer Validation",
"sources": { "sources": {
"particle-fixture": $source, "particle-fixture": $source,
"osm-basemap": {"type":"raster","tiles":["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],"tileSize":256,"maxzoom":19},
"validation-point": { "validation-point": {
"type": "geojson", "type": "geojson",
"data": {"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]}} "data": {"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]}}
@@ -523,11 +631,11 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}, },
"layers": [ "layers": [
{"id":"validation-background","type":"background","paint":{"background-color":"#102030"}}, {"id":"validation-background","type":"background","paint":{"background-color":"#102030"}},
{"id":"validation-below","type":"circle","source":"validation-point", ${if (realWeatherMode) "{\"id\":\"osm-basemap\",\"type\":\"raster\",\"source\":\"osm-basemap\",\"paint\":{\"raster-opacity\":1.0}}," else ""}
"paint":{"circle-radius":46,"circle-color":"#247ba0"}}, $validationDecorations
$weatherColorLayer
$particleLayer $particleLayer
{"id":"validation-above","type":"circle","source":"validation-point", $validationHud
"paint":{"circle-radius":16,"circle-color":"#ffe066"}}
] ]
} }
""".trimIndent() """.trimIndent()
@@ -554,6 +662,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
outState.putFloat(STATE_TRAIL_FADE, trailFade) outState.putFloat(STATE_TRAIL_FADE, trailFade)
outState.putBoolean(STATE_SPEED_COLOR, speedColorEnabled) outState.putBoolean(STATE_SPEED_COLOR, speedColorEnabled)
outState.putBoolean(STATE_TIME_WINDOW, timeWindowMode) outState.putBoolean(STATE_TIME_WINDOW, timeWindowMode)
outState.putBoolean(STATE_HOST_SUBMISSION, hostSubmissionMode)
outState.putBoolean(STATE_REAL_WEATHER, realWeatherMode)
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState) mapView.onSaveInstanceState(outState)
} }
@@ -579,6 +689,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private const val STATE_TRAIL_FADE = "particle_validation.trail_fade" private const val STATE_TRAIL_FADE = "particle_validation.trail_fade"
private const val STATE_SPEED_COLOR = "particle_validation.speed_color" private const val STATE_SPEED_COLOR = "particle_validation.speed_color"
private const val STATE_TIME_WINDOW = "particle_validation.time_window" private const val STATE_TIME_WINDOW = "particle_validation.time_window"
private const val STATE_HOST_SUBMISSION = "particle_validation.host_submission"
private const val STATE_REAL_WEATHER = "particle_validation.real_weather"
private const val PARTICLE_LAYER_ID = "particle-raster-core" private const val PARTICLE_LAYER_ID = "particle-raster-core"
private const val PARTICLE_SOURCE_ID = "particle-fixture" private const val PARTICLE_SOURCE_ID = "particle-fixture"
private const val MASK_GLOBAL = 0 private const val MASK_GLOBAL = 0

View File

@@ -15,7 +15,7 @@
<Button style="@style/ParticleValidationButton" android:id="@+id/validationActive"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationStatic"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationDisable"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationEnable"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationGlobal"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationOceanA"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationOceanB"/> <Button style="@style/ParticleValidationButton" android:id="@+id/validationActive"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationStatic"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationDisable"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationEnable"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationGlobal"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationOceanA"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationOceanB"/>
<Button style="@style/ParticleValidationButton" android:id="@+id/validationUniformEast"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationUniformNorth"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVortex"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationTimeWindow"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationMix0"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationMix50"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationMix100"/> <Button style="@style/ParticleValidationButton" android:id="@+id/validationUniformEast"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationUniformNorth"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVortex"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationTimeWindow"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationMix0"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationMix50"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationMix100"/>
<Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle256"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle1024"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle4096"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle16384"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle65536"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle262144"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle1048576"/> <Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle256"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle1024"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle4096"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle16384"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle65536"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle262144"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle1048576"/>
<Button style="@style/ParticleValidationButton" android:id="@+id/validationAdvanceWindow"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationDuplicateWindow"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationProductionInvalid"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationHostFieldSubmit"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationHostFieldDuplicate"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationHostFieldInvalid"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualDefault"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualSizeDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualSizeUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualLengthDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualLengthUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualOpacityDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualOpacityUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualTrailDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualTrailUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualFadeDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualFadeUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualColor"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationDateline"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationWorldWrap"/> <Button style="@style/ParticleValidationButton" android:id="@+id/validationAdvanceWindow"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationDuplicateWindow"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationProductionInvalid"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationHostFieldSubmit"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationRealWeather"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationHostFieldDuplicate"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationHostFieldInvalid"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualDefault"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualSizeDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualSizeUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualLengthDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualLengthUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualOpacityDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualOpacityUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualTrailDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualTrailUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualFadeDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualFadeUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualColor"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationDateline"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationWorldWrap"/>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -12,6 +12,7 @@
#include <mbgl/layermanager/location_indicator_layer_factory.hpp> #include <mbgl/layermanager/location_indicator_layer_factory.hpp>
#include <mbgl/layermanager/particle_validation_layer_factory.hpp> #include <mbgl/layermanager/particle_validation_layer_factory.hpp>
#include <mbgl/layermanager/raster_particle_layer_factory.hpp> #include <mbgl/layermanager/raster_particle_layer_factory.hpp>
#include <mbgl/layermanager/weather_color_layer_factory.hpp>
#include <mbgl/layermanager/raster_layer_factory.hpp> #include <mbgl/layermanager/raster_layer_factory.hpp>
#include <mbgl/layermanager/symbol_layer_factory.hpp> #include <mbgl/layermanager/symbol_layer_factory.hpp>
#include <mbgl/util/logging.hpp> #include <mbgl/util/logging.hpp>
@@ -54,6 +55,7 @@ LayerManagerDefault::LayerManagerDefault() {
// VALIDATION ONLY: 30-01 source-less Core RenderLayer experiment. // VALIDATION ONLY: 30-01 source-less Core RenderLayer experiment.
addLayerType(std::make_unique<ParticleValidationLayerFactory>()); addLayerType(std::make_unique<ParticleValidationLayerFactory>());
addLayerType(std::make_unique<RasterParticleLayerFactory>()); addLayerType(std::make_unique<RasterParticleLayerFactory>());
addLayerType(std::make_unique<WeatherColorLayerFactory>());
#if !defined(MBGL_LAYER_FILL_DISABLE_ALL) #if !defined(MBGL_LAYER_FILL_DISABLE_ALL)
addLayerType(std::make_unique<FillLayerFactory>()); addLayerType(std::make_unique<FillLayerFactory>());
#endif #endif

View File

@@ -3,6 +3,8 @@ layout (std140) uniform ParticleGpuValidationUBO {
highp vec4 u_marker; highp vec4 u_marker;
highp vec4 u_mask; highp vec4 u_mask;
highp vec4 u_visual; highp vec4 u_visual;
highp vec4 u_field_bounds;
highp vec4 u_field_scale;
highp mat4 u_matrix; highp mat4 u_matrix;
}; };
@@ -24,31 +26,68 @@ vec3 particleSpeedColor(float speed) {
return mix(mix(slow, medium, smoothstep(0.0, 0.55, t)), fast, smoothstep(0.45, 1.0, t)); return mix(mix(slow, medium, smoothstep(0.0, 0.55, t)), fast, smoothstep(0.45, 1.0, t));
} }
vec2 fieldUv(vec2 world) {
return vec2((world.x - u_field_bounds.x) / (u_field_bounds.z - u_field_bounds.x),
(world.y - u_field_bounds.y) / (u_field_bounds.w - u_field_bounds.y));
}
bool validFieldUv(vec2 uv) {
return all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0)));
}
vec3 weatherSpeedColor(float speed) {
// Fixed 0..30 m/s scale: no per-frame or per-dataset auto stretching.
float t = clamp(speed / 30.0, 0.0, 1.0);
vec3 slow = vec3(0.08, 0.18, 0.65);
vec3 medium = vec3(0.08, 0.80, 0.78);
vec3 fast = vec3(0.98, 0.22, 0.10);
return mix(mix(slow, medium, smoothstep(0.0, 0.55, t)), fast, smoothstep(0.45, 1.0, t));
}
void main() { void main() {
vec4 previous = texture(u_previous, v_uv);
float mode = u_pass.x; float mode = u_pass.x;
if (mode > 4.5) {
vec2 uv = fieldUv(v_world);
if (!validFieldUv(uv)) discard;
vec4 field0 = texture(u_field_texture, uv);
vec4 field1 = texture(u_next_field_texture, uv);
if (field0.a < 0.5 || field1.a < 0.5) discard;
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 (any(isnan(velocity))) discard;
fragColor = vec4(weatherSpeedColor(length(velocity)), u_visual.x);
return;
}
vec4 previous = texture(u_previous, v_uv);
if (mode < 0.5) { if (mode < 0.5) {
// 40-01: State A/B samples the uploaded OfflineFixture Particle Field. // 40-01: State A/B samples the uploaded OfflineFixture Particle Field.
// RG stores field-normalized position, B stores age, and the Field // RG stores field-normalized position, B stores age, and the Field
// texture encodes canonical U-east/V-north at that geographic sample. // texture encodes canonical U-east/V-north at that geographic sample.
// u_mask.w is the explicit State lifecycle flag: new RenderTargets are // u_mask.w is the explicit State lifecycle flag: new RenderTargets are
// cleared opaque by MapLibre, so sampled alpha cannot identify a seed. // cleared opaque by MapLibre, so sampled alpha cannot identify a seed.
vec2 position = u_mask.w > 0.5 ? v_uv : previous.rg; vec2 seed = mix(u_field_bounds.xy, u_field_bounds.zw, v_uv);
vec2 position = u_mask.w > 0.5 ? seed : previous.rg;
// 40-02: decode each independent Field texture, then interpolate the // 40-02: decode each independent Field texture, then interpolate the
// velocity on GPU. u_marker.x is the clamped Layer timeMix property. // velocity on GPU. u_marker.x is the clamped Layer timeMix property.
vec4 field0 = texture(u_field_texture, position); vec2 uv = fieldUv(position);
vec4 field1 = texture(u_next_field_texture, position); vec4 field0 = texture(u_field_texture, uv);
vec2 velocity = mix(field0.rg * 2.0 - 1.0, field1.rg * 2.0 - 1.0, u_marker.x); vec4 field1 = texture(u_next_field_texture, uv);
if (field0.a < 0.5 || field1.a < 0.5) { vec2 velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x,
position = v_uv; (field1.rg * 2.0 - 1.0) * u_field_scale.y,
u_marker.x);
if (!validFieldUv(uv) || field0.a < 0.5 || field1.a < 0.5) {
position = seed;
velocity = vec2(0.0); velocity = vec2(0.0);
} }
// Field V remains north-positive. Mercator Y increases southward, so // Field V remains north-positive. Mercator Y increases southward, so
// convert only at the physical-velocity -> map-displacement boundary. // convert only at the physical-velocity -> map-displacement boundary.
vec2 mercatorVelocity = vec2(velocity.x, -velocity.y); vec2 mercatorVelocity = vec2(velocity.x, -velocity.y);
position = fract(position + mercatorVelocity * u_pass.y * 0.004); position = position + mercatorVelocity * u_pass.y * 0.00004;
if (any(isnan(velocity))) position = v_uv; if (!validFieldUv(fieldUv(position)) || any(isnan(velocity))) position = seed;
fragColor = vec4(position, fract(previous.b + 0.03125 * u_pass.y), 1.0); fragColor = vec4(position, fract(previous.b + 0.03125 * u_pass.y), 1.0);
} else if (mode < 1.5) { } else if (mode < 1.5) {
// Formal Trail first fades the previous target. Its full-particle point // Formal Trail first fades the previous target. Its full-particle point

View File

@@ -10,6 +10,8 @@ layout (std140) uniform ParticleGpuValidationUBO {
highp vec4 u_marker; highp vec4 u_marker;
highp vec4 u_mask; highp vec4 u_mask;
highp vec4 u_visual; highp vec4 u_visual;
highp vec4 u_field_bounds;
highp vec4 u_field_scale;
highp mat4 u_matrix; highp mat4 u_matrix;
}; };
@@ -17,12 +19,23 @@ uniform sampler2D u_state_texture;
uniform sampler2D u_field_texture; uniform sampler2D u_field_texture;
uniform sampler2D u_next_field_texture; uniform sampler2D u_next_field_texture;
vec2 fieldUv(vec2 world) {
return vec2((world.x - u_field_bounds.x) / (u_field_bounds.z - u_field_bounds.x),
(world.y - u_field_bounds.y) / (u_field_bounds.w - u_field_bounds.y));
}
bool validFieldUv(vec2 uv) {
return all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0)));
}
void main() { void main() {
v_uv = a_pos; v_uv = a_pos;
v_world = a_pos; v_world = a_pos;
v_particle_velocity = vec2(0.0); v_particle_velocity = vec2(0.0);
v_particle_valid = 1.0; v_particle_valid = 1.0;
if (u_pass.x < 1.5) { if (u_pass.x > 4.5) {
gl_Position = u_matrix * vec4(a_pos * 8192.0, 0.0, 1.0);
} else if (u_pass.x < 1.5) {
gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0); gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0);
} else if (u_pass.x > 3.5) { } else if (u_pass.x > 3.5) {
// 40-05: a_pos.x packs State texel X plus a static line endpoint. // 40-05: a_pos.x packs State texel X plus a static line endpoint.
@@ -33,10 +46,13 @@ void main() {
vec2 stateIndex = vec2(floor(a_pos.x * 0.5), a_pos.y); vec2 stateIndex = vec2(floor(a_pos.x * 0.5), a_pos.y);
vec2 stateUV = (stateIndex + vec2(0.5)) / u_mask.xy; vec2 stateUV = (stateIndex + vec2(0.5)) / u_mask.xy;
vec2 position = texture(u_state_texture, stateUV).rg; vec2 position = texture(u_state_texture, stateUV).rg;
vec4 field0 = texture(u_field_texture, position); vec2 uv = fieldUv(position);
vec4 field1 = texture(u_next_field_texture, position); vec4 field0 = texture(u_field_texture, uv);
vec2 velocity = mix(field0.rg * 2.0 - 1.0, field1.rg * 2.0 - 1.0, u_marker.x); vec4 field1 = texture(u_next_field_texture, uv);
bool valid = field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity)); 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 mercatorVelocity = vec2(velocity.x, -velocity.y);
float speed = length(mercatorVelocity); float speed = length(mercatorVelocity);
v_uv = position; v_uv = position;

View File

@@ -0,0 +1,13 @@
#include <mbgl/layermanager/weather_color_layer_factory.hpp>
#include <mbgl/renderer/layers/render_weather_color_layer.hpp>
#include <mbgl/style/layers/weather_color_layer.hpp>
#include <mbgl/style/layers/weather_color_layer_impl.hpp>
namespace mbgl {
const style::LayerTypeInfo* WeatherColorLayerFactory::getTypeInfo() const noexcept { return style::WeatherColorLayer::Impl::staticTypeInfo(); }
std::unique_ptr<style::Layer> WeatherColorLayerFactory::createLayer(const std::string& id, const style::conversion::Convertible& value) noexcept {
const auto source = getSource(value); return source ? std::make_unique<style::WeatherColorLayer>(id, *source) : nullptr;
}
std::unique_ptr<RenderLayer> WeatherColorLayerFactory::createRenderLayer(Immutable<style::Layer::Impl> impl) noexcept {
return std::make_unique<RenderWeatherColorLayer>(std::move(impl));
}
} // namespace mbgl

View File

@@ -121,15 +121,21 @@ const char* fixtureID(const uint8_t mode) {
} }
std::shared_ptr<PremultipliedImage> makeVelocityFieldImage(const particle::ParticleFieldSnapshot& field) { std::shared_ptr<PremultipliedImage> makeVelocityFieldImage(const particle::ParticleFieldSnapshot& field) {
float scale = 1.0f;
for (size_t index = 0; index < field.valid.size(); ++index) {
if (!field.valid[index]) continue;
scale = std::max(scale, std::abs(field.velocity[index * 2]));
scale = std::max(scale, std::abs(field.velocity[index * 2 + 1]));
}
auto image = std::make_shared<PremultipliedImage>(Size{field.width, field.height}); auto image = std::make_shared<PremultipliedImage>(Size{field.width, field.height});
for (uint32_t y = 0; y < field.height; ++y) { for (uint32_t y = 0; y < field.height; ++y) {
for (uint32_t x = 0; x < field.width; ++x) { for (uint32_t x = 0; x < field.width; ++x) {
const auto cell = (static_cast<size_t>(y) * field.width + x) * 2; const auto cell = (static_cast<size_t>(y) * field.width + x) * 2;
const auto pixel = (static_cast<size_t>(y) * field.width + x) * PremultipliedImage::channels; const auto pixel = (static_cast<size_t>(y) * field.width + x) * PremultipliedImage::channels;
image->data[pixel + 0] = static_cast<uint8_t>(std::clamp(field.velocity[cell] * 0.5f + 0.5f, 0.0f, 1.0f) * image->data[pixel + 0] = static_cast<uint8_t>(std::clamp(field.velocity[cell] / scale * 0.5f + 0.5f, 0.0f, 1.0f) *
255.0f); 255.0f);
image->data[pixel + 1] = static_cast<uint8_t>( image->data[pixel + 1] = static_cast<uint8_t>(
std::clamp(field.velocity[cell + 1] * 0.5f + 0.5f, 0.0f, 1.0f) * 255.0f); std::clamp(field.velocity[cell + 1] / scale * 0.5f + 0.5f, 0.0f, 1.0f) * 255.0f);
image->data[pixel + 2] = 0; image->data[pixel + 2] = 0;
image->data[pixel + 3] = field.valid[y * field.width + x] ? 255 : 0; image->data[pixel + 3] = field.valid[y * field.width + x] ? 255 : 0;
} }
@@ -137,6 +143,28 @@ std::shared_ptr<PremultipliedImage> makeVelocityFieldImage(const particle::Parti
return image; return image;
} }
float fieldVelocityScale(const particle::ParticleFieldSnapshot& field) {
float scale = 1.0f;
for (size_t index = 0; index < field.valid.size(); ++index) {
if (!field.valid[index]) continue;
scale = std::max(scale, std::abs(field.velocity[index * 2]));
scale = std::max(scale, std::abs(field.velocity[index * 2 + 1]));
}
return scale;
}
double mercatorY(const double latitude) {
const auto radians = latitude * M_PI / 180.0;
return (1.0 - std::log(std::tan(radians) + 1.0 / std::cos(radians)) / M_PI) * 0.5;
}
std::array<float, 4> fieldBounds(const particle::ParticleFieldSnapshot& field) {
return {static_cast<float>((field.west + 180.0) / 360.0),
static_cast<float>(mercatorY(field.north)),
static_cast<float>((field.east + 180.0) / 360.0),
static_cast<float>(mercatorY(field.south))};
}
// VALIDATION ONLY: deterministic global fixture with a diagonal coast. Alpha // VALIDATION ONLY: deterministic global fixture with a diagonal coast. Alpha
// 255 means OCEAN, alpha 0 means LAND. It is intentionally tiny and is not a // 255 means OCEAN, alpha 0 means LAND. It is intentionally tiny and is not a
// production coastline source. // production coastline source.
@@ -205,6 +233,8 @@ public:
uint64_t gpuFieldGeneration = 0; uint64_t gpuFieldGeneration = 0;
uint64_t nextFieldGeneration = 0; uint64_t nextFieldGeneration = 0;
uint64_t gpuNextFieldGeneration = 0; uint64_t gpuNextFieldGeneration = 0;
float fieldScale = 1.0f;
float nextFieldScale = 1.0f;
uint64_t fieldUploadCount = 0; uint64_t fieldUploadCount = 0;
uint64_t resetCount = 0; uint64_t resetCount = 0;
float timeMix = 0.0f; float timeMix = 0.0f;
@@ -404,6 +434,8 @@ public:
resources->particleLength, resources->particleLength,
resources->trailOpacity, resources->trailOpacity,
resources->formalDisplay ? resources->trailFade : 0.965f}, resources->formalDisplay ? resources->trailFade : 0.965f},
.fieldBounds = fieldBounds(resources->field),
.fieldScale = {resources->fieldScale, resources->nextFieldScale, 0.0f, 0.0f},
.matrix = util::cast<float>(parameters.matrixForTile({worldWrap, {0, 0, 0}})), .matrix = util::cast<float>(parameters.matrixForTile({worldWrap, {0, 0, 0}})),
}; };
drawable.mutableUniformBuffers().createOrUpdate(shaders::idParticleGpuValidationUBO, &ubo, parameters.context); drawable.mutableUniformBuffers().createOrUpdate(shaders::idParticleGpuValidationUBO, &ubo, parameters.context);
@@ -894,9 +926,9 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
owned->nextField = owned->field; owned->nextField = owned->field;
owned->nextFieldGeneration = owned->fieldGeneration; owned->nextFieldGeneration = owned->fieldGeneration;
} }
owned->fieldImage = makeVelocityFieldImage(owned->field); owned->fieldImage = makeVelocityFieldImage(owned->field);
owned->fieldTexture = context.createTexture2D(); owned->fieldTexture = context.createTexture2D();
owned->nextFieldImage = makeVelocityFieldImage(owned->nextField); owned->nextFieldImage = makeVelocityFieldImage(owned->nextField);
owned->nextFieldTexture = context.createTexture2D(); owned->nextFieldTexture = context.createTexture2D();
if (!owned->fieldImage || !owned->fieldTexture || !owned->nextFieldImage || !owned->nextFieldTexture) { if (!owned->fieldImage || !owned->fieldTexture || !owned->nextFieldImage || !owned->nextFieldTexture) {
Log::Error(Event::Render, "ParticleGPU: field resource creation failed"); Log::Error(Event::Render, "ParticleGPU: field resource creation failed");
@@ -910,7 +942,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
" renderLayerInstanceId=" + std::to_string(owned->renderLayerInstanceId) + " renderLayerInstanceId=" + std::to_string(owned->renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(owned->generation)); " resourceGeneration=" + std::to_string(owned->generation));
owned->fieldTexture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest, owned->fieldTexture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest,
.wrapU = gfx::TextureWrapType::Repeat, .wrapU = gfx::TextureWrapType::Clamp,
.wrapV = gfx::TextureWrapType::Clamp}); .wrapV = gfx::TextureWrapType::Clamp});
owned->nextFieldTexture->setImage(owned->nextFieldImage); owned->nextFieldTexture->setImage(owned->nextFieldImage);
Log::Info(Event::Render, Log::Info(Event::Render,
@@ -920,11 +952,13 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
" renderLayerInstanceId=" + std::to_string(owned->renderLayerInstanceId) + " renderLayerInstanceId=" + std::to_string(owned->renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(owned->generation)); " resourceGeneration=" + std::to_string(owned->generation));
owned->nextFieldTexture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest, owned->nextFieldTexture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest,
.wrapU = gfx::TextureWrapType::Repeat, .wrapU = gfx::TextureWrapType::Clamp,
.wrapV = gfx::TextureWrapType::Clamp}); .wrapV = gfx::TextureWrapType::Clamp});
owned->gpuFieldGeneration = owned->fieldGeneration; owned->gpuFieldGeneration = owned->fieldGeneration;
owned->gpuNextFieldGeneration = owned->nextFieldGeneration; owned->gpuNextFieldGeneration = owned->nextFieldGeneration;
owned->fieldUploadCount = 2; owned->fieldUploadCount = 2;
owned->fieldScale = fieldVelocityScale(owned->field);
owned->nextFieldScale = fieldVelocityScale(owned->nextField);
owned->fixtureMode = fixtureMode; owned->fixtureMode = fixtureMode;
} catch (const std::exception& error) { } catch (const std::exception& error) {
Log::Error(Event::Render, "ParticleField: fixture load failed: " + std::string(error.what())); Log::Error(Event::Render, "ParticleField: fixture load failed: " + std::string(error.what()));
@@ -1053,14 +1087,16 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
const bool windowOverlap = reuseOldT0 || reuseOldT1 || reuseNewT0 || reuseNewT1; const bool windowOverlap = reuseOldT0 || reuseOldT1 || reuseNewT0 || reuseNewT1;
const auto upload = [&](const particle::ParticleFieldSnapshot& field, const auto upload = [&](const particle::ParticleFieldSnapshot& field,
std::shared_ptr<PremultipliedImage>& image, std::shared_ptr<PremultipliedImage>& image,
gfx::Texture2DPtr& texture) { gfx::Texture2DPtr& texture,
float& scale) {
image = makeVelocityFieldImage(field); image = makeVelocityFieldImage(field);
texture = context.createTexture2D(); texture = context.createTexture2D();
if (!image || !texture) return false; if (!image || !texture) return false;
texture->setImage(image); texture->setImage(image);
texture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest, texture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest,
.wrapU = gfx::TextureWrapType::Repeat, .wrapU = gfx::TextureWrapType::Clamp,
.wrapV = gfx::TextureWrapType::Clamp}); .wrapV = gfx::TextureWrapType::Clamp});
scale = fieldVelocityScale(field);
++resources->fieldUploadCount; ++resources->fieldUploadCount;
Log::Info(Event::Render, Log::Info(Event::Render,
"ParticleGPU: event=FIELD_UPLOAD reason=NEW_CONTENT fieldUploadCount=" + "ParticleGPU: event=FIELD_UPLOAD reason=NEW_CONTENT fieldUploadCount=" +
@@ -1074,11 +1110,13 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
const char* destination, const char* destination,
particle::ParticleFieldSnapshot& assigned, particle::ParticleFieldSnapshot& assigned,
std::shared_ptr<PremultipliedImage>& image, std::shared_ptr<PremultipliedImage>& image,
gfx::Texture2DPtr& texture) { gfx::Texture2DPtr& texture,
float& scale) {
if (particle::sameFieldGpuContent(oldT0, wanted)) { if (particle::sameFieldGpuContent(oldT0, wanted)) {
assigned = wanted; assigned = wanted;
image = resources->fieldImage; image = resources->fieldImage;
texture = resources->fieldTexture; texture = resources->fieldTexture;
scale = resources->fieldScale;
Log::Info(Event::Render, Log::Info(Event::Render,
"ParticleGPU: event=FIELD_REUSE from=OLD_T0 to=" + std::string(destination) + "ParticleGPU: event=FIELD_REUSE from=OLD_T0 to=" + std::string(destination) +
" oldGeneration=" + std::to_string(oldT0.generation) + " oldGeneration=" + std::to_string(oldT0.generation) +
@@ -1091,6 +1129,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
assigned = wanted; assigned = wanted;
image = resources->nextFieldImage; image = resources->nextFieldImage;
texture = resources->nextFieldTexture; texture = resources->nextFieldTexture;
scale = resources->nextFieldScale;
Log::Info(Event::Render, Log::Info(Event::Render,
"ParticleGPU: event=FIELD_REUSE from=OLD_T1 to=" + std::string(destination) + "ParticleGPU: event=FIELD_REUSE from=OLD_T1 to=" + std::string(destination) +
" oldGeneration=" + std::to_string(oldT1.generation) + " oldGeneration=" + std::to_string(oldT1.generation) +
@@ -1100,7 +1139,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
return true; return true;
} }
assigned = wanted; assigned = wanted;
return upload(assigned, image, texture); return upload(assigned, image, texture, scale);
}; };
particle::ParticleFieldSnapshot assignedT0; particle::ParticleFieldSnapshot assignedT0;
particle::ParticleFieldSnapshot assignedT1; particle::ParticleFieldSnapshot assignedT1;
@@ -1108,8 +1147,10 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
std::shared_ptr<PremultipliedImage> imageT1; std::shared_ptr<PremultipliedImage> imageT1;
gfx::Texture2DPtr textureT0; gfx::Texture2DPtr textureT0;
gfx::Texture2DPtr textureT1; gfx::Texture2DPtr textureT1;
if (!takeSlot(nextT0, "NEW_T0", assignedT0, imageT0, textureT0) || float scaleT0 = 1.0f;
!takeSlot(nextT1, "NEW_T1", assignedT1, imageT1, textureT1)) { float scaleT1 = 1.0f;
if (!takeSlot(nextT0, "NEW_T0", assignedT0, imageT0, textureT0, scaleT0) ||
!takeSlot(nextT1, "NEW_T1", assignedT1, imageT1, textureT1, scaleT1)) {
Log::Error(Event::Render, "ParticleGPU: field switch resource creation failed"); Log::Error(Event::Render, "ParticleGPU: field switch resource creation failed");
return; return;
} }
@@ -1119,6 +1160,8 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
resources->nextFieldImage = std::move(imageT1); resources->nextFieldImage = std::move(imageT1);
resources->fieldTexture = std::move(textureT0); resources->fieldTexture = std::move(textureT0);
resources->nextFieldTexture = std::move(textureT1); resources->nextFieldTexture = std::move(textureT1);
resources->fieldScale = scaleT0;
resources->nextFieldScale = scaleT1;
resources->fieldGeneration = resources->field.generation; resources->fieldGeneration = resources->field.generation;
resources->nextFieldGeneration = resources->nextField.generation; resources->nextFieldGeneration = resources->nextField.generation;
resources->gpuFieldGeneration = resources->fieldGeneration; resources->gpuFieldGeneration = resources->fieldGeneration;

View File

@@ -0,0 +1,191 @@
#include <mbgl/renderer/layers/render_weather_color_layer.hpp>
#include <mbgl/gfx/color_mode.hpp>
#include <mbgl/gfx/context.hpp>
#include <mbgl/gfx/cull_face_mode.hpp>
#include <mbgl/gfx/drawable_builder.hpp>
#include <mbgl/gfx/drawable_tweaker.hpp>
#include <mbgl/gfx/shader_registry.hpp>
#include <mbgl/gfx/texture2d.hpp>
#include <mbgl/gfx/vertex_attribute.hpp>
#include <mbgl/renderer/buckets/heatmap_bucket.hpp>
#include <mbgl/renderer/layer_group.hpp>
#include <mbgl/renderer/render_static_data.hpp>
#include <mbgl/renderer/sources/render_raster_array_source.hpp>
#include <mbgl/shaders/particle_gpu_validation_ubo.hpp>
#include <mbgl/style/layers/weather_color_layer_impl.hpp>
#include <mbgl/util/image.hpp>
#include <mbgl/util/logging.hpp>
#include <mbgl/util/mat4.hpp>
#include <algorithm>
#include <cmath>
namespace mbgl {
namespace {
constexpr auto ShaderName = "ParticleGpuValidationShader";
double mercatorY(const double latitude) {
const auto radians = latitude * M_PI / 180.0;
return (1.0 - std::log(std::tan(radians) + 1.0 / std::cos(radians)) / M_PI) * 0.5;
}
std::array<float, 4> bounds(const particle::ParticleFieldSnapshot& field) {
return {static_cast<float>((field.west + 180.0) / 360.0), static_cast<float>(mercatorY(field.north)),
static_cast<float>((field.east + 180.0) / 360.0), static_cast<float>(mercatorY(field.south))};
}
float scale(const particle::ParticleFieldSnapshot& field) {
float result = 1.0f;
for (size_t index = 0; index < field.valid.size(); ++index) if (field.valid[index]) {
result = std::max(result, std::abs(field.velocity[index * 2]));
result = std::max(result, std::abs(field.velocity[index * 2 + 1]));
}
return result;
}
std::shared_ptr<PremultipliedImage> image(const particle::ParticleFieldSnapshot& field, const float fieldScale) {
auto result = std::make_shared<PremultipliedImage>(Size{field.width, field.height});
for (uint32_t y = 0; y < field.height; ++y) for (uint32_t x = 0; x < field.width; ++x) {
const auto cell = (static_cast<size_t>(y) * field.width + x) * 2;
const auto pixel = (static_cast<size_t>(y) * field.width + x) * PremultipliedImage::channels;
result->data[pixel] = static_cast<uint8_t>(std::clamp(field.velocity[cell] / fieldScale * .5f + .5f, 0.0f, 1.0f) * 255.0f);
result->data[pixel + 1] = static_cast<uint8_t>(std::clamp(field.velocity[cell + 1] / fieldScale * .5f + .5f, 0.0f, 1.0f) * 255.0f);
result->data[pixel + 2] = 0;
result->data[pixel + 3] = field.valid[y * field.width + x] ? 255 : 0;
}
return result;
}
struct Resources {
gfx::ShaderProgramBasePtr shader;
std::shared_ptr<gfx::VertexVector<HeatmapTextureLayoutVertex>> vertices;
SegmentVector segments;
particle::ParticleFieldSnapshot t0;
particle::ParticleFieldSnapshot t1;
std::shared_ptr<PremultipliedImage> image0;
std::shared_ptr<PremultipliedImage> image1;
gfx::Texture2DPtr texture0;
gfx::Texture2DPtr texture1;
float scale0 = 1.0f;
float scale1 = 1.0f;
};
class Tweaker final : public gfx::DrawableTweaker {
public:
Tweaker(std::shared_ptr<Resources> resources_, float mix_, float opacity_, int16_t wrap_)
: resources(std::move(resources_)), mix(mix_), opacity(opacity_), wrap(wrap_) {}
void init(gfx::Drawable&) override {}
void execute(gfx::Drawable& drawable, PaintParameters& parameters) override {
drawable.setTexture(resources->texture0, shaders::idParticleGpuValidationPreviousTexture);
drawable.setTexture(resources->texture0, shaders::idParticleGpuValidationFieldTexture);
drawable.setTexture(resources->texture1, shaders::idParticleGpuValidationNextFieldTexture);
const shaders::ParticleGpuValidationUBO ubo = {
.pass = {5.0f, 0.0f, 0.0f, 0.0f}, .marker = {mix, 0.0f, 0.0f, 0.0f}, .mask = {0, 0, 0, 0},
.visual = {opacity, 0, 0, 0}, .fieldBounds = bounds(resources->t0),
.fieldScale = {resources->scale0, resources->scale1, 0, 0},
.matrix = util::cast<float>(parameters.matrixForTile({wrap, {0, 0, 0}})),
};
drawable.mutableUniformBuffers().createOrUpdate(shaders::idParticleGpuValidationUBO, &ubo, parameters.context);
}
private:
std::shared_ptr<Resources> resources;
float mix;
float opacity;
int16_t wrap;
};
bool same(const particle::ParticleFieldSnapshot& a, const particle::ParticleFieldSnapshot& b) {
return particle::sameFieldGpuContent(a, b);
}
bool uploadField(gfx::Context& context, const particle::ParticleFieldSnapshot& field,
std::shared_ptr<PremultipliedImage>& outputImage, gfx::Texture2DPtr& outputTexture, float& outputScale) {
outputScale = scale(field);
outputImage = image(field, outputScale);
outputTexture = context.createTexture2D();
if (!outputTexture) return false;
outputTexture->setImage(outputImage);
outputTexture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Linear,
.wrapU = gfx::TextureWrapType::Clamp,
.wrapV = gfx::TextureWrapType::Clamp});
return true;
}
} // namespace
RenderWeatherColorLayer::RenderWeatherColorLayer(Immutable<style::Layer::Impl> impl_)
: RenderLayer(makeMutable<style::WeatherColorLayerProperties>(
impl_, static_cast<const style::WeatherColorLayer::Impl&>(*impl_).opacity)) {}
RenderWeatherColorLayer::~RenderWeatherColorLayer() = default;
void RenderWeatherColorLayer::evaluate(const PropertyEvaluationParameters&) {
auto properties = makeMutable<style::WeatherColorLayerProperties>(
baseImpl, static_cast<const style::WeatherColorLayer::Impl&>(*baseImpl).opacity);
passes = RenderPass::Translucent;
properties->renderPasses = mbgl::underlying_type(passes);
evaluatedProperties = std::move(properties);
}
bool RenderWeatherColorLayer::needsRepaint() const { return isLayerRenderable(); }
void RenderWeatherColorLayer::markLayerRenderable(bool willRender, UniqueChangeRequestVec& changes) { RenderLayer::markLayerRenderable(willRender, changes); }
void RenderWeatherColorLayer::layerRemoved(UniqueChangeRequestVec& changes) { resources.reset(); RenderLayer::layerRemoved(changes); }
void RenderWeatherColorLayer::markContextDestroyed() { resources.reset(); }
void RenderWeatherColorLayer::prepare(const LayerPrepareParameters& parameters) {
if (!parameters.source || parameters.source->baseImpl->type != style::SourceType::RasterArray) return;
const auto& source = static_cast<const RenderRasterArraySource&>(*parameters.source);
sourceTimeline = source.impl().timeline;
if (sourceTimeline) {
const auto frame = sourceTimeline->frame(); sourceField = *frame.t0; sourceNextField = *frame.t1;
} else if (source.impl().field) {
sourceField = *source.impl().field; sourceNextField = source.impl().nextField ? *source.impl().nextField : *source.impl().field;
}
}
void RenderWeatherColorLayer::update(gfx::ShaderRegistry& shaders, gfx::Context& context, const TransformState& transformState,
const std::shared_ptr<UpdateParameters>&, const RenderTree&, UniqueChangeRequestVec& changes) {
if (!layerGroup) if (auto group = context.createLayerGroup(layerIndex, 1, getID())) setLayerGroup(std::move(group), changes);
if (!layerGroup || !isLayerRenderable() || !sourceField || !sourceNextField) return;
if (sourceTimeline) { const auto frame = sourceTimeline->frame(); sourceField = *frame.t0; sourceNextField = *frame.t1; }
auto state = std::static_pointer_cast<Resources>(resources);
if (!state) {
state = std::make_shared<Resources>(); state->shader = context.getGenericShader(shaders, ShaderName);
state->vertices = std::make_shared<gfx::VertexVector<HeatmapTextureLayoutVertex>>(RenderStaticData::heatmapTextureVertices());
state->segments = RenderStaticData::heatmapTextureSegments();
if (!state->shader || !uploadField(context, *sourceField, state->image0, state->texture0, state->scale0) ||
!uploadField(context, *sourceNextField, state->image1, state->texture1, state->scale1)) return;
state->t0 = *sourceField; state->t1 = *sourceNextField; resources = state;
Log::Info(Event::Render, "WeatherColorLayer: event=WEATHER_COLOR_FIELD_READY T0=" + state->t0.fieldID +
" T1=" + state->t1.fieldID + " scale=0..30mps");
} else if (!same(state->t0, *sourceField) || !same(state->t1, *sourceNextField)) {
const auto old0 = state->t0; const auto old1 = state->t1;
auto install = [&](const particle::ParticleFieldSnapshot& wanted, particle::ParticleFieldSnapshot& assigned,
std::shared_ptr<PremultipliedImage>& outputImage, gfx::Texture2DPtr& outputTexture, float& outputScale) {
if (same(old0, wanted)) { assigned = wanted; return true; }
if (same(old1, wanted)) { assigned = wanted; outputImage = state->image1; outputTexture = state->texture1; outputScale = state->scale1; return true; }
assigned = wanted; return uploadField(context, assigned, outputImage, outputTexture, outputScale);
};
particle::ParticleFieldSnapshot t0, t1; std::shared_ptr<PremultipliedImage> i0, i1; gfx::Texture2DPtr x0, x1; float s0 = 1, s1 = 1;
if (!install(*sourceField, t0, i0, x0, s0) || !install(*sourceNextField, t1, i1, x1, s1)) return;
state->t0 = std::move(t0); state->t1 = std::move(t1); state->image0 = std::move(i0); state->image1 = std::move(i1);
state->texture0 = std::move(x0); state->texture1 = std::move(x1); state->scale0 = s0; state->scale1 = s1;
Log::Info(Event::Render, "WeatherColorLayer: event=WEATHER_COLOR_FIELD_READY T0=" + state->t0.fieldID + " T1=" + state->t1.fieldID);
}
const float mix = sourceTimeline ? sourceTimeline->frame().mix : 0.0f;
const float opacity = static_cast<const style::WeatherColorLayer::Impl&>(*baseImpl).opacity;
auto& group = static_cast<LayerGroup&>(*layerGroup); group.clearDrawables();
const auto center = transformState.getLatLng(LatLng::Unwrapped);
const auto centerWrap = static_cast<int16_t>(std::floor((center.longitude() + 180.0) / 360.0));
for (int16_t wrap = centerWrap - 1; wrap <= centerWrap + 1; ++wrap) {
auto attributes = context.createVertexAttributeArray();
if (const auto& attribute = attributes->set(shaders::idParticleGpuValidationPosVertexAttribute))
attribute->setSharedRawData(state->vertices, offsetof(HeatmapTextureLayoutVertex, a1), 0, sizeof(HeatmapTextureLayoutVertex), gfx::AttributeDataType::Short2);
auto builder = context.createDrawableBuilder(getID() + "-weather-color-" + std::to_string(wrap));
builder->setShader(state->shader); builder->setRenderPass(RenderPass::Translucent); builder->setEnableDepth(false);
builder->setColorMode(gfx::ColorMode::alphaBlended()); builder->setCullFaceMode(gfx::CullFaceMode::disabled());
builder->setVertexAttributes(std::move(attributes)); builder->setRawVertices({}, state->vertices->elements(), gfx::AttributeDataType::Short2);
builder->setSegments(gfx::Triangles(), RenderStaticData::quadTriangleIndices().vector(), state->segments.data(), state->segments.size());
builder->setTexture(state->texture0, shaders::idParticleGpuValidationPreviousTexture);
builder->setTexture(state->texture0, shaders::idParticleGpuValidationFieldTexture);
builder->setTexture(state->texture1, shaders::idParticleGpuValidationNextFieldTexture);
builder->addTweaker(std::make_shared<Tweaker>(state, mix, opacity, wrap)); builder->flush(context);
auto drawables = builder->clearDrawables(); if (drawables.size() == 1 && drawables.front()) group.addDrawable(std::move(drawables.front()));
}
}
} // namespace mbgl

View File

@@ -0,0 +1,32 @@
#pragma once
#include <mbgl/renderer/render_layer.hpp>
#include <mbgl/style/sources/raster_array_source.hpp>
namespace mbgl {
class RenderWeatherColorLayer final : public RenderLayer {
public:
explicit RenderWeatherColorLayer(Immutable<style::Layer::Impl>);
~RenderWeatherColorLayer() override;
void update(gfx::ShaderRegistry&, gfx::Context&, const TransformState&, const std::shared_ptr<UpdateParameters>&,
const RenderTree&, UniqueChangeRequestVec&) override;
private:
void transition(const TransitionParameters&) override {}
void evaluate(const PropertyEvaluationParameters&) override;
bool hasTransition() const override { return false; }
bool hasCrossfade() const override { return false; }
bool needsRepaint() const override;
void prepare(const LayerPrepareParameters&) override;
void markLayerRenderable(bool, UniqueChangeRequestVec&) override;
void layerRemoved(UniqueChangeRequestVec&) override;
void markContextDestroyed() override;
std::shared_ptr<void> resources;
std::optional<particle::ParticleFieldSnapshot> sourceField;
std::optional<particle::ParticleFieldSnapshot> sourceNextField;
std::shared_ptr<style::RasterArrayTimelineRuntime> sourceTimeline;
};
} // namespace mbgl

View File

@@ -9,8 +9,8 @@ namespace mbgl::style {
class RasterParticleLayer::Impl final : public Layer::Impl { class RasterParticleLayer::Impl final : public Layer::Impl {
public: public:
Impl(const std::string& id, const std::string& source) Impl(const std::string& layerID, const std::string& sourceID)
: Layer::Impl(id, source) {} : Layer::Impl(layerID, sourceID) {}
Impl(const Impl&) = default; Impl(const Impl&) = default;
bool hasLayoutDifference(const Layer::Impl&) const override { return false; } bool hasLayoutDifference(const Layer::Impl&) const override { return false; }
void stringifyLayout(rapidjson::Writer<rapidjson::StringBuffer>&) const override {} void stringifyLayout(rapidjson::Writer<rapidjson::StringBuffer>&) const override {}

View File

@@ -0,0 +1,61 @@
#include <mbgl/style/layers/weather_color_layer.hpp>
#include <mbgl/style/conversion/constant.hpp>
#include <mbgl/style/conversion_impl.hpp>
#include <mbgl/style/layer_observer.hpp>
#include <mbgl/style/layers/weather_color_layer_impl.hpp>
#include <algorithm>
#include <cmath>
namespace mbgl::style {
namespace {
constexpr const char* OpacityProperty = "weather-color-opacity";
const LayerTypeInfo typeInfo{.type = "weather-color",
.source = LayerTypeInfo::Source::Required,
.pass3d = LayerTypeInfo::Pass3D::NotRequired,
.layout = LayerTypeInfo::Layout::NotRequired,
.fadingTiles = LayerTypeInfo::FadingTiles::NotRequired,
.crossTileIndex = LayerTypeInfo::CrossTileIndex::NotRequired,
.tileKind = LayerTypeInfo::TileKind::NotRequired};
} // namespace
const LayerTypeInfo* WeatherColorLayer::Impl::staticTypeInfo() noexcept { return &typeInfo; }
WeatherColorLayer::WeatherColorLayer(const std::string& id, const std::string& source)
: Layer(makeMutable<Impl>(id, source)) {}
WeatherColorLayer::WeatherColorLayer(Immutable<Impl> impl_) : Layer(std::move(impl_)) {}
WeatherColorLayer::~WeatherColorLayer() { weakFactory.invalidateWeakPtrs(); }
const WeatherColorLayer::Impl& WeatherColorLayer::impl() const { return static_cast<const Impl&>(*baseImpl); }
Mutable<WeatherColorLayer::Impl> WeatherColorLayer::mutableImpl() const { return makeMutable<Impl>(impl()); }
float WeatherColorLayer::getOpacity() const { return impl().opacity; }
void WeatherColorLayer::setOpacity(float value) {
const auto clamped = std::clamp(value, 0.0f, 1.0f);
if (clamped == getOpacity()) return;
auto next = mutableImpl();
next->opacity = clamped;
baseImpl = std::move(next);
observer->onLayerChanged(*this);
}
std::optional<conversion::Error> WeatherColorLayer::setPropertyInternal(const std::string& name,
const conversion::Convertible& value) {
if (name != OpacityProperty) return conversion::Error{"unknown weather-color property: " + name};
conversion::Error error;
auto converted = conversion::convert<float>(value, error);
if (!converted || !std::isfinite(*converted)) return conversion::Error{"weather-color-opacity must be finite"};
setOpacity(*converted);
return std::nullopt;
}
StyleProperty WeatherColorLayer::getProperty(const std::string& name) const {
return name == OpacityProperty ? StyleProperty(Value{getOpacity()}, StyleProperty::Kind::Constant) : StyleProperty{};
}
Value WeatherColorLayer::serialize() const {
auto result = Layer::serialize();
serializeProperty(result, getProperty(OpacityProperty), OpacityProperty, true);
return result;
}
std::unique_ptr<Layer> WeatherColorLayer::cloneRef(const std::string& id) const {
auto next = mutableImpl(); next->id = id; return std::unique_ptr<Layer>(new WeatherColorLayer(std::move(next)));
}
Mutable<Layer::Impl> WeatherColorLayer::mutableBaseImpl() const { return staticMutableCast<Layer::Impl>(mutableImpl()); }
} // namespace mbgl::style

View File

@@ -0,0 +1,27 @@
#pragma once
#include <mbgl/style/layer_impl.hpp>
#include <mbgl/style/layer_properties.hpp>
#include <mbgl/style/layers/weather_color_layer.hpp>
namespace mbgl::style {
class WeatherColorLayer::Impl final : public Layer::Impl {
public:
Impl(const std::string& layerID, const std::string& sourceID) : Layer::Impl(layerID, sourceID) {}
Impl(const Impl&) = default;
bool hasLayoutDifference(const Layer::Impl&) const override { return false; }
void stringifyLayout(rapidjson::Writer<rapidjson::StringBuffer>&) const override {}
float opacity = 0.62f;
DECLARE_LAYER_TYPE_INFO;
};
class WeatherColorLayerProperties final : public LayerProperties {
public:
WeatherColorLayerProperties(Immutable<Layer::Impl> impl, float opacity_)
: LayerProperties(std::move(impl)), opacity(opacity_) {}
expression::Dependency getDependencies() const noexcept override { return expression::Dependency::None; }
float opacity;
};
} // namespace mbgl::style

View File

@@ -219,7 +219,7 @@ RasterArraySource::RasterArraySource(std::string id,
std::string nextFixtureID, std::string nextFixtureID,
std::string nextMetadataURL, std::string nextMetadataURL,
std::string nextFieldURL, std::string nextFieldURL,
std::vector<RasterArrayRuntimeFieldDescriptor> runtimeFields) std::vector<RasterArrayRuntimeFieldDescriptor> runtimeFieldDescriptors)
: Source(makeMutable<Impl>(std::move(id), : Source(makeMutable<Impl>(std::move(id),
std::move(fixtureID), std::move(fixtureID),
std::move(metadataURL), std::move(metadataURL),
@@ -227,10 +227,10 @@ RasterArraySource::RasterArraySource(std::string id,
std::move(nextFixtureID), std::move(nextFixtureID),
std::move(nextMetadataURL), std::move(nextMetadataURL),
std::move(nextFieldURL), std::move(nextFieldURL),
std::move(runtimeFields))) {} std::move(runtimeFieldDescriptors))) {}
RasterArraySource::RasterArraySource(std::string id, std::vector<RasterArrayLocalFieldDescriptor> localFields) RasterArraySource::RasterArraySource(std::string id, std::vector<RasterArrayLocalFieldDescriptor> localFieldDescriptors)
: Source(makeMutable<Impl>(std::move(id), std::move(localFields))) {} : Source(makeMutable<Impl>(std::move(id), std::move(localFieldDescriptors))) {}
RasterArraySource::RasterArraySource(std::string id) RasterArraySource::RasterArraySource(std::string id)
: Source(makeMutable<Impl>(std::move(id))) {} : Source(makeMutable<Impl>(std::move(id))) {}
@@ -786,7 +786,12 @@ bool RasterArraySource::controlRuntimeTimeline(const int command, const float va
} }
bool RasterArraySource::supportsLayerType(const LayerTypeInfo* info) const { bool RasterArraySource::supportsLayerType(const LayerTypeInfo* info) const {
return info && std::string(info->type) == "raster-particle"; if (!info) return false;
const std::string type{info->type};
// Both displays consume the exact same canonical Source-owned snapshots.
// WeatherColorLayer is intentionally a separate renderer, not a second
// source, cache, timeline or field normalizer.
return type == "raster-particle" || type == "weather-color";
} }
Mutable<Source::Impl> RasterArraySource::createMutable() const noexcept { Mutable<Source::Impl> RasterArraySource::createMutable() const noexcept {

View File

@@ -8,7 +8,7 @@ namespace mbgl::style {
class RasterArraySource::Impl final : public Source::Impl { class RasterArraySource::Impl final : public Source::Impl {
public: public:
Impl(std::string id, Impl(std::string sourceID,
std::string fixtureID_, std::string fixtureID_,
std::string metadataURL_, std::string metadataURL_,
std::string fieldURL_, std::string fieldURL_,
@@ -16,7 +16,7 @@ public:
std::string nextMetadataURL_ = {}, std::string nextMetadataURL_ = {},
std::string nextFieldURL_ = {}, std::string nextFieldURL_ = {},
std::vector<RasterArrayRuntimeFieldDescriptor> runtimeFields_ = {}) std::vector<RasterArrayRuntimeFieldDescriptor> runtimeFields_ = {})
: Source::Impl(SourceType::RasterArray, std::move(id)), : Source::Impl(SourceType::RasterArray, std::move(sourceID)),
fixtureID(std::move(fixtureID_)), fixtureID(std::move(fixtureID_)),
metadataURL(std::move(metadataURL_)), metadataURL(std::move(metadataURL_)),
fieldURL(std::move(fieldURL_)), fieldURL(std::move(fieldURL_)),
@@ -24,10 +24,10 @@ public:
nextMetadataURL(std::move(nextMetadataURL_)), nextMetadataURL(std::move(nextMetadataURL_)),
nextFieldURL(std::move(nextFieldURL_)), nextFieldURL(std::move(nextFieldURL_)),
runtimeFields(std::move(runtimeFields_)) {} runtimeFields(std::move(runtimeFields_)) {}
Impl(std::string id, std::vector<RasterArrayLocalFieldDescriptor> localFields_) Impl(std::string sourceID, std::vector<RasterArrayLocalFieldDescriptor> localFields_)
: Source::Impl(SourceType::RasterArray, std::move(id)), localFields(std::move(localFields_)) {} : Source::Impl(SourceType::RasterArray, std::move(sourceID)), localFields(std::move(localFields_)) {}
explicit Impl(std::string id) explicit Impl(std::string sourceID)
: Source::Impl(SourceType::RasterArray, std::move(id)), : Source::Impl(SourceType::RasterArray, std::move(sourceID)),
hostFieldSubmission(true) {} hostFieldSubmission(true) {}
Impl(const Impl&) = default; Impl(const Impl&) = default;
Impl(const Impl& other, particle::ParticleFieldSnapshot field_) Impl(const Impl& other, particle::ParticleFieldSnapshot field_)

View File

@@ -12,6 +12,8 @@
#include <mbgl/style/layers/line_layer_impl.hpp> #include <mbgl/style/layers/line_layer_impl.hpp>
#include <mbgl/style/layers/raster_layer.hpp> #include <mbgl/style/layers/raster_layer.hpp>
#include <mbgl/style/layers/raster_layer_impl.hpp> #include <mbgl/style/layers/raster_layer_impl.hpp>
#include <mbgl/style/layers/raster_particle_layer_impl.hpp>
#include <mbgl/style/layers/weather_color_layer_impl.hpp>
#include <mbgl/style/source_impl.hpp> #include <mbgl/style/source_impl.hpp>
#include <mbgl/style/sources/custom_geometry_source.hpp> #include <mbgl/style/sources/custom_geometry_source.hpp>
#include <mbgl/style/sources/geojson_source.hpp> #include <mbgl/style/sources/geojson_source.hpp>
@@ -175,6 +177,12 @@ TEST(Source, RasterArrayRenderItem) {
EXPECT_FALSE(item->hasRenderPass(RenderPass::Translucent)); EXPECT_FALSE(item->hasRenderPass(RenderPass::Translucent));
} }
TEST(Source, RasterArraySharesCanonicalSnapshotsWithWeatherColorLayer) {
style::RasterArraySource source{"source", "fixture", "metadata", "field"};
EXPECT_TRUE(source.supportsLayerType(style::RasterParticleLayer::Impl::staticTypeInfo()));
EXPECT_TRUE(source.supportsLayerType(style::WeatherColorLayer::Impl::staticTypeInfo()));
}
TEST(Source, RasterArraySnapshotsUseProcessWideGenerations) { TEST(Source, RasterArraySnapshotsUseProcessWideGenerations) {
SourceTest test; SourceTest test;
const auto eastMetadata = fixtureBytes("uniform-east", "metadata.json"); const auto eastMetadata = fixtureBytes("uniform-east", "metadata.json");

View File

@@ -17,6 +17,7 @@
#include <mbgl/style/layers/raster_layer.hpp> #include <mbgl/style/layers/raster_layer.hpp>
#include <mbgl/style/layers/raster_layer_impl.hpp> #include <mbgl/style/layers/raster_layer_impl.hpp>
#include <mbgl/style/layers/raster_particle_layer.hpp> #include <mbgl/style/layers/raster_particle_layer.hpp>
#include <mbgl/style/layers/weather_color_layer.hpp>
#include <mbgl/style/layers/symbol_layer.hpp> #include <mbgl/style/layers/symbol_layer.hpp>
#include <mbgl/style/layers/symbol_layer_impl.hpp> #include <mbgl/style/layers/symbol_layer_impl.hpp>
#include <mbgl/style/style_impl.hpp> #include <mbgl/style/style_impl.hpp>
@@ -162,6 +163,16 @@ TEST(Layer, RasterParticleVisualProperties) {
EXPECT_FLOAT_EQ(0.995f, layer.getTrailFade()); EXPECT_FLOAT_EQ(0.995f, layer.getTrailFade());
} }
TEST(Layer, WeatherColorUsesIndependentFixedOpacity) {
WeatherColorLayer layer{"weather-color", "raster-array"};
ASSERT_STREQ("weather-color", layer.getTypeInfo()->type);
EXPECT_FLOAT_EQ(0.62f, layer.getOpacity());
layer.setOpacity(0.35f);
EXPECT_FLOAT_EQ(0.35f, layer.getOpacity());
layer.setOpacity(4.0f);
EXPECT_FLOAT_EQ(1.0f, layer.getOpacity());
}
TEST(Layer, FillProperties) { TEST(Layer, FillProperties) {
auto layer = std::make_unique<FillLayer>("fill", "source"); auto layer = std::make_unique<FillLayer>("fill", "source");
ASSERT_STREQ("fill", layer->getTypeInfo()->type); ASSERT_STREQ("fill", layer->getTypeInfo()->type);

View File

@@ -404,3 +404,9 @@
- 只读核对 WeatherV2 `bin_export.py`、`decode_wind()`、QBIN/NSFT export 后,确认 `wind_vectors.bin` 为完整拼接 `WVECBIN1` grid:row-major U/V/speed float32-le planes + valid uint8 plane,U=eastward、V=northward,单位 m/s;QBIN/NSFT 均由同一 decoded U/V Field 生成。 - 只读核对 WeatherV2 `bin_export.py`、`decode_wind()`、QBIN/NSFT export 后,确认 `wind_vectors.bin` 为完整拼接 `WVECBIN1` grid:row-major U/V/speed float32-le planes + valid uint8 plane,U=eastward、V=northward,单位 m/s;QBIN/NSFT 均由同一 decoded U/V Field 生成。
- 从 run `2026080600` 生成真实 ECMWF HRES `2026080700/06/12/18` 四帧。原始日本及周边 bounds 为 112.5/0.0/157.5/55.776573,`513×769`,不裁剪、不重采样;输出 canonical interleaved U/V Android assets,每帧 3,155,976 bytes。 - 从 run `2026080600` 生成真实 ECMWF HRES `2026080700/06/12/18` 四帧。原始日本及周边 bounds 为 112.5/0.0/157.5/55.776573,`513×769`,不裁剪、不重采样;输出 canonical interleaved U/V Android assets,每帧 3,155,976 bytes。
- 转换器独立放在 `tools/weatherv2_wind_canonical/`,保留 valid/noData:invalid 写既有 -9999 sentinel,不伪造为 0 风;四帧 hash 不同,重复 T0 hash 相同,source→canonical 抽样误差 0。未改 Core/Renderer/Android,也未执行 Android/Jest;等待下一阶段集中 Android 集成验收。 - 转换器独立放在 `tools/weatherv2_wind_canonical/`,保留 valid/noData:invalid 写既有 -9999 sentinel,不伪造为 0 风;四帧 hash 不同,重复 T0 hash 相同,source→canonical 抽样误差 0。未改 Core/Renderer/Android,也未执行 Android/Jest;等待下一阶段集中 Android 集成验收。
# STEP-055 — Android 真实 WeatherV2 风场色场/粒子综合可视化(IMPLEMENTED / WAITING_ANDROID_VALIDATION)
- 复用 STEP-054 的真实 ECMWF HRES WeatherV2 canonical T0–T3(513×769、原始 validTime、U eastward/V northward、m/s),未重生成或重采样。
- 新增独立 `weather-color` 层:同一 canonical T0/T1 的 U/V 先插值再计算风速,固定 0–30 m/s 色标;与 RasterParticleLayer 保持分离。
- `MORE → REAL WEATHER` 通过既有 Host Submission Contract 一键提交真实字段,使用 OSM 无 Key 底图、自动 fit metadata bounds,并恢复旋转/Activity 重建后的可见内容。
- Android 综合验收待用户手工完成;详见 STEP-055 人工验收指令。