fix: stabilize particle visibility across zoom levels
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-05 11:20:33 +08:00
parent 067bfdc38b
commit f926a3f007
6 changed files with 141 additions and 33 deletions

View File

@@ -53,8 +53,13 @@ constexpr float EarthCircumferenceMeters = 40075016.68557849f;
// moves about 100 m (roughly 0.65 px at z9), while a 30 m/s sample remains
// below a four-pixel segment at that zoom.
constexpr float VisualSimulationSecondsPerRealSecond = 1200.0f;
constexpr float ParticleLifecycleSeconds = 14400.0f;
constexpr float VisualSimulationReferenceZoom = 9.0f;
constexpr float MinVisualSimulationSecondsPerRealSecond = 300.0f;
constexpr float MaxVisualSimulationSecondsPerRealSecond = 153600.0f;
constexpr float MaxSimulationDeltaSeconds = 2400.0f;
constexpr float ParticleLifecycleRealSeconds = 12.0f;
constexpr float TrailFadeReferenceFramesPerSecond = 60.0f;
constexpr auto TrailCameraStableDelay = std::chrono::milliseconds(150);
std::atomic_uint64_t nextGpuGeneration{0};
std::atomic_uint64_t nextMaskGeneration{0};
@@ -248,11 +253,13 @@ public:
bool worldWrapLogged = false;
bool antimeridianLogged = false;
bool trailCameraKnown = false;
bool trailCameraInvalidated = false;
uint64_t lastCameraLogFrame = 0;
std::chrono::steady_clock::time_point lastPlaybackHeartbeat{};
std::chrono::steady_clock::time_point lastSimulationTick{};
std::chrono::steady_clock::time_point lastMotionLog{};
std::chrono::steady_clock::time_point lastLifecycleLog{};
std::chrono::steady_clock::time_point lastTrailCameraChange{};
uint64_t maskGeneration = 0;
uint64_t fieldGeneration = 0;
uint64_t gpuFieldGeneration = 0;
@@ -393,6 +400,8 @@ public:
gfx::Texture2DPtr mask_,
gfx::Texture2DPtr stateTexture_,
float simulationStep_,
float wallDeltaSeconds_,
float effectiveVisualSpeedScale_,
int16_t worldWrap_,
uint8_t maskMode_)
: state(std::move(state_)),
@@ -404,6 +413,8 @@ public:
mask(std::move(mask_)),
stateTexture(std::move(stateTexture_)),
simulationStep(simulationStep_),
wallDeltaSeconds(wallDeltaSeconds_),
effectiveVisualSpeedScale(effectiveVisualSpeedScale_),
worldWrap(worldWrap_),
maskMode(maskMode_) {}
@@ -454,9 +465,15 @@ public:
Log::Info(Event::Render,
"ParticleMotion: fieldId=" + resources->field.fieldID + " sampleU=" +
std::to_string(u) + " sampleV=" + std::to_string(v) +
" dt=" + std::to_string(simulationStep) +
" visualSpeedScale=" + std::to_string(VisualSimulationSecondsPerRealSecond) +
" zoom=" + std::to_string(parameters.state.getZoom()) +
" referenceZoom=" + std::to_string(VisualSimulationReferenceZoom) +
" effectiveVisualSpeedScale=" + std::to_string(effectiveVisualSpeedScale) +
" wallDeltaSeconds=" + std::to_string(wallDeltaSeconds) +
" simulationDeltaSeconds=" + std::to_string(simulationStep) +
" estimatedStateDeltaWorld=" + std::to_string(dx) + "," + std::to_string(dy) +
" estimatedPixelDelta=" +
std::to_string(std::sqrt(dx * dx + dy * dy) * 512.0f *
std::exp2(static_cast<float>(parameters.state.getZoom()))) +
" simulationFrame=" + std::to_string(resources->committedFrame));
}
}
@@ -465,9 +482,7 @@ public:
Log::Info(Event::Render,
"ParticleLifecycle: configuredSlots=" + std::to_string(resources->particleCount) +
" initialAge=HASH_STAGGERED maxAge=HASH_JITTERED_0.75_1.25 "
"lifecycleBaseSimulationSeconds=" + std::to_string(ParticleLifecycleSeconds) +
" lifecycleBaseRealSeconds=" +
std::to_string(ParticleLifecycleSeconds / VisualSimulationSecondsPerRealSecond) +
"lifecycleBaseRealSeconds=" + std::to_string(ParticleLifecycleRealSeconds) +
" respawn=GPU_HASH_ON_INVALID_OR_AGE_EXPIRED"
" fieldId=" + resources->field.fieldID + " simulationFrame=" +
std::to_string(resources->committedFrame) + " readback=disabled");
@@ -494,10 +509,13 @@ public:
// be an explicit lifecycle signal, never inferred from sampled alpha.
const bool seedState = pass == ValidationPass::State && !resources->stateSeeded;
const shaders::ParticleGpuValidationUBO ubo = {
.pass = {static_cast<float>(pass), frame, maskMode == 0 ? 0.0f : 1.0f, static_cast<float>(maskMode)},
.pass = {static_cast<float>(pass),
frame,
maskMode == 0 ? 0.0f : 1.0f,
resources->formalDisplay ? wallDeltaSeconds : static_cast<float>(maskMode)},
.marker = {markerX,
resources->speedColorEnabled ? 1.0f : 0.0f,
resources->formalDisplay ? ParticleLifecycleSeconds : 0.035f,
resources->formalDisplay ? ParticleLifecycleRealSeconds : 0.035f,
resources->formalDisplay ? TrailFadeReferenceFramesPerSecond : 0.965f},
.mask = {static_cast<float>(resources->stateSize.width),
static_cast<float>(resources->stateSize.height),
@@ -633,6 +651,8 @@ private:
gfx::Texture2DPtr mask;
gfx::Texture2DPtr stateTexture;
float simulationStep;
float wallDeltaSeconds;
float effectiveVisualSpeedScale;
int16_t worldWrap;
uint8_t maskMode;
};
@@ -693,7 +713,9 @@ bool buildQuad(gfx::Context& context,
const uint8_t writeSlot,
const int16_t worldWrap,
const uint8_t maskMode,
const bool clearGroup) {
const bool clearGroup,
const float wallDeltaSeconds = 0.0f,
const float effectiveVisualSpeedScale = VisualSimulationSecondsPerRealSecond) {
if (clearGroup) {
group.clearDrawables();
}
@@ -734,7 +756,19 @@ bool buildQuad(gfx::Context& context,
builder->setTexture(stateTexture, shaders::idParticleGpuValidationStateTexture);
}
builder->addTweaker(std::make_shared<ParticleGpuValidationTweaker>(
resources, pass, readSlot, writeSlot, source, target, mask, stateTexture, simulationStep, worldWrap, maskMode));
resources,
pass,
readSlot,
writeSlot,
source,
target,
mask,
stateTexture,
simulationStep,
wallDeltaSeconds,
effectiveVisualSpeedScale,
worldWrap,
maskMode));
// DrawableBuilder owns the current drawable until flush finalizes it.
builder->flush(context);
@@ -793,6 +827,8 @@ bool buildParticlePoints(gfx::Context& context,
resources->maskTexture,
currentStateTexture,
0.0f,
0.0f,
VisualSimulationSecondsPerRealSecond,
0,
0));
builder->flush(context);
@@ -1355,26 +1391,45 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
const auto viewport = validViewportSize(transformState.getSize());
const auto trailCamera = transformState.getLatLng(LatLng::Unwrapped);
const auto cameraChanged = resources->formalDisplay && resources->trailCameraKnown &&
(resources->trailSize != viewport ||
std::abs(resources->trailCameraLatitude - trailCamera.latitude()) > 0.00001 ||
std::abs(resources->trailCameraLongitude - trailCamera.longitude()) > 0.00001 ||
std::abs(resources->trailCameraZoom - transformState.getZoom()) > 0.0001 ||
std::abs(resources->trailCameraBearing - transformState.getBearing()) > 0.0001 ||
std::abs(resources->trailCameraPitch - transformState.getPitch()) > 0.0001);
if (cameraChanged) {
const auto reason = resources->trailSize != viewport ? "resize"
: std::abs(resources->trailCameraZoom - transformState.getZoom()) > 0.0001 ? "zoom"
: std::abs(resources->trailCameraBearing - transformState.getBearing()) > 0.0001 ? "rotate"
: std::abs(resources->trailCameraPitch - transformState.getPitch()) > 0.0001 ? "pitch"
: "pan";
const auto cameraNow = std::chrono::steady_clock::now();
const bool viewportChanged = resources->formalDisplay && resources->trailTargets[0] && resources->trailTargets[1] &&
resources->trailSize != viewport;
const bool transformChanged = resources->formalDisplay && resources->trailCameraKnown &&
(std::abs(resources->trailCameraLatitude - trailCamera.latitude()) > 0.00001 ||
std::abs(resources->trailCameraLongitude - trailCamera.longitude()) > 0.00001 ||
std::abs(resources->trailCameraZoom - transformState.getZoom()) > 0.0001 ||
std::abs(resources->trailCameraBearing - transformState.getBearing()) > 0.0001 ||
std::abs(resources->trailCameraPitch - transformState.getPitch()) > 0.0001);
if (viewportChanged) {
const auto previousSize = resources->trailSize;
resources->trailTargets = {};
resources->trailSize = {};
resources->trailRead = 0;
resources->trailSeeded = false;
resources->trailCameraInvalidated = true;
resources->lastTrailCameraChange = cameraNow;
Log::Info(Event::Render,
"ParticleTrail: event=CAMERA_RESET reason=" + std::string(reason) + " statePreserved=true "
"fieldResubmit=false timelineReset=false");
"ParticleTrail: event=VIEWPORT_RESIZE old=" + std::to_string(previousSize.width) + "x" +
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) {
if (!resources->trailCameraInvalidated) {
const auto reason = std::abs(resources->trailCameraZoom - transformState.getZoom()) > 0.0001 ? "zoom"
: std::abs(resources->trailCameraBearing - transformState.getBearing()) > 0.0001 ? "rotate"
: std::abs(resources->trailCameraPitch - transformState.getPitch()) > 0.0001 ? "pitch"
: "pan";
resources->trailCameraInvalidated = true;
resources->trailSeeded = false;
Log::Info(Event::Render,
"ParticleTrail: event=CAMERA_INVALIDATE reason=" + std::string(reason) +
" statePreserved=true fieldResubmit=false timelineReset=false");
}
resources->lastTrailCameraChange = cameraNow;
} 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");
}
resources->trailCameraKnown = true;
resources->trailCameraLatitude = trailCamera.latitude();
@@ -1463,7 +1518,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
0,
maskMode,
true);
const bool particlesBuilt = !resources->formalDisplay ||
const bool particlesBuilt = !resources->formalDisplay || resources->trailCameraInvalidated ||
buildParticlePoints(context,
resources,
trailGroup,
@@ -1562,8 +1617,14 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
}
resources->lastSimulationTick = now;
const float trailWallDeltaSeconds = std::clamp(wallDeltaSeconds, 0.0f, 0.250f);
const float visualSimulationDeltaSeconds =
std::clamp(wallDeltaSeconds, 1.0f / 240.0f, 0.050f) * VisualSimulationSecondsPerRealSecond;
const float zoom = static_cast<float>(transformState.getZoom());
const float effectiveVisualSpeedScale = std::clamp(
VisualSimulationSecondsPerRealSecond * std::exp2(VisualSimulationReferenceZoom - zoom),
MinVisualSimulationSecondsPerRealSecond,
MaxVisualSimulationSecondsPerRealSecond);
const float visualSimulationDeltaSeconds = std::min(
std::clamp(wallDeltaSeconds, 1.0f / 240.0f, 0.050f) * effectiveVisualSpeedScale,
MaxSimulationDeltaSeconds);
const bool stateBuilt = buildQuad(context,
resources,
@@ -1579,7 +1640,9 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
stateWrite,
0,
maskMode,
true);
true,
trailWallDeltaSeconds,
effectiveVisualSpeedScale);
const bool trailBuilt = buildQuad(context,
resources,
trailGroup,
@@ -1594,8 +1657,10 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
trailWrite,
0,
maskMode,
true);
const bool particlesBuilt = !resources->formalDisplay ||
true,
trailWallDeltaSeconds,
effectiveVisualSpeedScale);
const bool particlesBuilt = !resources->formalDisplay || resources->trailCameraInvalidated ||
buildParticlePoints(context,
resources,
trailGroup,