From a81b464d9b6381ae5b2fb953358d25c20d62bb78 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 7 Sep 2026 10:43:11 +0800 Subject: [PATCH] feat: add Android host weather runtime facade --- .../shaders/gl/particle_gpu_validation.hpp | 45 +++++++-- .../shaders/particle_gpu_validation_ubo.hpp | 8 +- .../style/layers/raster_particle_layer.hpp | 5 + .../mbgl/style/layers/weather_color_layer.hpp | 3 + .../mbgl/style/layers/weather_color_stop.hpp | 28 ++++++ .../style/sources/raster_array_source.hpp | 6 ++ .../src/cpp/native_map_view.cpp | 69 ++++++++++++++ .../src/cpp/native_map_view.hpp | 5 + .../maplibre/android/maps/MapLibreMap.java | 25 +++++ .../org/maplibre/android/maps/NativeMap.java | 10 ++ .../maplibre/android/maps/NativeMapView.java | 34 +++++++ .../maplibre/android/maps/WeatherRuntime.java | 92 +++++++++++++++++++ .../ParticleCoreLayerValidationActivity.kt | 37 +++++--- shaders/particle_gpu_validation.fragment.glsl | 40 ++++++-- shaders/particle_gpu_validation.vertex.glsl | 5 + .../render_particle_validation_layer.cpp | 49 ++++++++-- .../layers/render_weather_color_layer.cpp | 35 +++++-- src/mbgl/renderer/render_orchestrator.cpp | 21 +++++ .../particle_render_layer_properties.hpp | 10 +- .../style/layers/raster_particle_layer.cpp | 13 +++ .../layers/raster_particle_layer_impl.hpp | 3 + src/mbgl/style/layers/weather_color_layer.cpp | 11 +++ .../style/layers/weather_color_layer_impl.hpp | 2 + .../style/sources/raster_array_source.cpp | 64 +++++++++++++ test/style/source.test.cpp | 22 +++++ test/style/style_layer.test.cpp | 18 ++++ 26 files changed, 614 insertions(+), 46 deletions(-) create mode 100644 include/mbgl/style/layers/weather_color_stop.hpp create mode 100644 platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java diff --git a/include/mbgl/shaders/gl/particle_gpu_validation.hpp b/include/mbgl/shaders/gl/particle_gpu_validation.hpp index eb8b4407f..b970d4a04 100644 --- a/include/mbgl/shaders/gl/particle_gpu_validation.hpp +++ b/include/mbgl/shaders/gl/particle_gpu_validation.hpp @@ -23,6 +23,11 @@ layout (std140) uniform ParticleGpuValidationUBO { highp vec4 u_field_bounds; highp vec4 u_respawn_bounds; highp vec4 u_field_scale; + highp vec4 u_particle_color; + highp vec4 u_trail_color; + highp vec4 u_weather_stops[8]; + highp vec4 u_weather_colors[8]; + highp vec4 u_weather_ramp; highp mat4 u_matrix; }; @@ -103,6 +108,11 @@ void main() { highp vec4 u_field_bounds; highp vec4 u_respawn_bounds; highp vec4 u_field_scale; + highp vec4 u_particle_color; + highp vec4 u_trail_color; + highp vec4 u_weather_stops[8]; + highp vec4 u_weather_colors[8]; + highp vec4 u_weather_ramp; highp mat4 u_matrix; }; @@ -174,6 +184,20 @@ bool fieldVelocityAt(vec2 position, out vec2 velocity) { length(velocity) > 0.00001; } +vec4 weatherRampColor(float speed) { + int count = int(u_weather_ramp.x); + if (count < 2) return vec4(weatherSpeedColor(speed), 1.0); + vec4 color = u_weather_colors[0]; + for (int i = 1; i < 8; ++i) { + if (i >= count) break; + float lo = u_weather_stops[i - 1].x; + float hi = u_weather_stops[i].x; + if (speed <= hi) return mix(color, u_weather_colors[i], clamp((speed - lo) / (hi - lo), 0.0, 1.0)); + color = u_weather_colors[i]; + } + return color; +} + void main() { float mode = u_pass.x; @@ -187,7 +211,9 @@ void main() { (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); + vec4 color = weatherRampColor(length(velocity)); + float alpha = color.a * u_visual.x; + fragColor = vec4(color.rgb * alpha, alpha); return; } @@ -293,7 +319,9 @@ void main() { // trailFade remains the UI's retention-at-60Hz value. Raising it // to elapsed wall time makes visual persistence FPS-independent. float fadeFactor = pow(clamp(u_visual.w, 0.0001, 1.0), u_pass.y * u_marker.w); - fragColor = vec4(previous.rgb * fadeFactor, 1.0); + // R is history coverage, G marks the current segment, B its speed. + // Apply independent Host colours only at composite time. + fragColor = vec4(previous.r * fadeFactor, 0.0, 0.0, 1.0); return; } @@ -309,10 +337,7 @@ void main() { } else if (mode > 3.5) { if (v_particle_valid < 0.5) discard; float speed = length(v_particle_velocity); - vec3 color = u_marker.y > 0.5 ? particleSpeedColor(speed) : vec3(0.08, 0.55, 1.0); - // Trail targets are RGB accumulation buffers. Opacity changes source - // energy only; it never alters State, Field textures, or time windows. - fragColor = vec4(color * u_visual.x, 1.0); + fragColor = vec4(1.0, 1.0, clamp(speed / 30.0, 0.0, 1.0), 1.0); } else { // The formal RasterParticle layer displays the Trail texture written by // the current State -> Trail pass. Legacy ParticleValidation keeps its @@ -321,9 +346,11 @@ void main() { float ocean = texture(u_mask_texture, v_world).a; if (u_pass.z > 0.5 && ocean < 0.5) discard; if (u_mask.z > 0.5) { - vec3 color = previous.rgb * u_visual.z; - float alpha = clamp(max(color.r, max(color.g, color.b)), 0.0, 0.96); - fragColor = vec4(color, alpha); + bool current = previous.g > 0.5; + vec4 base = current ? u_particle_color : u_trail_color; + if (current && u_marker.y > 0.5) base.rgb = particleSpeedColor(previous.b * 30.0); + float alpha = clamp(previous.r * base.a * (current ? u_visual.x : u_visual.z), 0.0, 1.0); + fragColor = vec4(base.rgb * alpha, alpha); return; } diff --git a/include/mbgl/shaders/particle_gpu_validation_ubo.hpp b/include/mbgl/shaders/particle_gpu_validation_ubo.hpp index f8e21bcf0..7db0a9921 100644 --- a/include/mbgl/shaders/particle_gpu_validation_ubo.hpp +++ b/include/mbgl/shaders/particle_gpu_validation_ubo.hpp @@ -22,9 +22,15 @@ struct alignas(16) ParticleGpuValidationUBO { // Per-texture signed velocity decoding scales in canonical m/s. // z is viewportDensityActive for the formal validation path. std::array fieldScale; + // Host-owned base colours. They are independent from WeatherColor stops. + std::array particleColor; + std::array trailColor; + std::array, 8> weatherStops; + std::array, 8> weatherColors; + std::array weatherRamp; std::array matrix; }; -static_assert(sizeof(ParticleGpuValidationUBO) == 11 * 16); +static_assert(sizeof(ParticleGpuValidationUBO) == 30 * 16); } // namespace shaders } // namespace mbgl diff --git a/include/mbgl/style/layers/raster_particle_layer.hpp b/include/mbgl/style/layers/raster_particle_layer.hpp index d3b496ad2..0d6e6be64 100644 --- a/include/mbgl/style/layers/raster_particle_layer.hpp +++ b/include/mbgl/style/layers/raster_particle_layer.hpp @@ -3,6 +3,7 @@ #include #include +#include namespace mbgl::style { @@ -30,6 +31,10 @@ public: void setTrailFade(float); bool isSpeedColorEnabled() const; void setSpeedColorEnabled(bool); + std::array getParticleColor() const; + void setParticleColor(std::array); + std::array getTrailColor() const; + void setTrailColor(std::array); class Impl; const Impl& impl() const; Mutable mutableImpl() const; diff --git a/include/mbgl/style/layers/weather_color_layer.hpp b/include/mbgl/style/layers/weather_color_layer.hpp index cf1c1bee0..90bedcb9d 100644 --- a/include/mbgl/style/layers/weather_color_layer.hpp +++ b/include/mbgl/style/layers/weather_color_layer.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include namespace mbgl::style { @@ -13,6 +14,8 @@ public: ~WeatherColorLayer() override; float getOpacity() const; void setOpacity(float); + const std::vector& getColorStops() const; + bool setColorStops(std::vector); class Impl; const Impl& impl() const; Mutable mutableImpl() const; diff --git a/include/mbgl/style/layers/weather_color_stop.hpp b/include/mbgl/style/layers/weather_color_stop.hpp new file mode 100644 index 000000000..dc761068c --- /dev/null +++ b/include/mbgl/style/layers/weather_color_stop.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +namespace mbgl::style { + +// Straight normalized RGBA validation. Particle colours do not depend on ramps. +inline bool validWeatherRGBA(const std::array& color) { + for (float c : color) if (!std::isfinite(c) || c < 0.0f || c > 1.0f) return false; + return true; +} +struct WeatherColorStop { + float speedMps; + std::array color; + bool operator==(const WeatherColorStop& other) const { return speedMps == other.speedMps && color == other.color; } +}; +constexpr size_t MaxWeatherColorStops = 8; +inline bool validWeatherColorStops(const std::vector& stops) { + if (stops.size() < 2 || stops.size() > MaxWeatherColorStops) return false; + for (size_t i = 0; i < stops.size(); ++i) { + if (!std::isfinite(stops[i].speedMps) || !validWeatherRGBA(stops[i].color) || + (i && stops[i - 1].speedMps >= stops[i].speedMps)) return false; + } + return true; +} +} // namespace mbgl::style diff --git a/include/mbgl/style/sources/raster_array_source.hpp b/include/mbgl/style/sources/raster_array_source.hpp index d19b025c1..7d049bf73 100644 --- a/include/mbgl/style/sources/raster_array_source.hpp +++ b/include/mbgl/style/sources/raster_array_source.hpp @@ -67,6 +67,7 @@ public: bool pause(); bool setSpeed(float); bool seek(size_t); + bool seekTimestamp(uint64_t); bool step(int); bool injectGap(); bool injectInvalid(); @@ -154,6 +155,11 @@ public: // update API. This must return Noop without changing the Source window. RasterArrayRuntimeFieldUpdateResult repeatRuntimeFieldWindow(); RasterArrayHostFieldSubmitResult submitHostField(const std::string& metadataText, const std::vector& payload); + // Clears producer-owned Host ingress state while retaining the Source and + // its Style layers for a subsequent T0/T1 submission. + bool clearHostFields(); + // Producer-facing timestamp seek. Core alone selects T0/T1 and MIX. + bool seekRuntimeTimelineToTimestamp(uint64_t timestamp); // Low-frequency JNI commands for the Source-owned validation timeline. bool controlRuntimeTimeline(int command, float value = 0.0f); bool supportsLayerType(const LayerTypeInfo*) const override; diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp index 45a5f9267..f728c4684 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include #include #include @@ -29,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -1169,6 +1172,48 @@ jni::jboolean NativeMapView::setStyleLayerFloatProperty(JNIEnv& env, return true; } +jni::jboolean NativeMapView::setStyleLayerVisibility(JNIEnv& env, const jni::String& layerId, const jni::jboolean visible) { + auto* layer = map->getStyle().getLayer(jni::Make(env, layerId)); + if (!layer) return false; + layer->setVisibility(visible ? mbgl::style::VisibilityType::Visible : mbgl::style::VisibilityType::None); + return true; +} + +jni::jboolean NativeMapView::setRasterParticleColors(JNIEnv& env, + const jni::String& layerId, + const jni::Array& particle, + const jni::Array& trail) { + auto* layer = dynamic_cast(map->getStyle().getLayer(jni::Make(env, layerId))); + if (!layer || particle.Length(env) != 4 || trail.Length(env) != 4) return false; + std::array particleColor, trailColor; + jni::GetArrayRegion(env, particle, 0, 4, particleColor.data()); + jni::GetArrayRegion(env, trail, 0, 4, trailColor.data()); + if (!mbgl::style::validWeatherRGBA(particleColor) || !mbgl::style::validWeatherRGBA(trailColor)) return false; + layer->setParticleColor(particleColor); + layer->setTrailColor(trailColor); + return true; +} + +jni::jboolean NativeMapView::setWeatherColorStops(JNIEnv& env, + const jni::String& layerId, + const jni::Array& speeds, + const jni::Array& rgba) { + auto* layer = dynamic_cast(map->getStyle().getLayer(jni::Make(env, layerId))); + const auto count = speeds.Length(env); + if (!layer || count < 2 || count > static_cast(mbgl::style::MaxWeatherColorStops) || rgba.Length(env) != count * 4) return false; + std::vector speedValues(count), rgbaValues(static_cast(count) * 4); + jni::GetArrayRegion(env, speeds, 0, count, speedValues.data()); + jni::GetArrayRegion(env, rgba, 0, count * 4, rgbaValues.data()); + std::vector stops; + stops.reserve(count); + for (jni::jsize i = 0; i < count; ++i) { + std::array color; + std::copy_n(rgbaValues.data() + i * 4, 4, color.data()); + stops.push_back({speedValues[i], color}); + } + return layer->setColorStops(std::move(stops)); +} + jni::jboolean NativeMapView::advanceRasterArraySourceFieldWindow(JNIEnv& env, const jni::String& sourceId) { const auto id = jni::Make(env, sourceId); auto* source = map->getStyle().getSource(id); @@ -1227,6 +1272,25 @@ jni::jint NativeMapView::submitRasterArraySourceHostField(JNIEnv& env, return static_cast(result); } +jni::jboolean NativeMapView::clearRasterArraySourceHostFields(JNIEnv& env, const jni::String& sourceId) { + const auto id = jni::Make(env, sourceId); + auto* source = dynamic_cast(map->getStyle().getSource(id)); + if (!source) return false; + const bool cleared = source->clearHostFields(); + mbgl::Log::Info(mbgl::Event::JNI, + "WeatherRuntime: event=CLEAR_FIELDS sourceId=" + id + " result=" + + (cleared ? "SUCCESS" : "REJECTED")); + return cleared; +} + +jni::jboolean NativeMapView::seekRasterArraySourceTimelineToTimestamp(JNIEnv& env, + const jni::String& sourceId, + const jni::jlong timestamp) { + const auto id = jni::Make(env, sourceId); + auto* source = dynamic_cast(map->getStyle().getSource(id)); + return source && timestamp >= 0 && source->seekRuntimeTimelineToTimestamp(static_cast(timestamp)); +} + jni::jboolean NativeMapView::controlRasterArraySourceTimeline(JNIEnv& env, const jni::String& sourceId, const jni::jint command, @@ -1625,9 +1689,14 @@ void NativeMapView::registerNative(jni::JNIEnv& env) { METHOD(&NativeMapView::getLayers, "nativeGetLayers"), METHOD(&NativeMapView::getLayer, "nativeGetLayer"), METHOD(&NativeMapView::setStyleLayerFloatProperty, "nativeSetStyleLayerFloatProperty"), + METHOD(&NativeMapView::setStyleLayerVisibility, "nativeSetStyleLayerVisibility"), + METHOD(&NativeMapView::setRasterParticleColors, "nativeSetRasterParticleColors"), + METHOD(&NativeMapView::setWeatherColorStops, "nativeSetWeatherColorStops"), METHOD(&NativeMapView::advanceRasterArraySourceFieldWindow, "nativeAdvanceRasterArraySourceFieldWindow"), METHOD(&NativeMapView::repeatRasterArraySourceFieldWindow, "nativeRepeatRasterArraySourceFieldWindow"), METHOD(&NativeMapView::submitRasterArraySourceHostField, "nativeSubmitRasterArraySourceHostField"), + METHOD(&NativeMapView::clearRasterArraySourceHostFields, "nativeClearRasterArraySourceHostFields"), + METHOD(&NativeMapView::seekRasterArraySourceTimelineToTimestamp, "nativeSeekRasterArraySourceTimelineToTimestamp"), METHOD(&NativeMapView::controlRasterArraySourceTimeline, "nativeControlRasterArraySourceTimeline"), METHOD(&NativeMapView::addLayer, "nativeAddLayer"), METHOD(&NativeMapView::addLayerAbove, "nativeAddLayerAbove"), diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp index 7c67bd446..67960770d 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp @@ -282,12 +282,17 @@ public: jni::Local> getLayer(JNIEnv&, const jni::String&); jni::jboolean setStyleLayerFloatProperty(JNIEnv&, const jni::String&, const jni::String&, jni::jfloat); + jni::jboolean setStyleLayerVisibility(JNIEnv&, const jni::String&, jni::jboolean); + jni::jboolean setRasterParticleColors(JNIEnv&, const jni::String&, const jni::Array&, const jni::Array&); + jni::jboolean setWeatherColorStops(JNIEnv&, const jni::String&, const jni::Array&, const jni::Array&); jni::jboolean advanceRasterArraySourceFieldWindow(JNIEnv&, const jni::String&); jni::jboolean repeatRasterArraySourceFieldWindow(JNIEnv&, const jni::String&); jni::jint submitRasterArraySourceHostField(JNIEnv&, const jni::String&, const jni::String&, const jni::Array&); + jni::jboolean clearRasterArraySourceHostFields(JNIEnv&, const jni::String&); + jni::jboolean seekRasterArraySourceTimelineToTimestamp(JNIEnv&, const jni::String&, jni::jlong); jni::jboolean controlRasterArraySourceTimeline(JNIEnv&, const jni::String&, jni::jint, jni::jfloat); void addLayer(JNIEnv&, jlong, const jni::String&); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java index ce43e7fdd..f1d61c14d 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java @@ -291,6 +291,21 @@ public final class MapLibreMap { return nativeMapView.setStyleLayerFloatProperty(layerId, propertyName, value); } + /** Low-frequency style visibility used by the typed WeatherRuntime facade. */ + public boolean setStyleLayerVisibility(String layerId, boolean visible) { + return nativeMapView.setStyleLayerVisibility(layerId, visible); + } + + /** Sets independent particle and trail RGBA colours without a field upload. */ + public boolean setRasterParticleColors(String layerId, float[] particleColor, float[] trailColor) { + return nativeMapView.setRasterParticleColors(layerId, particleColor, trailColor); + } + + /** Installs a validated WeatherColor speed ramp without changing particle style. */ + public boolean setWeatherColorStops(String layerId, float[] speedsMps, float[] rgbaStops) { + return nativeMapView.setWeatherColorStops(layerId, speedsMps, rgbaStops); + } + /** Advances a Source-owned raster-array runtime Field window without reloading the Style. */ public boolean advanceRasterArraySourceFieldWindow(String sourceId) { return nativeMapView.advanceRasterArraySourceFieldWindow(sourceId); @@ -309,6 +324,16 @@ public final class MapLibreMap { return nativeMapView.submitRasterArraySourceHostField(sourceId, metadata, payload); } + /** Clears Host-owned canonical fields while retaining the raster-array Source and Style layers. */ + public boolean clearRasterArraySourceHostFields(String sourceId) { + return nativeMapView.clearRasterArraySourceHostFields(sourceId); + } + + /** Requests a Core-owned T0/T1/MIX selection for a producer timestamp in milliseconds. */ + public boolean seekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp) { + return nativeMapView.seekRasterArraySourceTimelineToTimestamp(sourceId, timestamp); + } + /** Sends a low-frequency playback command to the Core-owned raster-array timeline. */ public boolean controlRasterArraySourceTimeline(String sourceId, int command, float value) { return nativeMapView.controlRasterArraySourceTimeline(sourceId, command, value); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java index 18326e416..5fb1bced3 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java @@ -141,12 +141,22 @@ interface NativeMap { boolean setStyleLayerFloatProperty(String layerId, String propertyName, float value); + boolean setStyleLayerVisibility(String layerId, boolean visible); + + boolean setRasterParticleColors(String layerId, float[] particleColor, float[] trailColor); + + boolean setWeatherColorStops(String layerId, float[] speedsMps, float[] rgbaStops); + boolean advanceRasterArraySourceFieldWindow(String sourceId); boolean repeatRasterArraySourceFieldWindow(String sourceId); int submitRasterArraySourceHostField(String sourceId, String metadata, byte[] payload); + boolean clearRasterArraySourceHostFields(String sourceId); + + boolean seekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp); + boolean controlRasterArraySourceTimeline(String sourceId, int command, float value); boolean removeLayer(@NonNull String layerId); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java index b448a64ea..511477c96 100755 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java @@ -956,6 +956,18 @@ final class NativeMapView implements NativeMap { && nativeSetStyleLayerFloatProperty(layerId, propertyName, value); } + public boolean setStyleLayerVisibility(String layerId, boolean visible) { + return !checkState("setStyleLayerVisibility") && nativeSetStyleLayerVisibility(layerId, visible); + } + + public boolean setRasterParticleColors(String layerId, float[] particleColor, float[] trailColor) { + return !checkState("setRasterParticleColors") && nativeSetRasterParticleColors(layerId, particleColor, trailColor); + } + + public boolean setWeatherColorStops(String layerId, float[] speedsMps, float[] rgbaStops) { + return !checkState("setWeatherColorStops") && nativeSetWeatherColorStops(layerId, speedsMps, rgbaStops); + } + @Override public boolean advanceRasterArraySourceFieldWindow(String sourceId) { return !checkState("advanceRasterArraySourceFieldWindow") @@ -975,6 +987,18 @@ final class NativeMapView implements NativeMap { : nativeSubmitRasterArraySourceHostField(sourceId, metadata, payload); } + @Override + public boolean clearRasterArraySourceHostFields(String sourceId) { + return !checkState("clearRasterArraySourceHostFields") + && nativeClearRasterArraySourceHostFields(sourceId); + } + + @Override + public boolean seekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp) { + return !checkState("seekRasterArraySourceTimelineToTimestamp") + && nativeSeekRasterArraySourceTimelineToTimestamp(sourceId, timestamp); + } + @Override public boolean controlRasterArraySourceTimeline(String sourceId, int command, float value) { return !checkState("controlRasterArraySourceTimeline") @@ -1715,6 +1739,10 @@ final class NativeMapView implements NativeMap { @Keep private native boolean nativeSetStyleLayerFloatProperty(String layerId, String propertyName, float value); + @Keep private native boolean nativeSetStyleLayerVisibility(String layerId, boolean visible); + @Keep private native boolean nativeSetRasterParticleColors(String layerId, float[] particleColor, float[] trailColor); + @Keep private native boolean nativeSetWeatherColorStops(String layerId, float[] speedsMps, float[] rgbaStops); + @Keep private native boolean nativeAdvanceRasterArraySourceFieldWindow(String sourceId); @@ -1724,6 +1752,12 @@ final class NativeMapView implements NativeMap { @Keep private native int nativeSubmitRasterArraySourceHostField(String sourceId, String metadata, byte[] payload); + @Keep + private native boolean nativeClearRasterArraySourceHostFields(String sourceId); + + @Keep + private native boolean nativeSeekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp); + @Keep private native boolean nativeControlRasterArraySourceTimeline(String sourceId, int command, float value); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java new file mode 100644 index 000000000..689785061 --- /dev/null +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java @@ -0,0 +1,92 @@ +package org.maplibre.android.maps; + +import androidx.annotation.NonNull; + +import java.util.List; + +/** + * Main-thread, low-frequency Host facade for one raster-array weather runtime. + * It owns no provider, network, route, ETA, Snapshot, T0/T1, or GPU state. + */ +public final class WeatherRuntime { + public enum State { DISABLED, WAITING_FOR_FIELDS, READY, ERROR } + public enum PlaybackState { PLAY, PAUSE } + public enum PlaybackSpeed { HALF(0.5f), NORMAL(1.0f), DOUBLE(2.0f); final float value; PlaybackSpeed(float value) { this.value = value; } } + public enum FieldSubmitResult { + REJECTED_SOURCE_MODE(-1), REJECTED_ADAPTER(-2), REJECTED_INCOMPATIBLE(-3), REJECTED_NON_MONOTONIC(-4), REJECTED_CAPACITY(-5), + ACCEPTED_PENDING_INITIAL_WINDOW(1), INITIAL_WINDOW_READY(2), ACCEPTED_NEXT_SNAPSHOT(3), DUPLICATE(4), UNKNOWN(Integer.MIN_VALUE); + final int code; FieldSubmitResult(int code) { this.code = code; } + public int getCode() { return code; } + static FieldSubmitResult fromCode(int code) { for (FieldSubmitResult value : values()) if (value.code == code) return value; return UNKNOWN; } + public boolean accepted() { return code > 0; } + } + public enum CommandResult { ACCEPTED, REJECTED } + + public static final class Config { + public final String sourceId; + public final String weatherColorLayerId; + public final String particleLayerId; + public Config(@NonNull String sourceId, @NonNull String weatherColorLayerId, @NonNull String particleLayerId) { + if (sourceId.isEmpty() || weatherColorLayerId.isEmpty() || particleLayerId.isEmpty()) throw new IllegalArgumentException("weather runtime IDs must be non-empty"); + this.sourceId = sourceId; this.weatherColorLayerId = weatherColorLayerId; this.particleLayerId = particleLayerId; + } + } + public static final class ColorStop { + public final float speedMps, red, green, blue, alpha; + public ColorStop(float speedMps, float red, float green, float blue, float alpha) { + this.speedMps = speedMps; this.red = red; this.green = green; this.blue = blue; this.alpha = alpha; + } + } + + private final MapLibreMap map; + private final Config config; + private State state = State.WAITING_FOR_FIELDS; + private boolean weatherColorVisible = true; + private boolean particleVisible = true; + + public WeatherRuntime(@NonNull MapLibreMap map, @NonNull Config config) { this.map = map; this.config = config; } + public State getState() { return state; } + public Config getConfig() { return config; } + + public FieldSubmitResult submitWindField(@NonNull String metadataJson, @NonNull byte[] payloadBytes) { + requireEnabled(); + if (metadataJson.isEmpty() || payloadBytes.length == 0) { state = State.ERROR; return FieldSubmitResult.REJECTED_ADAPTER; } + FieldSubmitResult result = FieldSubmitResult.fromCode(map.submitRasterArraySourceHostField(config.sourceId, metadataJson, payloadBytes)); + if (result == FieldSubmitResult.INITIAL_WINDOW_READY || result == FieldSubmitResult.ACCEPTED_NEXT_SNAPSHOT || result == FieldSubmitResult.DUPLICATE) state = State.READY; + else if (!result.accepted()) state = State.ERROR; + return result; + } + public CommandResult clearWindFields() { if (state == State.DISABLED) return CommandResult.REJECTED; boolean ok = map.clearRasterArraySourceHostFields(config.sourceId); if (ok) state = State.WAITING_FOR_FIELDS; return result(ok); } + public CommandResult setWeatherEnabled(boolean enabled) { + if (!enabled) { + map.controlRasterArraySourceTimeline(config.sourceId, 1, 0f); + boolean color = map.setStyleLayerVisibility(config.weatherColorLayerId, false); + boolean particle = map.setStyleLayerVisibility(config.particleLayerId, false); + boolean clear = map.clearRasterArraySourceHostFields(config.sourceId); + state = (color && particle && clear) ? State.DISABLED : State.ERROR; + return result(color && particle && clear); + } + boolean color = map.setStyleLayerVisibility(config.weatherColorLayerId, weatherColorVisible); + boolean particle = map.setStyleLayerVisibility(config.particleLayerId, particleVisible); + if (color && particle) state = State.WAITING_FOR_FIELDS; else state = State.ERROR; + return result(color && particle); + } + public CommandResult setWeatherColorVisible(boolean visible) { weatherColorVisible = visible; return result(map.setStyleLayerVisibility(config.weatherColorLayerId, visible && state != State.DISABLED)); } + public CommandResult setParticleVisible(boolean visible) { particleVisible = visible; return result(map.setStyleLayerVisibility(config.particleLayerId, visible && state != State.DISABLED)); } + public CommandResult setForecastTime(long timestampMs) { requireEnabled(); return result(timestampMs >= 0 && map.seekRasterArraySourceTimelineToTimestamp(config.sourceId, timestampMs)); } + public CommandResult setPlaybackState(@NonNull PlaybackState playback) { requireEnabled(); return result(map.controlRasterArraySourceTimeline(config.sourceId, playback == PlaybackState.PLAY ? 0 : 1, 0f)); } + public CommandResult setPlaybackSpeed(@NonNull PlaybackSpeed speed) { requireEnabled(); return result(map.controlRasterArraySourceTimeline(config.sourceId, 2, speed.value)); } + public CommandResult setWeatherColorOpacity(float opacity) { return result(Float.isFinite(opacity) && map.setStyleLayerFloatProperty(config.weatherColorLayerId, "weather-color-opacity", opacity)); } + public CommandResult setParticleVisual(String property, float value) { return result(Float.isFinite(value) && map.setStyleLayerFloatProperty(config.particleLayerId, property, value)); } + public CommandResult setParticleColors(float red, float green, float blue, float alpha, float trailRed, float trailGreen, float trailBlue, float trailAlpha) { + return result(map.setRasterParticleColors(config.particleLayerId, new float[]{red, green, blue, alpha}, new float[]{trailRed, trailGreen, trailBlue, trailAlpha})); + } + public CommandResult setWeatherColorStops(@NonNull List stops) { + if (stops.size() < 2 || stops.size() > 8) return CommandResult.REJECTED; + float[] speeds = new float[stops.size()]; float[] colors = new float[stops.size() * 4]; + for (int i = 0; i < stops.size(); i++) { ColorStop stop = stops.get(i); speeds[i] = stop.speedMps; colors[i * 4] = stop.red; colors[i * 4 + 1] = stop.green; colors[i * 4 + 2] = stop.blue; colors[i * 4 + 3] = stop.alpha; } + return result(map.setWeatherColorStops(config.weatherColorLayerId, speeds, colors)); + } + private static CommandResult result(boolean accepted) { return accepted ? CommandResult.ACCEPTED : CommandResult.REJECTED; } + private void requireEnabled() { if (state == State.DISABLED) throw new IllegalStateException("WeatherRuntime is disabled; enable and submit fields first"); } +} diff --git a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt index a8401ded8..25c28a935 100644 --- a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt +++ b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt @@ -14,6 +14,7 @@ import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.maps.MapView import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style +import org.maplibre.android.maps.WeatherRuntime import org.maplibre.android.style.layers.PaintPropertyValue import org.maplibre.android.testapp.R @@ -47,6 +48,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { // Display mirror only; Core remains the playback state authority. private var timelineState = "NOT READY" private var timelineSpeed = "1x" + private var weatherRuntime: WeatherRuntime? = null override fun onCreate(savedInstanceState: Bundle?) { active = savedInstanceState?.getBoolean(STATE_ACTIVE, true) ?: true @@ -355,7 +357,14 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { private fun controlTimeline(command: Int, value: Float) { if (!styleReady || !enabled || !timeWindowMode) { Log.i(TAG, "ParticleTimeline: event=REJECTED reason=STYLE_NOT_READY"); return } - if (!maplibreMap.controlRasterArraySourceTimeline(PARTICLE_SOURCE_ID, command, value)) { + val runtime = weatherRuntime + val accepted = when (command) { + 0 -> runtime?.setPlaybackState(WeatherRuntime.PlaybackState.PLAY) + 1 -> runtime?.setPlaybackState(WeatherRuntime.PlaybackState.PAUSE) + 2 -> when (value) { 0.5f -> runtime?.setPlaybackSpeed(WeatherRuntime.PlaybackSpeed.HALF); 1.0f -> runtime?.setPlaybackSpeed(WeatherRuntime.PlaybackSpeed.NORMAL); 2.0f -> runtime?.setPlaybackSpeed(WeatherRuntime.PlaybackSpeed.DOUBLE); else -> null } + else -> null // Existing fixture-only diagnostic commands remain outside the REAL WEATHER Host facade. + } + if (accepted != WeatherRuntime.CommandResult.ACCEPTED && (runtime == null || !maplibreMap.controlRasterArraySourceTimeline(PARTICLE_SOURCE_ID, command, value))) { Log.i(TAG, "ParticleTimeline: event=REJECTED command=$command") } else { timelineState = when (command) { 0 -> "PLAY"; 1 -> "PAUSE"; 6 -> "WAIT"; 7 -> "INVALID"; 8 -> "PAUSE"; else -> timelineState } @@ -417,6 +426,11 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { Log.i(TAG, "ParticleGPU: event=STYLE_LOAD epoch=$styleLoadEpoch reason=$reason") maplibreMap.setStyle(Style.Builder().fromJson(validationStyle(active, enabled, reason))) { styleReady = true + if (realWeatherMode) { + weatherRuntime = WeatherRuntime(maplibreMap, WeatherRuntime.Config(PARTICLE_SOURCE_ID, WEATHER_COLOR_LAYER_ID, PARTICLE_LAYER_ID)) + } else { + weatherRuntime = null + } if (hostSubmissionMode) { if (realWeatherMode) submitRealWeatherDataset() else submitHostValidationDataset() } else if (timeWindowMode) { @@ -461,7 +475,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { return } Log.i(TAG, "ParticleGPU: event=MIX_PROPERTY_REQUEST value=$requestedTimeMix styleEpoch=$styleLoadEpoch") - if (!maplibreMap.setStyleLayerFloatProperty(PARTICLE_LAYER_ID, "raster-particle-time-mix", requestedTimeMix)) { + if (!setParticleFloat("raster-particle-time-mix", requestedTimeMix)) { Log.i(TAG, "ParticleGPU: event=MIX_REJECTED reason=CORE_LAYER_NOT_FOUND styleEpoch=$styleLoadEpoch") return } @@ -480,12 +494,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { return } Log.i(TAG, "ParticleGPU: event=PARTICLE_COUNT_REQUEST requestedParticleCount=$requestedParticleCount styleEpoch=$styleLoadEpoch") - if (!maplibreMap.setStyleLayerFloatProperty( - PARTICLE_LAYER_ID, - "raster-particle-particle-count", - requestedParticleCount.toFloat() - ) - ) { + if (!setParticleFloat("raster-particle-particle-count", requestedParticleCount.toFloat())) { Log.i(TAG, "ParticleGPU: event=PARTICLE_COUNT_REJECTED reason=CORE_PROPERTY_REJECTED requestedParticleCount=$requestedParticleCount styleEpoch=$styleLoadEpoch") return } @@ -500,7 +509,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { return } Log.i(TAG, "ParticleGPU: event=VISUAL_PROPERTY_REQUEST property=$property value=$requested styleEpoch=$styleLoadEpoch") - if (!maplibreMap.setStyleLayerFloatProperty(PARTICLE_LAYER_ID, property, requested)) { + if (!setParticleFloat(property, requested)) { Log.i(TAG, "ParticleGPU: event=VISUAL_PROPERTY_REJECTED reason=CORE_PROPERTY_REJECTED property=$property styleEpoch=$styleLoadEpoch") return } @@ -515,7 +524,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { return } val value = if (requested) 1.0f else 0.0f - if (!maplibreMap.setStyleLayerFloatProperty(PARTICLE_LAYER_ID, "raster-particle-speed-color-enabled", value)) { + if (!setParticleFloat("raster-particle-speed-color-enabled", value)) { Log.i(TAG, "ParticleGPU: event=VISUAL_PROPERTY_REJECTED reason=CORE_PROPERTY_REJECTED property=raster-particle-speed-color-enabled styleEpoch=$styleLoadEpoch") return } @@ -524,6 +533,11 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { Log.i(TAG, "ParticleGPU: event=VISUAL_PROPERTY_ONLY property=raster-particle-speed-color-enabled value=$value styleEpoch=$styleLoadEpoch") } + private fun setParticleFloat(property: String, value: Float): Boolean { + return weatherRuntime?.setParticleVisual(property, value) == WeatherRuntime.CommandResult.ACCEPTED || + (weatherRuntime == null && maplibreMap.setStyleLayerFloatProperty(PARTICLE_LAYER_ID, property, value)) + } + private fun resetVisualDefaults() { setVisualFloat("raster-particle-line-width", DEFAULT_LINE_WIDTH) { lineWidth = DEFAULT_LINE_WIDTH } setVisualFloat("raster-particle-particle-length", DEFAULT_PARTICLE_LENGTH) { particleLength = DEFAULT_PARTICLE_LENGTH } @@ -589,7 +603,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { 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) + weatherRuntime?.submitWindField(metadata, payload)?.getCode() ?: MapLibreMap.HOST_FIELD_REJECTED_SOURCE_MODE } catch (error: Exception) { Log.e(TAG, "ParticleField: event=REAL_WEATHER_SUBMIT_REJECTED index=$index reason=ASSET_READ_FAILED", error) MapLibreMap.HOST_FIELD_REJECTED_ADAPTER @@ -806,6 +820,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { 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 WEATHER_COLOR_LAYER_ID = "weather-color-real" private const val PARTICLE_SOURCE_ID = "particle-fixture" private const val MASK_GLOBAL = 0 private const val MASK_OCEAN_A = 1 diff --git a/shaders/particle_gpu_validation.fragment.glsl b/shaders/particle_gpu_validation.fragment.glsl index 25f714553..05dd1efb3 100644 --- a/shaders/particle_gpu_validation.fragment.glsl +++ b/shaders/particle_gpu_validation.fragment.glsl @@ -6,6 +6,11 @@ layout (std140) uniform ParticleGpuValidationUBO { highp vec4 u_field_bounds; highp vec4 u_respawn_bounds; highp vec4 u_field_scale; + highp vec4 u_particle_color; + highp vec4 u_trail_color; + highp vec4 u_weather_stops[8]; + highp vec4 u_weather_colors[8]; + highp vec4 u_weather_ramp; highp mat4 u_matrix; }; @@ -77,6 +82,20 @@ bool fieldVelocityAt(vec2 position, out vec2 velocity) { length(velocity) > 0.00001; } +vec4 weatherRampColor(float speed) { + int count = int(u_weather_ramp.x); + if (count < 2) return vec4(weatherSpeedColor(speed), 1.0); + vec4 color = u_weather_colors[0]; + for (int i = 1; i < 8; ++i) { + if (i >= count) break; + float lo = u_weather_stops[i - 1].x; + float hi = u_weather_stops[i].x; + if (speed <= hi) return mix(color, u_weather_colors[i], clamp((speed - lo) / (hi - lo), 0.0, 1.0)); + color = u_weather_colors[i]; + } + return color; +} + void main() { float mode = u_pass.x; @@ -90,7 +109,9 @@ void main() { (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); + vec4 color = weatherRampColor(length(velocity)); + float alpha = color.a * u_visual.x; + fragColor = vec4(color.rgb * alpha, alpha); return; } @@ -196,7 +217,9 @@ void main() { // trailFade remains the UI's retention-at-60Hz value. Raising it // to elapsed wall time makes visual persistence FPS-independent. float fadeFactor = pow(clamp(u_visual.w, 0.0001, 1.0), u_pass.y * u_marker.w); - fragColor = vec4(previous.rgb * fadeFactor, 1.0); + // R is history coverage, G marks the current segment, B its speed. + // Apply independent Host colours only at composite time. + fragColor = vec4(previous.r * fadeFactor, 0.0, 0.0, 1.0); return; } @@ -212,10 +235,7 @@ void main() { } else if (mode > 3.5) { if (v_particle_valid < 0.5) discard; float speed = length(v_particle_velocity); - vec3 color = u_marker.y > 0.5 ? particleSpeedColor(speed) : vec3(0.08, 0.55, 1.0); - // Trail targets are RGB accumulation buffers. Opacity changes source - // energy only; it never alters State, Field textures, or time windows. - fragColor = vec4(color * u_visual.x, 1.0); + fragColor = vec4(1.0, 1.0, clamp(speed / 30.0, 0.0, 1.0), 1.0); } else { // The formal RasterParticle layer displays the Trail texture written by // the current State -> Trail pass. Legacy ParticleValidation keeps its @@ -224,9 +244,11 @@ void main() { float ocean = texture(u_mask_texture, v_world).a; if (u_pass.z > 0.5 && ocean < 0.5) discard; if (u_mask.z > 0.5) { - vec3 color = previous.rgb * u_visual.z; - float alpha = clamp(max(color.r, max(color.g, color.b)), 0.0, 0.96); - fragColor = vec4(color, alpha); + bool current = previous.g > 0.5; + vec4 base = current ? u_particle_color : u_trail_color; + if (current && u_marker.y > 0.5) base.rgb = particleSpeedColor(previous.b * 30.0); + float alpha = clamp(previous.r * base.a * (current ? u_visual.x : u_visual.z), 0.0, 1.0); + fragColor = vec4(base.rgb * alpha, alpha); return; } diff --git a/shaders/particle_gpu_validation.vertex.glsl b/shaders/particle_gpu_validation.vertex.glsl index f3a988cec..0fbec276f 100644 --- a/shaders/particle_gpu_validation.vertex.glsl +++ b/shaders/particle_gpu_validation.vertex.glsl @@ -13,6 +13,11 @@ layout (std140) uniform ParticleGpuValidationUBO { highp vec4 u_field_bounds; highp vec4 u_respawn_bounds; highp vec4 u_field_scale; + highp vec4 u_particle_color; + highp vec4 u_trail_color; + highp vec4 u_weather_stops[8]; + highp vec4 u_weather_colors[8]; + highp vec4 u_weather_ramp; highp mat4 u_matrix; }; diff --git a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp index 468d80c91..958dea623 100644 --- a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp +++ b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp @@ -81,6 +81,8 @@ struct ParticleSettings { float trailOpacity; float trailFade; bool speedColorEnabled; + std::array particleColor; + std::array trailColor; }; ParticleSettings particleSettings(const Immutable& value) { @@ -97,11 +99,12 @@ ParticleSettings particleSettings(const Immutable& value) { layer.particleOpacity, layer.trailOpacity, layer.trailFade, - layer.speedColorEnabled}; + layer.speedColorEnabled, layer.particleColor, layer.trailColor}; } assert(value->getTypeInfo() == style::ParticleValidationLayer::Impl::staticTypeInfo()); const auto& layer = static_cast(*value); - return {layer.active, layer.maskMode, layer.fixtureMode, false, 0.0f, 256, 2.0f, 0.018f, 1.0f, 1.0f, 0.965f, false}; + return {layer.active, layer.maskMode, layer.fixtureMode, false, 0.0f, 256, 2.0f, 0.018f, 1.0f, 1.0f, 0.965f, false, + {0.08f, 0.55f, 1.0f, 1.0f}, {1.0f, 1.0f, 1.0f, 1.0f}}; } enum class ValidationPass : uint8_t { @@ -340,6 +343,8 @@ public: float trailOpacity = 1.0f; float trailFade = 0.92f; bool speedColorEnabled = true; + std::array particleColor{0.08f, 0.55f, 1.0f, 1.0f}; + std::array trailColor{1.0f, 1.0f, 1.0f, 1.0f}; uint8_t fixtureMode = 255; uint8_t loggedMaskMode = 255; Size trailSize{}; @@ -595,6 +600,11 @@ public: resources->nextFieldScale, resources->viewportDensityActive ? 1.0f : 0.0f, 0.0f}, + .particleColor = resources->particleColor, + .trailColor = resources->trailColor, + .weatherStops = {}, + .weatherColors = {}, + .weatherRamp = {}, .matrix = util::cast(parameters.matrixForTile({worldWrap, {0, 0, 0}})), }; drawable.mutableUniformBuffers().createOrUpdate(shaders::idParticleGpuValidationUBO, &ubo, parameters.context); @@ -925,7 +935,9 @@ RenderRasterParticleLayer::RenderRasterParticleLayer(ImmutablegetTypeInfo() == style::RasterParticleLayer::Impl::staticTypeInfo(); @@ -962,7 +974,9 @@ void RenderRasterParticleLayer::evaluate(const PropertyEvaluationParameters&) { settings.particleOpacity, settings.trailOpacity, settings.trailFade, - settings.speedColorEnabled); + settings.speedColorEnabled, + settings.particleColor, + settings.trailColor); passes = RenderPass::Translucent; properties->renderPasses = mbgl::underlying_type(passes); evaluatedProperties = std::move(properties); @@ -981,7 +995,8 @@ void RenderRasterParticleLayer::evaluate(const PropertyEvaluationParameters&) { bool RenderRasterParticleLayer::needsRepaint() const { const auto& properties = static_cast(*evaluatedProperties); - return properties.active && baseImpl->visibility != style::VisibilityType::None; + return properties.active && (!formalSource || (sourceField && sourceNextField)) && + baseImpl->visibility != style::VisibilityType::None; } void RenderRasterParticleLayer::markLayerRenderable(bool willRender, UniqueChangeRequestVec& changes) { @@ -1039,6 +1054,8 @@ void RenderRasterParticleLayer::prepare(const LayerPrepareParameters& parameters sourceTimelineWindowRevision = frame.windowRevision; return; } + sourceField.reset(); + sourceNextField.reset(); if (source.impl().field) { sourceField = *source.impl().field; sourceNextField = source.impl().nextField ? *source.impl().nextField : *source.impl().field; @@ -1056,9 +1073,23 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, setLayerGroup(std::move(group), changes); } } - if (!layerGroup || !isLayerRenderable()) { + if (formalSource && (!sourceField || !sourceNextField)) { + if (layerGroup) static_cast(*layerGroup).clearDrawables(); + if (gpuResources) { + Log::Info(Event::Render, "WeatherRuntime: event=PARTICLE_RESOURCE_RELEASE reason=FIELD_WINDOW_CLEAR layerId=" + getID()); + Log::Info(Event::Render, "WeatherRuntime: event=TRAIL_RESOURCE_RELEASE reason=FIELD_WINDOW_CLEAR layerId=" + getID()); + removeTargets(gpuResources->registeredTargets, changes); + gpuResources->registeredTargets.clear(); + // Targets contain tweakers retaining Resources; break that cycle. + for (const auto& target : gpuResources->stateTargets) + if (target && target->getLayerGroup(0)) target->getLayerGroup(0)->clearDrawables(); + for (const auto& target : gpuResources->trailTargets) + if (target && target->getLayerGroup(0)) target->getLayerGroup(0)->clearDrawables(); + gpuResources.reset(); + } return; } + if (!layerGroup || !isLayerRenderable()) return; const auto& properties = static_cast(*evaluatedProperties); const bool active = properties.active; @@ -1416,7 +1447,9 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources->particleOpacity != properties.particleOpacity || resources->trailOpacity != properties.trailOpacity || resources->trailFade != properties.trailFade || - resources->speedColorEnabled != properties.speedColorEnabled; + resources->speedColorEnabled != properties.speedColorEnabled || + resources->particleColor != properties.particleColor || + resources->trailColor != properties.trailColor; if (visualChanged) { resources->lineWidth = properties.lineWidth; resources->particleLength = properties.particleLength; @@ -1424,6 +1457,8 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources->trailOpacity = properties.trailOpacity; resources->trailFade = properties.trailFade; resources->speedColorEnabled = properties.speedColorEnabled; + resources->particleColor = properties.particleColor; + resources->trailColor = properties.trailColor; Log::Info(Event::Render, "ParticleGPU: event=VISUAL_PROPERTY_APPLIED lineWidth=" + std::to_string(resources->lineWidth) + " streakLength=" + std::to_string(resources->particleLength) + diff --git a/src/mbgl/renderer/layers/render_weather_color_layer.cpp b/src/mbgl/renderer/layers/render_weather_color_layer.cpp index 8cf2f3d20..b33e176e7 100644 --- a/src/mbgl/renderer/layers/render_weather_color_layer.cpp +++ b/src/mbgl/renderer/layers/render_weather_color_layer.cpp @@ -73,20 +73,31 @@ struct Resources { class Tweaker final : public gfx::DrawableTweaker { public: - Tweaker(std::shared_ptr resources_, float mix_, float opacity_, int16_t wrap_) - : resources(std::move(resources_)), mix(mix_), opacity(opacity_), wrap(wrap_) {} + Tweaker(std::shared_ptr resources_, float mix_, float opacity_, int16_t wrap_, + std::vector stops_) + : resources(std::move(resources_)), mix(mix_), opacity(opacity_), wrap(wrap_), stops(std::move(stops_)) {} 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 = { + 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), .respawnBounds = bounds(resources->t0), .fieldScale = {resources->scale0, resources->scale1, 0, 0}, + .particleColor = {0.08f, 0.55f, 1.0f, 1.0f}, + .trailColor = {1.0f, 1.0f, 1.0f, 1.0f}, + .weatherStops = {}, + .weatherColors = {}, + .weatherRamp = {}, .matrix = util::cast(parameters.matrixForTile({wrap, {0, 0, 0}})), }; + ubo.weatherRamp[0] = static_cast(stops.size()); + for (size_t i = 0; i < stops.size(); ++i) { + ubo.weatherStops[i][0] = stops[i].speedMps; + ubo.weatherColors[i] = stops[i].color; + } drawable.mutableUniformBuffers().createOrUpdate(shaders::idParticleGpuValidationUBO, &ubo, parameters.context); } private: @@ -94,6 +105,7 @@ private: float mix; float opacity; int16_t wrap; + std::vector stops; }; bool same(const particle::ParticleFieldSnapshot& a, const particle::ParticleFieldSnapshot& b) { @@ -125,7 +137,7 @@ void RenderWeatherColorLayer::evaluate(const PropertyEvaluationParameters&) { properties->renderPasses = mbgl::underlying_type(passes); evaluatedProperties = std::move(properties); } -bool RenderWeatherColorLayer::needsRepaint() const { return isLayerRenderable(); } +bool RenderWeatherColorLayer::needsRepaint() const { return isLayerRenderable() && sourceField && sourceNextField; } 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(); } @@ -137,12 +149,20 @@ void RenderWeatherColorLayer::prepare(const LayerPrepareParameters& parameters) 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; + } else { + sourceField.reset(); sourceNextField.reset(); } } void RenderWeatherColorLayer::update(gfx::ShaderRegistry& shaders, gfx::Context& context, const TransformState& transformState, const std::shared_ptr&, 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 (!sourceField || !sourceNextField) { + if (layerGroup) static_cast(*layerGroup).clearDrawables(); + if (resources) Log::Info(Event::Render, "WeatherRuntime: event=WEATHER_COLOR_RESOURCE_RELEASE reason=FIELD_WINDOW_CLEAR layerId=" + getID()); + resources.reset(); + return; + } + if (!layerGroup || !isLayerRenderable()) return; if (sourceTimeline) { const auto frame = sourceTimeline->frame(); sourceField = *frame.t0; sourceNextField = *frame.t1; } auto state = std::static_pointer_cast(resources); if (!state) { @@ -158,7 +178,7 @@ void RenderWeatherColorLayer::update(gfx::ShaderRegistry& shaders, gfx::Context& const auto old0 = state->t0; const auto old1 = state->t1; auto install = [&](const particle::ParticleFieldSnapshot& wanted, particle::ParticleFieldSnapshot& assigned, std::shared_ptr& outputImage, gfx::Texture2DPtr& outputTexture, float& outputScale) { - if (same(old0, wanted)) { assigned = wanted; return true; } + if (same(old0, wanted)) { assigned = wanted; outputImage = state->image0; outputTexture = state->texture0; outputScale = state->scale0; 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); }; @@ -185,7 +205,8 @@ void RenderWeatherColorLayer::update(gfx::ShaderRegistry& shaders, gfx::Context& builder->setTexture(state->texture0, shaders::idParticleGpuValidationPreviousTexture); builder->setTexture(state->texture0, shaders::idParticleGpuValidationFieldTexture); builder->setTexture(state->texture1, shaders::idParticleGpuValidationNextFieldTexture); - builder->addTweaker(std::make_shared(state, mix, opacity, wrap)); builder->flush(context); + builder->addTweaker(std::make_shared(state, mix, opacity, wrap, + static_cast(*baseImpl).colorStops)); builder->flush(context); auto drawables = builder->clearDrawables(); if (drawables.size() == 1 && drawables.front()) group.addDrawable(std::move(drawables.front())); } } diff --git a/src/mbgl/renderer/render_orchestrator.cpp b/src/mbgl/renderer/render_orchestrator.cpp index 0f0a92fac..92db8441d 100644 --- a/src/mbgl/renderer/render_orchestrator.cpp +++ b/src/mbgl/renderer/render_orchestrator.cpp @@ -443,6 +443,18 @@ std::unique_ptr RenderOrchestrator::createRenderTree( } } + // Source-driven weather unload must reach hidden layers too. Normal + // visibility only suspends drawing; an empty raster-array releases resources. + for (auto& entry : orderedLayers) { + auto& layer = entry.get(); + const auto source = renderSources.find(layer.baseImpl->source); + if (!layer.isLayerRenderable() && source != renderSources.end() && + source->second->baseImpl->type == style::SourceType::RasterArray) { + layer.prepare({.source = source->second.get(), .imageManager = *imageManager, + .patternAtlas = *patternAtlas, .lineAtlas = *lineAtlas, + .state = updateParameters->transformState}); + } + } auto opaquePassCutOffEstimation = layerRenderItems.size(); for (auto& renderItem : layerRenderItems) { RenderLayer& renderLayer = renderItem.layer; @@ -969,6 +981,15 @@ void RenderOrchestrator::updateLayers(gfx::ShaderRegistry& shaders, std::vector> changes; changes.reserve(items.size() * 3); + for (auto& entry : orderedLayers) { + auto& layer = entry.get(); + const auto source = renderSources.find(layer.baseImpl->source); + if (!layer.isLayerRenderable() && source != renderSources.end() && + source->second->baseImpl->type == style::SourceType::RasterArray) { + layer.update(shaders, context, state, updateParameters, renderTree, changes); + } + } + for (const auto& item : items) { auto& renderLayer = item.layer.get(); #if MLN_RENDER_BACKEND_OPENGL diff --git a/src/mbgl/style/layers/particle_render_layer_properties.hpp b/src/mbgl/style/layers/particle_render_layer_properties.hpp index e8f17e57b..05acde234 100644 --- a/src/mbgl/style/layers/particle_render_layer_properties.hpp +++ b/src/mbgl/style/layers/particle_render_layer_properties.hpp @@ -3,6 +3,7 @@ #include #include +#include namespace mbgl::style { @@ -19,7 +20,9 @@ public: float particleOpacity_, float trailOpacity_, float trailFade_, - bool speedColorEnabled_) + bool speedColorEnabled_, + std::array particleColor_, + std::array trailColor_) : LayerProperties(std::move(impl)), active(active_), maskMode(maskMode_), @@ -31,7 +34,8 @@ public: particleOpacity(particleOpacity_), trailOpacity(trailOpacity_), trailFade(trailFade_), - speedColorEnabled(speedColorEnabled_) {} + speedColorEnabled(speedColorEnabled_), + particleColor(particleColor_), trailColor(trailColor_) {} expression::Dependency getDependencies() const noexcept override { return expression::Dependency::None; } bool active; uint8_t maskMode; @@ -44,6 +48,8 @@ public: float trailOpacity; float trailFade; bool speedColorEnabled; + std::array particleColor; + std::array trailColor; }; } // namespace mbgl::style diff --git a/src/mbgl/style/layers/raster_particle_layer.cpp b/src/mbgl/style/layers/raster_particle_layer.cpp index d3b923231..01f2b8bd0 100644 --- a/src/mbgl/style/layers/raster_particle_layer.cpp +++ b/src/mbgl/style/layers/raster_particle_layer.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -176,6 +177,18 @@ void RasterParticleLayer::setTrailFade(float value) { bool RasterParticleLayer::isSpeedColorEnabled() const { return impl().speedColorEnabled; } +std::array RasterParticleLayer::getParticleColor() const { return impl().particleColor; } +void RasterParticleLayer::setParticleColor(std::array value) { + if (!validWeatherRGBA(value)) return; + if (value == getParticleColor()) return; + auto next = mutableImpl(); next->particleColor = value; baseImpl = std::move(next); observer->onLayerChanged(*this); +} +std::array RasterParticleLayer::getTrailColor() const { return impl().trailColor; } +void RasterParticleLayer::setTrailColor(std::array value) { + if (!validWeatherRGBA(value)) return; + if (value == getTrailColor()) return; + auto next = mutableImpl(); next->trailColor = value; baseImpl = std::move(next); observer->onLayerChanged(*this); +} void RasterParticleLayer::setSpeedColorEnabled(bool value) { if (value != isSpeedColorEnabled()) { auto next = mutableImpl(); diff --git a/src/mbgl/style/layers/raster_particle_layer_impl.hpp b/src/mbgl/style/layers/raster_particle_layer_impl.hpp index 48fc78727..b4b7809cd 100644 --- a/src/mbgl/style/layers/raster_particle_layer_impl.hpp +++ b/src/mbgl/style/layers/raster_particle_layer_impl.hpp @@ -4,6 +4,7 @@ #include #include +#include namespace mbgl::style { @@ -24,6 +25,8 @@ public: float trailOpacity = 1.0f; float trailFade = 0.92f; bool speedColorEnabled = true; + std::array particleColor{{0.08f, 0.55f, 1.0f, 1.0f}}; + std::array trailColor{{1.0f, 1.0f, 1.0f, 1.0f}}; DECLARE_LAYER_TYPE_INFO; }; diff --git a/src/mbgl/style/layers/weather_color_layer.cpp b/src/mbgl/style/layers/weather_color_layer.cpp index 23108f57c..a55e07392 100644 --- a/src/mbgl/style/layers/weather_color_layer.cpp +++ b/src/mbgl/style/layers/weather_color_layer.cpp @@ -29,6 +29,7 @@ const WeatherColorLayer::Impl& WeatherColorLayer::impl() const { return static_c Mutable WeatherColorLayer::mutableImpl() const { return makeMutable(impl()); } float WeatherColorLayer::getOpacity() const { return impl().opacity; } void WeatherColorLayer::setOpacity(float value) { + if (!std::isfinite(value)) return; const auto clamped = std::clamp(value, 0.0f, 1.0f); if (clamped == getOpacity()) return; auto next = mutableImpl(); @@ -36,6 +37,16 @@ void WeatherColorLayer::setOpacity(float value) { baseImpl = std::move(next); observer->onLayerChanged(*this); } +const std::vector& WeatherColorLayer::getColorStops() const { return impl().colorStops; } +bool WeatherColorLayer::setColorStops(std::vector stops) { + if (!validWeatherColorStops(stops)) return false; + if (stops == getColorStops()) return true; + auto next = mutableImpl(); + next->colorStops = std::move(stops); + baseImpl = std::move(next); + observer->onLayerChanged(*this); + return true; +} std::optional WeatherColorLayer::setPropertyInternal(const std::string& name, const conversion::Convertible& value) { if (name != OpacityProperty) return conversion::Error{"unknown weather-color property: " + name}; diff --git a/src/mbgl/style/layers/weather_color_layer_impl.hpp b/src/mbgl/style/layers/weather_color_layer_impl.hpp index b54911c1c..8de2e7dd7 100644 --- a/src/mbgl/style/layers/weather_color_layer_impl.hpp +++ b/src/mbgl/style/layers/weather_color_layer_impl.hpp @@ -13,6 +13,8 @@ public: bool hasLayoutDifference(const Layer::Impl&) const override { return false; } void stringifyLayout(rapidjson::Writer&) const override {} float opacity = 0.62f; + // Empty preserves the historical shader palette until a Host supplies stops. + std::vector colorStops; DECLARE_LAYER_TYPE_INFO; }; diff --git a/src/mbgl/style/sources/raster_array_source.cpp b/src/mbgl/style/sources/raster_array_source.cpp index fc2c5019c..3bd36812f 100644 --- a/src/mbgl/style/sources/raster_array_source.cpp +++ b/src/mbgl/style/sources/raster_array_source.cpp @@ -10,6 +10,7 @@ #include #include +#include #include namespace mbgl::style { @@ -182,6 +183,34 @@ bool RasterArrayTimelineRuntime::seek(const size_t index) { std::to_string(t0) + " t1Index=" + std::to_string(t0 + 1) + " mix=" + std::to_string(mix)); return true; } + +bool RasterArrayTimelineRuntime::seekTimestamp(const uint64_t timestamp) { + std::lock_guard lock(mutex); + if (gap || invalid || entries.size() < 2 || timestamp < entries.front().timestamp || timestamp > entries.back().timestamp) return false; + const auto previousT0 = t0; + const auto previousState = state; + const auto upper = std::upper_bound(entries.begin(), entries.end(), timestamp, [](uint64_t value, const Entry& entry) { + return value < entry.timestamp; + }); + if (upper == entries.begin()) return false; + if (upper == entries.end()) { + t0 = entries.size() - 2; + mix = 1.0f; + state = RasterArrayPlaybackState::Ended; + } else { + t0 = static_cast(std::distance(entries.begin(), upper) - 1); + const auto duration = static_cast(entries[t0 + 1].timestamp - entries[t0].timestamp); + mix = duration > 0.0f ? static_cast(timestamp - entries[t0].timestamp) / duration : 0.0f; + state = previousState == RasterArrayPlaybackState::Playing ? RasterArrayPlaybackState::Playing + : RasterArrayPlaybackState::Paused; + } + if (t0 != previousT0) ++windowRevision; + lastTick = std::chrono::steady_clock::now(); + Log::Info(Event::Render, "ParticleTimeline: event=PLAYBACK_SEEK_TIMESTAMP timestamp=" + std::to_string(timestamp) + + " t0Index=" + std::to_string(t0) + " t1Index=" + std::to_string(t0 + 1) + + " mix=" + std::to_string(mix)); + return true; +} bool RasterArrayTimelineRuntime::step(const int direction) { std::lock_guard lock(mutex); const auto target = direction < 0 ? (t0 == 0 ? 0 : t0 - 1) : std::min(t0 + 1, entries.size() - 2); @@ -756,6 +785,41 @@ RasterArrayHostFieldSubmitResult RasterArraySource::submitHostField(const std::s return RasterArrayHostFieldSubmitResult::AcceptedNextSnapshot; } +bool RasterArraySource::clearHostFields() { + if (!impl().hostFieldSubmission) { + Log::Warning(Event::Render, "WeatherRuntime: event=FIELD_WINDOW_CLEAR_REJECTED reason=SOURCE_MODE sourceId=" + getID()); + return false; + } + if (timeline) timeline->pause(); + Log::Info(Event::Render, "WeatherRuntime: event=TIMELINE_STOP sourceId=" + getID()); + pendingHostField.reset(); + timeline.reset(); + auto updated = makeMutable(impl()); + updated->timeline.reset(); + updated->field.reset(); + updated->nextField.reset(); + baseImpl = std::move(updated); + loaded = true; // An empty Host ingress is loaded; it has no drawable field. + runtimeUpdateSequence = 0; + observer->onSourceChanged(*this); + Log::Info(Event::Render, "WeatherRuntime: event=FIELD_WINDOW_CLEAR sourceId=" + getID()); + Log::Info(Event::Render, "WeatherRuntime: event=SNAPSHOT_CLEAR sourceId=" + getID()); + return true; +} + +bool RasterArraySource::seekRuntimeTimelineToTimestamp(const uint64_t timestamp) { + if (!timeline) { + Log::Warning(Event::Render, "WeatherRuntime: event=SEEK_TIMESTAMP_REJECTED reason=TIMELINE_NOT_READY sourceId=" + getID()); + return false; + } + const bool accepted = timeline->seekTimestamp(timestamp); + if (!accepted) { + Log::Warning(Event::Render, "WeatherRuntime: event=SEEK_TIMESTAMP_REJECTED reason=OUT_OF_RANGE sourceId=" + getID() + + " timestamp=" + std::to_string(timestamp)); + } + return accepted; +} + bool RasterArraySource::controlRuntimeTimeline(const int command, const float value) { if (!timeline) { Log::Warning(Event::Render, "ParticleTimeline: event=REJECTED reason=TIMELINE_NOT_READY sourceId=" + getID()); diff --git a/test/style/source.test.cpp b/test/style/source.test.cpp index e2ac9b73a..8259af7e5 100644 --- a/test/style/source.test.cpp +++ b/test/style/source.test.cpp @@ -426,6 +426,28 @@ TEST(Source, HostFieldSubmissionBuildsOneCoreOwnedTimeline) { EXPECT_EQ(source.impl().timeline->frame().t0Index, 2u); } +TEST(Source, HostFieldClearAndTimestampSeekAreFailClosed) { + SourceTest test; + style::RasterArraySource source{"host-submission"}; + source.setObserver(&test.styleObserver); + source.loadDescription(*test.fileSource); + const auto east = fixtureBytes("uniform-east", "field.f32"); + const auto north = fixtureBytes("uniform-north", "field.f32"); + const std::vector eastPayload(east.begin(), east.end()); + const std::vector northPayload(north.begin(), north.end()); + EXPECT_EQ(source.submitHostField(localMetadata(0), eastPayload), style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow); + EXPECT_TRUE(source.clearHostFields()); + EXPECT_FALSE(source.impl().field); + EXPECT_FALSE(source.impl().timeline); + EXPECT_EQ(source.submitHostField(localMetadata(0), eastPayload), style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow); + EXPECT_EQ(source.submitHostField(localMetadata(1), northPayload), style::RasterArrayHostFieldSubmitResult::InitialWindowReady); + EXPECT_TRUE(source.seekRuntimeTimelineToTimestamp(202609040050ULL)); + EXPECT_FLOAT_EQ(source.impl().timeline->frame().mix, 0.5f); + EXPECT_FALSE(source.seekRuntimeTimelineToTimestamp(202609039999ULL)); + EXPECT_TRUE(source.clearHostFields()); + EXPECT_FALSE(source.seekRuntimeTimelineToTimestamp(202609040050ULL)); +} + TEST(Source, HostFieldSubmissionStyleContractCreatesEmptyIngress) { SourceTest test; test.style.loadJSON(R"STYLE({ diff --git a/test/style/style_layer.test.cpp b/test/style/style_layer.test.cpp index b5df4b87f..c8d15d29d 100644 --- a/test/style/style_layer.test.cpp +++ b/test/style/style_layer.test.cpp @@ -29,6 +29,7 @@ #include #include +#include using namespace mbgl; using namespace mbgl::style; @@ -166,6 +167,15 @@ TEST(Layer, RasterParticleVisualProperties) { EXPECT_FLOAT_EQ(8.0f, layer.getLineWidth()); EXPECT_FLOAT_EQ(0.0005f, layer.getParticleLength()); EXPECT_FLOAT_EQ(0.995f, layer.getTrailFade()); + + const std::array particleColor{0.2f, 0.3f, 0.4f, 0.5f}; + const std::array trailColor{0.7f, 0.6f, 0.5f, 0.4f}; + layer.setParticleColor(particleColor); + layer.setTrailColor(trailColor); + EXPECT_EQ(layer.getParticleColor(), particleColor); + EXPECT_EQ(layer.getTrailColor(), trailColor); + layer.setParticleColor({-1.0f, 0.0f, 0.0f, 1.0f}); + EXPECT_EQ(layer.getParticleColor(), particleColor); } TEST(Layer, WeatherColorUsesIndependentFixedOpacity) { @@ -176,6 +186,14 @@ TEST(Layer, WeatherColorUsesIndependentFixedOpacity) { EXPECT_FLOAT_EQ(0.35f, layer.getOpacity()); layer.setOpacity(4.0f); EXPECT_FLOAT_EQ(1.0f, layer.getOpacity()); + + std::vector valid{{0.0f, {0, 0, 1, 1}}, {10.0f, {0, 1, 0, 1}}, {20.0f, {1, 0, 0, 1}}}; + EXPECT_TRUE(layer.setColorStops(valid)); + EXPECT_EQ(layer.getColorStops(), valid); + EXPECT_FALSE(layer.setColorStops({{0.0f, {0, 0, 1, 1}}})); + EXPECT_FALSE(layer.setColorStops({{0.0f, {0, 0, 1, 1}}, {0.0f, {1, 0, 0, 1}}})); + EXPECT_FALSE(layer.setColorStops({{0.0f, {0, 0, 1, 1}}, {10.0f, {std::numeric_limits::quiet_NaN(), 0, 0, 1}}})); + EXPECT_EQ(layer.getColorStops(), valid); } TEST(Layer, FillProperties) {