feat: add runtime particle timeline playback
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-03 21:23:29 +08:00
parent 73bf01ebc0
commit 49b2e8aebd
17 changed files with 491 additions and 140 deletions

View File

@@ -0,0 +1,27 @@
# STEP-047 Runtime Timeline 与 Playback
状态:IMPLEMENTED / WAITING FOR ANDROID VALIDATION
## 目标与边界
40-06 在已冻结的 40-04 Runtime Field Window 与 40-05 正式 streak 渲染之上增加 Core-owned Timeline/Playback。它不接入网络、GRIB、provider、HTTP 或 Android 定时器;Android 只通过 JNI 发送低频命令。
`RasterArraySource` 持有有界(最多 8 个)不可变 Field 快照、严格递增的逻辑时间、当前 T0/T1、cursor、播放状态、速度和 gap/invalid/recover 状态。渲染层每帧只取得 Core 给出的 T0/T1/mix;不会获知文件、provider 或网络。
## 实现
- Validation timeline 使用 6 个 fixture snapshot:east、north、vortex、nodata、east、north;逻辑间隔为 4 秒。
- `PLAY/PAUSE`、0.5x/1x/2x、STEP PREV/NEXT、SEEK START/MID/END、GAP、INVALID、RECOVER 均为 Source Core 命令,经薄 JNI bridge 调用。
- 自动边界维持 `OLD_T1 → NEW_T0` 资源重用语义;只在窗口 revision 改变时交给既有 Renderer Field reuse/upload 路径处理。正常 MIX 是 Core clock 的 scalar 更新,不会触发 Style reload。
- Gap/invalid 到达边界时 fail-closed 保持当前有效 T0/T1;recover 后可继续操作;末尾进入 END。
- Validation UI 改为状态栏与 `TIME | VIS | COUNT | MAP | MORE` 五个菜单入口,原动作仍可由菜单触达。
## 不变契约
- U=east-positive、V=north-positive、Mercator `dy=-v` 不变。
- 40-02 MIX property-only、40-03 full particle draw、40-04 runtime window reuse/noop/fail-closed 不变。
- 无未来 GPU 纹理预上传;仅 Source 内有界 CPU snapshot cache。
## 验证状态
已执行 Linux/OpenGL Core 精确编译与 Source 单元测试构建;未运行 Android Gradle、ADB、Jest 或实体机。Android x86_64 手工验收仍待执行。

View File

@@ -0,0 +1,32 @@
# 40-06 Runtime Timeline 与 Playback Codex 完成报告
状态:IMPLEMENTED / WAITING FOR ANDROID VALIDATION
## 完成内容
STEP-047 已实现由 `RasterArraySource` 唯一持有的 Timeline/Playback runtime。它保存有界 immutable snapshots、严格递增 logical timestamps、cursor、T0/T1、mix、播放/暂停、速度以及 gap/invalid/recover/end 状态。Renderer 不保存 Android 状态,不了解 provider/files/network,只读取 Source Core 的当前窗口和 mix。
自动播放在边界生成新的窗口 revision,沿用既有 `OLD_T1 → NEW_T0` overlap GPU reuse;正常推进不要求 Style reload、Layer recreate、simulation reset 或 trail clear。MIX 是 Core clock 每帧产生的 scalar,不使用 Android Timer/JNI 每帧调用。
Android 侧新增薄 `controlRasterArraySourceTimeline(sourceId, command, value)` bridge;Activity 仅触发低频控制。Validation 控制台已收敛为 `TIME | VIS | COUNT | MAP | MORE`,所有既有动作可达,并增加 GAP/INVALID/RECOVER。
## Core 覆盖
Source unit test 覆盖:五个快照、play/pause、速度合法性、step、seek/end。源码还校验时间戳严格递增与最多八个验证 cache entries。
## 未做内容
未接入真实天气网络、GRIB、Server、forecast cache、供应商协议、React Native、iOS/Metal/Vulkan 或新的粒子视觉能力;未重写 40-02/03/04/05 已冻结路径。
## 用户 Android x86_64 验收命令
```bat
cd /d C:\path\to\MapLibreParticleLab\platform\android
.\gradlew.bat :MapLibreAndroidTestApp:assembleOpenglDebug -Pandroid.injected.build.abi=x86_64 --console=plain
```
APK:`MapLibreAndroidTestApp/build/intermediates/apk/opengl/debug/MapLibreAndroidTestApp-opengl-debug.apk`。
建议手工验收:TIME→LOAD TIMELINE,PLAY,0.5x/1x/2x、pause/resume、step/seek;观察自动窗口边界 reuse;GAP/INVALID 在边界 fail-closed,RECOVER 后继续;再回归 MIX、VIS、COUNT、MAP、DISABLE/ENABLE。
本轮未执行 Android Gradle、ADB、Jest 或实体机测试。

View File

@@ -3,7 +3,11 @@
#include <mbgl/particle/field/offline_fixture_particle_adapter.hpp>
#include <mbgl/style/source.hpp>
#include <chrono>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <vector>
namespace mbgl {
@@ -17,6 +21,65 @@ struct RasterArrayRuntimeFieldDescriptor {
std::string fixtureID;
std::string metadataURL;
std::string fieldURL;
// Validation-only logical time in milliseconds. A production adapter owns
// its own timeline metadata; this keeps fixtures deterministic.
uint64_t timestamp = 0;
};
enum class RasterArrayPlaybackState : uint8_t {
Ready,
Playing,
Paused,
Waiting,
Ended
};
struct RasterArrayTimelineFrame {
std::shared_ptr<const particle::ParticleFieldSnapshot> t0;
std::shared_ptr<const particle::ParticleFieldSnapshot> t1;
float mix = 0.0f;
size_t t0Index = 0;
size_t t1Index = 0;
uint64_t windowRevision = 0;
RasterArrayPlaybackState state = RasterArrayPlaybackState::Ready;
};
// Source-owned playback state. Android only sends low-frequency commands;
// render frames obtain an immutable T0/T1 pair and scalar mix from Core.
class RasterArrayTimelineRuntime {
public:
struct Entry {
std::shared_ptr<const particle::ParticleFieldSnapshot> field;
uint64_t timestamp = 0;
};
explicit RasterArrayTimelineRuntime(std::vector<Entry>);
RasterArrayTimelineFrame tick();
bool play();
bool pause();
bool setSpeed(float);
bool seek(size_t);
bool step(int);
bool injectGap();
bool injectInvalid();
bool recover();
RasterArrayTimelineFrame frame() const;
size_t cacheCapacity() const { return cacheCapacity_; }
private:
RasterArrayTimelineFrame frameLocked() const;
bool moveToLocked(size_t);
mutable std::mutex mutex;
std::vector<Entry> entries;
size_t t0 = 0;
float mix = 0.0f;
float speed = 1.0f;
RasterArrayPlaybackState state = RasterArrayPlaybackState::Ready;
bool gap = false;
bool invalid = false;
uint64_t windowRevision = 1;
std::chrono::steady_clock::time_point lastTick{};
const size_t cacheCapacity_ = 8;
};
enum class RasterArrayRuntimeFieldUpdateResult : uint8_t {
@@ -55,6 +118,8 @@ public:
// Validation convenience: resubmit the current T1 through the formal
// update API. This must return Noop without changing the Source window.
RasterArrayRuntimeFieldUpdateResult repeatRuntimeFieldWindow();
// Low-frequency JNI commands for the Source-owned validation timeline.
bool controlRuntimeTimeline(int command, float value = 0.0f);
bool supportsLayerType(const LayerTypeInfo*) const override;
mapbox::base::WeakPtr<Source> makeWeakPtr() override { return weakFactory.makeWeakPtr(); }
@@ -73,6 +138,7 @@ private:
void tryLoad();
void tryLoadRuntimeField(size_t);
void tryInitializeTimeline();
RasterArrayRuntimeFieldUpdateResult rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult,
const std::string& reason) const;
@@ -87,6 +153,7 @@ private:
std::vector<RuntimeFieldCandidate> runtimeFields;
size_t nextRuntimeField = 0;
uint64_t runtimeUpdateSequence = 0;
std::shared_ptr<RasterArrayTimelineRuntime> timeline;
mapbox::base::WeakPtrFactory<Source> weakFactory{this};
};

View File

@@ -1204,6 +1204,20 @@ jni::jboolean NativeMapView::repeatRasterArraySourceFieldWindow(JNIEnv& env, con
return success;
}
jni::jboolean NativeMapView::controlRasterArraySourceTimeline(JNIEnv& env,
const jni::String& sourceId,
const jni::jint command,
const jni::jfloat value) {
const auto id = jni::Make<std::string>(env, sourceId);
auto* source = dynamic_cast<mbgl::style::RasterArraySource*>(map->getStyle().getSource(id));
if (!source) return false;
const bool success = source->controlRuntimeTimeline(command, value);
mbgl::Log::Info(mbgl::Event::JNI,
"ParticleTimeline: event=COMMAND sourceId=" + id + " command=" + std::to_string(command) +
" result=" + (success ? "SUCCESS" : "REJECTED"));
return success;
}
void NativeMapView::addLayer(JNIEnv& env, jlong nativeLayerPtr, const jni::String& before) {
assert(nativeLayerPtr != 0);
@@ -1590,6 +1604,7 @@ void NativeMapView::registerNative(jni::JNIEnv& env) {
METHOD(&NativeMapView::setStyleLayerFloatProperty, "nativeSetStyleLayerFloatProperty"),
METHOD(&NativeMapView::advanceRasterArraySourceFieldWindow, "nativeAdvanceRasterArraySourceFieldWindow"),
METHOD(&NativeMapView::repeatRasterArraySourceFieldWindow, "nativeRepeatRasterArraySourceFieldWindow"),
METHOD(&NativeMapView::controlRasterArraySourceTimeline, "nativeControlRasterArraySourceTimeline"),
METHOD(&NativeMapView::addLayer, "nativeAddLayer"),
METHOD(&NativeMapView::addLayerAbove, "nativeAddLayerAbove"),
METHOD(&NativeMapView::addLayerAt, "nativeAddLayerAt"),

View File

@@ -284,6 +284,7 @@ public:
jni::jboolean setStyleLayerFloatProperty(JNIEnv&, const jni::String&, const jni::String&, jni::jfloat);
jni::jboolean advanceRasterArraySourceFieldWindow(JNIEnv&, const jni::String&);
jni::jboolean repeatRasterArraySourceFieldWindow(JNIEnv&, const jni::String&);
jni::jboolean controlRasterArraySourceTimeline(JNIEnv&, const jni::String&, jni::jint, jni::jfloat);
void addLayer(JNIEnv&, jlong, const jni::String&);

View File

@@ -301,6 +301,11 @@ public final class MapLibreMap {
return nativeMapView.repeatRasterArraySourceFieldWindow(sourceId);
}
/** Sends a low-frequency playback command to the Core-owned raster-array timeline. */
public boolean controlRasterArraySourceTimeline(String sourceId, int command, float value) {
return nativeMapView.controlRasterArraySourceTimeline(sourceId, command, value);
}
// Style
/**

View File

@@ -145,6 +145,8 @@ interface NativeMap {
boolean repeatRasterArraySourceFieldWindow(String sourceId);
boolean controlRasterArraySourceTimeline(String sourceId, int command, float value);
boolean removeLayer(@NonNull String layerId);
boolean removeLayer(@NonNull Layer layer);

View File

@@ -968,6 +968,12 @@ final class NativeMapView implements NativeMap {
&& nativeRepeatRasterArraySourceFieldWindow(sourceId);
}
@Override
public boolean controlRasterArraySourceTimeline(String sourceId, int command, float value) {
return !checkState("controlRasterArraySourceTimeline")
&& nativeControlRasterArraySourceTimeline(sourceId, command, value);
}
@Override
public void addLayer(@NonNull Layer layer) {
if (checkState("addLayer")) {
@@ -1708,6 +1714,9 @@ final class NativeMapView implements NativeMap {
@Keep
private native boolean nativeRepeatRasterArraySourceFieldWindow(String sourceId);
@Keep
private native boolean nativeControlRasterArraySourceTimeline(String sourceId, int command, float value);
@Keep
private native void nativeAddLayer(long layerPtr, String before) throws CannotAddLayerException;

View File

@@ -3,6 +3,7 @@ package org.maplibre.android.testapp.activity.particle
import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.PopupMenu
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
@@ -148,6 +149,56 @@ 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.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, R.id.validationVisualColor)) }
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.validationAdvanceWindow, R.id.validationDuplicateWindow)) }
}
private fun showMenu(anchor: android.view.View, title: String, actions: IntArray) {
PopupMenu(this, anchor).apply {
actions.forEachIndexed { index, id -> menu.add(0, index, index, actionLabel(id)) }
setOnMenuItemClickListener { item -> findViewById<Button>(actions[item.itemId]).performClick(); true }
show()
}
}
private fun showTimeMenu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
val labels = arrayOf("LOAD TIMELINE", "PLAY", "PAUSE", "SPEED 0.5x", "SPEED 1x", "SPEED 2x", "STEP PREV", "STEP NEXT", "SEEK START", "SEEK MID", "SEEK END", "INJECT GAP", "INJECT INVALID", "RECOVER")
labels.forEachIndexed { index, label -> menu.add(0, index, index, label) }
setOnMenuItemClickListener { item ->
when (item.itemId) {
0 -> findViewById<Button>(R.id.validationTimeWindow).performClick()
1 -> controlTimeline(0, 0.0f)
2 -> controlTimeline(1, 0.0f)
3 -> controlTimeline(2, 0.5f)
4 -> controlTimeline(2, 1.0f)
5 -> controlTimeline(2, 2.0f)
6 -> controlTimeline(3, 0.0f)
7 -> controlTimeline(4, 0.0f)
8 -> controlTimeline(5, 0.0f)
9 -> controlTimeline(5, 2.0f)
10 -> controlTimeline(5, 5.0f)
11 -> controlTimeline(6, 0.0f)
12 -> controlTimeline(7, 0.0f)
13 -> controlTimeline(8, 0.0f)
}; true
}
show()
}
}
private fun controlTimeline(command: Int, value: Float) {
if (!styleReady || !enabled || !timeWindowMode) { Log.i(TAG, "ParticleTimeline: event=REJECTED reason=STYLE_NOT_READY"); return }
if (!maplibreMap.controlRasterArraySourceTimeline(PARTICLE_SOURCE_ID, command, value)) {
Log.i(TAG, "ParticleTimeline: event=REJECTED command=$command")
} else Log.i(TAG, "ParticleTimeline: event=COMMAND command=$command value=$value")
}
private fun actionLabel(id: Int): String = when (id) {
R.id.validationVisualDefault -> "VIS DEFAULT"; R.id.validationVisualSizeDown -> "SIZE -"; R.id.validationVisualSizeUp -> "SIZE +"; R.id.validationVisualLengthDown -> "LENGTH -"; R.id.validationVisualLengthUp -> "LENGTH +"; R.id.validationVisualOpacityDown -> "OPACITY -"; R.id.validationVisualOpacityUp -> "OPACITY +"; R.id.validationVisualTrailDown -> "TRAIL -"; R.id.validationVisualTrailUp -> "TRAIL +"; R.id.validationVisualFadeDown -> "FADE -"; R.id.validationVisualFadeUp -> "FADE +"; R.id.validationVisualColor -> "COLOR"; R.id.validationParticle256 -> "256"; R.id.validationParticle1024 -> "1K"; R.id.validationParticle4096 -> "4K"; R.id.validationParticle16384 -> "16K"; R.id.validationParticle65536 -> "64K"; R.id.validationParticle262144 -> "256K"; R.id.validationParticle1048576 -> "1M"; R.id.validationGlobal -> "GLOBAL"; R.id.validationOceanA -> "OCEAN A"; R.id.validationOceanB -> "OCEAN B"; R.id.validationDateline -> "DATELINE"; R.id.validationWorldWrap -> "WORLD WRAP"; R.id.validationActive -> "ACTIVE"; R.id.validationStatic -> "STATIC"; R.id.validationDisable -> "DISABLE"; R.id.validationEnable -> "ENABLE"; R.id.validationUniformEast -> "UNIFORM EAST"; R.id.validationUniformNorth -> "UNIFORM NORTH"; R.id.validationVortex -> "VORTEX"; R.id.validationAdvanceWindow -> "ADVANCE WINDOW"; else -> "DUPLICATE T1"
}
private fun loadValidationStyle(active: Boolean, enabled: Boolean, reason: String = "STYLE_CHANGE") {
@@ -357,7 +408,11 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
{"fixture":"vortex","metadata":"asset://integration/particle-fixtures/vortex/metadata.json",
"field":"asset://integration/particle-fixtures/vortex/field.f32"},
{"fixture":"nodata","metadata":"asset://integration/particle-fixtures/nodata/metadata.json",
"field":"asset://integration/particle-fixtures/nodata/field.f32"}
"field":"asset://integration/particle-fixtures/nodata/field.f32"},
{"fixture":"uniform-east","metadata":"asset://integration/particle-fixtures/uniform-east/metadata.json",
"field":"asset://integration/particle-fixtures/uniform-east/field.f32"},
{"fixture":"uniform-north","metadata":"asset://integration/particle-fixtures/uniform-north/metadata.json",
"field":"asset://integration/particle-fixtures/uniform-north/field.f32"}
]
},
"validation-point": {

View File

@@ -1,134 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.maplibre.android.maps.MapView
android:id="@id/mapView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="#DD102030"
android:orientation="vertical"
android:padding="4dp">
<TextView
android:id="@+id/validationStatus"
android:layout_width="match_parent"
android:layout_height="36dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:text="@string/particle_validation_active_status"
android:textColor="@android:color/white"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationParticle256" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_256" />
<Button android:id="@+id/validationParticle1024" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_1024" />
<Button android:id="@+id/validationParticle4096" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_4096" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationParticle16384" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_16384" />
<Button android:id="@+id/validationParticle65536" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_65536" />
<Button android:id="@+id/validationParticle262144" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_262144" />
<Button android:id="@+id/validationParticle1048576" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_1048576" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationVisualDefault" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_default" />
<Button android:id="@+id/validationVisualSizeDown" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_size_down" />
<Button android:id="@+id/validationVisualSizeUp" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_size_up" />
<Button android:id="@+id/validationVisualLengthDown" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_length_down" />
<Button android:id="@+id/validationVisualLengthUp" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_length_up" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationVisualOpacityDown" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_opacity_down" />
<Button android:id="@+id/validationVisualOpacityUp" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_opacity_up" />
<Button android:id="@+id/validationVisualTrailDown" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_trail_down" />
<Button android:id="@+id/validationVisualTrailUp" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_trail_up" />
<Button android:id="@+id/validationVisualColor" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_color" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationVisualFadeDown" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_fade_down" />
<Button android:id="@+id/validationVisualFadeUp" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_fade_up" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationUniformEast" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_uniform_east" />
<Button android:id="@+id/validationUniformNorth" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_uniform_north" />
<Button android:id="@+id/validationVortex" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_vortex" />
<Button android:id="@+id/validationTimeWindow" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_time_window" />
<Button android:id="@+id/validationAdvanceWindow" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_advance_window" />
<Button android:id="@+id/validationDuplicateWindow" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_duplicate_window" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationMix0" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_mix_0" />
<Button android:id="@+id/validationMix50" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_mix_50" />
<Button android:id="@+id/validationMix100" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_mix_100" />
<Button android:id="@+id/validationActive" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_active" />
<Button android:id="@+id/validationStatic" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_static" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationDisable" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_disable" />
<Button android:id="@+id/validationEnable" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_enable" />
<Button android:id="@+id/validationGlobal" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_global" />
<Button android:id="@+id/validationOceanA" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_ocean_a" />
<Button android:id="@+id/validationOceanB" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_ocean_b" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationDateline" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_dateline" />
<Button android:id="@+id/validationWorldWrap" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_world_wrap" />
</LinearLayout>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent">
<org.maplibre.android.maps.MapView android:id="@id/mapView" android:layout_width="match_parent" android:layout_height="match_parent" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:background="#DD102030" android:orientation="vertical" android:padding="4dp">
<TextView android:id="@+id/validationStatus" android:layout_width="match_parent" android:layout_height="42dp" android:gravity="center_vertical" android:maxLines="2" android:paddingLeft="4dp" android:paddingRight="4dp" android:textColor="@android:color/white" android:textSize="12sp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="40dp" android:orientation="horizontal">
<Button android:id="@+id/validationMenuTime" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="TIME" />
<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/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. -->
<LinearLayout android:visibility="gone" android:layout_width="1dp" android:layout_height="1dp" android:orientation="vertical">
<Button style="@style/ParticleValidationButton" android:id="@+id/validationActive"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationStatic"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationDisable"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationEnable"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationGlobal"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationOceanA"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationOceanB"/>
<Button style="@style/ParticleValidationButton" android:id="@+id/validationUniformEast"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationUniformNorth"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVortex"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationTimeWindow"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationMix0"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationMix50"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationMix100"/>
<Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle256"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle1024"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle4096"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle16384"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle65536"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle262144"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationParticle1048576"/>
<Button style="@style/ParticleValidationButton" android:id="@+id/validationAdvanceWindow"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationDuplicateWindow"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualDefault"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualSizeDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualSizeUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualLengthDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualLengthUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualOpacityDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualOpacityUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualTrailDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualTrailUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualFadeDown"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualFadeUp"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationVisualColor"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationDateline"/><Button style="@style/ParticleValidationButton" android:id="@+id/validationWorldWrap"/>
</LinearLayout>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="ParticleValidationButton">
<item name="android:layout_width">1dp</item>
<item name="android:layout_height">32dp</item>
<item name="android:minHeight">32dp</item>
<item name="android:minWidth">0dp</item>

View File

@@ -772,6 +772,14 @@ void RenderRasterParticleLayer::prepare(const LayerPrepareParameters& parameters
return;
}
const auto& source = static_cast<const RenderRasterArraySource&>(*parameters.source);
sourceTimeline = source.impl().timeline;
if (sourceTimeline) {
const auto frame = sourceTimeline->frame();
sourceField = *frame.t0;
sourceNextField = *frame.t1;
sourceTimelineWindowRevision = frame.windowRevision;
return;
}
if (source.impl().field) {
sourceField = *source.impl().field;
sourceNextField = source.impl().nextField ? *source.impl().nextField : *source.impl().field;
@@ -798,6 +806,16 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
const uint8_t maskMode = properties.maskMode;
const uint8_t fixtureMode = properties.fixtureMode;
const uint32_t particleCount = properties.particleCount;
float timelineMix = properties.timeMix;
if (formalSource && sourceTimeline) {
const auto frame = sourceTimeline->tick();
timelineMix = frame.mix;
if (frame.windowRevision != sourceTimelineWindowRevision) {
sourceField = *frame.t0;
sourceNextField = *frame.t1;
sourceTimelineWindowRevision = frame.windowRevision;
}
}
auto resources = gpuResources;
if (resources && resources->contextDestroyed) {
// VALIDATION ONLY: follow the 30-02 resource-generation rule. No stale
@@ -1095,16 +1113,18 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
return;
}
}
const auto requestedTimeMix = std::clamp(properties.timeMix, 0.0f, 1.0f);
const auto requestedTimeMix = std::clamp(timelineMix, 0.0f, 1.0f);
if (resources->timeMix != requestedTimeMix) {
resources->timeMix = requestedTimeMix;
Log::Info(Event::Render,
"ParticleGPU: event=TIME_MIX_UPDATE value=" + std::to_string(resources->timeMix) +
" T0=" + resources->field.fixtureID + " T1=" + resources->nextField.fixtureID +
" renderLayerInstanceId=" + std::to_string(resources->renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(resources->generation) +
" fieldUploadCount=" + std::to_string(resources->fieldUploadCount) +
" resetCount=" + std::to_string(resources->resetCount));
if (!sourceTimeline || std::abs(resources->timeMix * 4.0f - std::round(resources->timeMix * 4.0f)) < 0.01f) {
Log::Info(Event::Render,
"ParticleGPU: event=TIME_MIX_UPDATE value=" + std::to_string(resources->timeMix) +
" T0=" + resources->field.fixtureID + " T1=" + resources->nextField.fixtureID +
" renderLayerInstanceId=" + std::to_string(resources->renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(resources->generation) +
" fieldUploadCount=" + std::to_string(resources->fieldUploadCount) +
" resetCount=" + std::to_string(resources->resetCount));
}
}
const bool visualChanged = resources->lineWidth != properties.lineWidth ||

View File

@@ -2,6 +2,7 @@
#include <mbgl/particle/field/offline_fixture_particle_adapter.hpp>
#include <mbgl/renderer/render_layer.hpp>
#include <mbgl/style/sources/raster_array_source.hpp>
namespace mbgl {
@@ -33,6 +34,8 @@ private:
std::shared_ptr<ParticleGpuResources> gpuResources;
std::optional<particle::ParticleFieldSnapshot> sourceField;
std::optional<particle::ParticleFieldSnapshot> sourceNextField;
std::shared_ptr<style::RasterArrayTimelineRuntime> sourceTimeline;
uint64_t sourceTimelineWindowRevision = 0;
bool formalSource = false;
uint64_t renderLayerInstanceId = 0;
};

View File

@@ -9,6 +9,7 @@
#include <mbgl/util/logging.hpp>
#include <atomic>
#include <cmath>
namespace mbgl::style {
@@ -21,6 +22,138 @@ std::atomic_uint64_t nextRasterArrayFieldGeneration{0};
} // namespace
RasterArrayTimelineRuntime::RasterArrayTimelineRuntime(std::vector<Entry> entries_)
: entries(std::move(entries_)) {
if (entries.size() < 2) {
throw std::invalid_argument("particle timeline requires at least two snapshots");
}
for (size_t index = 1; index < entries.size(); ++index) {
if (entries[index - 1].timestamp >= entries[index].timestamp) {
throw std::invalid_argument("particle timeline timestamps must be strictly increasing");
}
}
if (entries.size() > cacheCapacity_) {
throw std::invalid_argument("particle timeline exceeds bounded validation cache");
}
}
RasterArrayTimelineFrame RasterArrayTimelineRuntime::frameLocked() const {
return {entries[t0].field, entries[t0 + 1].field, mix, t0, t0 + 1, windowRevision, state};
}
RasterArrayTimelineFrame RasterArrayTimelineRuntime::frame() const {
std::lock_guard<std::mutex> lock(mutex);
return frameLocked();
}
bool RasterArrayTimelineRuntime::moveToLocked(const size_t nextT0) {
if (nextT0 + 1 >= entries.size()) return false;
t0 = nextT0;
mix = 0.0f;
++windowRevision;
return true;
}
RasterArrayTimelineFrame RasterArrayTimelineRuntime::tick() {
std::lock_guard<std::mutex> lock(mutex);
const auto now = std::chrono::steady_clock::now();
if (lastTick == std::chrono::steady_clock::time_point{}) {
lastTick = now;
return frameLocked();
}
const auto elapsed = std::chrono::duration<float, std::milli>(now - lastTick).count();
lastTick = now;
if (state != RasterArrayPlaybackState::Playing) return frameLocked();
const auto duration = static_cast<float>(entries[t0 + 1].timestamp - entries[t0].timestamp);
mix += elapsed * speed / duration;
while (mix >= 1.0f) {
if (gap || invalid) {
mix = 1.0f;
state = RasterArrayPlaybackState::Waiting;
Log::Warning(Event::Render,
std::string("ParticleTimeline: event=") + (invalid ? "INVALID_WAITING" : "GAP_WAITING") +
" t1=" + entries[t0 + 1].field->fixtureID);
return frameLocked();
}
if (!moveToLocked(t0 + 1)) {
mix = 1.0f;
state = RasterArrayPlaybackState::Ended;
Log::Info(Event::Render, "ParticleTimeline: event=END t1=" + entries[t0 + 1].field->fixtureID);
return frameLocked();
}
Log::Info(Event::Render,
"ParticleTimeline: event=WINDOW_BOUNDARY oldT1=" + entries[t0].field->fixtureID +
" newT1=" + entries[t0 + 1].field->fixtureID + " result=APPLIED reuse=OLD_T1_TO_NEW_T0");
}
return frameLocked();
}
bool RasterArrayTimelineRuntime::play() {
std::lock_guard<std::mutex> lock(mutex);
if (state == RasterArrayPlaybackState::Waiting || state == RasterArrayPlaybackState::Ended) return false;
state = RasterArrayPlaybackState::Playing;
lastTick = std::chrono::steady_clock::now();
Log::Info(Event::Render, "ParticleTimeline: event=PLAY speed=" + std::to_string(speed));
return true;
}
bool RasterArrayTimelineRuntime::pause() {
std::lock_guard<std::mutex> lock(mutex);
if (state == RasterArrayPlaybackState::Waiting || state == RasterArrayPlaybackState::Ended) return false;
state = RasterArrayPlaybackState::Paused;
Log::Info(Event::Render, "ParticleTimeline: event=PAUSE");
return true;
}
bool RasterArrayTimelineRuntime::setSpeed(const float value) {
if (value != 0.5f && value != 1.0f && value != 2.0f) return false;
std::lock_guard<std::mutex> lock(mutex);
speed = value;
Log::Info(Event::Render, "ParticleTimeline: event=SPEED value=" + std::to_string(speed));
return true;
}
bool RasterArrayTimelineRuntime::seek(const size_t index) {
std::lock_guard<std::mutex> lock(mutex);
if (index >= entries.size()) return false;
if (index + 1 == entries.size()) {
t0 = index - 1;
mix = 1.0f;
state = RasterArrayPlaybackState::Ended;
} else {
moveToLocked(index);
state = RasterArrayPlaybackState::Paused;
}
Log::Info(Event::Render, "ParticleTimeline: event=SEEK index=" + std::to_string(index));
return true;
}
bool RasterArrayTimelineRuntime::step(const int direction) {
std::lock_guard<std::mutex> lock(mutex);
const auto target = direction < 0 ? (t0 == 0 ? 0 : t0 - 1) : std::min(t0 + 1, entries.size() - 2);
moveToLocked(target);
state = RasterArrayPlaybackState::Paused;
Log::Info(Event::Render, "ParticleTimeline: event=STEP direction=" + std::to_string(direction));
return true;
}
bool RasterArrayTimelineRuntime::injectGap() {
std::lock_guard<std::mutex> lock(mutex);
gap = true;
invalid = false;
Log::Info(Event::Render, "ParticleTimeline: event=GAP_INJECTED");
return true;
}
bool RasterArrayTimelineRuntime::injectInvalid() {
std::lock_guard<std::mutex> lock(mutex);
invalid = true;
gap = false;
Log::Info(Event::Render, "ParticleTimeline: event=INVALID_INJECTED");
return true;
}
bool RasterArrayTimelineRuntime::recover() {
std::lock_guard<std::mutex> lock(mutex);
gap = invalid = false;
if (state == RasterArrayPlaybackState::Waiting) state = RasterArrayPlaybackState::Paused;
Log::Info(Event::Render, "ParticleTimeline: event=RECOVERED");
return true;
}
RasterArraySource::RasterArraySource(std::string id,
std::string fixtureID,
std::string metadataURL,
@@ -173,6 +306,7 @@ void RasterArraySource::tryLoad() {
}
observer->onSourceLoaded(*this);
observer->onSourceChanged(*this);
tryInitializeTimeline();
} catch (const std::exception& error) {
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(error.what())));
}
@@ -187,6 +321,34 @@ void RasterArraySource::tryLoadRuntimeField(const size_t index) {
Log::Info(Event::Render,
"ParticleField: runtime candidate ready fixtureId=" + candidate.snapshot->fixtureID +
" dataVersion=" + std::to_string(candidate.snapshot->dataVersion));
tryInitializeTimeline();
} catch (const std::exception& error) {
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(error.what())));
}
}
void RasterArraySource::tryInitializeTimeline() {
if (timeline || !loaded || !impl().field || !impl().nextField || runtimeFields.empty()) return;
std::vector<RasterArrayTimelineRuntime::Entry> entries;
entries.reserve(runtimeFields.size() + 2);
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*impl().field), 0});
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*impl().nextField), 4000});
for (size_t index = 0; index < runtimeFields.size(); ++index) {
if (!runtimeFields[index].snapshot) return;
auto field = *runtimeFields[index].snapshot;
field.generation = ++nextRasterArrayFieldGeneration;
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(std::move(field)),
8000 + static_cast<uint64_t>(index) * 4000});
}
try {
timeline = std::make_shared<RasterArrayTimelineRuntime>(std::move(entries));
auto updated = makeMutable<Impl>(impl());
updated->timeline = timeline;
baseImpl = std::move(updated);
Log::Info(Event::Render,
"ParticleTimeline: event=READY snapshots=" + std::to_string(runtimeFields.size() + 2) +
" cacheCapacity=" + std::to_string(timeline->cacheCapacity()));
observer->onSourceChanged(*this);
} catch (const std::exception& error) {
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(error.what())));
}
@@ -241,6 +403,10 @@ RasterArrayRuntimeFieldUpdateResult RasterArraySource::updateRuntimeFieldWindow(
}
RasterArrayRuntimeFieldUpdateResult RasterArraySource::advanceRuntimeFieldWindow() {
if (timeline) {
return timeline->step(1) ? RasterArrayRuntimeFieldUpdateResult::Applied
: RasterArrayRuntimeFieldUpdateResult::RejectedNoCandidate;
}
if (nextRuntimeField >= runtimeFields.size()) {
return rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult::RejectedNoCandidate, "NO_PRELOADED_FIELD");
}
@@ -262,12 +428,45 @@ RasterArrayRuntimeFieldUpdateResult RasterArraySource::advanceRuntimeFieldWindow
}
RasterArrayRuntimeFieldUpdateResult RasterArraySource::repeatRuntimeFieldWindow() {
if (timeline) {
Log::Info(Event::Render, "ParticleField: event=FIELD_WINDOW_UPDATE_APPLIED result=NOOP sourceId=" + getID());
return RasterArrayRuntimeFieldUpdateResult::Noop;
}
if (!loaded || !impl().nextField) {
return rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult::RejectedNotLoaded, "SOURCE_NOT_LOADED");
}
return updateRuntimeFieldWindow(*impl().nextField);
}
bool RasterArraySource::controlRuntimeTimeline(const int command, const float value) {
if (!timeline) {
Log::Warning(Event::Render, "ParticleTimeline: event=REJECTED reason=TIMELINE_NOT_READY sourceId=" + getID());
return false;
}
switch (command) {
case 0:
return timeline->play();
case 1:
return timeline->pause();
case 2:
return timeline->setSpeed(value);
case 3:
return timeline->step(-1);
case 4:
return timeline->step(1);
case 5:
return timeline->seek(static_cast<size_t>(value));
case 6:
return timeline->injectGap();
case 7:
return timeline->injectInvalid();
case 8:
return timeline->recover();
default:
return false;
}
}
bool RasterArraySource::supportsLayerType(const LayerTypeInfo* info) const {
return info && std::string(info->type) == "raster-particle";
}

View File

@@ -34,6 +34,7 @@ public:
nextMetadataURL(other.nextMetadataURL),
nextFieldURL(other.nextFieldURL),
runtimeFields(other.runtimeFields),
timeline(other.timeline),
field(std::move(field_)) {}
Impl(const Impl& other, particle::ParticleFieldSnapshot field_, particle::ParticleFieldSnapshot nextField_)
: Impl(other, std::move(field_)) {
@@ -48,6 +49,7 @@ public:
std::string nextMetadataURL;
std::string nextFieldURL;
std::vector<RasterArrayRuntimeFieldDescriptor> runtimeFields;
std::shared_ptr<RasterArrayTimelineRuntime> timeline;
std::optional<particle::ParticleFieldSnapshot> field;
std::optional<particle::ParticleFieldSnapshot> nextField;
};

View File

@@ -241,6 +241,25 @@ TEST(Source, RasterArrayGpuReuseConsumesNewRevision) {
EXPECT_FALSE(particle::fieldIdentityChanged(assignedAfterReuse, wantedNewT0));
}
TEST(Source, RasterArrayTimelineKeepsCoreOwnedWindowAndPlaybackControls) {
std::vector<style::RasterArrayTimelineRuntime::Entry> entries;
for (uint64_t index = 0; index < 5; ++index) {
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(
particle::OfflineFixtureParticleAdapter::load("uniform-east", index + 1)),
index * 4000});
}
style::RasterArrayTimelineRuntime timeline{std::move(entries)};
EXPECT_EQ(timeline.frame().t0Index, 0u);
EXPECT_TRUE(timeline.play());
EXPECT_TRUE(timeline.pause());
EXPECT_TRUE(timeline.setSpeed(0.5f));
EXPECT_FALSE(timeline.setSpeed(3.0f));
EXPECT_TRUE(timeline.step(1));
EXPECT_EQ(timeline.frame().t0Index, 1u);
EXPECT_TRUE(timeline.seek(4));
EXPECT_EQ(timeline.frame().state, style::RasterArrayPlaybackState::Ended);
}
TEST(Source, RasterArrayRuntimeFieldWindowAdvancesWithoutReplacingTheSource) {
SourceTest test;
const auto eastMetadata = fixtureBytes("uniform-east", "metadata.json");

View File

@@ -324,3 +324,10 @@
- pan、zoom、bearing、DATELINE、WORLD WRAP、DISABLE/ENABLE、屏幕/生命周期恢复、GLOBAL/OCEAN A/OCEAN B 均通过;Renderer 持续 `State -> Trail -> Composite` 且 `hazard=SAFE`。
- 容量档 256、1K、4K、16K、64K、256K、1M 在新的 streak + speed color 路径均通过。4K 以上在小 Validation viewport 内趋于高密度铺满是可读性现象,不是功能失败;1M 仍只是当前验证设备上的已验证容量,不是全设备保证或产品默认值。
- Android logcat 的 `TimeStats: RenderEngineTimes are already at its maximum size[64]` 是 RenderEngine/虚拟机环境噪声,未伴随粒子 Renderer crash 或中断,不判为本阶段失败。未进入 WeatherColorLayer、真实网络/GRIB、Vulkan、iOS/Metal 或生产 mask 等范围外工作。
## 2026-09-03 — 40-06 / STEP-047 Runtime Timeline 与 Playback(代码阶段)
- `RasterArraySource` 现持有唯一的有界 Timeline runtime:六个离线 immutable snapshot、严格递增逻辑时间、T0/T1、mix、cursor、播放/暂停、0.5x/1x/2x、step、seek、gap/invalid/recover/end。Android 只经薄 JNI 发低频命令,没有 Android timer 或 per-frame JNI。
- RenderRasterParticleLayer 只读取 Source Core 给出的当前 T0/T1/mix;窗口 revision 变化继续进入既有 Field identity/GPU reuse 路径,自动边界维持 `OLD_T1 -> NEW_T0` 的重用语义。正常 timeline MIX 不需要 Style reload。
- Validation UI 收敛为 `TIME | VIS | COUNT | MAP | MORE`;原有动作均由菜单保留,TIME 提供 load/play/pause/speed/step/seek/gap/invalid/recover。
- 已加 Source timeline 单元覆盖并运行 Linux/OpenGL Core 精确构建;未运行 Android Gradle、ADB、实体机或 Jest。状态仍为 `IMPLEMENTED / WAITING FOR ANDROID VALIDATION`,等待 Android x86_64 逐项验收。