diff --git a/docs/50_Step记录/STEP-047_Runtime_Timeline与Playback_v1.md b/docs/50_Step记录/STEP-047_Runtime_Timeline与Playback_v1.md new file mode 100644 index 000000000..9fa478d7b --- /dev/null +++ b/docs/50_Step记录/STEP-047_Runtime_Timeline与Playback_v1.md @@ -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 手工验收仍待执行。 diff --git a/docs/报告/40-06_Runtime_Timeline与Playback_Codex完成报告_v1.md b/docs/报告/40-06_Runtime_Timeline与Playback_Codex完成报告_v1.md new file mode 100644 index 000000000..256777d03 --- /dev/null +++ b/docs/报告/40-06_Runtime_Timeline与Playback_Codex完成报告_v1.md @@ -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 或实体机测试。 diff --git a/include/mbgl/style/sources/raster_array_source.hpp b/include/mbgl/style/sources/raster_array_source.hpp index adf21a732..9a157f320 100644 --- a/include/mbgl/style/sources/raster_array_source.hpp +++ b/include/mbgl/style/sources/raster_array_source.hpp @@ -3,7 +3,11 @@ #include #include +#include +#include +#include #include +#include #include 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 t0; + std::shared_ptr 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 field; + uint64_t timestamp = 0; + }; + + explicit RasterArrayTimelineRuntime(std::vector); + 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 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 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 runtimeFields; size_t nextRuntimeField = 0; uint64_t runtimeUpdateSequence = 0; + std::shared_ptr timeline; mapbox::base::WeakPtrFactory weakFactory{this}; }; diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp index ada3e7cbc..a6f39375e 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp @@ -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(env, sourceId); + auto* source = dynamic_cast(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"), diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp index 547986ca1..0664eddbc 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp @@ -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&); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java index f20aa342b..669da053d 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java @@ -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 /** diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java index 6b97fcde8..8e810354c 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java @@ -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); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java index cd7c9a856..ff647f3c7 100755 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java @@ -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; diff --git a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt index 3ea551938..5909124e6 100644 --- a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt +++ b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt @@ -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