fix: tune real weather particle rendering
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-05 08:34:36 +08:00
parent 8a0873a5fb
commit 6c7886b0c3
11 changed files with 364 additions and 38 deletions

View File

@@ -32,3 +32,16 @@ Core 仍拥有 Timeline、Playback 和 Runtime Field Window。颜色层不 tick
Codex 未执行 Android Gradle、APK build/install、ADB、App 启动、设备/模拟器测试或 Jest。Linux/OpenGL 受影响 translation unit 精确编译与相关定向测试结果见完成报告。
Android 仍需用户一次性手工综合验收,步骤见 `docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v1.md`。已知 Validation UI 的 MIX 文本滞后以及 WAITING/INVALID 可见性仍为 NON-BLOCKING,不在本阶段单独修复。
## 首轮 Android 视觉修复(仍待复验)
首轮实机确认真实 WeatherV2 T0–T3 Host submission、513×769 canonical Field、WeatherColorLayer 与 REAL WEATHER composite 均可工作;本轮只修复 Validation 显示与粒子物理位移表达。
- REAL WEATHER 的 raster basemap 从 OSM 换为 GSI 标准地图:`https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png`。该 source 与 weather/particle source 独立,GSI tile 不可用时仍为 fail-soft;`REAL_WEATHER_COMPOSITE_READY` 明确记录 `basemap=GSI failSoft=true`。
- 底部入口变为 `TIME | VIS | COUNT | MAP | REAL WEATHER | MORE`。REAL WEATHER 不再位于 MORE:首次点击使用既有 Host Submission 提交真实 T0–T3、fit bounds、开启专属低频 `PARTICLE TUNING` 菜单;已激活时再次点击只显示/关闭菜单,不 reload Style、Dataset、Field 或 Timeline。激活状态以按钮 selected/yellow 显示。
- REAL WEATHER 专属默认值为 P=2048、W=0.75、L=0.0040、O=0.75、T=0.55、F=0.88;合成场景退出时恢复进入前的旧 validation visual state,故未改变 synthetic 场景默认值。HUD 与调参菜单读取同一实际 runtime state,二级菜单关闭后 HUD 仍显示。
- Tuning 用低频离散 `-/+` 操作覆盖 particleCount、lineWidth、streakLength、particleOpacity、trailOpacity、trailFade,并提供 RESET DEFAULT。非 particleCount 参数仅通过既有 layer property 更新;不 Style reload、不重新 submit Field、不 reset Timeline/Window。particleCount 允许仅重建 State/draw resources。
- 为支持任务规定的 2048/512/8192 档,Core 明确接受这些 validation presets,State texture 使用 `ceil(sqrt(count))`,只为实际 count 建 particle vertices;并将 visual property 下限扩到 lineWidth=0.25、streakLength=0.0005。
- 旧 shader 用无单位 `velocity * frame * 0.00004`,使真实 m/s 没有可审计的 geography conversion。现改为 U/V m/s → Web Mercator world displacement:`dx=U*dt/40075016.6856`、`dy=-V*dt/(40075016.6856*cos(latitude))`。`dt` 是稳定 wall delta(1/240–0.05s)乘 6000 simulation-seconds/real-second 的 Validation visual clock;它不改 canonical U/V、validTime、Timeline mix 或 Playback ownership,只使日本 zoom 下真实 m/s 有可见流线速度。低频 `ParticleMotion` 日志记录 sampleU/V、dt、visualSpeedScale、估算 world delta 和 simulationFrame,无 GPU readback。
本轮未修改 STEP-054 assets、Adapter、Host canonical contract、Timeline、Runtime Field Window、Renderer provider/network 边界或 WeatherColorLayer 数据链。状态继续为 **IMPLEMENTED / WAITING_ANDROID_VALIDATION**;新人工验收见 `docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v2.md`。

View File

@@ -0,0 +1,47 @@
# STEP-055 Android 真实 WeatherV2 风场综合可视化人工验收 v2
状态:等待用户手工 Android 验收。Codex 不执行本文件中的 Gradle、APK、ADB 或设备操作。
## Windows 11 构建、安装与日志
在 Windows 11 项目目录执行:
```powershell
./gradlew :MapLibreAndroidTestApp:assembleOpenGLDebug --console=plain
adb -s 192.168.1.146 install -r <APK_PATH>
adb -s 192.168.1.146 logcat -c
adb -s 192.168.1.146 logcat | findstr /I "REAL_WEATHER GSI ParticleTuning ParticleMotion ParticleTimeline WINDOW_BOUNDARY FIELD_REUSE FIELD_UPLOAD DUPLICATE INVALID simulationRebuild trailCleared"
```
## 入口与默认画面
1. 进入 `Particle Core Layer Validation`。
2. 底栏应为 `TIME | VIS | COUNT | MAP | REAL WEATHER | MORE`;MORE 中不应再有 REAL WEATHER。
3. 点击 `REAL WEATHER`。确认 GSI 日本及周边地图、半透明 WeatherColorLayer 和细密粒子共同出现,且日志有 `REAL_WEATHER_COMPOSITE_READY ... basemap=GSI`。
4. HUD 应持续显示:`P 2048 · W 0.75 · L 0.0040 · O 0.75 · T 0.55 · F 0.88`。粒子应明显持续运动,而不是粗大橙色块状轨迹。
GSI tile 若网络不可用,记录 `BASEMAP`/GSI 错误即可;WeatherColorLayer、粒子、Timeline 不得因此黑屏或 crash。
## 调参、RESET 与暂停
再次点击已激活的 `REAL WEATHER`,确认仅打开/关闭 `PARTICLE TUNING`,不出现 `STYLE_LOAD reason=REAL_WEATHER`、不重新加载 Dataset 或提交 Host fields。使用各 `-/+` 项分别改变 P/W/L/O/T/F:HUD 必须立刻显示实际生效值,且菜单关闭后仍显示。
- P 可在 256、512、1024、2048、4096、8192、16384 间调整;允许只见 particle State/draw resources 重建。
- W/L/O/T/F 调整不得产生 STYLE_LOAD、Field upload、Timeline reset、Runtime Window reset 或 Weather dataset reload。
- `RESET DEFAULT` 后 HUD 恢复 P=2048、W=0.75、L=0.0040、O=0.75、T=0.55、F=0.88。
- 不点 PLAY 或在 `TIME → PAUSE` 后,Timeline cursor/MIX 可冻结,但粒子仍应在当前固定风场运动。
## Timeline、边界与地图交互
1. `TIME → PLAY / RESTART`,确认 READY、PLAY、自动 MIX 和 T0→T3。
2. 观察至少两次 `production→production` boundary:T0/T1→T1/T2→T2/T3。每次应有 `FIELD_REUSE from=OLD_T1`、只上传 `NEW_T1`、`simulationRebuild=false`、`trailCleared=false`。
3. 检查每约 1 秒的 `ParticleMotion`:sampleU/sampleV 非零时,有 `dt`、`visualSpeedScale=6000` 和非零 `estimatedStateDeltaWorld`;此日志为 CPU 估算,不做持续 GPU readback。
4. Pan、Zoom、Rotate,并进行 Activity Rotation:GSI、色场和粒子保持地理对齐;旋转后应自动恢复,无需点击按钮。
## 负向与保护检查
- `MORE → HOST DUPLICATE`:当前 Host window 返回 NOOP,fieldUploadCount/simulationResetCount 无增量。
- `MORE → HOST INVALID`:Adapter reject,保持最后有效 Field,Renderer/色场连续,无黑屏、无 crash。
- 本次没有 Weather Service、HTTP、React Native 或 Jest;真实 WeatherV2 仍来自 Android asset。
保存日志中的 GSI、ParticleTuning、ParticleMotion、Timeline/Boundary、Duplicate/Invalid 片段及任何视觉问题。MIX 文字可能滞后、WAITING/INVALID 视觉不够明显仍是 NON-BLOCKING observability,应以 Core 日志判断。

View File

@@ -0,0 +1,45 @@
# STEP-055 真实 WeatherV2 风场综合可视化修复 Codex 完成报告 v2
## 基线与结果
- Starting HEAD:`8a0873a5fb2c8867c7e2a126b4d4c185077dd6f9`
- 分支:`particle/main`
- 状态:**IMPLEMENTED / WAITING_ANDROID_VALIDATION**
- 本轮针对首轮 Android 实机视觉问题修复;Android Gradle、APK、ADB、App/设备测试与 Jest 均未执行。
## 已完成
- REAL WEATHER basemap 改为 GSI 标准地图,并以 `basemap=GSI failSoft=true` 记录独立底图配置;不引入 key、商业 SDK 或天气网络。
- REAL WEATHER 现在是 MAP 旁的一级菜单,MORE 不再重复。首次点击提交现有真实 T0–T3 Host dataset、fit bounds 并打开 tuning;再次点击只切换菜单。退出真实场景恢复进入前的 synthetic visual state。
- 真实场默认 P=2048、W=0.75、L=0.0040、O=0.75、T=0.55、F=0.88;HUD 和 `PARTICLE TUNING` 使用同一状态。离散低频调参/RESET 不重载 Style、不重提 Field、不重置 Timeline 或 Runtime Window;只有 P 允许 State/draw resource recreate。
- Core 粒子数支持明确的 validation presets 256/512/1024/2048/4096/8192/16384(并保留既有高档位);State grid 改为 `ceil(sqrt(count))`,剩余 texels不绘制。lineWidth/streakLength 下限分别为 0.25/0.0005。
## m/s 位移结论
此前 State shader 使用 `position += velocity * frame * 0.00004`:它没有 m/s、秒、地球尺度或纬度解释,真实数据虽能推进 A/B,但无法审计真实地理位移,粗宽 trail 也掩盖小位移。
现链路保持 canonical U=eastward、V=northward、单位 m/s:wall delta 限制为 1/240–0.05s,作为明确视觉仿真时钟乘 6000 simulation-seconds/real-second;shader 使用 `dx=U*dt/40075016.6856` 与 `dy=-V*dt/(40075016.6856*cos(latitude))` 写入 Web Mercator world state。6000 是 rendering-only time dilation,用于在日本 validation zoom 下将真实 m/s 的亚像素 real-time displacement 显示成可观察流线;它不修改 payload、Adapter、Snapshot、validTime、MIX 或 Timeline playback。低频 `ParticleMotion` 仅报告 CPU field sample 与同公式估算 delta,无 GPU→CPU readback。
Pause 仍只冻结 forecast Timeline;Particle State update 保持 active,因此固定风场下粒子继续运动。
## 架构保护与未做项
未修改 STEP-054 WeatherV2 assets、ParticleFieldDescriptor/Snapshot/DataAdapter、Host Submission contract、Timeline/Playback、Runtime Field Window 或 WeatherColorLayer 数据链。继续保持 A/B→B/C→C/D、OLD_T1→NEW_T0 reuse、only NEW_T1 upload、MIX property-only、正常 boundary `simulationRebuild=false`/`trailCleared=false`;未在 Renderer 引入 provider/network/vendor 逻辑,未接 Weather Service、React Native 或 Jest。
Rotation/Viewport 只纳入本轮人工验收,未扩大实现范围。MIX 文本滞后及 WAITING/INVALID 状态视觉仍为 NON-BLOCKING observability。
## Linux / 静态验证
- `node shaders/generate_shader_code.mjs`:PASS。
- OpenGL CMake 受影响 Core TU 精确编译:`render_particle_validation_layer.cpp`、`raster_particle_layer.cpp`、`render_weather_color_layer.cpp`、`raster_array_source.cpp`:PASS。
- `test/style/style_layer.test.cpp` 以同一 OpenGL CMake 参数精确编译:PASS(含 2048 preset 与 visual bounds 更新)。
- `mbgl-test-runner` 构建已启动以复用现有 target,但在进入无关 Core translation units 前按本任务定向边界停止;不作为通过声明。
- `git diff --check`:待文档完成后的最终检查。
## Android 后续一次验收
按 [人工验收指令 v2](../60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v2.md) 在 Windows 11 只做一次构建/install。重点检查 GSI、默认细密且明显运动的粒子、HUD/tuning/RESET、Pause 下 simulation、ParticleMotion、T0→T3 两次 boundary、Pan/Zoom/Rotate/Rotation、Duplicate、Invalid 和 Renderer continuity。
## Git 收尾
本报告将在本任务相关代码和文档提交后补充实际 commit、push 与 Final HEAD。无关未跟踪的历史 diff、任务书和日志保持未触碰、未 stage。

View File

@@ -120,6 +120,12 @@ bool validFieldUv(vec2 uv) {
return all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0)));
}
const float EARTH_CIRCUMFERENCE_METERS = 40075016.68557849;
float mercatorLatitudeRadians(float worldY) {
return atan(sinh(3.141592653589793 * (1.0 - 2.0 * worldY)));
}
vec3 weatherSpeedColor(float speed) {
// Fixed 0..30 m/s scale: no per-frame or per-dataset auto stretching.
float t = clamp(speed / 30.0, 0.0, 1.0);
@@ -168,10 +174,16 @@ void main() {
position = seed;
velocity = vec2(0.0);
}
// Field V remains north-positive. Mercator Y increases southward, so
// convert only at the physical-velocity -> map-displacement boundary.
vec2 mercatorVelocity = vec2(velocity.x, -velocity.y);
position = position + mercatorVelocity * u_pass.y * 0.00004;
// U/V remain canonical metres per second. u_pass.y is the explicit
// visual simulation delta in seconds; the validation clock may run
// faster than wall time, but it never changes the field contract.
// Web Mercator x spans Earth's circumference; y additionally scales
// with sec(latitude). V is north-positive while world y is southward.
float latitude = mercatorLatitudeRadians(position.y);
float dx = velocity.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS;
float dy = -velocity.y * u_pass.y /
(EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01));
position = position + vec2(dx, dy);
if (!validFieldUv(fieldUv(position)) || any(isnan(velocity))) position = seed;
fragColor = vec4(position, fract(previous.b + 0.03125 * u_pass.y), 1.0);
} else if (mode < 1.5) {

View File

@@ -1,6 +1,7 @@
package org.maplibre.android.testapp.activity.particle
import android.os.Bundle
import android.graphics.Color
import android.util.Log
import android.widget.Button
import android.widget.PopupMenu
@@ -37,6 +38,10 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var productionFeedMode = false
private var hostSubmissionMode = false
private var realWeatherMode = false
private var realWeatherTuningMenu: PopupMenu? = null
private var realWeatherMenuAnchor: android.view.View? = null
private var openRealWeatherTuningAfterStyle = false
private var preRealWeatherVisuals: ParticleVisuals? = null
private var styleLoadEpoch = 0L
private var styleReady = false
// Display mirror only; Core remains the playback state authority.
@@ -69,6 +74,11 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
mapView = findViewById(R.id.mapView)
status = findViewById(R.id.validationStatus)
mapView.addOnDidFailLoadingMapListener { errorMessage ->
if (realWeatherMode) {
Log.w(TAG, "ParticleField: event=BASEMAP_TILE_ERROR provider=GSI reason=$errorMessage failSoft=true")
}
}
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { map ->
maplibreMap = map
@@ -78,6 +88,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
.zoom(2.0)
.build()
}
updateRealWeatherButtonState()
loadValidationStyle(active = active, enabled = enabled, reason = "INITIAL")
}
@@ -103,6 +114,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (this::maplibreMap.isInitialized) setValidationProperties(active = active, maskMode = MASK_OCEAN_B)
}
findViewById<Button>(R.id.validationUniformEast).setOnClickListener {
leaveRealWeatherMode()
timeWindowMode = false
productionFeedMode = false
hostSubmissionMode = false
@@ -111,6 +123,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "UNIFORM_EAST")
}
findViewById<Button>(R.id.validationUniformNorth).setOnClickListener {
leaveRealWeatherMode()
timeWindowMode = false
productionFeedMode = false
hostSubmissionMode = false
@@ -119,6 +132,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "UNIFORM_NORTH")
}
findViewById<Button>(R.id.validationVortex).setOnClickListener {
leaveRealWeatherMode()
timeWindowMode = false
productionFeedMode = false
hostSubmissionMode = false
@@ -127,6 +141,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "VORTEX")
}
findViewById<Button>(R.id.validationTimeWindow).setOnClickListener {
leaveRealWeatherMode()
timeWindowMode = true
productionFeedMode = true
hostSubmissionMode = false
@@ -153,6 +168,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (this::maplibreMap.isInitialized) duplicateRuntimeFieldWindow()
}
findViewById<Button>(R.id.validationProductionInvalid).setOnClickListener {
leaveRealWeatherMode()
productionFeedMode = true
hostSubmissionMode = false
realWeatherMode = false
@@ -161,6 +177,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "INVALID_PRODUCTION_METADATA")
}
findViewById<Button>(R.id.validationHostFieldSubmit).setOnClickListener {
leaveRealWeatherMode()
timeWindowMode = true
productionFeedMode = false
hostSubmissionMode = true
@@ -174,14 +191,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (this::maplibreMap.isInitialized) submitHostValidationField(3, duplicate = true)
}
findViewById<Button>(R.id.validationRealWeather).setOnClickListener {
timeWindowMode = true
productionFeedMode = false
hostSubmissionMode = true
realWeatherMode = true
fixtureMode = FIXTURE_UNIFORM_EAST
timeMix = 0.0f
timelineState = "NOT READY"
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "REAL_WEATHER")
activateRealWeather(realWeatherMenuAnchor)
}
findViewById<Button>(R.id.validationHostFieldInvalid).setOnClickListener {
if (this::maplibreMap.isInitialized) submitInvalidHostValidationField()
@@ -206,11 +216,107 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
maplibreMap.cameraPosition = CameraPosition.Builder().target(LatLng(0.0, 0.0)).zoom(0.0).build()
Log.i(TAG, "ParticleValidation30_03: WORLD_WRAP preset center=0,0 zoom=0")
}
findViewById<Button>(R.id.validationMenuTime).setOnClickListener { showTimeMenu(it) }
findViewById<Button>(R.id.validationMenuVisual).setOnClickListener { showMenu(it, "VIS", intArrayOf(R.id.validationVisualDefault, R.id.validationVisualColor, R.id.validationVisualSizeDown, R.id.validationVisualSizeUp, R.id.validationVisualLengthDown, R.id.validationVisualLengthUp, R.id.validationVisualOpacityDown, R.id.validationVisualOpacityUp, R.id.validationVisualTrailDown, R.id.validationVisualTrailUp, R.id.validationVisualFadeDown, R.id.validationVisualFadeUp)) }
findViewById<Button>(R.id.validationMenuCount).setOnClickListener { showMenu(it, "COUNT", intArrayOf(R.id.validationParticle256, R.id.validationParticle1024, R.id.validationParticle4096, R.id.validationParticle16384, R.id.validationParticle65536, R.id.validationParticle262144, R.id.validationParticle1048576)) }
findViewById<Button>(R.id.validationMenuMap).setOnClickListener { showMenu(it, "MAP", intArrayOf(R.id.validationGlobal, R.id.validationOceanA, R.id.validationOceanB, R.id.validationDateline, R.id.validationWorldWrap)) }
findViewById<Button>(R.id.validationMenuMore).setOnClickListener { showMenu(it, "MORE", intArrayOf(R.id.validationActive, R.id.validationStatic, R.id.validationDisable, R.id.validationEnable, R.id.validationUniformEast, R.id.validationUniformNorth, R.id.validationVortex, R.id.validationTimeWindow, R.id.validationHostFieldSubmit, R.id.validationRealWeather, R.id.validationProductionInvalid, R.id.validationHostFieldDuplicate, R.id.validationHostFieldInvalid, R.id.validationAdvanceWindow, R.id.validationDuplicateWindow)) }
findViewById<Button>(R.id.validationMenuTime).setOnClickListener { dismissRealWeatherTuning(); showTimeMenu(it) }
findViewById<Button>(R.id.validationMenuVisual).setOnClickListener { dismissRealWeatherTuning(); showMenu(it, "VIS", intArrayOf(R.id.validationVisualDefault, R.id.validationVisualColor, R.id.validationVisualSizeDown, R.id.validationVisualSizeUp, R.id.validationVisualLengthDown, R.id.validationVisualLengthUp, R.id.validationVisualOpacityDown, R.id.validationVisualOpacityUp, R.id.validationVisualTrailDown, R.id.validationVisualTrailUp, R.id.validationVisualFadeDown, R.id.validationVisualFadeUp)) }
findViewById<Button>(R.id.validationMenuCount).setOnClickListener { dismissRealWeatherTuning(); showMenu(it, "COUNT", intArrayOf(R.id.validationParticle256, R.id.validationParticle1024, R.id.validationParticle4096, R.id.validationParticle16384, R.id.validationParticle65536, R.id.validationParticle262144, R.id.validationParticle1048576)) }
findViewById<Button>(R.id.validationMenuMap).setOnClickListener { dismissRealWeatherTuning(); showMenu(it, "MAP", intArrayOf(R.id.validationGlobal, R.id.validationOceanA, R.id.validationOceanB, R.id.validationDateline, R.id.validationWorldWrap)) }
findViewById<Button>(R.id.validationMenuRealWeather).setOnClickListener { activateRealWeather(it) }
findViewById<Button>(R.id.validationMenuMore).setOnClickListener { dismissRealWeatherTuning(); showMenu(it, "MORE", intArrayOf(R.id.validationActive, R.id.validationStatic, R.id.validationDisable, R.id.validationEnable, R.id.validationUniformEast, R.id.validationUniformNorth, R.id.validationVortex, R.id.validationTimeWindow, R.id.validationHostFieldSubmit, R.id.validationProductionInvalid, R.id.validationHostFieldDuplicate, R.id.validationHostFieldInvalid, R.id.validationAdvanceWindow, R.id.validationDuplicateWindow)) }
}
private data class ParticleVisuals(val count: Int, val width: Float, val length: Float, val opacity: Float, val trailOpacity: Float, val trailFade: Float)
private fun activateRealWeather(anchor: android.view.View?) {
realWeatherMenuAnchor = anchor ?: findViewById(R.id.validationMenuRealWeather)
if (realWeatherMode) {
if (realWeatherTuningMenu == null) showRealWeatherTuning(realWeatherMenuAnchor!!) else dismissRealWeatherTuning()
return
}
preRealWeatherVisuals = ParticleVisuals(particleCount, lineWidth, particleLength, particleOpacity, trailOpacity, trailFade)
particleCount = REAL_WEATHER_PARTICLE_COUNT
lineWidth = REAL_WEATHER_LINE_WIDTH
particleLength = REAL_WEATHER_STREAK_LENGTH
particleOpacity = REAL_WEATHER_PARTICLE_OPACITY
trailOpacity = REAL_WEATHER_TRAIL_OPACITY
trailFade = REAL_WEATHER_TRAIL_FADE
timeWindowMode = true
productionFeedMode = false
hostSubmissionMode = true
realWeatherMode = true
fixtureMode = FIXTURE_UNIFORM_EAST
timeMix = 0.0f
timelineState = "NOT READY"
openRealWeatherTuningAfterStyle = true
updateRealWeatherButtonState()
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "REAL_WEATHER")
}
private fun leaveRealWeatherMode() {
dismissRealWeatherTuning()
if (realWeatherMode) {
preRealWeatherVisuals?.let { visuals ->
particleCount = visuals.count; lineWidth = visuals.width; particleLength = visuals.length
particleOpacity = visuals.opacity; trailOpacity = visuals.trailOpacity; trailFade = visuals.trailFade
}
}
preRealWeatherVisuals = null
realWeatherMode = false
openRealWeatherTuningAfterStyle = false
updateRealWeatherButtonState()
}
private fun updateRealWeatherButtonState() {
val button = findViewById<Button>(R.id.validationMenuRealWeather)
button.isSelected = realWeatherMode
button.isActivated = realWeatherMode
button.setTextColor(if (realWeatherMode) Color.YELLOW else Color.WHITE)
}
private fun dismissRealWeatherTuning() { realWeatherTuningMenu?.dismiss(); realWeatherTuningMenu = null }
private fun showRealWeatherTuning(anchor: android.view.View) {
if (!realWeatherMode) return
PopupMenu(this, anchor).also { popup ->
realWeatherTuningMenu = popup
popup.menu.add(0, 100, 0, "PARTICLE TUNING").isEnabled = false
arrayOf("P -", "P +", "W -", "W +", "L -", "L +", "O -", "O +", "T -", "T +", "F -", "F +", "RESET DEFAULT")
.forEachIndexed { index, label -> popup.menu.add(0, index, index, label) }
popup.setOnMenuItemClickListener { item -> adjustRealWeatherTuning(item.itemId); true }
popup.setOnDismissListener { if (realWeatherTuningMenu === popup) realWeatherTuningMenu = null }
popup.show()
}
}
private fun adjustRealWeatherTuning(action: Int) {
when (action) {
0 -> setParticleCount(nextRealWeatherCount(-1)); 1 -> setParticleCount(nextRealWeatherCount(1))
2 -> setVisualFloat("raster-particle-line-width", (lineWidth - 0.25f).coerceAtLeast(0.25f)) { lineWidth = it }
3 -> setVisualFloat("raster-particle-line-width", (lineWidth + 0.25f).coerceAtMost(3.0f)) { lineWidth = it }
4 -> setVisualFloat("raster-particle-particle-length", (particleLength - 0.001f).coerceAtLeast(0.0005f)) { particleLength = it }
5 -> setVisualFloat("raster-particle-particle-length", (particleLength + 0.001f).coerceAtMost(0.020f)) { particleLength = it }
6 -> setVisualFloat("raster-particle-particle-opacity", (particleOpacity - 0.05f).coerceAtLeast(0.10f)) { particleOpacity = it }
7 -> setVisualFloat("raster-particle-particle-opacity", (particleOpacity + 0.05f).coerceAtMost(1.0f)) { particleOpacity = it }
8 -> setVisualFloat("raster-particle-trail-opacity", (trailOpacity - 0.05f).coerceAtLeast(0.0f)) { trailOpacity = it }
9 -> setVisualFloat("raster-particle-trail-opacity", (trailOpacity + 0.05f).coerceAtMost(1.0f)) { trailOpacity = it }
10 -> setVisualFloat("raster-particle-trail-fade", (trailFade - 0.01f).coerceAtLeast(0.80f)) { trailFade = it }
11 -> setVisualFloat("raster-particle-trail-fade", (trailFade + 0.01f).coerceAtMost(0.99f)) { trailFade = it }
12 -> resetRealWeatherDefaults()
}
Log.i(TAG, "ParticleTuning: event=APPLY realWeather=true action=$action values=${realWeatherHudValues()}")
}
private fun nextRealWeatherCount(direction: Int): Int {
val index = REAL_WEATHER_PARTICLE_COUNTS.indexOf(particleCount).let { if (it < 0) 3 else it }
return REAL_WEATHER_PARTICLE_COUNTS[(index + direction).coerceIn(0, REAL_WEATHER_PARTICLE_COUNTS.lastIndex)]
}
private fun resetRealWeatherDefaults() {
setParticleCount(REAL_WEATHER_PARTICLE_COUNT)
setVisualFloat("raster-particle-line-width", REAL_WEATHER_LINE_WIDTH) { lineWidth = it }
setVisualFloat("raster-particle-particle-length", REAL_WEATHER_STREAK_LENGTH) { particleLength = it }
setVisualFloat("raster-particle-particle-opacity", REAL_WEATHER_PARTICLE_OPACITY) { particleOpacity = it }
setVisualFloat("raster-particle-trail-opacity", REAL_WEATHER_TRAIL_OPACITY) { trailOpacity = it }
setVisualFloat("raster-particle-trail-fade", REAL_WEATHER_TRAIL_FADE) { trailFade = it }
}
private fun showMenu(anchor: android.view.View, title: String, actions: IntArray) {
@@ -317,6 +423,11 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
timelineState = "READY"
}
status.text = statusText()
updateRealWeatherButtonState()
if (openRealWeatherTuningAfterStyle && realWeatherMode) {
openRealWeatherTuningAfterStyle = false
realWeatherMenuAnchor?.let { showRealWeatherTuning(it) }
}
}
}
@@ -489,7 +600,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
status.text = statusText()
Log.i(TAG, "ParticleField: event=REAL_WEATHER_INITIAL_WINDOW_READY results=${results.joinToString(",")} timeline=$timelineState")
Log.i(TAG, "WeatherColorLayer: event=WEATHER_COLOR_FIELD_READY source=$PARTICLE_SOURCE_ID scale=0..30mps")
Log.i(TAG, "ParticleField: event=REAL_WEATHER_COMPOSITE_READY source=$PARTICLE_SOURCE_ID basemap=OSM color=weather-color particle=raster-particle")
Log.i(TAG, "ParticleField: event=REAL_WEATHER_COMPOSITE_READY source=$PARTICLE_SOURCE_ID basemap=GSI failSoft=true color=weather-color particle=raster-particle")
}
private fun fitRealWeatherBounds() {
@@ -560,9 +671,12 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
"EAST"
}
val playback = if (timeWindowMode) " · ${if (realWeatherMode) "REAL WEATHER" else if (hostSubmissionMode) "HOST" else if (productionFeedMode) "PRODUCTION" else "FIXTURE"} · TL $timelineState · $timelineSpeed" else ""
return "$mask · $runtime · $fields$playback · MIX ${(timeMix * 100).toInt()}% · $particleCount · W ${"%.1f".format(lineWidth)} L ${"%.3f".format(particleLength)} O ${(particleOpacity * 100).toInt()} T ${(trailOpacity * 100).toInt()} F ${(trailFade * 100).toInt()} C ${if (speedColorEnabled) "ON" else "OFF"}"
val summary = "$mask · $runtime · $fields$playback · MIX ${(timeMix * 100).toInt()}%"
return if (realWeatherMode) "$summary\n${realWeatherHudValues()}" else "$summary · $particleCount · W ${"%.1f".format(lineWidth)} L ${"%.3f".format(particleLength)} O ${(particleOpacity * 100).toInt()} T ${(trailOpacity * 100).toInt()} F ${(trailFade * 100).toInt()} C ${if (speedColorEnabled) "ON" else "OFF"}"
}
private fun realWeatherHudValues(): String = "P $particleCount · W ${"%.2f".format(lineWidth)} · L ${"%.4f".format(particleLength)} · O ${"%.2f".format(particleOpacity)} · T ${"%.2f".format(trailOpacity)} · F ${"%.2f".format(trailFade)}"
private fun validationStyle(active: Boolean, enabled: Boolean, reason: String): String {
val fixtureId = fixtureId()
val nextFixtureId = if (timeWindowMode) {
@@ -623,7 +737,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
"name": "Particle Core Layer Validation",
"sources": {
"particle-fixture": $source,
"osm-basemap": {"type":"raster","tiles":["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],"tileSize":256,"maxzoom":19},
"gsi-basemap": {"type":"raster","tiles":["https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png"],"tileSize":256,"maxzoom":18},
"validation-point": {
"type": "geojson",
"data": {"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]}}
@@ -631,7 +745,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
},
"layers": [
{"id":"validation-background","type":"background","paint":{"background-color":"#102030"}},
${if (realWeatherMode) "{\"id\":\"osm-basemap\",\"type\":\"raster\",\"source\":\"osm-basemap\",\"paint\":{\"raster-opacity\":1.0}}," else ""}
${if (realWeatherMode) "{\"id\":\"gsi-basemap\",\"type\":\"raster\",\"source\":\"gsi-basemap\",\"paint\":{\"raster-opacity\":1.0}}," else ""}
$validationDecorations
$weatherColorLayer
$particleLayer
@@ -700,8 +814,11 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private const val FIXTURE_UNIFORM_NORTH = 1
private const val FIXTURE_VORTEX = 2
private const val PARTICLE_COUNT_256 = 256
private const val PARTICLE_COUNT_512 = 512
private const val PARTICLE_COUNT_1024 = 1024
private const val PARTICLE_COUNT_2048 = 2048
private const val PARTICLE_COUNT_4096 = 4096
private const val PARTICLE_COUNT_8192 = 8192
private const val PARTICLE_COUNT_16384 = 16384
private const val PARTICLE_COUNT_65536 = 65536
private const val PARTICLE_COUNT_262144 = 262144
@@ -711,5 +828,12 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private const val DEFAULT_PARTICLE_OPACITY = 1.0f
private const val DEFAULT_TRAIL_OPACITY = 1.0f
private const val DEFAULT_TRAIL_FADE = 0.92f
private const val REAL_WEATHER_PARTICLE_COUNT = PARTICLE_COUNT_2048
private const val REAL_WEATHER_LINE_WIDTH = 0.75f
private const val REAL_WEATHER_STREAK_LENGTH = 0.004f
private const val REAL_WEATHER_PARTICLE_OPACITY = 0.75f
private const val REAL_WEATHER_TRAIL_OPACITY = 0.55f
private const val REAL_WEATHER_TRAIL_FADE = 0.88f
private val REAL_WEATHER_PARTICLE_COUNTS = intArrayOf(PARTICLE_COUNT_256, PARTICLE_COUNT_512, PARTICLE_COUNT_1024, PARTICLE_COUNT_2048, PARTICLE_COUNT_4096, PARTICLE_COUNT_8192, PARTICLE_COUNT_16384)
}
}

View File

@@ -8,6 +8,7 @@
<Button android:id="@+id/validationMenuVisual" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="VIS" />
<Button android:id="@+id/validationMenuCount" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="COUNT" />
<Button android:id="@+id/validationMenuMap" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="MAP" />
<Button android:id="@+id/validationMenuRealWeather" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="REAL WEATHER" android:textSize="10sp" />
<Button android:id="@+id/validationMenuMore" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="MORE" />
</LinearLayout>
<!-- Existing action IDs are invisible menu targets; no permanent button grid. -->

View File

@@ -35,6 +35,12 @@ bool validFieldUv(vec2 uv) {
return all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0)));
}
const float EARTH_CIRCUMFERENCE_METERS = 40075016.68557849;
float mercatorLatitudeRadians(float worldY) {
return atan(sinh(3.141592653589793 * (1.0 - 2.0 * worldY)));
}
vec3 weatherSpeedColor(float speed) {
// Fixed 0..30 m/s scale: no per-frame or per-dataset auto stretching.
float t = clamp(speed / 30.0, 0.0, 1.0);
@@ -83,10 +89,16 @@ void main() {
position = seed;
velocity = vec2(0.0);
}
// Field V remains north-positive. Mercator Y increases southward, so
// convert only at the physical-velocity -> map-displacement boundary.
vec2 mercatorVelocity = vec2(velocity.x, -velocity.y);
position = position + mercatorVelocity * u_pass.y * 0.00004;
// U/V remain canonical metres per second. u_pass.y is the explicit
// visual simulation delta in seconds; the validation clock may run
// faster than wall time, but it never changes the field contract.
// Web Mercator x spans Earth's circumference; y additionally scales
// with sec(latitude). V is north-positive while world y is southward.
float latitude = mercatorLatitudeRadians(position.y);
float dx = velocity.x * u_pass.y / EARTH_CIRCUMFERENCE_METERS;
float dy = -velocity.y * u_pass.y /
(EARTH_CIRCUMFERENCE_METERS * max(cos(latitude), 0.01));
position = position + vec2(dx, dy);
if (!validFieldUv(fieldUv(position)) || any(isnan(velocity))) position = seed;
fragColor = vec4(position, fract(previous.b + 0.03125 * u_pass.y), 1.0);
} else if (mode < 1.5) {

View File

@@ -46,6 +46,11 @@ namespace {
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;
std::atomic_uint64_t nextGpuGeneration{0};
std::atomic_uint64_t nextMaskGeneration{0};
@@ -105,7 +110,7 @@ Size validViewportSize(const Size size) {
}
Size particleStateSize(const uint32_t particleCount) {
const auto side = static_cast<uint32_t>(std::sqrt(static_cast<float>(particleCount)));
const auto side = static_cast<uint32_t>(std::ceil(std::sqrt(static_cast<float>(particleCount))));
return {side, side};
}
@@ -228,6 +233,8 @@ public:
bool antimeridianLogged = 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{};
uint64_t maskGeneration = 0;
uint64_t fieldGeneration = 0;
uint64_t gpuFieldGeneration = 0;
@@ -287,6 +294,7 @@ public:
pending = false;
bootstrapPending = false;
staticFreezeLogged = false;
lastSimulationTick = {};
for (const auto& target : stateTargets) {
if (target && target->getLayerGroup(0)) {
target->getLayerGroup(0)->clearDrawables();
@@ -404,6 +412,33 @@ public:
}
}
if (pass == ValidationPass::State) {
const auto now = std::chrono::steady_clock::now();
if (now - resources->lastMotionLog >= std::chrono::seconds(1)) {
resources->lastMotionLog = now;
const auto valid = std::find(resources->field.valid.begin(), resources->field.valid.end(), uint8_t{1});
if (valid != resources->field.valid.end()) {
const auto cell = static_cast<size_t>(std::distance(resources->field.valid.begin(), valid));
const auto row = cell / resources->field.width;
const auto latitude = resources->field.north +
(resources->field.south - resources->field.north) *
(static_cast<float>(row) / std::max(1u, resources->field.height - 1));
const auto u = resources->field.velocity[cell * 2];
const auto v = resources->field.velocity[cell * 2 + 1];
const auto cosLatitude = std::max(0.01f, std::cos(static_cast<float>(latitude * M_PI / 180.0)));
const auto dx = u * simulationStep / EarthCircumferenceMeters;
const auto dy = -v * simulationStep / (EarthCircumferenceMeters * cosLatitude);
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) +
" estimatedStateDeltaWorld=" + std::to_string(dx) + "," + std::to_string(dy) +
" simulationFrame=" + std::to_string(resources->committedFrame));
}
}
}
drawable.setTexture(source, shaders::idParticleGpuValidationPreviousTexture);
if (mask) {
drawable.setTexture(mask, shaders::idParticleGpuValidationMaskTexture);
@@ -1400,6 +1435,15 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
auto& trailGroup = static_cast<LayerGroup&>(*resources->trailTargets[trailWrite]->getLayerGroup(0));
auto& compositeGroup = static_cast<LayerGroup&>(*layerGroup);
const auto now = std::chrono::steady_clock::now();
float wallDeltaSeconds = 1.0f / 60.0f;
if (resources->lastSimulationTick.time_since_epoch().count() != 0) {
wallDeltaSeconds = std::chrono::duration<float>(now - resources->lastSimulationTick).count();
}
resources->lastSimulationTick = now;
const float visualSimulationDeltaSeconds =
std::clamp(wallDeltaSeconds, 1.0f / 240.0f, 0.050f) * VisualSimulationSecondsPerRealSecond;
const bool stateBuilt = buildQuad(context,
resources,
stateGroup,
@@ -1408,7 +1452,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
resources->stateTargets[stateWrite]->getTexture(),
resources->maskTexture,
{},
1.0f,
visualSimulationDeltaSeconds,
ValidationPass::State,
resources->stateRead,
stateWrite,

View File

@@ -23,9 +23,25 @@ constexpr uint32_t MinimumParticleCount = 256;
constexpr uint32_t MaximumParticleCount = 1024 * 1024;
bool isSupportedParticleCount(const uint32_t count) {
if (count < MinimumParticleCount || count > MaximumParticleCount) return false;
const auto side = static_cast<uint32_t>(std::sqrt(static_cast<float>(count)));
return side * side == count;
// The particle State texture is a ceil(sqrt(count)) grid. The final row
// may contain unused texels, but draw resources address only count
// particles. Keep the public validation presets explicit rather than
// accepting arbitrary resource sizes.
switch (count) {
case 256:
case 512:
case 1024:
case 2048:
case 4096:
case 8192:
case 16384:
case 65536:
case 262144:
case 1048576:
return count >= MinimumParticleCount && count <= MaximumParticleCount;
default:
return false;
}
}
const LayerTypeInfo typeInfo{.type = "raster-particle",
.source = LayerTypeInfo::Source::Required,
@@ -101,7 +117,7 @@ float RasterParticleLayer::getLineWidth() const {
return impl().lineWidth;
}
void RasterParticleLayer::setLineWidth(float value) {
const auto clamped = std::clamp(value, 1.0f, 8.0f);
const auto clamped = std::clamp(value, 0.25f, 8.0f);
if (clamped != getLineWidth()) {
auto next = mutableImpl();
next->lineWidth = clamped;
@@ -113,7 +129,7 @@ float RasterParticleLayer::getParticleLength() const {
return impl().particleLength;
}
void RasterParticleLayer::setParticleLength(float value) {
const auto clamped = std::clamp(value, 0.002f, 0.080f);
const auto clamped = std::clamp(value, 0.0005f, 0.080f);
if (clamped != getParticleLength()) {
auto next = mutableImpl();
next->particleLength = clamped;
@@ -196,7 +212,7 @@ std::optional<conversion::Error> RasterParticleLayer::setPropertyInternal(const
if (!converted || !std::isfinite(*converted) || std::floor(*converted) != *converted ||
!isSupportedParticleCount(static_cast<uint32_t>(*converted))) {
return conversion::Error{
"raster-particle-particle-count must be a supported square count from 256 to 1048576"};
"raster-particle-particle-count must be a supported validation preset from 256 to 1048576"};
}
setParticleCount(static_cast<uint32_t>(*converted));
return std::nullopt;

View File

@@ -121,12 +121,17 @@ TEST(Layer, RasterParticleCountProperties) {
layer.setParticleCount(65536);
EXPECT_EQ(65536u, layer.getParticleCount());
// Non-square and out-of-range counts are rejected by the Core Layer so
// RenderRasterParticleLayer always receives a valid State texture grid.
// Production-like validation also supports the explicit non-square
// presets used by the Android visual tuner. The Renderer rounds its State
// texture grid up and only draws the requested particle count.
layer.setParticleCount(2048);
EXPECT_EQ(2048u, layer.getParticleCount());
// Other values remain rejected by the Core Layer.
layer.setParticleCount(1000);
EXPECT_EQ(65536u, layer.getParticleCount());
EXPECT_EQ(2048u, layer.getParticleCount());
layer.setParticleCount(1048577);
EXPECT_EQ(65536u, layer.getParticleCount());
EXPECT_EQ(2048u, layer.getParticleCount());
}
TEST(Layer, RasterParticleVisualProperties) {
@@ -159,7 +164,7 @@ TEST(Layer, RasterParticleVisualProperties) {
layer.setParticleLength(-1.0f);
layer.setTrailFade(2.0f);
EXPECT_FLOAT_EQ(8.0f, layer.getLineWidth());
EXPECT_FLOAT_EQ(0.002f, layer.getParticleLength());
EXPECT_FLOAT_EQ(0.0005f, layer.getParticleLength());
EXPECT_FLOAT_EQ(0.995f, layer.getTrailFade());
}

View File

@@ -410,3 +410,10 @@
- 新增独立 `weather-color` 层:同一 canonical T0/T1 的 U/V 先插值再计算风速,固定 0–30 m/s 色标;与 RasterParticleLayer 保持分离。
- `MORE → REAL WEATHER` 通过既有 Host Submission Contract 一键提交真实字段,使用 OSM 无 Key 底图、自动 fit metadata bounds,并恢复旋转/Activity 重建后的可见内容。
- Android 综合验收待用户手工完成;详见 STEP-055 人工验收指令。
## 2026-09-05 — STEP-055 首轮 Android 视觉修复(IMPLEMENTED / WAITING_ANDROID_VALIDATION)
- 首轮实机已证实真实 WeatherV2 T0–T3、513×769 canonical Field、Host submit 和 WeatherColorLayer 可用;本轮将 REAL WEATHER 底图从 OSM 切到 GSI,并把入口提升为 `TIME | VIS | COUNT | MAP | REAL WEATHER | MORE` 的一级菜单。
- REAL WEATHER 首次加载采用 P=2048、W=.75、L=.0040、O=.75、T=.55、F=.88,并提供同状态 HUD、低频 `PARTICLE TUNING` 调整与 RESET;不重载 Style/Field/Timeline,只有容量调整会重建粒子 State/draw resources。
- 将原无单位 `velocity * 0.00004` 位移替换为 canonical m/s 到 Web Mercator world 的 Earth-circumference/cos(latitude) 换算;6000 simulation-seconds/real-second 是独立的 validation visual clock,不污染 U/V、validTime 或 Timeline。新增低频 `ParticleMotion` CPU 估算日志,未做 GPU readback。
- Linux OpenGL 受影响 Core/style test translation unit 精确编译与 shader generator 通过;Android Gradle/APK/ADB/Jest 未执行。等待用户按人工验收 v2 做一次综合复验。