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

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