diff --git a/include/mbgl/particle/field/field_data_adapter.hpp b/include/mbgl/particle/field/field_data_adapter.hpp index 7b013d31c..7c4fd01fa 100644 --- a/include/mbgl/particle/field/field_data_adapter.hpp +++ b/include/mbgl/particle/field/field_data_adapter.hpp @@ -101,6 +101,8 @@ struct ParticleFieldSnapshot { std::vector vector; std::vector valid; std::optional vectorSemantics; + // Internal interpretation identity. Never part of the public field ABI. + std::string gpuEncodingIdentity; bool isValid() const; bool isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot&) const; @@ -120,6 +122,28 @@ inline bool sameFieldGpuContent(const ParticleFieldSnapshot& current, const Part current.vector == next.vector && current.valid == next.valid; } +// A GPU slot may be reused only for the same immutable endpoint. A matching +// producer revision alone is insufficient, and equal bytes at another valid +// time must not silently inherit the previous endpoint's provenance. +inline bool sameFieldGpuEndpoint(const ParticleFieldSnapshot& a, const ParticleFieldSnapshot& b) { + return a.fieldID == b.fieldID && a.generation == b.generation && a.dataVersion == b.dataVersion && + a.validTime == b.validTime && a.fieldType == b.fieldType && a.displayDomain == b.displayDomain && + a.vectorSemantics == b.vectorSemantics && a.noData == b.noData && + a.fieldSha256 == b.fieldSha256 && a.gpuEncodingIdentity == b.gpuEncodingIdentity && + a.width == b.width && a.height == b.height && a.west == b.west && a.south == b.south && + a.east == b.east && a.north == b.north && a.vector == b.vector && a.valid == b.valid; +} + +enum class GpuEndpointSource : uint8_t { Upload, OldT0, OldT1 }; + +inline GpuEndpointSource gpuEndpointSource(const ParticleFieldSnapshot& oldT0, + const ParticleFieldSnapshot& oldT1, + const ParticleFieldSnapshot& wanted) { + if (sameFieldGpuEndpoint(oldT0, wanted)) return GpuEndpointSource::OldT0; + if (sameFieldGpuEndpoint(oldT1, wanted)) return GpuEndpointSource::OldT1; + return GpuEndpointSource::Upload; +} + struct ParticleFieldTimeWindow { ParticleFieldSnapshot t0; ParticleFieldSnapshot t1; diff --git a/include/mbgl/particle/field/particle_gpu_math.hpp b/include/mbgl/particle/field/particle_gpu_math.hpp new file mode 100644 index 000000000..407d00fe8 --- /dev/null +++ b/include/mbgl/particle/field/particle_gpu_math.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include + +namespace mbgl::particle { + +// Internal RGBA8 UNORM field representation. Nearest sampling and highp +// shader arithmetic reconstruct the byte with floor(sample*255 + 0.5). +inline uint8_t encodeSignedVelocity8(const float value, const float scale, const bool valid) { + if (!valid || !std::isfinite(value) || !std::isfinite(scale) || scale <= 0.0f) return 128; + return static_cast(128 + static_cast( + std::lround(std::clamp(value / scale, -1.0f, 1.0f) * 127.0f))); +} + +inline float decodeSignedVelocity8(const uint8_t byte, const float scale) { + return static_cast(static_cast(byte) - 128) * scale / 127.0f; +} + +inline std::array mercatorVelocityDelta(const double eastMps, + const double northMps, + const double latitudeDegrees, + const double visualSeconds) { + constexpr double circumference = 40075016.68557849; + constexpr double radiansPerDegree = 3.14159265358979323846 / 180.0; + const double cosine = std::max(std::cos(latitudeDegrees * radiansPerDegree), 0.01); + return {{eastMps * visualSeconds / (circumference * cosine), + -northMps * visualSeconds / (circumference * cosine)}}; +} + +} // namespace mbgl::particle diff --git a/include/mbgl/shaders/gl/particle_gpu_validation.hpp b/include/mbgl/shaders/gl/particle_gpu_validation.hpp index 3c9296da0..717c1ae0a 100644 --- a/include/mbgl/shaders/gl/particle_gpu_validation.hpp +++ b/include/mbgl/shaders/gl/particle_gpu_validation.hpp @@ -34,8 +34,8 @@ layout (std140) uniform ParticleGpuValidationUBO { uniform sampler2D u_state_texture; uniform sampler2D u_previous; -uniform sampler2D u_field_texture; -uniform sampler2D u_next_field_texture; +uniform highp sampler2D u_field_texture; +uniform highp 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), @@ -47,8 +47,8 @@ bool validFieldUv(vec2 uv) { } bool resolveAdvection(vec4 field0, vec4 field1, out vec2 advection) { - vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, + vec2 mixed = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x, + ((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y, u_marker.x); if (any(isnan(mixed)) || any(greaterThan(abs(mixed), vec2(3.4e38)))) return false; if (u_advection.x > 0.5) { @@ -116,7 +116,11 @@ void main() { } } )"; - static constexpr const char* fragment = R"(layout (std140) uniform ParticleGpuValidationUBO { + static constexpr const char* fragment = R"(// The shared GL prelude defaults fragment float to mediump. Byte reconstruction +// from RGBA8 UNORM and Mercator state require highp on ES 3.x. +precision highp float; + +layout (std140) uniform ParticleGpuValidationUBO { highp vec4 u_pass; highp vec4 u_marker; highp vec4 u_mask; @@ -135,8 +139,8 @@ void main() { uniform sampler2D u_previous; uniform sampler2D u_mask_texture; -uniform sampler2D u_field_texture; -uniform sampler2D u_next_field_texture; +uniform highp sampler2D u_field_texture; +uniform highp sampler2D u_next_field_texture; uniform sampler2D u_state_texture; in vec2 v_uv; in vec2 v_world; @@ -195,8 +199,8 @@ bool fieldAdvectionAt(vec2 position, out vec2 advection) { vec2 uv = fieldUv(position); vec4 field0 = texture(u_field_texture, uv); vec4 field1 = texture(u_next_field_texture, uv); - vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, + vec2 mixed = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x, + ((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y, u_marker.x); if (!validFieldUv(uv) || field0.a < 0.5 || field1.a < 0.5 || any(isnan(mixed)) || any(greaterThan(abs(mixed), vec2(3.4e38)))) return false; @@ -236,14 +240,14 @@ void main() { if (field0.a < 0.5 || field1.a < 0.5) discard; float fieldValue; if (u_weather_ramp.y > 0.5) { - vec2 vectorValue = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, + vec2 vectorValue = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x, + ((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y, u_marker.x); if (any(isnan(vectorValue))) discard; fieldValue = length(vectorValue); } else { - fieldValue = mix((field0.r * 2.0 - 1.0) * u_field_scale.x, - (field1.r * 2.0 - 1.0) * u_field_scale.y, + fieldValue = mix(((floor(field0.r * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x, + ((floor(field1.r * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y, u_marker.x); if (isnan(fieldValue)) discard; } @@ -286,7 +290,7 @@ void main() { if (!respawn) { float latitude = mercatorLatitudeRadians(position.y); - vec2 displacement = vec2(advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS, + vec2 displacement = vec2(advection.x * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)), -advection.y * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01))); vec2 nextPosition = position + displacement; @@ -337,10 +341,10 @@ void main() { // U/V remain canonical metres per second. u_pass.y is the explicit // visual simulation delta in seconds; the validation clock may run // faster than wall time, but it never changes the field contract. - // Web Mercator x spans Earth's circumference; y additionally scales - // with sec(latitude). V is north-positive while world y is southward. + // Both east and north metres scale with sec(latitude) in Web Mercator. + // V is north-positive while world y is southward. float latitude = mercatorLatitudeRadians(position.y); - float dx = advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS; + float dx = advection.x * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)); float dy = -advection.y * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)); position = position + vec2(dx, dy); diff --git a/include/mbgl/shaders/mtl/particle_gpu_validation.hpp b/include/mbgl/shaders/mtl/particle_gpu_validation.hpp index e6a5ad059..66778d3d3 100644 --- a/include/mbgl/shaders/mtl/particle_gpu_validation.hpp +++ b/include/mbgl/shaders/mtl/particle_gpu_validation.hpp @@ -46,7 +46,7 @@ float2 fieldUv(float2 world, device const ParticleGpuValidationUBO& u) { bool validFieldUv(float2 uv) { return all(uv >= float2(0.0)) && all(uv <= float2(1.0)); } bool finite2(float2 value) { return all(isfinite(value)) && all(abs(value) <= float2(3.4e38)); } bool resolveAdvection(float4 a, float4 b, device const ParticleGpuValidationUBO& u, thread float2& out) { - float2 mixed=mix((a.rg*2.0-1.0)*u.fieldScale.x,(b.rg*2.0-1.0)*u.fieldScale.y,u.marker.x); + float2 mixed=mix(((floor(a.rg*255.0+0.5)-128.0)/127.0)*u.fieldScale.x,((floor(b.rg*255.0+0.5)-128.0)/127.0)*u.fieldScale.y,u.marker.x); if (!finite2(mixed)) return false; if (u.advection.x > .5) { float l=length(mixed); if (!isfinite(l)||l>3.4e38||l<=u.advection.z) return false; out=mixed/l*u.advection.y; } else { out=mixed; return length(out)>0.00001; } @@ -126,12 +126,12 @@ float4 fragment fragmentMain(FragmentStage in [[stage_in]], device const Particl sampler previousSampler [[sampler(0)]], sampler maskSampler [[sampler(1)]], sampler fieldSampler [[sampler(2)]], sampler nextFieldSampler [[sampler(3)]], sampler stateSampler [[sampler(4)]]) { float mode=u.pass.x; if(mode>5.5){if(in.valid<.5)discard_fragment();float speed=length(in.velocity);return float4(1,1,clamp(speed/30.,0.,1.),1);} - if(mode>4.5){float2 uv=fieldUv(in.world,u); if(!validFieldUv(uv)) discard_fragment(); float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);if(a.a<.5||b.a<.5)discard_fragment();float value=u.weatherRamp.y>.5?length(mix((a.rg*2.-1.)*u.fieldScale.x,(b.rg*2.-1.)*u.fieldScale.y,u.marker.x)):mix((a.r*2.-1.)*u.fieldScale.x,(b.r*2.-1.)*u.fieldScale.y,u.marker.x);if(!isfinite(value))discard_fragment();float4 c=ramp(value,u);float alpha=c.a*u.visual.x;return float4(c.rgb*alpha,alpha);} + if(mode>4.5){float2 uv=fieldUv(in.world,u); if(!validFieldUv(uv)) discard_fragment(); float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);if(a.a<.5||b.a<.5)discard_fragment();float value=u.weatherRamp.y>.5?length(mix(((floor(a.rg*255.+.5)-128.)/127.)*u.fieldScale.x,((floor(b.rg*255.+.5)-128.)/127.)*u.fieldScale.y,u.marker.x)):mix(((floor(a.r*255.+.5)-128.)/127.)*u.fieldScale.x,((floor(b.r*255.+.5)-128.)/127.)*u.fieldScale.y,u.marker.x);if(!isfinite(value))discard_fragment();float4 c=ramp(value,u);float alpha=c.a*u.visual.x;return float4(c.rgb*alpha,alpha);} float4 old=previous.sample(previousSampler,in.uv); if(mode<.5){ float2 position=u.mask.w>.5?mix(u.fieldBounds.xy,u.fieldBounds.zw,in.uv):old.rg; float age=old.b; bool initial=u.mask.w>.5; bool respawn=initial; float2 advection; float maxAge=u.marker.z*mix(.75,1.25,particleHash(in.uv+float2(17.31,83.17))); - if(u.mask.z>.5){age=initial?particleHash(in.uv+float2(71.19,29.47))*maxAge:old.b+u.pass.w;float2 uv=fieldUv(position,u);float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);bool ok=validFieldUv(uv)&&a.a>=.5&&b.a>=.5&&resolveAdvection(a,b,u,advection);respawn=initial||!ok||age>=maxAge||(u.fieldScale.z>.5&&!initial&&(position.xu.respawnBounds.z||position.yu.respawnBounds.w));if(!respawn){float lat=mercatorLatitudeRadians(position.y);float2 n=position+float2(advection.x*u.pass.y/40075016.68557849,-advection.y*u.pass.y/(40075016.68557849*max(cos(lat),.01)));float2 nuv=fieldUv(n,u);float4 na=field.sample(fieldSampler,nuv),nb=nextField.sample(nextFieldSampler,nuv);if(validFieldUv(nuv)&&na.a>=.5&&nb.a>=.5&&resolveAdvection(na,nb,u,advection))position=n;else respawn=true;}if(respawn){bool found=false;for(int i=0;i<3;i++){float2 c=respawnSeed(in.uv,old.rg,float(i),u);float2 cuv=fieldUv(c,u);float4 ca=field.sample(fieldSampler,cuv),cb=nextField.sample(nextFieldSampler,cuv);if(validFieldUv(cuv)&&ca.a>=.5&&cb.a>=.5&&resolveAdvection(ca,cb,u,advection)){position=c;found=true;break;}}if(!found)position=respawnSeed(in.uv,old.rg,3.,u);if(!initial)age=0.;}return float4(position.x,position.y,age,respawn?0.:1.);} - float2 uv=fieldUv(position,u);float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);if(!validFieldUv(uv)||a.a<.5||b.a<.5||!resolveAdvection(a,b,u,advection)){position=mix(u.fieldBounds.xy,u.fieldBounds.zw,in.uv);advection=float2(0);}float lat=mercatorLatitudeRadians(position.y);position+=float2(advection.x*u.pass.y/40075016.68557849,-advection.y*u.pass.y/(40075016.68557849*max(cos(lat),.01)));if(!validFieldUv(fieldUv(position,u))||!finite2(advection))position=mix(u.fieldBounds.xy,u.fieldBounds.zw,in.uv);return float4(position.x,position.y,fract(old.b+.03125*u.pass.y),1.); + if(u.mask.z>.5){age=initial?particleHash(in.uv+float2(71.19,29.47))*maxAge:old.b+u.pass.w;float2 uv=fieldUv(position,u);float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);bool ok=validFieldUv(uv)&&a.a>=.5&&b.a>=.5&&resolveAdvection(a,b,u,advection);respawn=initial||!ok||age>=maxAge||(u.fieldScale.z>.5&&!initial&&(position.xu.respawnBounds.z||position.yu.respawnBounds.w));if(!respawn){float lat=mercatorLatitudeRadians(position.y);float2 n=position+float2(advection.x*u.pass.y/(40075016.68557849*max(cos(lat),.01)),-advection.y*u.pass.y/(40075016.68557849*max(cos(lat),.01)));float2 nuv=fieldUv(n,u);float4 na=field.sample(fieldSampler,nuv),nb=nextField.sample(nextFieldSampler,nuv);if(validFieldUv(nuv)&&na.a>=.5&&nb.a>=.5&&resolveAdvection(na,nb,u,advection))position=n;else respawn=true;}if(respawn){bool found=false;for(int i=0;i<3;i++){float2 c=respawnSeed(in.uv,old.rg,float(i),u);float2 cuv=fieldUv(c,u);float4 ca=field.sample(fieldSampler,cuv),cb=nextField.sample(nextFieldSampler,cuv);if(validFieldUv(cuv)&&ca.a>=.5&&cb.a>=.5&&resolveAdvection(ca,cb,u,advection)){position=c;found=true;break;}}if(!found)position=respawnSeed(in.uv,old.rg,3.,u);if(!initial)age=0.;}return float4(position.x,position.y,age,respawn?0.:1.);} + float2 uv=fieldUv(position,u);float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);if(!validFieldUv(uv)||a.a<.5||b.a<.5||!resolveAdvection(a,b,u,advection)){position=mix(u.fieldBounds.xy,u.fieldBounds.zw,in.uv);advection=float2(0);}float lat=mercatorLatitudeRadians(position.y);position+=float2(advection.x*u.pass.y/(40075016.68557849*max(cos(lat),.01)),-advection.y*u.pass.y/(40075016.68557849*max(cos(lat),.01)));if(!validFieldUv(fieldUv(position,u))||!finite2(advection))position=mix(u.fieldBounds.xy,u.fieldBounds.zw,in.uv);return float4(position.x,position.y,fract(old.b+.03125*u.pass.y),1.); } if(mode<1.5){if(u.mask.z>.5){float f=pow(clamp(u.visual.w,.0001,1.),u.pass.y*u.marker.w);return float4(old.r*f,0,0,1);}float marker=0.;float2 samples[4]={float2(.125),float2(.375),float2(.625),float2(.875)};for(int i=0;i<4;i++)marker=max(marker,1.-smoothstep(0.,u.marker.z,distance(in.uv,state.sample(stateSampler,samples[i]).rg)));return float4(min(old.rgb*u.marker.w+marker*float3(.08,.55,1),float3(1)),1);} if(mode>3.5){if(in.valid<.5)discard_fragment();float speed=length(in.velocity);return float4(1,1,clamp(speed/30.,0.,1.),1);} diff --git a/include/mbgl/style/sources/raster_array_source.hpp b/include/mbgl/style/sources/raster_array_source.hpp index 3009ee34f..cf8425a76 100644 --- a/include/mbgl/style/sources/raster_array_source.hpp +++ b/include/mbgl/style/sources/raster_array_source.hpp @@ -238,6 +238,9 @@ public: void setForecastPlaybackState(particle::ForecastPlaybackState); bool setForecastPlaybackSpeed(float); bool advanceForecastClock(uint64_t elapsedMs); + // Called once per Map update on the owner thread; render layers only read + // the resulting immutable projection and never advance this clock. + bool tickForecastClock(std::chrono::steady_clock::time_point now); particle::ColorProjectionResult resolveColorProjection() const; particle::ParticleProjectionResult resolveParticleProjection() const; bool clearEncodedField(const std::string& fieldKey); @@ -302,6 +305,10 @@ private: // across clear operations and resolve their own field-keyed windows. particle::EncodedFieldStore encodedFieldStore; particle::ForecastClock forecastClock; + std::chrono::steady_clock::time_point lastForecastTick{}; + // A Source change synchronously invokes Style::onUpdate, which re-enters + // Map::onUpdate. Prevent a second forecast tick during the first notification. + bool forecastTickInProgress = false; std::optional colorBinding; std::optional particleBinding; mapbox::base::WeakPtrFactory weakFactory{this}; 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 index 4b204866e..979fc7f95 100644 --- 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 @@ -66,6 +66,7 @@ public final class WeatherRuntime { // This is a Host-command bookkeeping hint for scheduling only. Core still // decides whether a resolved V4 window may actually advance simulation. @Nullable private String v4ParticleBindingFieldKey; + private boolean v4ForecastPlaying; private final Set submittedEncodedFieldKeys = new HashSet<>(); private boolean disposed; private Handler cadenceHandler; @@ -123,7 +124,7 @@ public final class WeatherRuntime { } public CommandResult setEncodedForecastTime(long timestamp) { requireAttached(); return timestamp < 0 ? CommandResult.REJECTED : encodedCommand(map.setRasterArraySourceEncodedForecastTime(config.sourceId, timestamp)); } /** State and speed intentionally remain independent V4 Core commands. */ - public CommandResult setEncodedForecastPlaybackState(boolean playing) { requireAttached(); return encodedCommand(map.setRasterArraySourceEncodedForecastPlaybackState(config.sourceId, playing)); } + public CommandResult setEncodedForecastPlaybackState(boolean playing) { requireAttached(); CommandResult result = encodedCommand(map.setRasterArraySourceEncodedForecastPlaybackState(config.sourceId, playing)); if (result == CommandResult.ACCEPTED) { v4ForecastPlaying = playing; cancelCadenceWake(); scheduleCadenceWake(); } return result; } public CommandResult setEncodedForecastPlaybackSpeed(float speed) { requireAttached(); return !Float.isFinite(speed) || speed <= 0 ? CommandResult.REJECTED : encodedCommand(map.setRasterArraySourceEncodedForecastPlaybackSpeed(config.sourceId, speed)); } /** v2 provider-neutral ingress; missing scalar/vector is expressed only by its explicit presence flag. */ public FieldSubmitResult submitField(@NonNull String metadataJson, @Nullable byte[] scalarBytes, @Nullable byte[] vectorBytes, @NonNull byte[] validityBytes) { @@ -223,7 +224,9 @@ public final class WeatherRuntime { private static CommandResult encodedCommand(int code) { if (code == 0) throw new IllegalStateException("SOURCE_MISSING"); return code > 0 ? CommandResult.ACCEPTED : CommandResult.REJECTED; } public enum FieldValueSource { SCALAR(0f), VECTOR_MAGNITUDE(1f); final float code; FieldValueSource(float code) { this.code = code; } } private boolean shouldWakeWeather() { - if (disposed || state == State.DISABLED || targetFps == TargetFps.AUTO) return false; + if (disposed || state == State.DISABLED) return false; + if (v4ForecastPlaying && !submittedEncodedFieldKeys.isEmpty()) return true; + if (targetFps == TargetFps.AUTO) return false; final boolean v4ParticleFieldReady = v4ParticleBindingFieldKey != null && submittedEncodedFieldKeys.contains(v4ParticleBindingFieldKey); return (v4ParticleFieldReady && particleVisible) || (state == State.READY && (particleVisible || (weatherColorVisible && playbackState == PlaybackState.PLAY))); @@ -233,7 +236,7 @@ public final class WeatherRuntime { try { if (cadenceHandler == null) cadenceHandler = new Handler(Looper.getMainLooper()); cadenceHandler.removeCallbacks(cadenceWake); - cadenceHandler.postDelayed(cadenceWake, targetFps == TargetFps.FPS_30 ? 34L : 17L); + cadenceHandler.postDelayed(cadenceWake, targetFps == TargetFps.FPS_60 ? 17L : 34L); } catch (RuntimeException ignored) { // Local JVM tests may not provide an Android Looper; Core cadence remains authoritative. } diff --git a/platform/android/MapLibreAndroidTestApp/src/main/assets/integration/step097-v4/wind/T0.qbin b/platform/android/MapLibreAndroidTestApp/src/main/assets/integration/step097-v4/wind/T0.qbin new file mode 100644 index 000000000..ce66b2614 Binary files /dev/null and b/platform/android/MapLibreAndroidTestApp/src/main/assets/integration/step097-v4/wind/T0.qbin differ diff --git a/platform/android/MapLibreAndroidTestApp/src/main/assets/integration/step097-v4/wind/T1.qbin b/platform/android/MapLibreAndroidTestApp/src/main/assets/integration/step097-v4/wind/T1.qbin new file mode 100644 index 000000000..8dc086026 Binary files /dev/null and b/platform/android/MapLibreAndroidTestApp/src/main/assets/integration/step097-v4/wind/T1.qbin differ diff --git a/platform/android/MapLibreAndroidTestApp/src/main/assets/integration/step097-v4/wind/contract.json b/platform/android/MapLibreAndroidTestApp/src/main/assets/integration/step097-v4/wind/contract.json new file mode 100644 index 000000000..797448005 --- /dev/null +++ b/platform/android/MapLibreAndroidTestApp/src/main/assets/integration/step097-v4/wind/contract.json @@ -0,0 +1 @@ +{"contractSchemaVersion":1,"profileID":"wind","profileVersion":1,"allowAdditionalPlanes":true,"sharedValidityRequired":true,"timeRelation":"VALID_GTE_REFERENCE","requiredPlanes":[{"semanticID":1,"valueClass":"VELOCITY_COMPONENT","unitID":1,"groupID":1,"component":"U_EAST","requiredFlag":true},{"semanticID":2,"valueClass":"VELOCITY_COMPONENT","unitID":1,"groupID":1,"component":"V_NORTH","requiredFlag":true},{"semanticID":3,"valueClass":"VALIDITY","unitID":0,"groupID":0,"component":"NONE","requiredFlag":true}]} 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 44ea1c5d8..01db93bde 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 @@ -46,6 +46,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { private var timeWindowMode = false private var productionFeedMode = false private var hostSubmissionMode = false + private var step097V4GoldenReady = false // Test-App-only state; never a Core window mirror. private var realWeatherMode = false private var pureParticleMode = false private var purePbfMode = false @@ -998,7 +999,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { private fun showTimeMenu(anchor: android.view.View) { PopupMenu(this, anchor).apply { - val labels = arrayOf("SCENARIO NORMAL", "PLAY / RESTART", "PAUSE", "SPEED 0.5x", "SPEED 1x", "SPEED 2x", "STEP PREV", "STEP NEXT", "SEEK START", "SEEK MID", "SEEK END", "SCENARIO GAP", "SCENARIO INVALID NEXT", "RECOVER NEXT") + val labels = arrayOf("SCENARIO NORMAL", "PLAY / RESTART", "PAUSE", "SPEED 0.5x", "SPEED 1x", "SPEED 2x", "STEP PREV", "STEP NEXT", "SEEK START", "SEEK MID", "SEEK END", "SCENARIO GAP", "SCENARIO INVALID NEXT", "RECOVER NEXT", "A2 V4 WIND TEST") labels.forEachIndexed { index, label -> menu.add(0, index, index, label) } setOnMenuItemClickListener { item -> when (item.itemId) { @@ -1016,12 +1017,69 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { 11 -> controlTimeline(6, 0.0f) 12 -> controlTimeline(7, 0.0f) 13 -> controlTimeline(8, 0.0f) + 14 -> showStep097V4Menu(anchor) }; true } show() } } + private fun showStep097V4Menu(anchor: android.view.View) { + PopupMenu(this, anchor).apply { + val labels = arrayOf("LOAD WIND T0/T1", "PLAY", "PAUSE", "SEEK 25%", "SEEK 75%", "SPEED 2x", "SPEED 1x", "CLEAR") + labels.forEachIndexed { index, label -> menu.add(0, index, index, label) } + setOnMenuItemClickListener { item -> + if (item.itemId == 0) loadStep097V4Golden() else controlStep097V4Golden(item.itemId) + true + } + show() + } + } + + private fun loadStep097V4Golden() { + if (!styleReady || !enabled || !hostSubmissionMode || realWeatherMode) { + Log.i(TAG, "STEP097_A2_V4_REJECT reason=SELECT_HOST_FIELD_FIRST styleReady=$styleReady") + return + } + step097V4GoldenReady = false + try { + val runtime = weatherRuntime ?: WeatherRuntime(maplibreMap, + WeatherRuntime.Config(PARTICLE_SOURCE_ID, WEATHER_COLOR_LAYER_ID, PARTICLE_LAYER_ID)) + .also { weatherRuntime = it } + Step097V4GoldenFixture.load(assets, runtime) + // The RN Golden QBIN covers Japan; default world center (0,0) + // would leave the valid field outside this device's viewport. + maplibreMap.cameraPosition = CameraPosition.Builder() + .target(LatLng(36.2, 137.5)).zoom(4.8).build() + step097V4GoldenReady = true + timelineState = "V4 READY" + status.text = statusText() + } catch (error: Exception) { + step097V4GoldenReady = false + timelineState = "V4 ERROR" + status.text = statusText() + Log.e(TAG, "STEP097_A2_V4_ERROR reason=GOLDEN_LOAD", error) + } + } + + private fun controlStep097V4Golden(action: Int) { + if (!styleReady || !step097V4GoldenReady) { + Log.i(TAG, "STEP097_A2_V4_REJECT reason=LOAD_GOLDEN_FIRST action=$action") + return + } + val runtime = weatherRuntime ?: return + val result = Step097V4GoldenFixture.command(runtime, action) + if (action == 7 && result == WeatherRuntime.CommandResult.ACCEPTED) step097V4GoldenReady = false + if (result == WeatherRuntime.CommandResult.ACCEPTED) { + timelineState = when (action) { + 1 -> "V4 PLAY"; 2 -> "V4 PAUSE"; 3 -> "V4 25%"; 4 -> "V4 75%" + 5 -> "V4 2x"; 6 -> "V4 1x"; 7 -> "V4 CLEARED"; else -> timelineState + } + status.text = statusText() + } + Log.i(TAG, "STEP097_A2_V4_COMMAND action=$action result=$result targetLabel=$timelineState") + } + private fun controlTimeline(command: Int, value: Float) { if (!styleReady || !enabled || !timeWindowMode) { Log.i(TAG, "ParticleTimeline: event=REJECTED reason=STYLE_NOT_READY"); return } val runtime = weatherRuntime @@ -1087,6 +1145,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { } private fun loadValidationStyle(active: Boolean, enabled: Boolean, reason: String = "STYLE_CHANGE") { + step097V4GoldenReady = false if (pureParticleMode) { loadPureParticleStyle(active, enabled, reason) return @@ -1657,6 +1716,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { purePbfMode -> "PURE PBF" navSeaPbfMode -> "NAVSEA PBF" realWeatherMode -> "REAL WEATHER" + step097V4GoldenReady -> "V4 GOLDEN" hostSubmissionMode -> "HOST" productionFeedMode -> "PRODUCTION" else -> "FIXTURE" diff --git a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step097V4GoldenFixture.kt b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step097V4GoldenFixture.kt new file mode 100644 index 000000000..03d4ece58 --- /dev/null +++ b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step097V4GoldenFixture.kt @@ -0,0 +1,46 @@ +package org.maplibre.android.testapp.activity.particle + +import android.content.res.AssetManager +import android.util.Log +import org.maplibre.android.maps.WeatherRuntime + +/** STEP-097 A2 test-only ingress. Uses the existing public Android V4 API. */ +internal object Step097V4GoldenFixture { + const val KEY = "fixture.wind" + const val T0_MS = 1_789_239_600_000L + const val T1_MS = 1_789_243_200_000L + const val MID_MS = 1_789_241_400_000L + private const val ROOT = "integration/step097-v4/wind/" + private const val TAG = "ParticleValidation" + + fun load(assets: AssetManager, runtime: WeatherRuntime) { + val contract = assets.open(ROOT + "contract.json").bufferedReader().use { it.readText() } + val clear = runtime.clearAllEncodedFields() + val color = runtime.setEncodedColorBinding(KEY, 1, 1, 1, 1) + val particle = runtime.setEncodedParticleBinding(KEY, 0, 1, 1, 2, 1, 0, 0, 1) + val t0 = runtime.submitEncodedField(KEY, "fixture-wind", 1L, 1L, + contract, assets.open(ROOT + "T0.qbin").use { it.readBytes() }) + val t1 = runtime.submitEncodedField(KEY, "fixture-wind", 2L, 2L, + contract, assets.open(ROOT + "T1.qbin").use { it.readBytes() }) + val time = runtime.setEncodedForecastTime(MID_MS) + Log.i(TAG, "STEP097_A2_V4_SUBMIT clear=$clear color=$color particle=$particle t0=$t0 t1=$t1 time=$time") + check(clear == WeatherRuntime.CommandResult.ACCEPTED && + color == WeatherRuntime.CommandResult.ACCEPTED && + particle == WeatherRuntime.CommandResult.ACCEPTED && + t0 == WeatherRuntime.EncodedFieldSubmitResult.ACCEPTED_INSERTED && + t1 == WeatherRuntime.EncodedFieldSubmitResult.ACCEPTED_INSERTED && + time == WeatherRuntime.CommandResult.ACCEPTED) { "V4 golden ingress rejected" } + Log.i(TAG, "STEP097_A2_V4_READY fieldKey=$KEY T0=$T0_MS T1=$T1_MS target=$MID_MS") + } + + fun command(runtime: WeatherRuntime, action: Int): WeatherRuntime.CommandResult = when (action) { + 1 -> runtime.setEncodedForecastPlaybackState(true) + 2 -> runtime.setEncodedForecastPlaybackState(false) + 3 -> runtime.setEncodedForecastTime(T0_MS + 900_000L) + 4 -> runtime.setEncodedForecastTime(T0_MS + 2_700_000L) + 5 -> runtime.setEncodedForecastPlaybackSpeed(2.0f) + 6 -> runtime.setEncodedForecastPlaybackSpeed(1.0f) + 7 -> runtime.clearAllEncodedFields() + else -> WeatherRuntime.CommandResult.REJECTED + } +} diff --git a/platform/ios/src/MLNParticleFieldRuntime.mm b/platform/ios/src/MLNParticleFieldRuntime.mm index 2d7756d18..92f752c81 100644 --- a/platform/ios/src/MLNParticleFieldRuntime.mm +++ b/platform/ios/src/MLNParticleFieldRuntime.mm @@ -103,6 +103,7 @@ std::vector charsFromData(NSData *data) { BOOL _particleVisible; NSString *_v4ParticleBindingFieldKey; NSMutableSet *_submittedEncodedFieldKeys; + BOOL _v4ForecastPlaying; NSTimer *_v4CadenceTimer; } - (BOOL)isV4ParticleCadenceReady; @@ -147,9 +148,11 @@ std::vector charsFromData(NSData *data) { - (void)refreshV4CadenceWake { [_v4CadenceTimer invalidate]; _v4CadenceTimer = nil; - if (_disposed || ![self isV4ParticleCadenceReady] || !_particleVisible || - _targetFps == MLNParticleFieldRuntimeTargetFpsAuto) return; - const NSTimeInterval interval = _targetFps == MLNParticleFieldRuntimeTargetFps30 ? 0.034 : 0.017; + const BOOL forecastWake = _v4ForecastPlaying && _submittedEncodedFieldKeys.count > 0; + if (_disposed || _state == MLNParticleFieldRuntimeStateDisabled || + (!forecastWake && (![self isV4ParticleCadenceReady] || !_particleVisible || + _targetFps == MLNParticleFieldRuntimeTargetFpsAuto))) return; + const NSTimeInterval interval = _targetFps == MLNParticleFieldRuntimeTargetFps60 ? 0.017 : 0.034; _v4CadenceTimer = [NSTimer scheduledTimerWithTimeInterval:interval target:self selector:@selector(runV4CadenceWake:) @@ -160,7 +163,7 @@ std::vector charsFromData(NSData *data) { - (void)runV4CadenceWake:(NSTimer *)timer { if (timer != _v4CadenceTimer) return; _v4CadenceTimer = nil; - if (_disposed || ![self isV4ParticleCadenceReady] || !_particleVisible) return; + if (_disposed) return; [_mapView triggerRepaint]; [self refreshV4CadenceWake]; } @@ -289,7 +292,9 @@ std::vector charsFromData(NSData *data) { } - (BOOL)setEncodedForecastPlaybackState:(BOOL)playing error:(NSError **)error { - return [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source, mbgl::style::WeatherColorLayer &, mbgl::style::RasterParticleLayer &) { source.setForecastPlaybackState(playing ? mbgl::particle::ForecastPlaybackState::Playing : mbgl::particle::ForecastPlaybackState::Paused); return true; }]; + const BOOL completed = [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source, mbgl::style::WeatherColorLayer &, mbgl::style::RasterParticleLayer &) { source.setForecastPlaybackState(playing ? mbgl::particle::ForecastPlaybackState::Playing : mbgl::particle::ForecastPlaybackState::Paused); return true; }]; + if (completed) { _v4ForecastPlaying = playing; [self refreshV4CadenceWake]; } + return completed; } - (BOOL)setEncodedForecastPlaybackSpeed:(float)speed error:(NSError **)error { diff --git a/shaders/particle_gpu_validation.fragment.glsl b/shaders/particle_gpu_validation.fragment.glsl index 819dfd052..f19d4e5aa 100644 --- a/shaders/particle_gpu_validation.fragment.glsl +++ b/shaders/particle_gpu_validation.fragment.glsl @@ -1,3 +1,7 @@ +// The shared GL prelude defaults fragment float to mediump. Byte reconstruction +// from RGBA8 UNORM and Mercator state require highp on ES 3.x. +precision highp float; + layout (std140) uniform ParticleGpuValidationUBO { highp vec4 u_pass; highp vec4 u_marker; @@ -17,8 +21,8 @@ layout (std140) uniform ParticleGpuValidationUBO { uniform sampler2D u_previous; uniform sampler2D u_mask_texture; -uniform sampler2D u_field_texture; -uniform sampler2D u_next_field_texture; +uniform highp sampler2D u_field_texture; +uniform highp sampler2D u_next_field_texture; uniform sampler2D u_state_texture; in vec2 v_uv; in vec2 v_world; @@ -77,8 +81,8 @@ bool fieldAdvectionAt(vec2 position, out vec2 advection) { vec2 uv = fieldUv(position); vec4 field0 = texture(u_field_texture, uv); vec4 field1 = texture(u_next_field_texture, uv); - vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, + vec2 mixed = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x, + ((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y, u_marker.x); if (!validFieldUv(uv) || field0.a < 0.5 || field1.a < 0.5 || any(isnan(mixed)) || any(greaterThan(abs(mixed), vec2(3.4e38)))) return false; @@ -118,14 +122,14 @@ void main() { if (field0.a < 0.5 || field1.a < 0.5) discard; float fieldValue; if (u_weather_ramp.y > 0.5) { - vec2 vectorValue = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, + vec2 vectorValue = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x, + ((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y, u_marker.x); if (any(isnan(vectorValue))) discard; fieldValue = length(vectorValue); } else { - fieldValue = mix((field0.r * 2.0 - 1.0) * u_field_scale.x, - (field1.r * 2.0 - 1.0) * u_field_scale.y, + fieldValue = mix(((floor(field0.r * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x, + ((floor(field1.r * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y, u_marker.x); if (isnan(fieldValue)) discard; } @@ -168,7 +172,7 @@ void main() { if (!respawn) { float latitude = mercatorLatitudeRadians(position.y); - vec2 displacement = vec2(advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS, + vec2 displacement = vec2(advection.x * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)), -advection.y * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01))); vec2 nextPosition = position + displacement; @@ -219,10 +223,10 @@ void main() { // U/V remain canonical metres per second. u_pass.y is the explicit // visual simulation delta in seconds; the validation clock may run // faster than wall time, but it never changes the field contract. - // Web Mercator x spans Earth's circumference; y additionally scales - // with sec(latitude). V is north-positive while world y is southward. + // Both east and north metres scale with sec(latitude) in Web Mercator. + // V is north-positive while world y is southward. float latitude = mercatorLatitudeRadians(position.y); - float dx = advection.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS; + float dx = advection.x * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)); float dy = -advection.y * u_pass.y / (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01)); position = position + vec2(dx, dy); diff --git a/shaders/particle_gpu_validation.vertex.glsl b/shaders/particle_gpu_validation.vertex.glsl index e8d497d4b..4a3ba9b49 100644 --- a/shaders/particle_gpu_validation.vertex.glsl +++ b/shaders/particle_gpu_validation.vertex.glsl @@ -24,8 +24,8 @@ layout (std140) uniform ParticleGpuValidationUBO { uniform sampler2D u_state_texture; uniform sampler2D u_previous; -uniform sampler2D u_field_texture; -uniform sampler2D u_next_field_texture; +uniform highp sampler2D u_field_texture; +uniform highp 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), @@ -37,8 +37,8 @@ bool validFieldUv(vec2 uv) { } bool resolveAdvection(vec4 field0, vec4 field1, out vec2 advection) { - vec2 mixed = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, - (field1.rg * 2.0 - 1.0) * u_field_scale.y, + vec2 mixed = mix(((floor(field0.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.x, + ((floor(field1.rg * 255.0 + 0.5) - 128.0) / 127.0) * u_field_scale.y, u_marker.x); if (any(isnan(mixed)) || any(greaterThan(abs(mixed), vec2(3.4e38)))) return false; if (u_advection.x > 0.5) { diff --git a/src/mbgl/map/map_impl.cpp b/src/mbgl/map/map_impl.cpp index a9918b2d9..35456c843 100644 --- a/src/mbgl/map/map_impl.cpp +++ b/src/mbgl/map/map_impl.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -111,6 +112,14 @@ void Map::Impl::onUpdate() { } TimePoint timePoint = mode == MapMode::Continuous ? Clock::now() : Clock::time_point::max(); + if (mode == MapMode::Continuous) { + const auto now = std::chrono::steady_clock::now(); + for (auto* source : style->getSources()) { + if (source->is()) { + static_cast(source)->tickForecastClock(now); + } + } + } transform.updateTransitions(timePoint); diff --git a/src/mbgl/particle/field/field_data_adapter.cpp b/src/mbgl/particle/field/field_data_adapter.cpp index 2d04613cc..a28dd2b67 100644 --- a/src/mbgl/particle/field/field_data_adapter.cpp +++ b/src/mbgl/particle/field/field_data_adapter.cpp @@ -73,6 +73,9 @@ ParticleFieldAdapterResult ParticleFieldDataAdapter::decode(const ParticleFieldD field.fieldID = d.fieldID; field.fieldType = d.fieldType; field.displayDomain = d.displayDomain; field.validTime = d.validTime; field.width = d.width; field.height = d.height; field.west = d.west; field.south = d.south; field.east = d.east; field.north = d.north; field.dataVersion = d.dataVersion; field.generation = d.generation; field.fieldSha256 = d.fieldSha256; field.vectorSemantics = d.vectorSemantics; + field.gpuEncodingIdentity = d.encoding + ":" + d.layout; + if (d.vector) field.gpuEncodingIdentity += ":" + d.vector->encoding + ":" + d.vector->layout; + if (d.validity) field.gpuEncodingIdentity += ":" + d.validity->encoding + ":" + d.validity->layout; field.valid.assign(payloads.validity->begin(), payloads.validity->end()); if (scalar) { field.scalar.resize(cells); std::memcpy(field.scalar.data(), payloads.scalar->data(), payloads.scalar->size()); } if (vector) { field.vector.resize(cells * 2); std::memcpy(field.vector.data(), payloads.vector->data(), payloads.vector->size()); } diff --git a/src/mbgl/particle/field/field_projection_materialization.cpp b/src/mbgl/particle/field/field_projection_materialization.cpp index 2ef7f9785..083a6a813 100644 --- a/src/mbgl/particle/field/field_projection_materialization.cpp +++ b/src/mbgl/particle/field/field_projection_materialization.cpp @@ -89,6 +89,27 @@ ParticleFieldSnapshot baseSnapshot(const DecodedFieldFrame& frame, const std::st snapshot.dataVersion = frame.frameContentIdentity.dataVersion; snapshot.generation = frame.frameContentIdentity.generation; snapshot.fieldSha256 = identity; + // Include all plane interpretation metadata, including validity and grid + // layout. Decoded floats alone cannot prove that a new revision has the + // same source semantics. + snapshot.gpuEncodingIdentity = std::to_string(frame.grid.layout) + ":" + + std::to_string(frame.grid.projection) + ":" + + std::to_string(frame.grid.sampleOrder) + ":" + + std::to_string(frame.grid.gridRegistration); + const auto appendPlane = [&snapshot](const DecodedFieldPlane& plane) { + uint32_t scaleBits = 0, offsetBits = 0; + std::memcpy(&scaleBits, &plane.scale, sizeof(scaleBits)); + std::memcpy(&offsetBits, &plane.offset, sizeof(offsetBits)); + snapshot.gpuEncodingIdentity += ":" + std::to_string(plane.key.semanticID) + ":" + + std::to_string(plane.key.groupID) + ":" + + std::to_string(static_cast(plane.key.component)) + ":" + + std::to_string(static_cast(plane.dtype)) + ":" + + std::to_string(static_cast(plane.valueClass)) + ":" + + std::to_string(plane.unitID) + ":" + + std::to_string(scaleBits) + ":" + std::to_string(offsetBits); + }; + appendPlane(frame.validityPlane); + for (const auto& plane : frame.planes) appendPlane(plane); return snapshot; } bool compatible(const DecodedFieldFrame& a, const DecodedFieldFrame& b) { diff --git a/src/mbgl/particle/field/field_runtime.cpp b/src/mbgl/particle/field/field_runtime.cpp index 769c5b7c3..6adc50115 100644 --- a/src/mbgl/particle/field/field_runtime.cpp +++ b/src/mbgl/particle/field/field_runtime.cpp @@ -217,7 +217,7 @@ bool ForecastClock::advance(const uint64_t elapsedMs) { if (wholeMilliseconds > std::numeric_limits::max() - requestedForecastTimeMs_) return false; requestedForecastTimeMs_ += wholeMilliseconds; fractionalHalfMillisecond_ = static_cast(accumulatedHalfMilliseconds % 2); - return true; + return wholeMilliseconds != 0; } FieldSeriesSubmitResult EncodedFieldStore::submit(const std::string& fieldKey, diff --git a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp index 618dc42e9..6a626672b 100644 --- a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp +++ b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -49,7 +50,6 @@ namespace { constexpr auto ShaderName = "ParticleGpuValidationShader"; constexpr Size MaskSize{16, 16}; -constexpr float EarthCircumferenceMeters = 40075016.68557849f; // This is a validation visual clock, not a multiplier on canonical U/V. At // 60 Hz it advances 20 simulation seconds per frame: a typical 5 m/s wind // moves about 100 m (roughly 0.65 px at z9), while a 30 m/s sample remains @@ -183,10 +183,11 @@ std::shared_ptr makeVelocityFieldImage(const particle::Parti for (uint32_t x = 0; x < field.width; ++x) { const auto cell = (static_cast(y) * field.width + x) * 2; const auto pixel = (static_cast(y) * field.width + x) * PremultipliedImage::channels; - image->data[pixel + 0] = static_cast( - std::clamp(field.vector[cell] / scale * 0.5f + 0.5f, 0.0f, 1.0f) * 255.0f); - image->data[pixel + 1] = static_cast( - std::clamp(field.vector[cell + 1] / scale * 0.5f + 0.5f, 0.0f, 1.0f) * 255.0f); + // RGBA8 UNORM has no exact midpoint. Byte 128 is an explicit + // signed zero; shaders round the sampled byte before decoding. + const bool valid = field.valid[y * field.width + x] != 0; + image->data[pixel + 0] = particle::encodeSignedVelocity8(field.vector[cell], scale, valid); + image->data[pixel + 1] = particle::encodeSignedVelocity8(field.vector[cell + 1], scale, valid); image->data[pixel + 2] = 0; image->data[pixel + 3] = field.valid[y * field.width + x] ? 255 : 0; } @@ -570,9 +571,9 @@ public: (static_cast(row) / std::max(1u, resources->field.height - 1)); const auto u = resources->field.vector[cell * 2]; const auto v = resources->field.vector[cell * 2 + 1]; - const auto cosLatitude = std::max(0.01f, std::cos(static_cast(latitude * M_PI / 180.0))); - const auto dx = u * simulationStep / EarthCircumferenceMeters; - const auto dy = -v * simulationStep / (EarthCircumferenceMeters * cosLatitude); + const auto delta = particle::mercatorVelocityDelta(u, v, latitude, simulationStep); + const auto dx = delta[0]; + const auto dy = delta[1]; Log::Info(Event::Render, "ParticleMotion: fieldId=" + resources->field.fieldID + " sampleU=" + std::to_string(u) + " sampleV=" + std::to_string(v) + @@ -1487,8 +1488,8 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, } const bool sourceChanged = formalSource && sourceField && sourceNextField && - (particle::fieldIdentityChanged(resources->field, *sourceField) || - particle::fieldIdentityChanged(resources->nextField, *sourceNextField)); + (!particle::sameFieldGpuEndpoint(resources->field, *sourceField) || + !particle::sameFieldGpuEndpoint(resources->nextField, *sourceNextField)); if ((!formalSource && resources->fixtureMode != fixtureMode) || sourceChanged) { try { const auto oldT0 = resources->field; @@ -1501,10 +1502,10 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, Log::Error(Event::Render, "ParticleGPU: rejected incompatible time-window fields"); return; } - const bool reuseOldT0 = particle::sameFieldGpuContent(oldT0, nextT0); - const bool reuseOldT1 = particle::sameFieldGpuContent(oldT1, nextT0); - const bool reuseNewT0 = particle::sameFieldGpuContent(oldT0, nextT1); - const bool reuseNewT1 = particle::sameFieldGpuContent(oldT1, nextT1); + const bool reuseOldT0 = particle::sameFieldGpuEndpoint(oldT0, nextT0); + const bool reuseOldT1 = particle::sameFieldGpuEndpoint(oldT1, nextT0); + const bool reuseNewT0 = particle::sameFieldGpuEndpoint(oldT0, nextT1); + const bool reuseNewT1 = particle::sameFieldGpuEndpoint(oldT1, nextT1); const bool windowOverlap = reuseOldT0 || reuseOldT1 || reuseNewT0 || reuseNewT1; const auto upload = [&](const particle::ParticleFieldSnapshot& field, std::shared_ptr& image, @@ -1534,7 +1535,8 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, std::shared_ptr& image, gfx::Texture2DPtr& texture, float& scale) { - if (particle::sameFieldGpuContent(oldT0, wanted)) { + if (particle::gpuEndpointSource(oldT0, oldT1, wanted) == particle::GpuEndpointSource::OldT0 && + resources->fieldTexture) { assigned = wanted; image = resources->fieldImage; texture = resources->fieldTexture; @@ -1547,7 +1549,8 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, " resourceGeneration=" + std::to_string(resources->generation)); return true; } - if (particle::sameFieldGpuContent(oldT1, wanted)) { + if (particle::gpuEndpointSource(oldT0, oldT1, wanted) == particle::GpuEndpointSource::OldT1 && + resources->nextFieldTexture) { assigned = wanted; image = resources->nextFieldImage; texture = resources->nextFieldTexture; diff --git a/src/mbgl/style/sources/raster_array_source.cpp b/src/mbgl/style/sources/raster_array_source.cpp index 5b4f49631..6287c593a 100644 --- a/src/mbgl/style/sources/raster_array_source.cpp +++ b/src/mbgl/style/sources/raster_array_source.cpp @@ -58,16 +58,25 @@ RasterArrayEncodedFieldSubmitResult mapEncodedFieldStoreResult(const particle::F return RasterArrayEncodedFieldSubmitResult::RejectedIncompatible; } -bool sameSnapshot(const particle::ParticleFieldSnapshot& a, const particle::ParticleFieldSnapshot& b) { - return a.fieldID == b.fieldID && a.dataVersion == b.dataVersion && a.generation == b.generation && - particle::sameFieldGpuContent(a, b); +bool sameSampleState(const particle::FieldSampleState& a, const particle::FieldSampleState& b) { + return a.requestedForecastTimeMs == b.requestedForecastTimeMs && a.mix == b.mix; } template bool sameProjection(const std::shared_ptr& a, const std::shared_ptr& b) { if (static_cast(a) != static_cast(b)) return false; - return !a || (a->contentIdentity == b->contentIdentity && a->sampleState.mix == b->sampleState.mix && - a->sampleState.requestedForecastTimeMs == b->sampleState.requestedForecastTimeMs && a->t0 && b->t0 && - a->t1 && b->t1 && sameSnapshot(*a->t0, *b->t0) && sameSnapshot(*a->t1, *b->t1)); + return !a || (a->contentIdentity == b->contentIdentity && sameSampleState(a->sampleState, b->sampleState) && + a->t0 == b->t0 && a->t1 == b->t1); +} +bool sameMaterializationInputs(const particle::ColorProjection& a, const particle::ColorProjection& b) { + return a.contentIdentity == b.contentIdentity && a.t0Frame == b.t0Frame && a.t1Frame == b.t1Frame && + a.t0PlaneStorage == b.t0PlaneStorage && a.t1PlaneStorage == b.t1PlaneStorage && + a.t0ValidityStorage == b.t0ValidityStorage && a.t1ValidityStorage == b.t1ValidityStorage; +} +bool sameMaterializationInputs(const particle::ParticleProjection& a, const particle::ParticleProjection& b) { + return a.contentIdentity == b.contentIdentity && a.t0Frame == b.t0Frame && a.t1Frame == b.t1Frame && + a.t0FirstPlaneStorage == b.t0FirstPlaneStorage && a.t1FirstPlaneStorage == b.t1FirstPlaneStorage && + a.t0SecondPlaneStorage == b.t0SecondPlaneStorage && a.t1SecondPlaneStorage == b.t1SecondPlaneStorage && + a.t0ValidityStorage == b.t0ValidityStorage && a.t1ValidityStorage == b.t1ValidityStorage; } } // namespace @@ -452,15 +461,20 @@ void RasterArraySource::setParticleBinding(particle::ParticleBinding binding) { bool RasterArraySource::setRequestedForecastTime(const uint64_t timestampMs) { const bool changed = forecastClock.setRequestedForecastTime(timestampMs); + if (changed) lastForecastTick = {}; if (changed) refreshV4RendererState(); return changed; } void RasterArraySource::setForecastPlaybackState(const particle::ForecastPlaybackState state) { + if (state == particle::ForecastPlaybackState::Paused) tickForecastClock(std::chrono::steady_clock::now()); state == particle::ForecastPlaybackState::Playing ? forecastClock.play() : forecastClock.pause(); + lastForecastTick = {}; } bool RasterArraySource::setForecastPlaybackSpeed(const float value) { + if (value != 0.5f && value != 1.0f && value != 2.0f) return false; + tickForecastClock(std::chrono::steady_clock::now()); return forecastClock.setPlaybackSpeed(value); } @@ -470,6 +484,32 @@ bool RasterArraySource::advanceForecastClock(const uint64_t elapsedMs) { return changed; } +bool RasterArraySource::tickForecastClock(const std::chrono::steady_clock::time_point now) { + // refreshV4RendererState() notifies SourceObserver synchronously. That may + // re-enter Map::onUpdate before the outer call returns; without this guard + // every nested update advances time again and overflows the UI thread stack. + if (forecastTickInProgress) return false; + forecastTickInProgress = true; + struct ResetTickGuard { + bool& flag; + ~ResetTickGuard() { flag = false; } + } reset{forecastTickInProgress}; + if (forecastClock.playbackState() != particle::ForecastPlaybackState::Playing || + forecastClock.requestedForecastTime() == 0 || encodedFieldStore.fieldCount() == 0) { + lastForecastTick = {}; + return false; + } + if (lastForecastTick == std::chrono::steady_clock::time_point{}) { + lastForecastTick = now; + return false; + } + if (now <= lastForecastTick) return false; + const auto elapsed = std::chrono::duration_cast(now - lastForecastTick).count(); + if (elapsed <= 0) return false; + lastForecastTick += std::chrono::milliseconds(elapsed); + return advanceForecastClock(static_cast(elapsed)); +} + particle::ColorProjectionResult RasterArraySource::resolveColorProjection() const { if (!colorBinding) return {particle::BindingProjectionError::InvalidBinding, {}}; return particle::BindingProjectionResolver::resolve( @@ -484,35 +524,50 @@ particle::ParticleProjectionResult RasterArraySource::resolveParticleProjection( bool RasterArraySource::clearEncodedField(const std::string& fieldKey) { const bool changed = encodedFieldStore.clear(fieldKey); + if (changed && encodedFieldStore.fieldCount() == 0) lastForecastTick = {}; if (changed) refreshV4RendererState(); return changed; } void RasterArraySource::clearAllEncodedFields() { encodedFieldStore.clearAll(); + lastForecastTick = {}; refreshV4RendererState(); } void RasterArraySource::refreshV4RendererState() { Impl::V4RendererState next; + const auto& current = impl().v4RendererState; if (colorBinding) { const auto resolved = resolveColorProjection(); - if (const auto materialized = resolved.accepted() ? particle::materializeColorProjection(*resolved.projection) - : std::nullopt) - next.color = std::make_shared(std::move(*materialized)); - if (next.color) - particle::incrementRendererDebugCounter(particle::RendererDebugCounter::V4ProjectionMaterialize); + if (resolved.accepted()) { + if (current.color && current.colorInput && + sameMaterializationInputs(*current.colorInput, *resolved.projection)) { + auto reused = std::make_shared(*current.color); + reused->sampleState = resolved.projection->sampleState; + next.color = std::move(reused); + } else if (auto materialized = particle::materializeColorProjection(*resolved.projection)) { + particle::incrementRendererDebugCounter(particle::RendererDebugCounter::V4ProjectionMaterialize); + next.color = std::make_shared(std::move(*materialized)); + } + if (next.color) next.colorInput = *resolved.projection; + } } if (particleBinding) { const auto resolved = resolveParticleProjection(); - if (const auto materialized = resolved.accepted() - ? particle::materializeParticleProjection(*resolved.projection) - : std::nullopt) - next.particle = std::make_shared(std::move(*materialized)); - if (next.particle) - particle::incrementRendererDebugCounter(particle::RendererDebugCounter::V4ProjectionMaterialize); + if (resolved.accepted()) { + if (current.particle && current.particleInput && + sameMaterializationInputs(*current.particleInput, *resolved.projection)) { + auto reused = std::make_shared(*current.particle); + reused->sampleState = resolved.projection->sampleState; + next.particle = std::move(reused); + } else if (auto materialized = particle::materializeParticleProjection(*resolved.projection)) { + particle::incrementRendererDebugCounter(particle::RendererDebugCounter::V4ProjectionMaterialize); + next.particle = std::make_shared(std::move(*materialized)); + } + if (next.particle) next.particleInput = *resolved.projection; + } } - const auto& current = impl().v4RendererState; if (sameProjection(current.color, next.color) && sameProjection(current.particle, next.particle)) return; auto updated = makeMutable(impl()); updated->v4RendererState = std::move(next); diff --git a/src/mbgl/style/sources/raster_array_source_impl.hpp b/src/mbgl/style/sources/raster_array_source_impl.hpp index c7ce1a71e..7c38a2955 100644 --- a/src/mbgl/style/sources/raster_array_source_impl.hpp +++ b/src/mbgl/style/sources/raster_array_source_impl.hpp @@ -12,6 +12,11 @@ public: struct V4RendererState { std::shared_ptr color; std::shared_ptr particle; + // Retain the exact immutable inputs used to build the renderer snapshots. + // Pointer identity is a conservative revision key and avoids hashing or + // comparing the decoded field contents on each forecast-clock tick. + std::optional colorInput; + std::optional particleInput; }; Impl(std::string sourceID, std::string fixtureID_, diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 21ad10e66..8bb807359 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -19,6 +19,7 @@ add_library( ${PROJECT_SOURCE_DIR}/test/math/wrap.test.cpp ${PROJECT_SOURCE_DIR}/test/platform/settings.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/field_runtime.test.cpp + ${PROJECT_SOURCE_DIR}/test/particle/particle_gpu_math.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/field_binding.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/field_profile_contract_json.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/qbin_v2_decoder.test.cpp diff --git a/test/particle/field_runtime.test.cpp b/test/particle/field_runtime.test.cpp index 0533c53b1..621f96b59 100644 --- a/test/particle/field_runtime.test.cpp +++ b/test/particle/field_runtime.test.cpp @@ -282,18 +282,18 @@ TEST(ParticleFieldRuntime, ForecastClockPreservesHalfMillisecondRemainder) { ASSERT_TRUE(clock.setRequestedForecastTime(1'000)); ASSERT_TRUE(clock.setPlaybackSpeed(0.5f)); clock.play(); - EXPECT_TRUE(clock.advance(1)); + EXPECT_FALSE(clock.advance(1)); EXPECT_EQ(clock.requestedForecastTime(), 1'000); ASSERT_TRUE(clock.setRequestedForecastTime(2'000)); - EXPECT_TRUE(clock.advance(1)); + EXPECT_FALSE(clock.advance(1)); EXPECT_EQ(clock.requestedForecastTime(), 2'000); EXPECT_TRUE(clock.advance(1)); EXPECT_EQ(clock.requestedForecastTime(), 2'001); ASSERT_TRUE(clock.setRequestedForecastTime(1'000)); - EXPECT_TRUE(clock.advance(1)); + EXPECT_FALSE(clock.advance(1)); EXPECT_EQ(clock.requestedForecastTime(), 1'000); - for (size_t i = 0; i < 8; ++i) EXPECT_TRUE(clock.advance(1)); + for (size_t i = 0; i < 8; ++i) EXPECT_EQ(clock.advance(1), i % 2 == 0); EXPECT_EQ(clock.requestedForecastTime(), 1'004); clock.pause(); EXPECT_FALSE(clock.advance(10)); diff --git a/test/particle/particle_gpu_math.test.cpp b/test/particle/particle_gpu_math.test.cpp new file mode 100644 index 000000000..988762011 --- /dev/null +++ b/test/particle/particle_gpu_math.test.cpp @@ -0,0 +1,88 @@ +#include +#include + +#include + +#include +#include + +using namespace mbgl; + +TEST(ParticleGpuField, EightBitSignedZeroAndCancellation) { + constexpr float scale = 1.0f; + const auto zero = particle::encodeSignedVelocity8(0.0f, scale, true); + const auto east = particle::encodeSignedVelocity8(0.25f, scale, true); + const auto west = particle::encodeSignedVelocity8(-0.25f, scale, true); + EXPECT_EQ(zero, 128); + EXPECT_FLOAT_EQ(particle::decodeSignedVelocity8(zero, scale), 0.0f); + EXPECT_EQ(static_cast(east) + static_cast(west), 256); + EXPECT_FLOAT_EQ((particle::decodeSignedVelocity8(east, scale) + + particle::decodeSignedVelocity8(west, scale)) * 0.5f, 0.0f); + EXPECT_LE(std::abs(particle::decodeSignedVelocity8(east, scale) - 0.25f), scale / 254.0f); + EXPECT_EQ(particle::encodeSignedVelocity8(std::numeric_limits::quiet_NaN(), scale, false), 128); + EXPECT_EQ(particle::encodeSignedVelocity8(1.0f, scale, false), 128); + // RGBA8 cannot preserve values below half a quantum; this explicit bound + // prevents a silent claim of arbitrary micro-wind accuracy. + EXPECT_EQ(particle::encodeSignedVelocity8(0.001f, scale, true), 128); +} + +TEST(ParticleGpuField, EndpointIdentityCoversBothPlanesValidityGridEncodingAndRevision) { + particle::ParticleFieldSnapshot a; + a.fieldID = "wind"; a.fieldType = "qbin-v4"; a.displayDomain = "GLOBAL"; + a.generation = 7; a.dataVersion = 3; a.validTime = 1000; + a.width = 1; a.height = 1; a.vector = {1.0f, 2.0f}; a.valid = {1}; + a.vectorSemantics = particle::VectorSemantics::Velocity; + a.gpuEncodingIdentity = "float32-le"; + auto b = a; + EXPECT_TRUE(particle::sameFieldGpuEndpoint(a, b)); + b.vector[1] = 3.0f; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b)); + b = a; b.valid[0] = 0; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b)); + b = a; b.west = 1.0; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b)); + b = a; b.gpuEncodingIdentity = "int16-scaled"; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b)); + b = a; ++b.dataVersion; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b)); + b = a; ++b.validTime; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b)); + b = a; ++b.generation; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b)); + b = a; b.vectorSemantics = particle::VectorSemantics::Direction; + EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b)); +} + +TEST(ParticleGpuField, BoundaryReusesOldT1AndMixOnlyRequiresNoEndpointUpload) { + particle::ParticleFieldSnapshot oldT0; + oldT0.fieldID = "wind"; oldT0.generation = 1; oldT0.validTime = 1000; + oldT0.width = 1; oldT0.height = 1; oldT0.vector = {1.0f, 2.0f}; oldT0.valid = {1}; + auto oldT1 = oldT0; oldT1.validTime = 2000; + auto newT1 = oldT0; newT1.validTime = 3000; + EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, oldT0), particle::GpuEndpointSource::OldT0); + EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, oldT1), particle::GpuEndpointSource::OldT1); + EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, newT1), particle::GpuEndpointSource::Upload); + // Changing MIX alone leaves the two immutable endpoint decisions intact. + const float mixA = 0.25f, mixB = 0.75f; + EXPECT_NE(mixA, mixB); + EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, oldT0), particle::GpuEndpointSource::OldT0); + EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, oldT1), particle::GpuEndpointSource::OldT1); + auto onlyV = oldT1; onlyV.vector[1] = -2.0f; + EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, onlyV), particle::GpuEndpointSource::Upload); + auto onlyValidity = oldT1; onlyValidity.valid[0] = 0; + EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, onlyValidity), particle::GpuEndpointSource::Upload); +} + +TEST(ParticleGpuField, MercatorVelocityUsesSameLatitudeMetricForFourDirections) { + constexpr double circumference = 40075016.68557849; + for (const double latitude : {0.0, 45.0, 60.0}) { + const double expected = 10.0 / (circumference * std::cos(latitude * 3.14159265358979323846 / 180.0)); + const auto east = particle::mercatorVelocityDelta(10, 0, latitude, 1); + const auto west = particle::mercatorVelocityDelta(-10, 0, latitude, 1); + const auto north = particle::mercatorVelocityDelta(0, 10, latitude, 1); + const auto south = particle::mercatorVelocityDelta(0, -10, latitude, 1); + EXPECT_NEAR(east[0], expected, 1e-15); + EXPECT_NEAR(west[0], -expected, 1e-15); + EXPECT_NEAR(north[1], -expected, 1e-15); + EXPECT_NEAR(south[1], expected, 1e-15); + EXPECT_DOUBLE_EQ(east[1], 0); + EXPECT_DOUBLE_EQ(north[0], 0); + } + const auto polar = particle::mercatorVelocityDelta(10, 10, 90, 2); + EXPECT_TRUE(std::isfinite(polar[0])); + EXPECT_TRUE(std::isfinite(polar[1])); + EXPECT_DOUBLE_EQ(polar[0], -polar[1]); +} diff --git a/test/source/raster_array_v4_host_api.test.cpp b/test/source/raster_array_v4_host_api.test.cpp index 5a51d91f9..aa48c3c59 100644 --- a/test/source/raster_array_v4_host_api.test.cpp +++ b/test/source/raster_array_v4_host_api.test.cpp @@ -1,8 +1,10 @@ #include #include #include +#include #include #include +#include #include @@ -332,6 +334,147 @@ TEST(Source, RasterArrayV4HostApiResolvesIndependentBindingsFromOneForecastClock EXPECT_TRUE(source.setForecastPlaybackSpeed(2.0f)); } +TEST(Source, RasterArrayV4ProductionClockTicksOnceForBothBindingsAndResetsOnCommands) { + style::RasterArraySource source{"v4-clock"}; + submit(source, "wind", "wind", Base, 1); + submit(source, "wind", "wind", Base + Hour, 2); + source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast}, + particle::FieldValueClass::VelocityComponent, 1}); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, + "wind", {1, 1, particle::FieldComponent::UEast}, + {2, 1, particle::FieldComponent::VNorth}, {}, 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + 1000)); + source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing); + const auto origin = std::chrono::steady_clock::now(); + EXPECT_FALSE(source.tickForecastClock(origin)); + EXPECT_TRUE(source.tickForecastClock(origin + std::chrono::milliseconds(100))); + EXPECT_FALSE(source.tickForecastClock(origin + std::chrono::milliseconds(100))); + const auto color = source.resolveColorProjection(); + const auto particle = source.resolveParticleProjection(); + ASSERT_TRUE(color.accepted()); + ASSERT_TRUE(particle.accepted()); + EXPECT_EQ(color.projection->sampleState.requestedForecastTimeMs, Base + 1100); + EXPECT_EQ(particle.projection->sampleState.requestedForecastTimeMs, Base + 1100); + source.setForecastPlaybackState(particle::ForecastPlaybackState::Paused); + EXPECT_FALSE(source.tickForecastClock(origin + std::chrono::seconds(3))); + EXPECT_EQ(source.resolveParticleProjection().projection->sampleState.requestedForecastTimeMs, Base + 1100); + ASSERT_TRUE(source.setForecastPlaybackSpeed(2.0f)); + source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing); + EXPECT_FALSE(source.tickForecastClock(origin + std::chrono::seconds(4))); + EXPECT_TRUE(source.tickForecastClock(origin + std::chrono::milliseconds(4100))); + EXPECT_EQ(source.resolveParticleProjection().projection->sampleState.requestedForecastTimeMs, Base + 1300); + ASSERT_TRUE(source.setRequestedForecastTime(Base + 2000)); + EXPECT_FALSE(source.tickForecastClock(origin + std::chrono::seconds(5))); + EXPECT_EQ(source.resolveParticleProjection().projection->sampleState.requestedForecastTimeMs, Base + 2000); + source.clearAllEncodedFields(); + EXPECT_FALSE(source.tickForecastClock(origin + std::chrono::seconds(6))); + EXPECT_FALSE(source.resolveParticleProjection().accepted()); +} + +TEST(Source, RasterArrayV4ClockSourceNotificationReentryDoesNotAdvanceTwice) { + style::RasterArraySource source{"v4-reentry"}; + submit(source, "wind", "wind", Base, 1); + submit(source, "wind", "wind", Base + Hour, 2); + source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast}, + particle::FieldValueClass::VelocityComponent, 1}); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, + "wind", {1, 1, particle::FieldComponent::UEast}, + {2, 1, particle::FieldComponent::VNorth}, {}, 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + 1000)); + source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing); + const auto origin = std::chrono::steady_clock::now(); + EXPECT_FALSE(source.tickForecastClock(origin)); + + struct ReentryObserver final : style::SourceObserver { + style::RasterArraySource& source; + std::chrono::steady_clock::time_point nestedTime; + int notifications = 0; + bool nestedAdvanced = false; + ReentryObserver(style::RasterArraySource& src, std::chrono::steady_clock::time_point time) + : source(src), nestedTime(time) {} + void onSourceChanged(style::Source&) override { + ++notifications; + nestedAdvanced = source.tickForecastClock(nestedTime) || nestedAdvanced; + } + } observer(source, origin + std::chrono::milliseconds(200)); + source.setObserver(&observer); + ASSERT_TRUE(source.tickForecastClock(origin + std::chrono::milliseconds(100))); + EXPECT_GE(observer.notifications, 1); + EXPECT_FALSE(observer.nestedAdvanced); + EXPECT_EQ(source.resolveParticleProjection().projection->sampleState.requestedForecastTimeMs, Base + 1100); + ASSERT_TRUE(source.tickForecastClock(origin + std::chrono::milliseconds(200))); + EXPECT_FALSE(observer.nestedAdvanced); + EXPECT_EQ(source.resolveParticleProjection().projection->sampleState.requestedForecastTimeMs, Base + 1200); + source.setObserver(nullptr); +} + +TEST(Source, RasterArrayV4MixTicksReuseImmutableMaterializationsUntilEndpointChanges) { + style::RasterArraySource source{"v4-materialization-cache"}; + submit(source, "wind", "wind", Base, 1); + submit(source, "wind", "wind", Base + Hour, 2); + source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast}, + particle::FieldValueClass::VelocityComponent, 1}); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, + "wind", {1, 1, particle::FieldComponent::UEast}, + {2, 1, particle::FieldComponent::VNorth}, {}, 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + 1000)); + + const auto beforeTicks = particle::rendererDebugCounterSnapshot(); + const auto initialColor = source.impl().v4RendererState.color; + const auto initialParticle = source.impl().v4RendererState.particle; + ASSERT_TRUE(initialColor); + ASSERT_TRUE(initialParticle); + ASSERT_TRUE(initialColor->t0); + ASSERT_TRUE(initialColor->t1); + ASSERT_TRUE(initialParticle->t0); + ASSERT_TRUE(initialParticle->t1); + + source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing); + const auto origin = std::chrono::steady_clock::now(); + EXPECT_FALSE(source.tickForecastClock(origin)); + for (uint64_t tick = 1; tick <= 50; ++tick) + EXPECT_TRUE(source.tickForecastClock(origin + std::chrono::milliseconds(tick * 16))); + + const auto afterTicks = particle::rendererDebugCounterSnapshot(); + const auto mixedColor = source.impl().v4RendererState.color; + const auto mixedParticle = source.impl().v4RendererState.particle; + ASSERT_TRUE(mixedColor); + ASSERT_TRUE(mixedParticle); + EXPECT_EQ(afterTicks.v4ProjectionMaterialize, beforeTicks.v4ProjectionMaterialize); + EXPECT_EQ(afterTicks.fieldTextureUpload, beforeTicks.fieldTextureUpload); + EXPECT_EQ(afterTicks.gpuResourceCreate, beforeTicks.gpuResourceCreate); + EXPECT_EQ(afterTicks.gpuResourceRelease, beforeTicks.gpuResourceRelease); + EXPECT_EQ(mixedColor->t0, initialColor->t0); + EXPECT_EQ(mixedColor->t1, initialColor->t1); + EXPECT_EQ(mixedParticle->t0, initialParticle->t0); + EXPECT_EQ(mixedParticle->t1, initialParticle->t1); + EXPECT_EQ(mixedParticle->sampleState.requestedForecastTimeMs, Base + 1800); + + submit(source, "wind", "wind", Base + 2 * Hour, 3); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + 1000)); + const auto afterEndpointChange = particle::rendererDebugCounterSnapshot(); + const auto advancedParticle = source.impl().v4RendererState.particle; + ASSERT_TRUE(advancedParticle); + EXPECT_EQ(afterEndpointChange.v4ProjectionMaterialize, afterTicks.v4ProjectionMaterialize + 2); + EXPECT_NE(advancedParticle->t0, initialParticle->t0); + EXPECT_NE(advancedParticle->t1, initialParticle->t1); + + const auto beforeRevision = afterEndpointChange.v4ProjectionMaterialize; + EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 4), windContract(), qbin(Base + Hour)), + style::RasterArrayEncodedFieldSubmitResult::AcceptedReplacedRevision); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + 1000)); + const auto afterRevision = particle::rendererDebugCounterSnapshot(); + const auto revisedParticle = source.impl().v4RendererState.particle; + ASSERT_TRUE(revisedParticle); + EXPECT_EQ(afterRevision.v4ProjectionMaterialize, beforeRevision + 2); + EXPECT_NE(revisedParticle->t0, advancedParticle->t0); + EXPECT_NE(revisedParticle->t1, advancedParticle->t1); + + source.clearAllEncodedFields(); + EXPECT_FALSE(source.impl().v4RendererState.color); + EXPECT_FALSE(source.impl().v4RendererState.particle); +} + TEST(Source, RasterArrayV4HostApiClearRetainsBindingsAndNeverTouchesV3) { style::RasterArraySource source{"v4"}; const auto v3T0 = particle::OfflineFixtureParticleAdapter::load("uniform-east", 71); diff --git a/test/style/source.test.cpp b/test/style/source.test.cpp index 040f72c08..10c29d56d 100644 --- a/test/style/source.test.cpp +++ b/test/style/source.test.cpp @@ -284,8 +284,17 @@ TEST(Source, RasterArrayGpuContentIdentityExcludesRuntimeGeneration) { ++changed.dataVersion; EXPECT_TRUE(particle::fieldIdentityChanged(east, reloadedEast)); + // Decoded content equivalence intentionally ignores producer generation and + // dataVersion; the stricter GPU endpoint identity must not ignore either. EXPECT_TRUE(particle::sameFieldGpuContent(east, reloadedEast)); - EXPECT_FALSE(particle::sameFieldGpuContent(east, changed)); + EXPECT_TRUE(particle::sameFieldGpuContent(east, changed)); + EXPECT_FALSE(particle::sameFieldGpuEndpoint(east, reloadedEast)); + EXPECT_FALSE(particle::sameFieldGpuEndpoint(reloadedEast, changed)); + + auto changedBytes = changed; + changedBytes.vector[0] += 1.0f; + EXPECT_FALSE(particle::sameFieldGpuContent(east, changedBytes)); + EXPECT_FALSE(particle::sameFieldGpuEndpoint(east, changedBytes)); } TEST(Source, ParticleFieldDataAdapterAcceptsNeutralDescriptor) {