From f8e715d080d69db779fe37ea7a5aa37c0f7c42c4 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Sat, 5 Sep 2026 12:23:15 +0800 Subject: [PATCH] fix: keep particle density stable across zoom --- ...055_Android真实WeatherV2风场综合可视化_v7.md | 28 ++++++ ...Ÿ实WeatherV2风场综合可视化_人工验收指令_v7.md | 22 +++++ .../shaders/gl/particle_gpu_validation.hpp | 15 ++- .../shaders/particle_gpu_validation_ubo.hpp | 6 +- shaders/particle_gpu_validation.fragment.glsl | 14 ++- shaders/particle_gpu_validation.vertex.glsl | 1 + .../render_particle_validation_layer.cpp | 94 ++++++++++++++++++- 粒子开发记录.md | 4 + 8 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 docs/50_Step记录/STEP-055_Android真实WeatherV2风场综合可视化_v7.md create mode 100644 docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v7.md diff --git a/docs/50_Step记录/STEP-055_Android真实WeatherV2风场综合可视化_v7.md b/docs/50_Step记录/STEP-055_Android真实WeatherV2风场综合可视化_v7.md new file mode 100644 index 000000000..ab7814497 --- /dev/null +++ b/docs/50_Step记录/STEP-055_Android真实WeatherV2风场综合可视化_v7.md @@ -0,0 +1,28 @@ +# STEP-055 Android 真实 WeatherV2 风场综合可视化 v7 + +状态:**IMPLEMENTED / WAITING_ANDROID_VALIDATION** + +Starting HEAD:`d9a9c8ed57605abca86893ba7fb3b5265db3c0dc` + +## Zoom 下 Viewport 粒子密度调整 + +- `configured particleCount` 不随 zoom 改变,仍由既有用户设置控制;本次没有按 zoom 重建 State slot,也没有新增均匀网格铺点。 +- C++ 使用当前 viewport 四角的 unwrapped Web Mercator AABB,水平和垂直各扩展 15%,再与 canonical Field bounds 取交集,作为稳定相机时的 `respawnBounds`。 +- shader 保留 `u_field_bounds` 作为唯一 Field 采样/UV 映射范围;新增独立 `u_respawn_bounds` 和 `viewportDensityActive`(`u_field_scale.z`),防止把可视范围误作数据有效范围。 +- camera moving 期间 `viewportDensityActive=false`,普通 invalid/age 生命周期仍在 full Field 复生,State 不 reset、Field 不重提、Timeline 不重置。稳定至少 150ms 后才启用 viewport domain。 +- stable 后,位于 expanded viewport 外的 slot 成为额外 respawn 条件;新出生 slot `age=0`、alpha=0,因此不会产生跨屏 segment。viewport 内的正常粒子不被统一重置。 +- 若四角 AABB 无效、交集为空或 extent 过小,安全回退 full Field bounds;日志为 `ParticleDensity mode=FIELD_FALLBACK fallback=true`,不会产生 NaN/inverted bounds。 + +## 保持的边界 + +State A/B=Float、Trail A/B=UnsignedByte、previousPosition→currentPosition、zoom-aware visual speed、wall-time lifecycle、time-based fade 与 camera Trail invalidate/stable resume 保持。canonical U/V=m/s、WeatherV2 assets、Host Submission、Timeline、Runtime Field Window、WeatherColorLayer 与 REAL WEATHER UI 未修改。 + +## 验证 + +- shader generator:PASS。 +- Linux OpenGL 精确编译 `render_particle_validation_layer.cpp`:PASS。 +- 既有非 Jest `style_layer.test.cpp` 精确编译:PASS。 +- `git diff --check`:PASS。 +- Android Gradle/APK/ADB/设备和 Jest:未执行。 + +下一步由用户按人工验收 v7 检查 z2–3/z5/z8–9 密度、相机稳定后的补充行为和 Timeline boundary 回归。 diff --git a/docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v7.md b/docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v7.md new file mode 100644 index 000000000..86c91dcb4 --- /dev/null +++ b/docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v7.md @@ -0,0 +1,22 @@ +# STEP-055 Android 真实 WeatherV2 风场综合可视化人工验收指令 v7 + +环境:Windows 11;设备 `192.168.1.146`。Android 构建、安装与设备操作由用户手工执行;Codex 未执行这些步骤。 + +```powershell +cd platform\android +$env:RENDERER='opengl'; $env:BUILDTYPE='Debug'; make apackage --console=plain +adb -s 192.168.1.146 install -r +adb -s 192.168.1.146 logcat -c +adb -s 192.168.1.146 logcat | Select-String 'Particle(Density|Motion|Lifecycle|Trail|Timeline|GPU|StateTarget)' +``` + +1. `REAL WEATHER` 自动 fit(z≈2–3):粒子持续可见、运动且自然分布;不得回到固定 world-length 粗短棍,也不应呈规则均匀点阵。 +2. z≈5:停止相机操作至少 150ms。确认 `ParticleTrail: event=CAMERA_STABLE_RESUME` 后出现一条 `ParticleDensity mode=VIEWPORT_RESPAWN cameraStable=true margin=0.15`;粒子密度应比 v8 明显改善。 +3. z≈8–9:不提高 `particleCount` 仍有足够可见粒子;不得有整批 reseed、大面积闪烁或高频越界 respawn。`ParticleDensity` 的 `respawnBounds` 必须位于 `fieldBounds` 内。 +4. 连续 pinch zoom/pan/rotate:交互期间 State 继续,`ParticleTrail` 只一次 `CAMERA_INVALIDATE`,不反复重建 texture;稳定后 resume 并启用 viewport respawn。不得出现 `SIMULATION_RESET`、Field resubmit 或 `PARTICLE_COUNT_CHANGE`。 +5. Timeline Pause:forecast cursor/mix 冻结,但 Particle simulation 和 wall-time lifecycle 继续;生命周期仍为 12 real seconds、hash jitter 9–15 seconds。 +6. `TIME → PLAY` 至 T3:继续确认 `OLD_T1 → NEW_T0` reuse、only NEW_T1 upload、`simulationRebuild=false`、`trailCleared=false`。正常 forecast boundary 不得因 density 逻辑清 Trail。 + +若 `ParticleDensity` 显示 `FIELD_FALLBACK`,保留完整日志和相机状态;这是空/无效/过小交集的 fail-safe,不得通过提高 particleCount 或改回固定 streak 绕过。 + +所有 ADB 命令必须显式使用 `adb -s 192.168.1.146 ...`。 diff --git a/include/mbgl/shaders/gl/particle_gpu_validation.hpp b/include/mbgl/shaders/gl/particle_gpu_validation.hpp index 2bf5febfa..eb8b4407f 100644 --- a/include/mbgl/shaders/gl/particle_gpu_validation.hpp +++ b/include/mbgl/shaders/gl/particle_gpu_validation.hpp @@ -21,6 +21,7 @@ layout (std140) uniform ParticleGpuValidationUBO { highp vec4 u_mask; highp vec4 u_visual; highp vec4 u_field_bounds; + highp vec4 u_respawn_bounds; highp vec4 u_field_scale; highp mat4 u_matrix; }; @@ -100,6 +101,7 @@ void main() { highp vec4 u_mask; highp vec4 u_visual; highp vec4 u_field_bounds; + highp vec4 u_respawn_bounds; highp vec4 u_field_scale; highp mat4 u_matrix; }; @@ -153,7 +155,12 @@ float particleHash(vec2 value) { vec2 respawnSeed(vec2 particleUV, vec2 entropy, float attempt) { vec2 salt = entropy * vec2(13.17, 19.91) + particleUV * vec2(97.23, 53.71) + attempt; vec2 random = vec2(particleHash(salt), particleHash(salt.yx + 41.29)); - return mix(u_field_bounds.xy, u_field_bounds.zw, random); + return mix(u_respawn_bounds.xy, u_respawn_bounds.zw, random); +} + +bool outsideRespawnBounds(vec2 position) { + return position.x < u_respawn_bounds.x || position.x > u_respawn_bounds.z || + position.y < u_respawn_bounds.y || position.y > u_respawn_bounds.w; } bool fieldVelocityAt(vec2 position, out vec2 velocity) { @@ -207,7 +214,11 @@ void main() { vec2 position = previous.rg; vec2 velocity; bool valid = !respawn && fieldVelocityAt(position, velocity); - if (!valid || age >= maxAge) { + // The field bounds remain the only sampling domain. Once the + // camera is stable, slots already outside the expanded viewport + // may recycle into its separate preferred respawn domain. + if (!valid || age >= maxAge || + (u_field_scale.z > 0.5 && !initialSeed && outsideRespawnBounds(position))) { respawn = true; } diff --git a/include/mbgl/shaders/particle_gpu_validation_ubo.hpp b/include/mbgl/shaders/particle_gpu_validation_ubo.hpp index 8664c7d80..f8e21bcf0 100644 --- a/include/mbgl/shaders/particle_gpu_validation_ubo.hpp +++ b/include/mbgl/shaders/particle_gpu_validation_ubo.hpp @@ -16,11 +16,15 @@ struct alignas(16) ParticleGpuValidationUBO { // west, north, east, south in normalized Web Mercator world coordinates. // Field textures are regional; they must never be sampled as global UVs. std::array fieldBounds; + // Preferred respawn domain. It is the expanded stable viewport intersected + // with fieldBounds, or fieldBounds when that intersection is unusable. + std::array respawnBounds; // Per-texture signed velocity decoding scales in canonical m/s. + // z is viewportDensityActive for the formal validation path. std::array fieldScale; std::array matrix; }; -static_assert(sizeof(ParticleGpuValidationUBO) == 10 * 16); +static_assert(sizeof(ParticleGpuValidationUBO) == 11 * 16); } // namespace shaders } // namespace mbgl diff --git a/shaders/particle_gpu_validation.fragment.glsl b/shaders/particle_gpu_validation.fragment.glsl index c79252e87..25f714553 100644 --- a/shaders/particle_gpu_validation.fragment.glsl +++ b/shaders/particle_gpu_validation.fragment.glsl @@ -4,6 +4,7 @@ layout (std140) uniform ParticleGpuValidationUBO { highp vec4 u_mask; highp vec4 u_visual; highp vec4 u_field_bounds; + highp vec4 u_respawn_bounds; highp vec4 u_field_scale; highp mat4 u_matrix; }; @@ -57,7 +58,12 @@ float particleHash(vec2 value) { vec2 respawnSeed(vec2 particleUV, vec2 entropy, float attempt) { vec2 salt = entropy * vec2(13.17, 19.91) + particleUV * vec2(97.23, 53.71) + attempt; vec2 random = vec2(particleHash(salt), particleHash(salt.yx + 41.29)); - return mix(u_field_bounds.xy, u_field_bounds.zw, random); + return mix(u_respawn_bounds.xy, u_respawn_bounds.zw, random); +} + +bool outsideRespawnBounds(vec2 position) { + return position.x < u_respawn_bounds.x || position.x > u_respawn_bounds.z || + position.y < u_respawn_bounds.y || position.y > u_respawn_bounds.w; } bool fieldVelocityAt(vec2 position, out vec2 velocity) { @@ -111,7 +117,11 @@ void main() { vec2 position = previous.rg; vec2 velocity; bool valid = !respawn && fieldVelocityAt(position, velocity); - if (!valid || age >= maxAge) { + // The field bounds remain the only sampling domain. Once the + // camera is stable, slots already outside the expanded viewport + // may recycle into its separate preferred respawn domain. + if (!valid || age >= maxAge || + (u_field_scale.z > 0.5 && !initialSeed && outsideRespawnBounds(position))) { respawn = true; } diff --git a/shaders/particle_gpu_validation.vertex.glsl b/shaders/particle_gpu_validation.vertex.glsl index 378cddc41..f3a988cec 100644 --- a/shaders/particle_gpu_validation.vertex.glsl +++ b/shaders/particle_gpu_validation.vertex.glsl @@ -11,6 +11,7 @@ layout (std140) uniform ParticleGpuValidationUBO { highp vec4 u_mask; highp vec4 u_visual; highp vec4 u_field_bounds; + highp vec4 u_respawn_bounds; highp vec4 u_field_scale; highp mat4 u_matrix; }; diff --git a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp index dc8580626..468d80c91 100644 --- a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp +++ b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp @@ -60,6 +60,8 @@ constexpr float MaxSimulationDeltaSeconds = 2400.0f; constexpr float ParticleLifecycleRealSeconds = 12.0f; constexpr float TrailFadeReferenceFramesPerSecond = 60.0f; constexpr auto TrailCameraStableDelay = std::chrono::milliseconds(150); +constexpr float ViewportRespawnMargin = 0.15f; +constexpr float MinimumRespawnBoundsExtent = 0.000001f; std::atomic_uint64_t nextGpuGeneration{0}; std::atomic_uint64_t nextMaskGeneration{0}; @@ -191,6 +193,65 @@ std::array fieldBounds(const particle::ParticleFieldSnapshot& field) { static_cast(mercatorY(field.south))}; } +struct RespawnDomain { + std::array bounds; + bool fallback = true; +}; + +bool validMercatorBounds(const std::array& bounds) { + return std::isfinite(bounds[0]) && std::isfinite(bounds[1]) && std::isfinite(bounds[2]) && + std::isfinite(bounds[3]) && bounds[2] - bounds[0] >= MinimumRespawnBoundsExtent && + bounds[3] - bounds[1] >= MinimumRespawnBoundsExtent; +} + +RespawnDomain viewportRespawnDomain(const TransformState& transformState, + const Size viewport, + const particle::ParticleFieldSnapshot& field) { + const auto fullFieldBounds = fieldBounds(field); + if (!validMercatorBounds(fullFieldBounds)) { + return {fullFieldBounds, true}; + } + + const std::array corners = { + ScreenCoordinate{0.0, 0.0}, + ScreenCoordinate{static_cast(viewport.width), 0.0}, + ScreenCoordinate{0.0, static_cast(viewport.height)}, + ScreenCoordinate{static_cast(viewport.width), static_cast(viewport.height)}, + }; + float minX = std::numeric_limits::infinity(); + float minY = std::numeric_limits::infinity(); + float maxX = -std::numeric_limits::infinity(); + float maxY = -std::numeric_limits::infinity(); + for (const auto& corner : corners) { + const auto latLng = transformState.screenCoordinateToLatLng(corner, LatLng::Unwrapped); + const auto x = static_cast((latLng.longitude() + 180.0) / 360.0); + const auto y = static_cast(mercatorY(latLng.latitude())); + minX = std::min(minX, x); + minY = std::min(minY, y); + maxX = std::max(maxX, x); + maxY = std::max(maxY, y); + } + const auto width = maxX - minX; + const auto height = maxY - minY; + if (!std::isfinite(width) || !std::isfinite(height) || width < MinimumRespawnBoundsExtent || + height < MinimumRespawnBoundsExtent) { + return {fullFieldBounds, true}; + } + + minX -= width * ViewportRespawnMargin; + maxX += width * ViewportRespawnMargin; + minY -= height * ViewportRespawnMargin; + maxY += height * ViewportRespawnMargin; + const std::array intersection = { + std::max(fullFieldBounds[0], minX), + std::max(fullFieldBounds[1], minY), + std::min(fullFieldBounds[2], maxX), + std::min(fullFieldBounds[3], maxY), + }; + return validMercatorBounds(intersection) ? RespawnDomain{intersection, false} + : RespawnDomain{fullFieldBounds, true}; +} + // VALIDATION ONLY: deterministic global fixture with a diagonal coast. Alpha // 255 means OCEAN, alpha 0 means LAND. It is intentionally tiny and is not a // production coastline source. @@ -254,6 +315,8 @@ public: bool antimeridianLogged = false; bool trailCameraKnown = false; bool trailCameraInvalidated = false; + bool viewportDensityActive = false; + bool viewportDensityLogged = false; uint64_t lastCameraLogFrame = 0; std::chrono::steady_clock::time_point lastPlaybackHeartbeat{}; std::chrono::steady_clock::time_point lastSimulationTick{}; @@ -286,6 +349,7 @@ public: double trailCameraZoom = 0.0; double trailCameraBearing = 0.0; double trailCameraPitch = 0.0; + std::array respawnBounds{}; std::array stateTargets; std::array trailTargets; @@ -526,7 +590,11 @@ public: resources->trailOpacity, resources->formalDisplay ? resources->trailFade : 0.965f}, .fieldBounds = fieldBounds(resources->field), - .fieldScale = {resources->fieldScale, resources->nextFieldScale, 0.0f, 0.0f}, + .respawnBounds = resources->respawnBounds, + .fieldScale = {resources->fieldScale, + resources->nextFieldScale, + resources->viewportDensityActive ? 1.0f : 0.0f, + 0.0f}, .matrix = util::cast(parameters.matrixForTile({worldWrap, {0, 0, 0}})), }; drawable.mutableUniformBuffers().createOrUpdate(shaders::idParticleGpuValidationUBO, &ubo, parameters.context); @@ -1407,6 +1475,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources->trailRead = 0; resources->trailSeeded = false; resources->trailCameraInvalidated = true; + resources->viewportDensityLogged = false; resources->lastTrailCameraChange = cameraNow; Log::Info(Event::Render, "ParticleTrail: event=VIEWPORT_RESIZE old=" + std::to_string(previousSize.width) + "x" + @@ -1420,6 +1489,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, : "pan"; resources->trailCameraInvalidated = true; resources->trailSeeded = false; + resources->viewportDensityLogged = false; Log::Info(Event::Render, "ParticleTrail: event=CAMERA_INVALIDATE reason=" + std::string(reason) + " statePreserved=true fieldResubmit=false timelineReset=false"); @@ -1437,6 +1507,28 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources->trailCameraZoom = transformState.getZoom(); resources->trailCameraBearing = transformState.getBearing(); resources->trailCameraPitch = transformState.getPitch(); + const auto respawnDomain = viewportRespawnDomain(transformState, viewport, resources->field); + resources->viewportDensityActive = resources->formalDisplay && !resources->trailCameraInvalidated && + !respawnDomain.fallback; + // During a camera gesture, State continues normally but all ordinary + // 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 (resources->formalDisplay && !resources->trailCameraInvalidated && !resources->viewportDensityLogged) { + const auto& bounds = resources->respawnBounds; + const auto& currentFieldBounds = fieldBounds(resources->field); + Log::Info(Event::Render, + "ParticleDensity: zoom=" + std::to_string(transformState.getZoom()) + + " mode=" + std::string(respawnDomain.fallback ? "FIELD_FALLBACK" : "VIEWPORT_RESPAWN") + + " cameraStable=true margin=" + std::to_string(ViewportRespawnMargin) + " respawnBounds=[" + + std::to_string(bounds[0]) + "," + std::to_string(bounds[1]) + "," + + std::to_string(bounds[2]) + "," + std::to_string(bounds[3]) + "] fieldBounds=[" + + std::to_string(currentFieldBounds[0]) + "," + std::to_string(currentFieldBounds[1]) + "," + + std::to_string(currentFieldBounds[2]) + "," + std::to_string(currentFieldBounds[3]) + + "] fallback=" + (respawnDomain.fallback ? "true" : "false") + + " configuredSlots=" + std::to_string(resources->particleCount)); + resources->viewportDensityLogged = true; + } const auto ensureTrailTargets = [&] { if (resources->trailTargets[0] && resources->trailTargets[1] && resources->trailSize == viewport) { return true; diff --git a/粒子开发记录.md b/粒子开发记录.md index 220d5300b..0db717d12 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -435,3 +435,7 @@ ## 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 验收。 + +## STEP-055 Zoom 下 Viewport 粒子密度调整 + +configured particleCount 保持不变;稳定相机后,State respawn 域改为“viewport 四角 Mercator AABB + 15% margin”与 canonical Field bounds 的交集。camera moving 期间继续从 full Field respawn,稳定至少 150ms 后才让 viewport 外 slot 回收进入该域;无效/空/过小交集安全回退 full Field。新增独立 respawnBounds UBO,不改变 Field sampling 边界、Timeline、Runtime、WeatherColorLayer 或 canonical U/V,仍待 Android 验收。