diff --git a/docs/50_Step记录/STEP-055_Android真实WeatherV2风场综合可视化_v4.md b/docs/50_Step记录/STEP-055_Android真实WeatherV2风场综合可视化_v4.md new file mode 100644 index 000000000..fac8840e2 --- /dev/null +++ b/docs/50_Step记录/STEP-055_Android真实WeatherV2风场综合可视化_v4.md @@ -0,0 +1,29 @@ +# STEP-055 Android 真实 WeatherV2 风场综合可视化 v4 + +状态:**IMPLEMENTED / WAITING_ANDROID_VALIDATION** + +Starting HEAD:`97aa019f6ff07d1b91e17c46ad0e6e6c0cd339d5` + +## 已确认问题 + +Float State 已使真实粒子恢复运动,但旧正式路径会在无效或越界时回固定 seed;同时 particle draw 以固定 world `streakLength` 构造短棍,viewport-sized Trail 又按全球 world 坐标使用。结果是密度向边缘集中、缩放后出现粗短线与闪烁,且每帧固定 fade 受 FPS 影响。 + +## 本轮修复 + +- State A/B 保持 Float,Trail A/B 保持 UnsignedByte。 +- State B 保存 simulation age;越界、无效、零速或 `3600` simulation seconds 到期时,在当前有效 Field bounds 内以 particle texel/旧位置哈希进行最多三次确定性重生。State A=0 作为 respawn 标记,下一次正常 advection 才设回 A=1。 +- 正式 particle segment 改为 `StateRead.previousPosition → StateWrite.currentPosition`;respawn 标记帧丢弃 segment,避免跨屏连线。REAL WEATHER 中 `streakLength` 保留兼容 UI/API,不再决定真实 segment 长度。 +- particle segment 通过当前 `matrixForTile()` 投影进入 viewport Trail;正式 Composite 改为一次 fullscreen view-space Trail 合成,而非把 viewport texture 当作全球 world texture。 +- 相机 pan/zoom/rotate/pitch/resize 变化时只释放并重建 Trail A/B,记录 `ParticleTrail: event=CAMERA_RESET`;State、Field、Timeline、Runtime Window 均保留。正常 T0/T1 forecast boundary 不触发该路径。 +- `trailFade` 保持“60 FPS 每帧保留率”UI 含义,实际每帧 fade 为 `pow(trailFade, wallDeltaSeconds * 60)`,因此以 wall time 一致衰减。 +- visual clock 从 `6000` 改为 `1200` simulation seconds / real second;60 FPS 时约为 20 simulation seconds / frame。5 m/s≈100 m/frame(z9≈0.65 px),30 m/s≈3.9 px,避免旧 6000 的高风速离散跳跃。 + +## 冻结边界 + +未修改 canonical Field、WeatherV2 assets、Host submission、Timeline、Runtime Field Window、WeatherColorLayer 或 Android UI。Timeline Pause 仍仅冻结 forecast;Particle State 在当前 Field 内继续更新。正常 window advance 仍维持 reuse、only NEW_T1 upload、`simulationRebuild=false` 与 `trailCleared=false`。 + +## 验证与待验收 + +已执行 shader generator、Linux OpenGL 受影响 C++ 精确编译、既有非 Jest `style_layer.test.cpp` 精确编译和 `git diff --check`。未执行 Android Gradle、APK、ADB、设备测试或 Jest。 + +下一步:由用户按 v5 指令进行一次 Android 综合验收,重点观察 Pause 下 2–3 分钟密度、缩放后的连续轨迹、相机 Trail reset、time-based fade、T0→T3 boundary 与 Rotation。 diff --git a/docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v5.md b/docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v5.md new file mode 100644 index 000000000..39ab7eb67 --- /dev/null +++ b/docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v5.md @@ -0,0 +1,41 @@ +# STEP-055 Android 真实 WeatherV2 风场综合可视化人工验收指令 v5 + +环境: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 +adb -s 192.168.1.146 logcat -c +adb -s 192.168.1.146 logcat | Select-String 'Particle(StateTarget|Lifecycle|Motion|Trail|Timeline|GPU|Render)' +``` + +## A. 固定 T0 / Timeline Pause + +进入 `REAL WEATHER`,保持 Timeline Pause,连续观察 2–3 分钟: + +- ParticleMotion 持续;`ParticleLifecycle: activeSlots=2048` 持续存在。 +- 可见密度不随时间持续下降,中央区域不逐步清空,粒子不长期堆积在边缘。 +- 粒子平滑运动,无大面积同步闪烁。 + +## B. 放大地图 + +Zoom 到日本/台风附近,确认 segment 是细小连续的真实位移轨迹,不再是固定 world-length 的粗橙色短棍或放大的 Trail texel。 + +## C. 相机变化 + +执行 Pan、Zoom、Rotate、Resize/Activity Rotation。确认一次 `ParticleTrail: event=CAMERA_RESET reason=... statePreserved=true fieldResubmit=false timelineReset=false`;旧 Trail 可清除、新 Trail 立即累积,粒子 world position 连续。 + +## D. Trail Fade + +在 REAL WEATHER 参数菜单调节 `F`。F 增大应明显延长 Trail 保留;Pause 不应停止 wall-time 衰减。 + +## E. Timeline PLAY + +执行 `TIME → PLAY` 至 T3。继续确认 `OLD_T1 → NEW_T0 reuse`、only `NEW_T1` upload、正常 boundary 的 `simulationRebuild=false` 和 `trailCleared=false`。正常 forecast boundary 不应出现 `ParticleTrail: event=CAMERA_RESET`。 + +## F. Rotation + +Activity Rotation 后确认 Field/State 能恢复、粒子持续运行、Trail 按新 viewport 重新建立,且无需点击按钮恢复。 + +若出现 State/Trail 创建失败,保留 `ParticleStateTarget: event=CREATE_FAILED` 日志;不得以 UByte fallback 或调高 speed scale 规避。 diff --git a/include/mbgl/shaders/gl/particle_gpu_validation.hpp b/include/mbgl/shaders/gl/particle_gpu_validation.hpp index 742af3ea2..1e29ac78c 100644 --- a/include/mbgl/shaders/gl/particle_gpu_validation.hpp +++ b/include/mbgl/shaders/gl/particle_gpu_validation.hpp @@ -26,6 +26,7 @@ layout (std140) uniform ParticleGpuValidationUBO { }; uniform sampler2D u_state_texture; +uniform sampler2D u_previous; uniform sampler2D u_field_texture; uniform sampler2D u_next_field_texture; @@ -48,15 +49,16 @@ void main() { } else if (u_pass.x < 1.5) { gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0); } else if (u_pass.x > 3.5) { - // 40-05: a_pos.x packs State texel X plus a static line endpoint. - // State position, interpolated Field velocity, speed color and streak - // orientation are all evaluated on GPU; the CPU never supplies live - // particle position, direction, or color data. + // a_pos.x packs State texel X plus one endpoint. The formal particle + // path draws the StateRead -> StateWrite displacement in current map + // clip space; it does not synthesize a fixed world-length streak. float endpoint = mod(a_pos.x, 2.0) * 2.0 - 1.0; vec2 stateIndex = vec2(floor(a_pos.x * 0.5), a_pos.y); vec2 stateUV = (stateIndex + vec2(0.5)) / u_mask.xy; - vec2 position = texture(u_state_texture, stateUV).rg; - vec2 uv = fieldUv(position); + vec4 previousState = texture(u_previous, stateUV); + vec4 currentState = texture(u_state_texture, stateUV); + vec2 currentPosition = currentState.rg; + vec2 uv = fieldUv(currentPosition); vec4 field0 = texture(u_field_texture, uv); vec4 field1 = texture(u_next_field_texture, uv); vec2 velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, @@ -65,21 +67,30 @@ void main() { bool valid = validFieldUv(uv) && field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity)); vec2 mercatorVelocity = vec2(velocity.x, -velocity.y); float speed = length(mercatorVelocity); - v_uv = position; - v_world = position; + vec2 segmentPosition = endpoint < 0.0 ? previousState.rg : currentPosition; + v_uv = stateUV; + v_world = currentPosition; v_particle_velocity = mercatorVelocity; - v_particle_valid = valid && speed > 0.00001 ? 1.0 : 0.0; + // A zero alpha is written by State on a respawn frame. This suppresses + // oldPosition -> newRandomPosition cross-screen segments. + v_particle_valid = valid && speed > 0.00001 && currentState.a > 0.5 && + distance(previousState.rg, currentPosition) > 0.000000001 + ? 1.0 + : 0.0; if (v_particle_valid < 0.5) { gl_Position = vec4(-2.0, -2.0, 0.0, 1.0); } else { - vec2 direction = mercatorVelocity / speed; - vec2 streakPosition = position + direction * endpoint * u_visual.y * 0.5; - gl_Position = vec4(streakPosition * 2.0 - 1.0, 0.0, 1.0); + gl_Position = u_matrix * vec4(segmentPosition * 8192.0, 0.0, 1.0); } } else { - // VALIDATION ONLY: a_pos is canonical z0 world geometry; u_matrix is - // PaintParameters::matrixForTile() for the selected MapLibre world copy. - gl_Position = u_matrix * vec4(a_pos * 8192.0, 0.0, 1.0); + if (u_mask.z > 0.5) { + // Trail targets are viewport-sized screen-space history buffers. + gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0); + } else { + // VALIDATION ONLY: a_pos is canonical z0 world geometry; u_matrix + // is PaintParameters::matrixForTile() for the selected world copy. + gl_Position = u_matrix * vec4(a_pos * 8192.0, 0.0, 1.0); + } } } )"; @@ -135,6 +146,27 @@ vec3 weatherSpeedColor(float speed) { return mix(mix(slow, medium, smoothstep(0.0, 0.55, t)), fast, smoothstep(0.45, 1.0, t)); } +float particleHash(vec2 value) { + return fract(sin(dot(value, vec2(127.1, 311.7))) * 43758.5453123); +} + +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); +} + +bool fieldVelocityAt(vec2 position, out vec2 velocity) { + vec2 uv = fieldUv(position); + vec4 field0 = texture(u_field_texture, uv); + vec4 field1 = texture(u_next_field_texture, uv); + velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, + (field1.rg * 2.0 - 1.0) * u_field_scale.y, + u_marker.x); + return validFieldUv(uv) && field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity)) && + length(velocity) > 0.00001; +} + void main() { float mode = u_pass.x; @@ -160,6 +192,55 @@ void main() { // texture encodes canonical U-east/V-north at that geographic sample. // u_mask.w is the explicit State lifecycle flag: new RenderTargets are // cleared opaque by MapLibre, so sampled alpha cannot identify a seed. + if (u_mask.z > 0.5) { + // Formal particles use GPU-resident lifecycle state. Each slot keeps + // its age in B; A=0 marks this frame as a respawn so Trail does not + // connect the retired position to the new randomized one. + bool respawn = u_mask.w > 0.5; + float age = respawn ? 0.0 : previous.b + u_pass.y; + vec2 position = previous.rg; + vec2 velocity; + bool valid = !respawn && fieldVelocityAt(position, velocity); + if (!valid || age >= u_marker.z) { + respawn = true; + } + + if (!respawn) { + float latitude = mercatorLatitudeRadians(position.y); + vec2 displacement = vec2(velocity.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS, + -velocity.y * u_pass.y / + (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01))); + vec2 nextPosition = position + displacement; + if (fieldVelocityAt(nextPosition, velocity)) { + position = nextPosition; + } else { + respawn = true; + } + } + + if (respawn) { + bool found = false; + // Fixed, bounded retries keep the State pass deterministic and + // avoid a CPU upload or an unbounded shader loop. + for (int attempt = 0; attempt < 3; ++attempt) { + vec2 candidate = respawnSeed(v_uv, previous.rg, float(attempt)); + if (fieldVelocityAt(candidate, velocity)) { + position = candidate; + found = true; + break; + } + } + if (!found) { + // Keep a deterministic in-bounds candidate and retry next + // frame; alpha still prevents an invalid visible segment. + position = respawnSeed(v_uv, previous.rg, 3.0); + } + age = 0.0; + } + fragColor = vec4(position, age, respawn ? 0.0 : 1.0); + return; + } + vec2 seed = mix(u_field_bounds.xy, u_field_bounds.zw, v_uv); vec2 position = u_mask.w > 0.5 ? seed : previous.rg; // 40-02: decode each independent Field texture, then interpolate the @@ -190,7 +271,10 @@ void main() { // Formal Trail first fades the previous target. Its full-particle point // draw follows in the same target and consumes every State texel. if (u_mask.z > 0.5) { - fragColor = vec4(previous.rgb * u_visual.w, 1.0); + // trailFade remains the UI's retention-at-60Hz value. Raising it + // to elapsed wall time makes visual persistence FPS-independent. + float fadeFactor = pow(clamp(u_visual.w, 0.0001, 1.0), u_pass.y * u_marker.w); + fragColor = vec4(previous.rgb * fadeFactor, 1.0); return; } diff --git a/shaders/particle_gpu_validation.fragment.glsl b/shaders/particle_gpu_validation.fragment.glsl index 96231f39b..695a033d7 100644 --- a/shaders/particle_gpu_validation.fragment.glsl +++ b/shaders/particle_gpu_validation.fragment.glsl @@ -50,6 +50,27 @@ vec3 weatherSpeedColor(float speed) { return mix(mix(slow, medium, smoothstep(0.0, 0.55, t)), fast, smoothstep(0.45, 1.0, t)); } +float particleHash(vec2 value) { + return fract(sin(dot(value, vec2(127.1, 311.7))) * 43758.5453123); +} + +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); +} + +bool fieldVelocityAt(vec2 position, out vec2 velocity) { + vec2 uv = fieldUv(position); + vec4 field0 = texture(u_field_texture, uv); + vec4 field1 = texture(u_next_field_texture, uv); + velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, + (field1.rg * 2.0 - 1.0) * u_field_scale.y, + u_marker.x); + return validFieldUv(uv) && field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity)) && + length(velocity) > 0.00001; +} + void main() { float mode = u_pass.x; @@ -75,6 +96,55 @@ void main() { // texture encodes canonical U-east/V-north at that geographic sample. // u_mask.w is the explicit State lifecycle flag: new RenderTargets are // cleared opaque by MapLibre, so sampled alpha cannot identify a seed. + if (u_mask.z > 0.5) { + // Formal particles use GPU-resident lifecycle state. Each slot keeps + // its age in B; A=0 marks this frame as a respawn so Trail does not + // connect the retired position to the new randomized one. + bool respawn = u_mask.w > 0.5; + float age = respawn ? 0.0 : previous.b + u_pass.y; + vec2 position = previous.rg; + vec2 velocity; + bool valid = !respawn && fieldVelocityAt(position, velocity); + if (!valid || age >= u_marker.z) { + respawn = true; + } + + if (!respawn) { + float latitude = mercatorLatitudeRadians(position.y); + vec2 displacement = vec2(velocity.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS, + -velocity.y * u_pass.y / + (EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01))); + vec2 nextPosition = position + displacement; + if (fieldVelocityAt(nextPosition, velocity)) { + position = nextPosition; + } else { + respawn = true; + } + } + + if (respawn) { + bool found = false; + // Fixed, bounded retries keep the State pass deterministic and + // avoid a CPU upload or an unbounded shader loop. + for (int attempt = 0; attempt < 3; ++attempt) { + vec2 candidate = respawnSeed(v_uv, previous.rg, float(attempt)); + if (fieldVelocityAt(candidate, velocity)) { + position = candidate; + found = true; + break; + } + } + if (!found) { + // Keep a deterministic in-bounds candidate and retry next + // frame; alpha still prevents an invalid visible segment. + position = respawnSeed(v_uv, previous.rg, 3.0); + } + age = 0.0; + } + fragColor = vec4(position, age, respawn ? 0.0 : 1.0); + return; + } + vec2 seed = mix(u_field_bounds.xy, u_field_bounds.zw, v_uv); vec2 position = u_mask.w > 0.5 ? seed : previous.rg; // 40-02: decode each independent Field texture, then interpolate the @@ -105,7 +175,10 @@ void main() { // Formal Trail first fades the previous target. Its full-particle point // draw follows in the same target and consumes every State texel. if (u_mask.z > 0.5) { - fragColor = vec4(previous.rgb * u_visual.w, 1.0); + // trailFade remains the UI's retention-at-60Hz value. Raising it + // to elapsed wall time makes visual persistence FPS-independent. + float fadeFactor = pow(clamp(u_visual.w, 0.0001, 1.0), u_pass.y * u_marker.w); + fragColor = vec4(previous.rgb * fadeFactor, 1.0); return; } diff --git a/shaders/particle_gpu_validation.vertex.glsl b/shaders/particle_gpu_validation.vertex.glsl index c0b0f1331..378cddc41 100644 --- a/shaders/particle_gpu_validation.vertex.glsl +++ b/shaders/particle_gpu_validation.vertex.glsl @@ -16,6 +16,7 @@ layout (std140) uniform ParticleGpuValidationUBO { }; uniform sampler2D u_state_texture; +uniform sampler2D u_previous; uniform sampler2D u_field_texture; uniform sampler2D u_next_field_texture; @@ -38,15 +39,16 @@ void main() { } else if (u_pass.x < 1.5) { gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0); } else if (u_pass.x > 3.5) { - // 40-05: a_pos.x packs State texel X plus a static line endpoint. - // State position, interpolated Field velocity, speed color and streak - // orientation are all evaluated on GPU; the CPU never supplies live - // particle position, direction, or color data. + // a_pos.x packs State texel X plus one endpoint. The formal particle + // path draws the StateRead -> StateWrite displacement in current map + // clip space; it does not synthesize a fixed world-length streak. float endpoint = mod(a_pos.x, 2.0) * 2.0 - 1.0; vec2 stateIndex = vec2(floor(a_pos.x * 0.5), a_pos.y); vec2 stateUV = (stateIndex + vec2(0.5)) / u_mask.xy; - vec2 position = texture(u_state_texture, stateUV).rg; - vec2 uv = fieldUv(position); + vec4 previousState = texture(u_previous, stateUV); + vec4 currentState = texture(u_state_texture, stateUV); + vec2 currentPosition = currentState.rg; + vec2 uv = fieldUv(currentPosition); vec4 field0 = texture(u_field_texture, uv); vec4 field1 = texture(u_next_field_texture, uv); vec2 velocity = mix((field0.rg * 2.0 - 1.0) * u_field_scale.x, @@ -55,20 +57,29 @@ void main() { bool valid = validFieldUv(uv) && field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity)); vec2 mercatorVelocity = vec2(velocity.x, -velocity.y); float speed = length(mercatorVelocity); - v_uv = position; - v_world = position; + vec2 segmentPosition = endpoint < 0.0 ? previousState.rg : currentPosition; + v_uv = stateUV; + v_world = currentPosition; v_particle_velocity = mercatorVelocity; - v_particle_valid = valid && speed > 0.00001 ? 1.0 : 0.0; + // A zero alpha is written by State on a respawn frame. This suppresses + // oldPosition -> newRandomPosition cross-screen segments. + v_particle_valid = valid && speed > 0.00001 && currentState.a > 0.5 && + distance(previousState.rg, currentPosition) > 0.000000001 + ? 1.0 + : 0.0; if (v_particle_valid < 0.5) { gl_Position = vec4(-2.0, -2.0, 0.0, 1.0); } else { - vec2 direction = mercatorVelocity / speed; - vec2 streakPosition = position + direction * endpoint * u_visual.y * 0.5; - gl_Position = vec4(streakPosition * 2.0 - 1.0, 0.0, 1.0); + gl_Position = u_matrix * vec4(segmentPosition * 8192.0, 0.0, 1.0); } } else { - // VALIDATION ONLY: a_pos is canonical z0 world geometry; u_matrix is - // PaintParameters::matrixForTile() for the selected MapLibre world copy. - gl_Position = u_matrix * vec4(a_pos * 8192.0, 0.0, 1.0); + if (u_mask.z > 0.5) { + // Trail targets are viewport-sized screen-space history buffers. + gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0); + } else { + // VALIDATION ONLY: a_pos is canonical z0 world geometry; u_matrix + // is PaintParameters::matrixForTile() for the selected world copy. + gl_Position = u_matrix * vec4(a_pos * 8192.0, 0.0, 1.0); + } } } diff --git a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp index a71ecc0bc..d1973f145 100644 --- a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp +++ b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp @@ -49,9 +49,12 @@ constexpr auto ShaderName = "ParticleGpuValidationShader"; constexpr Size MaskSize{16, 16}; constexpr float EarthCircumferenceMeters = 40075016.68557849f; // This is a validation visual clock, not a multiplier on canonical U/V. At -// Japan validation zooms, real-time m/s advances only fractions of a pixel per -// second; one real second therefore represents 6000 simulation seconds. -constexpr float VisualSimulationSecondsPerRealSecond = 6000.0f; +// 60 Hz it advances 20 simulation seconds per frame: a typical 5 m/s wind +// 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 = 3600.0f; +constexpr float TrailFadeReferenceFramesPerSecond = 60.0f; std::atomic_uint64_t nextGpuGeneration{0}; std::atomic_uint64_t nextMaskGeneration{0}; @@ -244,10 +247,12 @@ public: bool cameraDrawLogged = false; bool worldWrapLogged = false; bool antimeridianLogged = false; + bool trailCameraKnown = 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{}; uint64_t maskGeneration = 0; uint64_t fieldGeneration = 0; uint64_t gpuFieldGeneration = 0; @@ -269,6 +274,11 @@ public: uint8_t loggedMaskMode = 255; Size trailSize{}; Size stateSize{}; + double trailCameraLatitude = 0.0; + double trailCameraLongitude = 0.0; + double trailCameraZoom = 0.0; + double trailCameraBearing = 0.0; + double trailCameraPitch = 0.0; std::array stateTargets; std::array trailTargets; @@ -343,8 +353,8 @@ constexpr uint32_t MaxParticleVerticesPerSegment = std::numeric_limits void rebuildParticleDrawResources(const std::shared_ptr& resources) { auto vertices = std::make_shared>(); // Each State texel is represented by a two-vertex line. Its endpoints are - // static texel indices; State position, interpolated Field velocity and - // orientation remain GPU-owned in the vertex shader. + // static texel indices; the vertex shader reads StateRead/StateWrite and + // projects the true previous-to-current displacement in GPU space. vertices->reserve(resources->particleCount * 2); for (uint32_t particleIndex = 0; particleIndex < resources->particleCount; ++particleIndex) { const auto x = static_cast(particleIndex % resources->stateSize.width); @@ -450,6 +460,15 @@ public: " simulationFrame=" + std::to_string(resources->committedFrame)); } } + if (resources->formalDisplay && now - resources->lastLifecycleLog >= std::chrono::seconds(1)) { + resources->lastLifecycleLog = now; + Log::Info(Event::Render, + "ParticleLifecycle: activeSlots=" + std::to_string(resources->particleCount) + + " respawn=GPU_HASH_ON_INVALID_OR_AGE_EXPIRED lifecycleSeconds=" + + std::to_string(ParticleLifecycleSeconds) + + " fieldId=" + resources->field.fieldID + " simulationFrame=" + + std::to_string(resources->committedFrame) + " readback=disabled"); + } } drawable.setTexture(source, shaders::idParticleGpuValidationPreviousTexture); @@ -473,7 +492,10 @@ public: const bool seedState = pass == ValidationPass::State && !resources->stateSeeded; const shaders::ParticleGpuValidationUBO ubo = { .pass = {static_cast(pass), frame, maskMode == 0 ? 0.0f : 1.0f, static_cast(maskMode)}, - .marker = {markerX, resources->speedColorEnabled ? 1.0f : 0.0f, 0.035f, 0.965f}, + .marker = {markerX, + resources->speedColorEnabled ? 1.0f : 0.0f, + resources->formalDisplay ? ParticleLifecycleSeconds : 0.035f, + resources->formalDisplay ? TrailFadeReferenceFramesPerSecond : 0.965f}, .mask = {static_cast(resources->stateSize.width), static_cast(resources->stateSize.height), resources->formalDisplay ? 1.0f : 0.0f, @@ -490,7 +512,8 @@ public: if (pass == ValidationPass::Composite || pass == ValidationPass::FrozenComposite) { const auto center = parameters.state.getLatLng(LatLng::Unwrapped); - const bool primaryWorldCopy = worldWrap == wrapForLongitude(center.longitude()); + const bool primaryWorldCopy = resources->formalDisplay ? worldWrap == 0 + : worldWrap == wrapForLongitude(center.longitude()); if (primaryWorldCopy && !resources->displayModeLogged) { Log::Info(Event::Render, "ParticleRender: displayMode=" + @@ -725,12 +748,13 @@ bool buildParticlePoints(gfx::Context& context, const std::shared_ptr& resources, LayerGroup& group, const std::string& name, - const gfx::Texture2DPtr& stateTexture, + const gfx::Texture2DPtr& previousStateTexture, + const gfx::Texture2DPtr& currentStateTexture, const gfx::Texture2DPtr& target, const uint8_t stateSlot, const uint8_t trailSlot) { if (!resources->particleVertices || !resources->particleIndices || resources->particleSegments.empty() || - !stateTexture || !target) { + !previousStateTexture || !currentStateTexture || !target) { Log::Error(Event::Render, "ParticleGPU: event=PARTICLE_DRAW_REJECTED reason=MISSING_DRAW_RESOURCES"); return false; } @@ -755,16 +779,16 @@ bool buildParticlePoints(gfx::Context& context, resources->particleIndices, resources->particleSegments.data(), resources->particleSegments.size()); - builder->setTexture(stateTexture, shaders::idParticleGpuValidationPreviousTexture); - builder->setTexture(stateTexture, shaders::idParticleGpuValidationStateTexture); + builder->setTexture(previousStateTexture, shaders::idParticleGpuValidationPreviousTexture); + builder->setTexture(currentStateTexture, shaders::idParticleGpuValidationStateTexture); builder->addTweaker(std::make_shared(resources, ValidationPass::TrailParticles, stateSlot, trailSlot, - stateTexture, + previousStateTexture, target, resources->maskTexture, - stateTexture, + currentStateTexture, 0.0f, 0, 0)); @@ -1327,6 +1351,34 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources->registeredTargets.clear(); 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"; + resources->trailTargets = {}; + resources->trailSize = {}; + resources->trailRead = 0; + resources->trailSeeded = false; + Log::Info(Event::Render, + "ParticleTrail: event=CAMERA_RESET reason=" + std::string(reason) + " statePreserved=true " + "fieldResubmit=false timelineReset=false"); + } + resources->trailCameraKnown = true; + resources->trailCameraLatitude = trailCamera.latitude(); + resources->trailCameraLongitude = trailCamera.longitude(); + resources->trailCameraZoom = transformState.getZoom(); + resources->trailCameraBearing = transformState.getBearing(); + resources->trailCameraPitch = transformState.getPitch(); const auto ensureTrailTargets = [&] { if (resources->trailTargets[0] && resources->trailTargets[1] && resources->trailSize == viewport) { return true; @@ -1413,6 +1465,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources, trailGroup, getID() + "-static-bootstrap-trail-particles", + resources->stateTargets[resources->stateRead]->getTexture(), resources->stateTargets[stateWrite]->getTexture(), resources->trailTargets[trailWrite]->getTexture(), stateWrite, @@ -1421,7 +1474,9 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, bool compositeBuilt = true; const auto center = transformState.getLatLng(LatLng::Unwrapped); const auto centerWrap = wrapForLongitude(center.longitude()); - for (int16_t wrap = static_cast(centerWrap - 1); wrap <= centerWrap + 1; ++wrap) { + const auto firstWrap = resources->formalDisplay ? int16_t{0} : static_cast(centerWrap - 1); + const auto lastWrap = resources->formalDisplay ? int16_t{0} : static_cast(centerWrap + 1); + for (int16_t wrap = firstWrap; wrap <= lastWrap; ++wrap) { compositeBuilt = buildQuad(context, resources, compositeGroup, @@ -1462,7 +1517,9 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, compositeGroup.clearDrawables(); const auto center = transformState.getLatLng(LatLng::Unwrapped); const auto centerWrap = wrapForLongitude(center.longitude()); - for (int16_t wrap = static_cast(centerWrap - 1); wrap <= centerWrap + 1; ++wrap) { + const auto firstWrap = resources->formalDisplay ? int16_t{0} : static_cast(centerWrap - 1); + const auto lastWrap = resources->formalDisplay ? int16_t{0} : static_cast(centerWrap + 1); + for (int16_t wrap = firstWrap; wrap <= lastWrap; ++wrap) { buildQuad(context, resources, compositeGroup, @@ -1501,6 +1558,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, wallDeltaSeconds = std::chrono::duration(now - resources->lastSimulationTick).count(); } 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; @@ -1527,7 +1585,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources->trailTargets[trailWrite]->getTexture(), resources->maskTexture, resources->stateTargets[stateWrite]->getTexture(), - 1.0f, + trailWallDeltaSeconds, ValidationPass::Trail, resources->trailRead, trailWrite, @@ -1539,6 +1597,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources, trailGroup, getID() + "-trail-particles", + resources->stateTargets[resources->stateRead]->getTexture(), resources->stateTargets[stateWrite]->getTexture(), resources->trailTargets[trailWrite]->getTexture(), stateWrite, @@ -1547,7 +1606,9 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, bool compositeBuilt = true; const auto center = transformState.getLatLng(LatLng::Unwrapped); const auto centerWrap = wrapForLongitude(center.longitude()); - for (int16_t wrap = static_cast(centerWrap - 1); wrap <= centerWrap + 1; ++wrap) { + const auto firstWrap = resources->formalDisplay ? int16_t{0} : static_cast(centerWrap - 1); + const auto lastWrap = resources->formalDisplay ? int16_t{0} : static_cast(centerWrap + 1); + for (int16_t wrap = firstWrap; wrap <= lastWrap; ++wrap) { compositeBuilt = buildQuad(context, resources, compositeGroup, diff --git a/粒子开发记录.md b/粒子开发记录.md index e489974cf..e251fa0ca 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -426,3 +426,6 @@ ## STEP-055 Float State fail-closed 小修复 在 Particle Validation `createValidationTarget()` 增加局部标准异常与未知异常捕获。Float State RenderTarget/FBO 创建失败会记录结构化错误并返回空 target,不会向上抛出或降级到 UnsignedByte;State A/B=Float、Trail A/B=UnsignedByte 保持。Linux 精确编译通过,仍待 Android 验收。 +## STEP-055 生命周期、真实轨迹与 view-space Trail 修复 + +真实 Weather 路径改为 GPU age/哈希 respawn,StateRead→StateWrite 真实位移 segment,且 Trail 仅在当前 camera view 内累积。相机变化只重建 Trail;`trailFade` 改为 wall-time 指数衰减。visual clock 调整为 1200 simulation seconds/real second。canonical、Timeline、Runtime、WeatherColorLayer 未改,仍待 Android 综合验收。