feat: add runtime particle timeline playback
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:
27
docs/50_Step记录/STEP-047_Runtime_Timeline与Playback_v1.md
Normal file
27
docs/50_Step记录/STEP-047_Runtime_Timeline与Playback_v1.md
Normal 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 手工验收仍待执行。
|
||||
32
docs/报告/40-06_Runtime_Timeline与Playback_Codex完成报告_v1.md
Normal file
32
docs/报告/40-06_Runtime_Timeline与Playback_Codex完成报告_v1.md
Normal 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 或实体机测试。
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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&);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 逐项验收。
|
||||
|
||||
Reference in New Issue
Block a user