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

@@ -0,0 +1,16 @@
# STEP-055 Android 真实 WeatherV2 风场综合可视化 v6
状态:**IMPLEMENTED / WAITING_ANDROID_VALIDATION**
Starting HEAD:`067bfdc38baa32d6e474a502d67bc0910d18e247`
## 低缩放可见性与 Trail 稳定性修复
- visual scale 使用 `effectiveScale = clamp(1200 * 2^(9 - zoom), 300, 153600)`;simulation step 再限制为最多 `2400` seconds/frame。典型 60 FPS:z2≈2400(cap)、z5≈320、z9≈20 simulation seconds/frame。
- canonical U/V=m/s 与 m/s→Web Mercator 物理换算未改;只是 validation visual clock 依据 zoom 保持可见的屏幕位移。
- 生命周期改为 real seconds:base=12,hash jitter 后为 9–15 real seconds;State B 使用 wall delta,不再使用 simulation delta,因此 zoom、Timeline speed、Timeline Pause 不改变寿命。
- 普通 camera transform 首次触发 `CAMERA_INVALIDATE`,暂停 Trail segment 累积而 State 继续;连续操作不重复重建 texture;150ms 稳定后 `CAMERA_STABLE_RESUME`。仅真正 viewport size 改变才 `VIEWPORT_RESIZE` 并重新分配 Trail A/B。
State Float、Trail UByte、真实 previous→current segment、respawn/hash stagger、time-based fade、canonical、Timeline、Runtime Window、WeatherColorLayer 和 REAL WEATHER UI 保持不变。
shader generator、Linux OpenGL 精确编译、既有非 Jest style layer 精确编译和 `git diff --check` 已通过;Android 与 Jest 未执行。

View File

@@ -0,0 +1,20 @@
# STEP-055 Android 真实 WeatherV2 风场综合可视化人工验收指令 v6
环境:Windows 11;设备 `192.168.1.146`。Android 构建、安装和设备操作由用户手工执行。
```powershell
cd platform\android
$env:RENDERER='opengl'; $env:BUILDTYPE='Debug'; make apackage --console=plain
adb -s 192.168.1.146 install -r <APK路径>
adb -s 192.168.1.146 logcat -c
adb -s 192.168.1.146 logcat | Select-String 'Particle(Motion|Lifecycle|Trail|Timeline|GPU|StateTarget)'
```
1. REAL WEATHER 自动 fit(z≈2–3):粒子应持续可见和运动,不应只剩 WeatherColorLayer,也不得回到固定粗橙色短棍。
2. z≈5:粒子仍可见,`ParticleMotion` 检查 `referenceZoom=9`、有效 scale 和 simulation delta。
3. z≈9:不应飞跃或高频越界 respawn;日志中的 simulation delta 不得超过 2400。
4. Timeline Pause:forecast 不推进,Particle simulation 与 wall-time lifecycle 继续;`ParticleLifecycle` 应为 `lifecycleBaseRealSeconds=12`,不含 simulation lifecycle 字段。
5. 连续 pinch zoom/pan/rotate:每段交互最多一次 `CAMERA_INVALIDATE`,停止约 150ms 后一条 `CAMERA_STABLE_RESUME`;State/Field/Timeline 不重置。只有实际 size 变化才出现 `VIEWPORT_RESIZE`。
6. PLAY 至 T3:继续确认 OLD_T1→NEW_T0 reuse、only NEW_T1 upload、`simulationRebuild=false`、`trailCleared=false`。
所有 ADB 命令必须显式使用 `adb -s 192.168.1.146 ...`。若失败,保留完整 Particle 日志;不要通过改回 world-length streak 或提高固定 speed scale 规避。

View File

@@ -201,7 +201,9 @@ void main() {
float maxAge = u_marker.z * mix(0.75, 1.25, lifecycleHash);
float initialAgeHash = particleHash(v_uv + vec2(71.19, 29.47));
bool respawn = initialSeed;
float age = initialSeed ? initialAgeHash * maxAge : previous.b + u_pass.y;
// Lifecycle is wall-clock based: zoom-aware visual advection and
// forecast playback must not change respawn cadence.
float age = initialSeed ? initialAgeHash * maxAge : previous.b + u_pass.w;
vec2 position = previous.rg;
vec2 velocity;
bool valid = !respawn && fieldVelocityAt(position, velocity);

View File

@@ -105,7 +105,9 @@ void main() {
float maxAge = u_marker.z * mix(0.75, 1.25, lifecycleHash);
float initialAgeHash = particleHash(v_uv + vec2(71.19, 29.47));
bool respawn = initialSeed;
float age = initialSeed ? initialAgeHash * maxAge : previous.b + u_pass.y;
// Lifecycle is wall-clock based: zoom-aware visual advection and
// forecast playback must not change respawn cadence.
float age = initialSeed ? initialAgeHash * maxAge : previous.b + u_pass.w;
vec2 position = previous.rg;
vec2 velocity;
bool valid = !respawn && fieldVelocityAt(position, velocity);

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 ||
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 (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";
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,

View File

@@ -432,3 +432,6 @@
## STEP-055 生命周期错峰小修复
粒子基础生命周期调整为 14400 simulation seconds(12 real seconds),首次 seed 与 maxAge 都由稳定 hash 错峰,消除同步生命周期到期。日志改用 configuredSlots 并说明无 GPU readback;其他粒子视觉与数据链语义未改。
## STEP-055 低缩放可见性与 Trail 稳定性修复
Particle visual clock 改为基于 zoom 的有界 scale 与 2400s/frame clamp;State lifecycle 改为 12s base 的 wall-time hash jitter。普通 camera 交互只 invalidate 一次并在稳定后恢复 Trail,只有 resize 重分配 Trail texture。canonical 与 Core 时间线边界未改,待 Android 验收。