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