diff --git a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp index aff71d488..618dc42e9 100644 --- a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp +++ b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -339,6 +340,7 @@ public: bool trailCameraInvalidated = false; bool viewportDensityActive = false; bool viewportDensityLogged = false; + ParticleViewportReseedGesture viewportReseedGesture; uint64_t lastCameraLogFrame = 0; std::chrono::steady_clock::time_point lastPlaybackHeartbeat{}; std::chrono::steady_clock::time_point lastSimulationTick{}; @@ -419,6 +421,7 @@ public: pending = false; bootstrapPending = false; staticFreezeLogged = false; + viewportReseedGesture.cancel(); lastSimulationTick = {}; for (const auto& target : stateTargets) { if (target && target->getLayerGroup(0)) { @@ -1676,7 +1679,11 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, std::abs(resources->trailCameraZoom - transformState.getZoom()) > 0.0001 || std::abs(resources->trailCameraBearing - transformState.getBearing()) > 0.0001 || std::abs(resources->trailCameraPitch - transformState.getPitch()) > 0.0001); + bool cameraBecameStable = false; if (viewportChanged) { + if (transformChanged) { + resources->viewportReseedGesture.begin(resources->trailCameraZoom); + } const auto previousSize = resources->trailSize; resources->trailTargets = {}; resources->trailSize = {}; @@ -1690,6 +1697,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, std::to_string(previousSize.height) + " new=" + std::to_string(viewport.width) + "x" + std::to_string(viewport.height) + " statePreserved=true fieldResubmit=false timelineReset=false"); } else if (transformChanged) { + resources->viewportReseedGesture.begin(resources->trailCameraZoom); if (!resources->trailCameraInvalidated) { const auto reason = std::abs(resources->trailCameraZoom - transformState.getZoom()) > 0.0001 ? "zoom" : std::abs(resources->trailCameraBearing - transformState.getBearing()) > 0.0001 @@ -1707,9 +1715,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, } else if (resources->trailCameraInvalidated && cameraNow - resources->lastTrailCameraChange >= TrailCameraStableDelay) { resources->trailCameraInvalidated = false; - Log::Info( - Event::Render, - "ParticleTrail: event=CAMERA_STABLE_RESUME statePreserved=true fieldResubmit=false timelineReset=false"); + cameraBecameStable = true; } resources->trailCameraKnown = true; resources->trailCameraLatitude = trailCamera.latitude(); @@ -1724,6 +1730,35 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, // lifecycle respawns stay in the full Field. The viewport domain is only // enabled after the existing stable-camera debounce has completed. resources->respawnBounds = resources->viewportDensityActive ? respawnDomain.bounds : fieldBounds(resources->field); + if (cameraBecameStable) { + const auto reseedDecision = resources->viewportReseedGesture.finish(transformState.getZoom(), + resources->viewportDensityActive); + if (reseedDecision.action == ParticleViewportReseedAction::Reseed) { + resources->stateSeeded = false; + resources->trailSeeded = false; + const auto& bounds = resources->respawnBounds; + Log::Info(Event::Render, + "ParticleDensity: event=VIEWPORT_RESEED reason=ZOOM_OUT startZoom=" + + std::to_string(reseedDecision.startZoom) + " endZoom=" + + std::to_string(reseedDecision.endZoom) + " respawnBounds=[" + + std::to_string(bounds[0]) + "," + std::to_string(bounds[1]) + "," + + std::to_string(bounds[2]) + "," + std::to_string(bounds[3]) + + "] stateReseed=true trailReseed=true fieldResubmit=false timelineReset=false " + "gpuResourceRebuild=false"); + } else if (reseedDecision.action == ParticleViewportReseedAction::SkipInvalidDomain) { + Log::Info(Event::Render, + "ParticleDensity: event=VIEWPORT_RESEED reason=ZOOM_OUT action=SKIP " + "skipReason=INVALID_RESPAWN_DOMAIN startZoom=" + + std::to_string(reseedDecision.startZoom) + " endZoom=" + + std::to_string(reseedDecision.endZoom) + + " stateReseed=false trailReseed=false fieldResubmit=false timelineReset=false " + "gpuResourceRebuild=false"); + } + Log::Info(Event::Render, + "ParticleTrail: event=CAMERA_STABLE_RESUME statePreserved=" + + std::string(reseedDecision.action == ParticleViewportReseedAction::Reseed ? "false" : "true") + + " fieldResubmit=false timelineReset=false"); + } if (resources->formalDisplay && !resources->trailCameraInvalidated && !resources->viewportDensityLogged) { const auto& bounds = resources->respawnBounds; const auto& currentFieldBounds = fieldBounds(resources->field); diff --git a/src/mbgl/renderer/particle_viewport_reseed.hpp b/src/mbgl/renderer/particle_viewport_reseed.hpp new file mode 100644 index 000000000..d1521e275 --- /dev/null +++ b/src/mbgl/renderer/particle_viewport_reseed.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include + +namespace mbgl { + +// Ignore sub-step zoom noise while remaining far below a deliberate pinch or +// animated zoom level change. +constexpr double ParticleViewportReseedZoomEpsilon = 0.05; + +enum class ParticleViewportReseedAction : uint8_t { + None, + Reseed, + SkipInvalidDomain, +}; + +struct ParticleViewportReseedDecision { + ParticleViewportReseedAction action = ParticleViewportReseedAction::None; + double startZoom = 0.0; + double endZoom = 0.0; +}; + +class ParticleViewportReseedGesture { +public: + void begin(double zoom) noexcept { + if (!active) { + active = true; + startZoom = zoom; + } + } + + void cancel() noexcept { active = false; } + + bool isActive() const noexcept { return active; } + double getStartZoom() const noexcept { return startZoom; } + + ParticleViewportReseedDecision finish(double endZoom, bool viewportDomainValid) noexcept { + if (!active) { + return {}; + } + + active = false; + const auto gestureStartZoom = startZoom; + if (endZoom >= gestureStartZoom - ParticleViewportReseedZoomEpsilon) { + return {ParticleViewportReseedAction::None, gestureStartZoom, endZoom}; + } + + return {viewportDomainValid ? ParticleViewportReseedAction::Reseed + : ParticleViewportReseedAction::SkipInvalidDomain, + gestureStartZoom, + endZoom}; + } + +private: + bool active = false; + double startZoom = 0.0; +}; + +} // namespace mbgl diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 9d6dadd2f..61511defd 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -27,6 +27,7 @@ add_library( ${PROJECT_SOURCE_DIR}/test/plugin/plugin.test.cpp ${PROJECT_SOURCE_DIR}/test/renderer/image_manager.test.cpp ${PROJECT_SOURCE_DIR}/test/renderer/pattern_atlas.test.cpp + ${PROJECT_SOURCE_DIR}/test/renderer/particle_viewport_reseed.test.cpp ${PROJECT_SOURCE_DIR}/test/renderer/retained_underlay_cache.test.cpp ${PROJECT_SOURCE_DIR}/test/renderer/shader_registry.test.cpp $<$:${PROJECT_SOURCE_DIR}/test/renderer/wgsl_preprocessor.test.cpp> diff --git a/test/renderer/particle_viewport_reseed.test.cpp b/test/renderer/particle_viewport_reseed.test.cpp new file mode 100644 index 000000000..5f7ab3bdb --- /dev/null +++ b/test/renderer/particle_viewport_reseed.test.cpp @@ -0,0 +1,81 @@ +#include + +#include + +using namespace mbgl; + +TEST(ParticleViewportReseed, ZoomOutBeyondEpsilonReseedsOnce) { + ParticleViewportReseedGesture gesture; + gesture.begin(10.0); + + const auto decision = gesture.finish(8.0, true); + EXPECT_EQ(decision.action, ParticleViewportReseedAction::Reseed); + EXPECT_DOUBLE_EQ(decision.startZoom, 10.0); + EXPECT_DOUBLE_EQ(decision.endZoom, 8.0); + EXPECT_FALSE(gesture.isActive()); + EXPECT_EQ(gesture.finish(7.0, true).action, ParticleViewportReseedAction::None); +} + +TEST(ParticleViewportReseed, ZoomInDoesNotReseed) { + ParticleViewportReseedGesture gesture; + gesture.begin(8.0); + + EXPECT_EQ(gesture.finish(10.0, true).action, ParticleViewportReseedAction::None); + EXPECT_FALSE(gesture.isActive()); +} + +TEST(ParticleViewportReseed, PanDoesNotReseed) { + ParticleViewportReseedGesture gesture; + gesture.begin(8.0); + + EXPECT_EQ(gesture.finish(8.0, true).action, ParticleViewportReseedAction::None); +} + +TEST(ParticleViewportReseed, RotateOrPitchWithoutZoomDecreaseDoesNotReseed) { + ParticleViewportReseedGesture gesture; + gesture.begin(8.0); + + EXPECT_EQ(gesture.finish(8.01, true).action, ParticleViewportReseedAction::None); +} + +TEST(ParticleViewportReseed, ZoomJitterWithinEpsilonDoesNotReseed) { + ParticleViewportReseedGesture gesture; + gesture.begin(8.0); + + EXPECT_EQ(gesture.finish(8.0 - ParticleViewportReseedZoomEpsilon, true).action, + ParticleViewportReseedAction::None); +} + +TEST(ParticleViewportReseed, InvalidDomainConsumesZoomOutRequest) { + ParticleViewportReseedGesture gesture; + gesture.begin(10.0); + + const auto decision = gesture.finish(8.0, false); + EXPECT_EQ(decision.action, ParticleViewportReseedAction::SkipInvalidDomain); + EXPECT_FALSE(gesture.isActive()); + EXPECT_EQ(gesture.finish(7.0, true).action, ParticleViewportReseedAction::None); +} + +TEST(ParticleViewportReseed, ContinuousInvalidationKeepsFirstStartZoom) { + ParticleViewportReseedGesture gesture; + gesture.begin(10.0); + gesture.begin(9.0); + gesture.begin(8.0); + + EXPECT_DOUBLE_EQ(gesture.getStartZoom(), 10.0); + const auto decision = gesture.finish(7.0, true); + EXPECT_DOUBLE_EQ(decision.startZoom, 10.0); + EXPECT_EQ(decision.action, ParticleViewportReseedAction::Reseed); +} + +TEST(ParticleViewportReseed, NextGestureRefreshesStartZoom) { + ParticleViewportReseedGesture gesture; + gesture.begin(10.0); + EXPECT_EQ(gesture.finish(8.0, true).action, ParticleViewportReseedAction::Reseed); + + gesture.begin(6.0); + EXPECT_DOUBLE_EQ(gesture.getStartZoom(), 6.0); + const auto decision = gesture.finish(5.0, true); + EXPECT_DOUBLE_EQ(decision.startZoom, 6.0); + EXPECT_EQ(decision.action, ParticleViewportReseedAction::Reseed); +}