fix: use float particle state targets
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-05 09:08:14 +08:00
parent 37c9aa31da
commit f0f0623e06
9 changed files with 175 additions and 12 deletions

View File

@@ -0,0 +1,30 @@
# STEP-055 Android 真实 WeatherV2 风场综合可视化 v2
状态:**IMPLEMENTED / WAITING_ANDROID_VALIDATION**
Starting HEAD:`37c9aa31da1d2b3495c8802e6fd2f6b999562b06`
## 本轮问题与根因
Android 已确认真实 WeatherV2 Field、Host submission、WeatherColorLayer、Timeline State A/B 调度、非零 U/V 与非零 m/s→Web Mercator 位移均存在,但 Timeline Pause 下粒子仍肉眼静止。
根因是 Particle State A/B 与 Trail A/B 共用 `createValidationTarget()` 的 `UnsignedByte` target。State RG 保存的是 Web Mercator world position;RGBA8 的一个量化步长约为 `1/255=0.00392 world`,远大于日志中每个真实 Weather simulation step 的 `0.000003–0.000008 world`,写回后不能累积有效位移。
## 修复
- State A/B target 改为 `TextureChannelDataType::Float`,包括初次创建、context/resource recreation 和 particleCount 变更的 replacement targets。
- Trail A/B 保持 `TextureChannelDataType::UnsignedByte`,继续是 viewport-sized 颜色/透明度 target;没有升级 WeatherColorLayer 或任意无关 full-screen target。
- `RenderTarget` 新增 `isRenderable()`,在 Particle target 创建时立即驱动现有 `OffscreenTexture → GL texture → framebuffer` 创建/检查。Android OpenGL 已有映射链为 `Float → RGBA32F + GL_FLOAT → glFramebufferTexture2D → checkFramebuffer()`;无需新增 backend 抽象。
- Float State FBO 不能创建时不回退到 UByte:`ParticleStateTarget: event=CREATE_FAILED ... dataType=Float` 后停止该 State resource 创建;count change 同样明确返回 `STATE_RESOURCE_CREATE_FAILED dataType=Float`。
- 每个成功 target 输出 `CREATE_SUCCESS`,Trail pair 就绪时另输出一次 `ParticleStateTarget: stateAType=Float stateBType=Float trailAType=UnsignedByte trailBType=UnsignedByte`。
- Shader 注释校正为 State RG 保存 `Web Mercator world position`,不是 field-normalized position。
## 冻结边界
未修改 canonical Field、STEP-054 assets、Host Submission Contract、Timeline、Playback、Runtime Field Window、WeatherColorLayer、U/V=m/s 或 `visualSpeedScale=6000`。继续保持 Timeline Pause 只冻结 forecast 时间、Particle simulation 在当前 Field 中运行;Window reuse、NEW_T1-only、MIX property-only、normal boundary `simulationRebuild=false` / `trailCleared=false` 不变。
## 验证与待验收
已执行 shader 生成、Linux OpenGL 受影响 Core 精确编译、相关测试源精确编译和 `git diff --check`;未执行 Android Gradle/APK/ADB/App/设备或 Jest。
下一轮人工验收的第一优先项是:进入 REAL WEATHER、保持 Timeline Pause,确认 Float State 类型日志后粒子中心位置连续运动。若仍完全不动,停止调节 6000,改以单独任务调查 GPU State readback / State Update→Draw 链。

View File

@@ -0,0 +1,35 @@
# STEP-055 Android 真实 WeatherV2 风场综合可视化人工验收 v3
状态:等待用户手工 Android 验收。Codex 不执行 Android Gradle、APK、ADB、App 启动或设备测试。
## 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 "ParticleStateTarget ParticleMotion REAL_WEATHER ParticleTimeline FIELD_REUSE FIELD_UPLOAD simulationRebuild trailCleared"
```
## 第一优先:State 精度
1. 进入 `Particle Core Layer Validation → REAL WEATHER`,确认 GSI、真实色场、HUD 默认 `P 2048 · W 0.75 · L 0.0040 · O 0.75 · T 0.55 · F 0.88`。
2. **不要点击 PLAY,或在 `TIME → PAUSE` 后观察。** Particle position 必须在固定真实风场中连续、明显移动;Timeline cursor/MIX 冻结不应暂停 simulation。
3. 日志必须有:
```text
ParticleStateTarget: stateAType=Float stateBType=Float trailAType=UnsignedByte trailBType=UnsignedByte
ParticleStateTarget: event=CREATE_SUCCESS kind=STATE dataType=Float
ParticleMotion: ... sampleU=... sampleV=... dt=... visualSpeedScale=6000 estimatedStateDeltaWorld=...
```
4. 若有 `CREATE_FAILED kind=STATE dataType=Float`,记录完整错误;Particle State 应 fail-closed,不得静默以 UByte 继续。
5. 若 Float 日志正确但粒子仍完全不动,**停止调节 visualSpeedScale=6000**,保存日志并进入后续 GPU State texel readback / State Update→Draw 定向任务。
## 继续综合验收
在第一优先项通过后,沿用 v2 的 REAL WEATHER tuning/RESET、PLAY T0→T3、两次 Window Boundary、Duplicate/Invalid、Pan/Zoom/Rotate/Activity Rotation 检查。正常边界仍应为 OLD_T1→NEW_T0 reuse、only NEW_T1 upload、`simulationRebuild=false`、`trailCleared=false`。
本轮没有 Weather Service、React Native、Jest 或 canonical 数据变化。

View File

@@ -0,0 +1,39 @@
# STEP-055 真实 WeatherV2 风场 Particle State 纹理精度修复 Codex 完成报告 v3
## 基线、范围与根因
- Starting HEAD:`37c9aa31da1d2b3495c8802e6fd2f6b999562b06`
- 分支:`particle/main`
- 状态:**IMPLEMENTED / WAITING_ANDROID_VALIDATION**
真实 Weather State shader 已计算非零 m/s→Web Mercator 位移且 State A/B ping-pong 持续,但 State RG world coordinate 写回被共用的 RGBA8/UnsignedByte target 量化。`1/255≈0.00392 world` 大于 Android 已观测的 `0.000003–0.000008 world` 单步位移,故每步无法积累,是“simulation 在跑但粒子静止”的确定性根因。
## 修改
- `render_particle_validation_layer.cpp`:State A/B 初始创建与 particleCount replacement 均为 `TextureChannelDataType::Float`;Trail A/B 仍为 `UnsignedByte`。创建函数显式接收 data type/kind,并在 FBO 创建失败时 log error、拒绝 target,无 UByte fallback。
- `render_target.hpp/.cpp`:新增最小 `RenderTarget::isRenderable()` forwarding,使 target 创建即可验证当前后端实际 FBO。
- `particle_gpu_validation.fragment.glsl`:只修正 RG 为 Web Mercator world position 的注释;未改 m/s conversion 或 `visualSpeedScale=6000`。
- 生成的 OpenGL shader header 同步更新。
Android OpenGL 既有实现已经支持 Float:`gl::Context::createRenderTarget → RenderTarget → gl::OffscreenTexture → Texture2D(RGBA, Float) → GL_RGBA32F/GL_FLOAT → glFramebufferTexture2D → checkFramebuffer()`。因此未修改 Android/OpenGL backend;若设备 FBO 不完整,现有 `isRenderable()` 返回失败,Particle target 记录具体 Float failure 并 fail-closed。
运行时日志包括每个成功 `CREATE_SUCCESS`,以及 State/Trail pair summary:`stateAType=Float stateBType=Float trailAType=UnsignedByte trailBType=UnsignedByte`。这基于已实际完成 backend FBO validation,而非仅打印请求值。
## 架构保护
未修改 canonical Field、U/V 语义、STEP-054 assets、Host submission、Timeline、Runtime Field Window、WeatherColorLayer 或 6000 visual speed scale。没有新增 GPU readback、网络、Provider、React Native、Jest 或 Android UI 变化。
## 验证
- `node shaders/generate_shader_code.mjs`:PASS;generated OpenGL header 与 fragment source 同步。
- Linux OpenGL CMake 精确编译:`render_target.cpp`、`render_particle_validation_layer.cpp`、`gl/offscreen_texture.cpp`、`gl/texture2d.cpp`:PASS。
- 现有非 Jest `test/style/style_layer.test.cpp`:以相同 OpenGL CMake 参数精确编译 PASS;本轮没有适合脱离实际 GL context 的 Float FBO 单元 runner,因此 FBO runtime 完整性保留 Android 实机检查。
- `git diff --check`:PASS(最终提交前将再次执行)。
- Android Gradle/APK/ADB/App/设备:未执行。
- Jest:未执行,未修改。
人工验收说明:[v3](../60_测试/STEP-055_Android真实WeatherV2风场综合可视化_人工验收指令_v3.md)。第一门槛是 Timeline Pause 下 Float State 粒子连续运动;若仍不动,不调 6000,转入后续 GPU State 读回诊断。
## Git
本报告将在功能提交及 push 后记录实际 commit、push result 与 Final HEAD。用户既有未跟踪文件未触碰、未 stage。

View File

@@ -34,6 +34,9 @@ public:
/// Get the render target texture
const gfx::Texture2DPtr& getTexture();
/// Create and validate the backing offscreen framebuffer.
bool isRenderable();
/// @brief Add a layer group to the render target
/// @param replace Flag to replace if exists
/// @return whether added

View File

@@ -155,8 +155,8 @@ void main() {
vec4 previous = texture(u_previous, v_uv);
if (mode < 0.5) {
// 40-01: State A/B samples the uploaded OfflineFixture Particle Field.
// RG stores field-normalized position, B stores age, and the Field
// State A/B samples the uploaded canonical Particle Field.
// RG stores Web Mercator world position, B stores age, and the Field
// 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.

View File

@@ -70,8 +70,8 @@ void main() {
vec4 previous = texture(u_previous, v_uv);
if (mode < 0.5) {
// 40-01: State A/B samples the uploaded OfflineFixture Particle Field.
// RG stores field-normalized position, B stores age, and the Field
// State A/B samples the uploaded canonical Particle Field.
// RG stores Web Mercator world position, B stores age, and the Field
// 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.

View File

@@ -105,6 +105,18 @@ const char* slotName(const uint8_t slot) {
return slot == 0 ? "A" : "B";
}
const char* textureDataTypeName(const gfx::TextureChannelDataType type) {
switch (type) {
case gfx::TextureChannelDataType::UnsignedByte:
return "UnsignedByte";
case gfx::TextureChannelDataType::HalfFloat:
return "HalfFloat";
case gfx::TextureChannelDataType::Float:
return "Float";
}
return "Unknown";
}
Size validViewportSize(const Size size) {
return {std::max<uint32_t>(1, size.width), std::max<uint32_t>(1, size.height)};
}
@@ -601,17 +613,29 @@ private:
RenderTargetPtr createValidationTarget(gfx::Context& context,
const Size size,
const std::string& name,
const gfx::TextureFilterType filter) {
auto target = context.createRenderTarget(size, gfx::TextureChannelDataType::UnsignedByte);
if (!target || !target->getTexture()) {
const gfx::TextureFilterType filter,
const gfx::TextureChannelDataType type,
const char* targetKind) {
auto target = context.createRenderTarget(size, type);
if (!target || !target->isRenderable() || !target->getTexture()) {
Log::Error(Event::Render,
"ParticleStateTarget: event=CREATE_FAILED kind=" + std::string(targetKind) +
" dataType=" + textureDataTypeName(type) + " name=" + name);
return {};
}
target->getTexture()->setSamplerConfiguration(
{.filter = filter, .wrapU = gfx::TextureWrapType::Clamp, .wrapV = gfx::TextureWrapType::Clamp});
auto group = context.createLayerGroup(0, 1, name);
if (!group || !target->addLayerGroup(std::move(group), true)) {
Log::Error(Event::Render,
"ParticleStateTarget: event=CREATE_FAILED kind=" + std::string(targetKind) +
" dataType=" + textureDataTypeName(type) + " name=" + name + " reason=LAYER_GROUP");
return {};
}
Log::Info(Event::Render,
"ParticleStateTarget: event=CREATE_SUCCESS kind=" + std::string(targetKind) +
" dataType=" + textureDataTypeName(type) + " name=" + name + " size=" +
std::to_string(size.width) + "x" + std::to_string(size.height));
return target;
}
@@ -927,10 +951,16 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
rebuildParticleDrawResources(owned);
for (uint8_t i = 0; i < 2; ++i) {
owned->stateTargets[i] = createValidationTarget(
context, owned->stateSize, getID() + "-state-" + slotName(i), gfx::TextureFilterType::Nearest);
context,
owned->stateSize,
getID() + "-state-" + slotName(i),
gfx::TextureFilterType::Nearest,
gfx::TextureChannelDataType::Float,
"STATE");
}
if (!owned->shader || !owned->stateTargets[0] || !owned->stateTargets[1]) {
Log::Error(Event::Render, "ParticleValidation30_02: State resource creation failed");
Log::Error(Event::Render,
"ParticleStateTarget: event=CREATE_FAILED kind=STATE dataType=Float reason=FRAMEBUFFER_UNAVAILABLE");
return;
}
owned->maskFixture = makeCoastalMaskFixture();
@@ -1059,11 +1089,16 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
std::array<RenderTargetPtr, 2> replacementTargets;
for (uint8_t i = 0; i < 2; ++i) {
replacementTargets[i] = createValidationTarget(
context, requestedSize, getID() + "-state-" + slotName(i), gfx::TextureFilterType::Nearest);
context,
requestedSize,
getID() + "-state-" + slotName(i),
gfx::TextureFilterType::Nearest,
gfx::TextureChannelDataType::Float,
"STATE");
}
if (!replacementTargets[0] || !replacementTargets[1]) {
Log::Error(Event::Render,
"ParticleGPU: event=PARTICLE_COUNT_REJECTED reason=STATE_RESOURCE_CREATE_FAILED "
"ParticleGPU: event=PARTICLE_COUNT_REJECTED reason=STATE_RESOURCE_CREATE_FAILED dataType=Float "
"requestedParticleCount=" +
std::to_string(particleCount) +
" effectiveParticleCount=" + std::to_string(resources->particleCount) +
@@ -1286,7 +1321,12 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
resources->trailTargets = {};
for (uint8_t i = 0; i < 2; ++i) {
resources->trailTargets[i] = createValidationTarget(
context, viewport, getID() + "-trail-" + slotName(i), gfx::TextureFilterType::Linear);
context,
viewport,
getID() + "-trail-" + slotName(i),
gfx::TextureFilterType::Linear,
gfx::TextureChannelDataType::UnsignedByte,
"TRAIL");
}
if (!resources->trailTargets[0] || !resources->trailTargets[1]) {
Log::Error(Event::Render, "ParticleGPU: Trail resource creation failed");
@@ -1295,6 +1335,12 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
resources->trailSize = viewport;
resources->trailRead = 0;
resources->trailSeeded = false;
Log::Info(Event::Render,
"ParticleStateTarget: stateAType=Float stateBType=Float trailAType=UnsignedByte trailBType=UnsignedByte "
"stateGrid=" + std::to_string(resources->stateSize.width) + "x" +
std::to_string(resources->stateSize.height) + " trailSize=" +
std::to_string(viewport.width) + "x" + std::to_string(viewport.height) +
" resourceGeneration=" + std::to_string(resources->generation));
Log::Info(Event::Render,
"ParticleGPU: Trail A/B " + std::string(previousSize.isEmpty() ? "create" : "resize") +
" resourceGeneration=" + std::to_string(resources->generation) +

View File

@@ -21,6 +21,10 @@ const gfx::Texture2DPtr& RenderTarget::getTexture() {
return offscreenTexture->getTexture();
};
bool RenderTarget::isRenderable() {
return offscreenTexture && offscreenTexture->isRenderable();
}
bool RenderTarget::addLayerGroup(LayerGroupBasePtr layerGroup, const bool replace) {
const auto index = layerGroup->getLayerIndex();
const auto result = layerGroupsByLayerIndex.insert(std::make_pair(index, LayerGroupBasePtr{}));

View File

@@ -417,3 +417,9 @@
- 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 做一次综合复验。
## 2026-09-05 — STEP-055 Particle State Float 精度修复(IMPLEMENTED / WAITING_ANDROID_VALIDATION)
- Android 日志已将“真实 U/V、非零 world displacement、State A/B ping-pong 均存在但粒子静止”收敛为 RGBA8 State 量化:单步 `3e-6–8e-6 world` 小于 UByte `1/255` 的可表示间隔。
- State A/B 现在为 Float,Trail A/B 保持 UnsignedByte;初次创建、context recreation 与 particleCount replacement 均通过同一 Float target 路径,FBO 创建失败明确 fail-closed,不静默降级。
- 现有 OpenGL `Float → RGBA32F/GL_FLOAT → FBO check` 支持直接复用,未改 backend、canonical/Timeline/Window/WeatherColorLayer 或 visualSpeedScale=6000。下一次 Android 的首要检查是 Timeline Pause 下真实固定场粒子是否持续移动,并核对 `ParticleStateTarget` 类型日志。