fix: finalize GPU particle time interpolation
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-02 22:07:01 +08:00
parent d4c17fc8b0
commit 5dd4dbf496
11 changed files with 86 additions and 16 deletions

View File

@@ -4,6 +4,7 @@
- 前置:STEP-040(40-01)已在 Android OpenGL 手工验收为 PASS。
- 目标:在唯一正式链路 `RasterArraySource -> RasterParticleLayer -> GPU Simulation -> Trail -> Composite` 中,把单 Field 扩展为 T0/T1 两个独立 GPU Field,并在 State pass 对速度作 GPU `mix`。
- 实现提交:`4675a8a29dd6fc7b684bfb64913a8c9075ad8583`(`feat: add GPU particle time interpolation`)。
- 40-02C 最终修正:原实现的 MIX fallback、GPU identity 与 NORTH Mercator displacement 问题由 STEP-043 修正;在 Android T1--T10 前仍不得判 PASS。
## 本轮实现

View File

@@ -0,0 +1,7 @@
# STEP-043 40-02C Android 双时间片 GPU 时间插值最终修正
- 状态:`IMPLEMENTED / WAITING FOR ANDROID VALIDATION`
- 修正:MIX hard property-only guard、revision/content identity 分离、A/B→B/C content reuse、Mercator Y displacement 与 north-positive V 的边界转换。
- 规则:generation 仍是 source revision;GPU reuse 比较 fixtureID、dataVersion、已验证 `fieldSha256` 和 grid compatibility。MIX 未就绪只记录 `MIX_REJECTED`,不 reload style。
- 检查:shader generator、Linux Core/source test object、format、diff check 通过;未运行 Android Gradle/ADB。
- Android:T1--T10 仍待用户执行,不能判 PASS。

View File

@@ -0,0 +1,19 @@
# STEP-043 40-02C Android 双时间片 GPU 时间插值最终修正完成报告
状态:`IMPLEMENTED / WAITING FOR ANDROID VALIDATION`。开始 HEAD 为 `d4c17fc8b0d5005bb25e603bd0046fcb19602aa3`。
- NORTH:Field/decode/texture/timeMix 继续为 V north-positive;仅 State shader 最终 Mercator displacement 使用 `dy=-v`。
- MIX:Activity 记录 style epoch/ready;未 ready、disabled、layer 缺失或 time-window off 时记录 `MIX_REJECTED`,绝不 fallback `setStyle()`;正常路径仅 `setProperties` 并记录 `MIX_PROPERTY_ONLY`。
- Identity:generation/fixture/dataVersion 继续标识 revision;snapshot 保存已验证 metadata SHA-256,`sameFieldGpuContent` 用 fixture/data/hash/compatibility,令跨 reload 的 B 可复用。
- ADVANCE:Renderer slot matching/overlap 改用 GPU content identity,因此 B generation 变化但内容不变时可复用旧 texture,仅新 C 需要 upload;无内容 overlap 仍保留安全 reset。
- 检查:shader generator、Linux Core 与 source test object、clang-format、diff check 通过。未执行 Android Gradle/ADB。
用户手工:
```bash
./gradlew :MapLibreAndroidTestApp:assembleOpenglDebug --console=plain
adb -s 192.168.1.146 install -r platform/android/MapLibreAndroidTestApp/build/outputs/apk/opengl/debug/MapLibreAndroidTestApp-opengl-debug.apk
adb -s 192.168.1.146 shell am start -n org.maplibre.android.testapp/.activity.particle.ParticleCoreLayerValidationActivity
```
T1--T10(EAST/NORTH、MIX 0 upload/reset、MIX reject、layer lifecycle、B→C +1 upload、回归)均待用户 Android 日志/视觉验收。

View File

@@ -67,7 +67,10 @@ void main() {
position = v_uv;
velocity = vec2(0.0);
}
position = fract(position + velocity * u_pass.y * 0.004);
// 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 = fract(position + mercatorVelocity * u_pass.y * 0.004);
if (any(isnan(velocity))) position = v_uv;
fragColor = vec4(position, fract(previous.b + 0.03125 * u_pass.y), 1.0);
} else if (mode < 1.5) {

View File

@@ -24,6 +24,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var fixtureMode = FIXTURE_UNIFORM_EAST
private var timeMix = 0.0f
private var timeWindowMode = false
private var styleLoadEpoch = 0L
private var styleReady = false
override fun onCreate(savedInstanceState: Bundle?) {
active = savedInstanceState?.getBoolean(STATE_ACTIVE, true) ?: true
@@ -110,12 +112,15 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
}
private fun loadValidationStyle(active: Boolean, enabled: Boolean) {
private fun loadValidationStyle(active: Boolean, enabled: Boolean, reason: String = "STYLE_CHANGE") {
this.active = active
this.enabled = enabled
val state = statusText()
Log.i(TAG, "ParticleValidation30_03: Test App switching to $state")
styleReady = false
styleLoadEpoch += 1
Log.i(TAG, "ParticleGPU: event=STYLE_LOAD epoch=$styleLoadEpoch reason=$reason")
maplibreMap.setStyle(Style.Builder().fromJson(validationStyle(active, enabled))) {
styleReady = true
status.text = state
}
}
@@ -140,15 +145,20 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private fun setTimeMix(value: Float) {
timeMix = value.coerceIn(0.0f, 1.0f)
val layer = maplibreMap.style?.getLayer(PARTICLE_LAYER_ID)
if (layer == null || !enabled || !timeWindowMode) {
timeWindowMode = true
fixtureMode = FIXTURE_UNIFORM_EAST
loadValidationStyle(active, enabled)
val rejectReason = when {
!styleReady -> "STYLE_LOADING"
layer == null -> "LAYER_NOT_READY"
!enabled -> "DISABLED"
!timeWindowMode -> "TIME_WINDOW_OFF"
else -> null
}
if (rejectReason != null) {
Log.i(TAG, "ParticleGPU: event=MIX_REJECTED reason=$rejectReason styleEpoch=$styleLoadEpoch")
return
}
layer.setProperties(PaintPropertyValue("raster-particle-time-mix", timeMix))
status.text = statusText()
Log.i(TAG, "ParticleGPU: test timeMix=$timeMix; expect no Field upload or simulation rebuild")
Log.i(TAG, "ParticleGPU: event=MIX_PROPERTY_ONLY value=$timeMix styleEpoch=$styleLoadEpoch")
}
private fun statusText(): String {

View File

@@ -33,7 +33,10 @@ void main() {
position = v_uv;
velocity = vec2(0.0);
}
position = fract(position + velocity * u_pass.y * 0.004);
// 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 = fract(position + mercatorVelocity * u_pass.y * 0.004);
if (any(isnan(velocity))) position = v_uv;
fragColor = vec4(position, fract(previous.b + 0.03125 * u_pass.y), 1.0);
} else if (mode < 1.5) {

View File

@@ -193,7 +193,8 @@ ParticleFieldSnapshot OfflineFixtureParticleAdapter::load(const std::string& fix
if (bytes.size() != expected || member(integrity, "fieldByteLength").GetUint64() != expected) {
throw std::invalid_argument("fixture field length mismatch: " + fixtureID);
}
if (sha256(bytes) != member(integrity, "fieldSha256").GetString()) {
field.fieldSha256 = member(integrity, "fieldSha256").GetString();
if (sha256(bytes) != field.fieldSha256) {
throw std::invalid_argument("fixture field checksum mismatch: " + fixtureID);
}
field.velocity.resize(expected / sizeof(float));

View File

@@ -21,6 +21,9 @@ struct ParticleFieldSnapshot {
float noData = -9999.0f;
uint64_t dataVersion = 0;
uint64_t generation = 0;
// Verified integrity.fieldSha256 identifies immutable GPU-upload content
// across source reloads; generation remains a separate runtime revision.
std::string fieldSha256;
// Row-major, little-endian contract decoded as interleaved canonical
// U-east/V-north values. A noData component makes the cell invalid.
std::vector<float> velocity;
@@ -41,6 +44,11 @@ inline bool fieldIdentityChanged(const ParticleFieldSnapshot& current, const Par
current.dataVersion != next.dataVersion;
}
inline bool sameFieldGpuContent(const ParticleFieldSnapshot& current, const ParticleFieldSnapshot& next) {
return current.fixtureID == next.fixtureID && current.dataVersion == next.dataVersion &&
current.fieldSha256 == next.fieldSha256 && current.isTimeInterpolationCompatibleWith(next);
}
// A formal source time window. T0/T1 remain independent immutable snapshots;
// the renderer uploads and samples them independently on the GPU.
struct ParticleFieldTimeWindow {

View File

@@ -732,10 +732,10 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
Log::Error(Event::Render, "ParticleGPU: rejected incompatible time-window fields");
return;
}
const bool reuseOldT0 = !particle::fieldIdentityChanged(oldT0, nextT0);
const bool reuseOldT1 = !particle::fieldIdentityChanged(oldT1, nextT0);
const bool reuseNewT0 = !particle::fieldIdentityChanged(oldT0, nextT1);
const bool reuseNewT1 = !particle::fieldIdentityChanged(oldT1, nextT1);
const bool reuseOldT0 = particle::sameFieldGpuContent(oldT0, nextT0);
const bool reuseOldT1 = particle::sameFieldGpuContent(oldT1, nextT0);
const bool reuseNewT0 = particle::sameFieldGpuContent(oldT0, nextT1);
const bool reuseNewT1 = particle::sameFieldGpuContent(oldT1, nextT1);
const bool windowOverlap = reuseOldT0 || reuseOldT1 || reuseNewT0 || reuseNewT1;
const auto upload = [&](const particle::ParticleFieldSnapshot& field,
std::shared_ptr<PremultipliedImage>& image,
@@ -754,13 +754,13 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
particle::ParticleFieldSnapshot& assigned,
std::shared_ptr<PremultipliedImage>& image,
gfx::Texture2DPtr& texture) {
if (!particle::fieldIdentityChanged(oldT0, wanted)) {
if (particle::sameFieldGpuContent(oldT0, wanted)) {
assigned = oldT0;
image = resources->fieldImage;
texture = resources->fieldTexture;
return true;
}
if (!particle::fieldIdentityChanged(oldT1, wanted)) {
if (particle::sameFieldGpuContent(oldT1, wanted)) {
assigned = oldT1;
image = resources->nextFieldImage;
texture = resources->nextFieldTexture;

View File

@@ -219,6 +219,18 @@ TEST(Source, RasterArrayFieldIdentityIncludesFixtureAndDataVersion) {
EXPECT_FALSE(particle::fieldIdentityChanged(east, east));
}
TEST(Source, RasterArrayGpuContentIdentityExcludesRuntimeGeneration) {
const auto east = particle::OfflineFixtureParticleAdapter::load("uniform-east", 100);
auto reloadedEast = east;
reloadedEast.generation = 200;
auto changed = reloadedEast;
++changed.dataVersion;
EXPECT_TRUE(particle::fieldIdentityChanged(east, reloadedEast));
EXPECT_TRUE(particle::sameFieldGpuContent(east, reloadedEast));
EXPECT_FALSE(particle::sameFieldGpuContent(east, changed));
}
TEST(Source, RasterArrayTimeWindowKeepsIndependentCompatibleSnapshots) {
const auto east = particle::OfflineFixtureParticleAdapter::load("uniform-east", 11);
const auto north = particle::OfflineFixtureParticleAdapter::load("uniform-north", 12);

View File

@@ -238,3 +238,9 @@
- EAST/NORTH、T0/T1、ADVANCE、MIX、ACTIVE/STATIC、DISABLE/ENABLE、GLOBAL/OCEAN、DATELINE/WORLD WRAP 的 16 个既有 ID/click binding 均保留;状态改为单行 mask/runtime/field/mix 表达,不改变 40-02 业务语义。
- 未修改 C++ Core、shader、GPU、simulation、trail、composite、mask、Gradle 或 ABI。静态 ID/binding 核对与 diff check 通过;未运行 Android Gradle、ADB 或设备。状态为 `IMPLEMENTED / WAITING FOR ANDROID UI VALIDATION`。
- 实现提交为 `f4d519ca789c0488d6b9d8259427ac070b15eb63`(`ui: compact particle validation controls`);文档收尾提交另行记录。
## 2026-09-02 — STEP-043 / 40-02C 最终修正
- 修正 north-positive V 到 Mercator Y 的最终 displacement 符号;Field canonical contract 未变。
- MIX 移除 Style reload fallback,未就绪只拒绝并记录日志;GPU reuse 使用已验证 SHA-256 content identity,revision generation 仍独立保留。
- 状态:`IMPLEMENTED / WAITING FOR ANDROID VALIDATION`;未运行 Android Gradle/ADB,T1--T10 待用户验收。