fix(step097): complete A2 clock and GPU field correctness
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
tei
2026-09-26 15:17:43 +09:00
parent 5bb1a0e635
commit f49ffa68a1
26 changed files with 610 additions and 86 deletions

View File

@@ -101,6 +101,8 @@ struct ParticleFieldSnapshot {
std::vector<float> vector;
std::vector<uint8_t> valid;
std::optional<VectorSemantics> 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;

View File

@@ -0,0 +1,33 @@
#pragma once
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
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<uint8_t>(128 + static_cast<int>(
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<float>(static_cast<int>(byte) - 128) * scale / 127.0f;
}
inline std::array<double, 2> 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

View File

@@ -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);

View File

@@ -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.x<u.respawnBounds.x||position.x>u.respawnBounds.z||position.y<u.respawnBounds.y||position.y>u.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.x<u.respawnBounds.x||position.x>u.respawnBounds.z||position.y<u.respawnBounds.y||position.y>u.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);}

View File

@@ -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<particle::ColorBinding> colorBinding;
std::optional<particle::ParticleBinding> particleBinding;
mapbox::base::WeakPtrFactory<Source> weakFactory{this};

View File

@@ -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<String> 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.
}

View File

@@ -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}]}

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -103,6 +103,7 @@ std::vector<char> charsFromData(NSData *data) {
BOOL _particleVisible;
NSString *_v4ParticleBindingFieldKey;
NSMutableSet<NSString *> *_submittedEncodedFieldKeys;
BOOL _v4ForecastPlaying;
NSTimer *_v4CadenceTimer;
}
- (BOOL)isV4ParticleCadenceReady;
@@ -147,9 +148,11 @@ std::vector<char> 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<char> 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<char> 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 {

View File

@@ -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);

View File

@@ -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) {

View File

@@ -3,6 +3,7 @@
#include <mbgl/renderer/update_parameters.hpp>
#include <mbgl/storage/file_source.hpp>
#include <mbgl/style/style_impl.hpp>
#include <mbgl/style/sources/raster_array_source.hpp>
#include <mbgl/util/exception.hpp>
#include <mbgl/util/logging.hpp>
#include <mbgl/util/traits.hpp>
@@ -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<style::RasterArraySource>()) {
static_cast<style::RasterArraySource*>(source)->tickForecastClock(now);
}
}
}
transform.updateTransitions(timePoint);

View File

@@ -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()); }

View File

@@ -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<int>(plane.key.component)) + ":" +
std::to_string(static_cast<int>(plane.dtype)) + ":" +
std::to_string(static_cast<int>(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) {

View File

@@ -217,7 +217,7 @@ bool ForecastClock::advance(const uint64_t elapsedMs) {
if (wholeMilliseconds > std::numeric_limits<uint64_t>::max() - requestedForecastTimeMs_) return false;
requestedForecastTimeMs_ += wholeMilliseconds;
fractionalHalfMillisecond_ = static_cast<uint8_t>(accumulatedHalfMilliseconds % 2);
return true;
return wholeMilliseconds != 0;
}
FieldSeriesSubmitResult EncodedFieldStore::submit(const std::string& fieldKey,

View File

@@ -9,6 +9,7 @@
#include <mbgl/gfx/index_vector.hpp>
#include <mbgl/gfx/shader_registry.hpp>
#include <mbgl/gfx/texture2d.hpp>
#include <mbgl/particle/field/particle_gpu_math.hpp>
#include <mbgl/map/transform_state.hpp>
#include <mbgl/particle/field/offline_fixture_particle_adapter.hpp>
#include <mbgl/particle/renderer_debug_counters.hpp>
@@ -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<PremultipliedImage> makeVelocityFieldImage(const particle::Parti
for (uint32_t x = 0; x < field.width; ++x) {
const auto cell = (static_cast<size_t>(y) * field.width + x) * 2;
const auto pixel = (static_cast<size_t>(y) * field.width + x) * PremultipliedImage::channels;
image->data[pixel + 0] = static_cast<uint8_t>(
std::clamp(field.vector[cell] / scale * 0.5f + 0.5f, 0.0f, 1.0f) * 255.0f);
image->data[pixel + 1] = static_cast<uint8_t>(
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<float>(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<float>(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<PremultipliedImage>& image,
@@ -1534,7 +1535,8 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
std::shared_ptr<PremultipliedImage>& 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;

View File

@@ -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 <class Projection>
bool sameProjection(const std::shared_ptr<const Projection>& a, const std::shared_ptr<const Projection>& b) {
if (static_cast<bool>(a) != static_cast<bool>(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<std::chrono::milliseconds>(now - lastForecastTick).count();
if (elapsed <= 0) return false;
lastForecastTick += std::chrono::milliseconds(elapsed);
return advanceForecastClock(static_cast<uint64_t>(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<const particle::MaterializedColorProjection>(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<particle::MaterializedColorProjection>(*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<const particle::MaterializedColorProjection>(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<const particle::MaterializedParticleProjection>(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<particle::MaterializedParticleProjection>(*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<const particle::MaterializedParticleProjection>(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>(impl());
updated->v4RendererState = std::move(next);

View File

@@ -12,6 +12,11 @@ public:
struct V4RendererState {
std::shared_ptr<const particle::MaterializedColorProjection> color;
std::shared_ptr<const particle::MaterializedParticleProjection> 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<particle::ColorProjection> colorInput;
std::optional<particle::ParticleProjection> particleInput;
};
Impl(std::string sourceID,
std::string fixtureID_,

View File

@@ -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

View File

@@ -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));

View File

@@ -0,0 +1,88 @@
#include <mbgl/particle/field/field_data_adapter.hpp>
#include <mbgl/particle/field/particle_gpu_math.hpp>
#include <gtest/gtest.h>
#include <cmath>
#include <limits>
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<int>(east) + static_cast<int>(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<float>::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]);
}

View File

@@ -1,8 +1,10 @@
#include <mbgl/particle/ingress/qbin_v2_decoder.hpp>
#include <mbgl/particle/field/field_projection_materialization.hpp>
#include <mbgl/particle/field/offline_fixture_particle_adapter.hpp>
#include <mbgl/particle/renderer_debug_counters.hpp>
#include <mbgl/style/sources/raster_array_source.hpp>
#include <mbgl/style/sources/raster_array_source_impl.hpp>
#include <mbgl/style/source_observer.hpp>
#include <gtest/gtest.h>
@@ -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);

View File

@@ -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) {