fix: keep particle density stable across zoom
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-05 12:23:15 +08:00
parent d9a9c8ed57
commit f8e715d080
8 changed files with 178 additions and 6 deletions

View File

@@ -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 回归。

View File

@@ -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 <APK路径>
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 ...`。

View File

@@ -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;
}

View File

@@ -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<float, 4> fieldBounds;
// Preferred respawn domain. It is the expanded stable viewport intersected
// with fieldBounds, or fieldBounds when that intersection is unusable.
std::array<float, 4> respawnBounds;
// Per-texture signed velocity decoding scales in canonical m/s.
// z is viewportDensityActive for the formal validation path.
std::array<float, 4> fieldScale;
std::array<float, 16> matrix;
};
static_assert(sizeof(ParticleGpuValidationUBO) == 10 * 16);
static_assert(sizeof(ParticleGpuValidationUBO) == 11 * 16);
} // namespace shaders
} // namespace mbgl

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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<float, 4> fieldBounds(const particle::ParticleFieldSnapshot& field) {
static_cast<float>(mercatorY(field.south))};
}
struct RespawnDomain {
std::array<float, 4> bounds;
bool fallback = true;
};
bool validMercatorBounds(const std::array<float, 4>& 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<ScreenCoordinate, 4> corners = {
ScreenCoordinate{0.0, 0.0},
ScreenCoordinate{static_cast<double>(viewport.width), 0.0},
ScreenCoordinate{0.0, static_cast<double>(viewport.height)},
ScreenCoordinate{static_cast<double>(viewport.width), static_cast<double>(viewport.height)},
};
float minX = std::numeric_limits<float>::infinity();
float minY = std::numeric_limits<float>::infinity();
float maxX = -std::numeric_limits<float>::infinity();
float maxY = -std::numeric_limits<float>::infinity();
for (const auto& corner : corners) {
const auto latLng = transformState.screenCoordinateToLatLng(corner, LatLng::Unwrapped);
const auto x = static_cast<float>((latLng.longitude() + 180.0) / 360.0);
const auto y = static_cast<float>(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<float, 4> 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<float, 4> respawnBounds{};
std::array<RenderTargetPtr, 2> stateTargets;
std::array<RenderTargetPtr, 2> 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<float>(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;

View File

@@ -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 验收。