fix: tune real weather particle rendering
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
This commit is contained in:
@@ -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`。
|
||||
|
||||
47
docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v2.md
Normal file
47
docs/60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v2.md
Normal 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 日志判断。
|
||||
45
docs/报告/STEP-055_Android真实WeatherV2风场综合可视化_Codex完成报告_v2.md
Normal file
45
docs/报告/STEP-055_Android真实WeatherV2风场综合可视化_Codex完成报告_v2.md
Normal 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。
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 做一次综合复验。
|
||||
|
||||
Reference in New Issue
Block a user