fix: keep particle timeline playback ticking
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-04 08:17:29 +08:00
parent 0609199cce
commit 1343929474
6 changed files with 59 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
# STEP-047 Playback Tick 根因修复
状态:IMPLEMENTED / WAITING FOR ANDROID RE-VALIDATION
## Android Validation Round 2:PARTIAL / NEEDS FIX
PLAY 和 speed JNI command 均返回 SUCCESS;短暂的 `TIME_MIX_UPDATE` 后 mix 停止,但 State/Trail/Composite 与 RenderThread 持续运行。根因是 `RasterArrayTimelineRuntime::tick()` 只在 `RenderRasterParticleLayer::update()` 中调用;该函数只在 render tree/update 生命周期进入,不是每个已构建 drawable frame。
## 修复
将 Source-owned timeline tick 接入既有 `ParticleGpuValidationTweaker::execute()` 的 State pass。State drawable 每个正式粒子 simulation frame 执行一次;Trail、Composite 与 world copy 不 tick。因此不需要 Android Timer、每帧 JNI、新线程或循环。Tweaker 将本帧 Core mix 写回现有 resources UBO;现有 layer update 仍负责资源/窗口变更。
新增约每秒一次 `PLAYBACK_TICK` heartbeat,含 state、speed、cursor、mix、window、fieldUploadCount、simulationResetCount。
Round 3 P1--P4 待用户 Android 验证。未执行 Android Gradle、ADB 或 Jest。

View File

@@ -0,0 +1,7 @@
# 40-06 / STEP-047 Playback Tick 根因修复报告
状态:IMPLEMENTED / WAITING FOR ANDROID RE-VALIDATION
旧 tick 位于 `RenderRasterParticleLayer::update()`,其不是连续 drawable-frame hook,故 PLAY 后只有短暂 cursor/mix 更新。新的 tick 位于 State pass `ParticleGpuValidationTweaker::execute()`;粒子已有 continuous repaint/render 就是其驱动来源。没有 Android timer、每帧 JNI、线程或 while-playing loop。
PLAY/PAUSE/RESUME/speed 的 monotonic anchor 继续由 `RasterArrayTimelineRuntime` 持有;每帧仅更新 mix,不上传 Field、不 reload Style、不 reset simulation 或清 Trail。窗口边界仍由既有 layer update/reuse 路径处理。

View File

@@ -64,6 +64,7 @@ public:
bool injectInvalid();
bool recover();
RasterArrayTimelineFrame frame() const;
float playbackSpeed() const;
size_t cacheCapacity() const { return cacheCapacity_; }
private:

View File

@@ -35,6 +35,7 @@
#include <array>
#include <atomic>
#include <cmath>
#include <chrono>
#include <limits>
#include <sstream>
#include <vector>
@@ -198,6 +199,7 @@ public:
bool worldWrapLogged = false;
bool antimeridianLogged = false;
uint64_t lastCameraLogFrame = 0;
std::chrono::steady_clock::time_point lastPlaybackHeartbeat{};
uint64_t maskGeneration = 0;
uint64_t fieldGeneration = 0;
uint64_t gpuFieldGeneration = 0;
@@ -228,6 +230,7 @@ public:
gfx::Texture2DPtr stateTexture;
particle::ParticleFieldSnapshot field;
particle::ParticleFieldSnapshot nextField;
std::shared_ptr<style::RasterArrayTimelineRuntime> timeline;
std::shared_ptr<PremultipliedImage> fieldImage;
std::shared_ptr<PremultipliedImage> nextFieldImage;
std::shared_ptr<PremultipliedImage> maskFixture;
@@ -351,6 +354,26 @@ public:
return;
}
// update() is not called for every drawable frame. State executes once
// per particle simulation frame, making it the appropriate existing
// Core render lifecycle for the Source-owned monotonic playback clock.
if (pass == ValidationPass::State && resources->timeline) {
const auto frame = resources->timeline->tick();
resources->timeMix = frame.mix;
const auto now = std::chrono::steady_clock::now();
if (now - resources->lastPlaybackHeartbeat >= std::chrono::seconds(1)) {
resources->lastPlaybackHeartbeat = now;
Log::Info(
Event::Render,
"ParticleTimeline: event=PLAYBACK_TICK state=" + std::to_string(static_cast<int>(frame.state)) +
" speed=" + std::to_string(resources->timeline->playbackSpeed()) +
" cursor=" + std::to_string(frame.t0Index) + " mix=" + std::to_string(frame.mix) +
" window=" + std::to_string(frame.t0Index) + "/" + std::to_string(frame.t1Index) +
" fieldUploadCount=" + std::to_string(resources->fieldUploadCount) +
" simulationResetCount=" + std::to_string(resources->resetCount));
}
}
drawable.setTexture(source, shaders::idParticleGpuValidationPreviousTexture);
if (mask) {
drawable.setTexture(mask, shaders::idParticleGpuValidationMaskTexture);
@@ -1113,6 +1136,9 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
return;
}
}
// Keep the shared Core runtime alive in the per-frame drawable tweaker.
// This is a pointer to Source-owned state, not a second timeline.
resources->timeline = sourceTimeline;
const auto requestedTimeMix = std::clamp(timelineMix, 0.0f, 1.0f);
if (resources->timeMix != requestedTimeMix) {
resources->timeMix = requestedTimeMix;

View File

@@ -46,6 +46,11 @@ RasterArrayTimelineFrame RasterArrayTimelineRuntime::frame() const {
return frameLocked();
}
float RasterArrayTimelineRuntime::playbackSpeed() const {
std::lock_guard<std::mutex> lock(mutex);
return speed;
}
bool RasterArrayTimelineRuntime::moveToLocked(const size_t nextT0) {
if (nextT0 + 1 >= entries.size()) return false;
t0 = nextT0;

View File

@@ -337,3 +337,8 @@
- Round 1 为 `PARTIAL / NEEDS FIX`:主链、自动 MIX/window reuse 和紧凑 UI 通过,但 timeline 过短、END/seek 语义、异常入口、VIS 可达性与 cache 可观测性不足。
- Core validation timeline 改为约 200 秒;END PLAY restart、PLAYING/PAUSED seek 状态保持、GAP/INVALID fail-closed 和 RECOVER 保留此前状态。TIME 直接暴露 NORMAL/GAP/INVALID/RECOVER,VIS 将 COLOR 前置并使用明显的 validation 步长。
- 仍保持 Source 有界 CPU snapshot preload、GPU 仅 T0/T1、相邻窗口 reuse 与 STEP-046 visual property-only。状态为 `IMPLEMENTED / WAITING FOR ANDROID RE-VALIDATION`。
## 2026-09-04 — 40-06 / STEP-047 Playback Tick 根因修复
- Android Round 2 确认 PLAY/SPEED bridge 成功、RenderThread 持续,但 Timeline 只短暂推进。根因是 tick 错接到非持续的 RenderLayer update。
- tick 已移到每个正式 State drawable frame 的 Core tweaker;没有 Android timer、每帧 JNI、线程或自旋。新增低频 PLAYBACK_TICK heartbeat;状态继续 `IMPLEMENTED / WAITING FOR ANDROID RE-VALIDATION`。