diff --git a/examples/flutter/quickstart/integration_test/framerate_test.dart b/examples/flutter/quickstart/integration_test/framerate_test.dart index 108385c5d..2740993ee 100644 --- a/examples/flutter/quickstart/integration_test/framerate_test.dart +++ b/examples/flutter/quickstart/integration_test/framerate_test.dart @@ -1,8 +1,8 @@ // Integration test that verifies setTargetFramerate changes the rate at which // render work is accepted — not just an internal interval value. // -// It counts frames admitted past the FrameScheduler's native throttle and -// in-flight guard (via scheduledFrameCount) over wall-clock windows before and +// It counts handlers dispatched past the FrameScheduler's native throttle and +// in-flight guard (via dispatchedFrameCount) over wall-clock windows before and // after a setTargetFramerate call. The native // scheduler (CVDisplayLink / CADisplayLink / AChoreographer / DXGI) drives // the loop, so the numbers reflect work the device actually accepts. @@ -29,20 +29,20 @@ void main() { /// Frames-per-second accepted over a [window] of real wall-clock time, /// measured with a [Stopwatch] (system clock, unaffected by any test /// clock). Lets the native vsync-driven scheduler tick at its own cadence. - Future measureScheduledFps(Duration window) async { + Future measureDispatchedFps(Duration window) async { final sw = Stopwatch()..start(); - final start = FrameScheduler.instance.scheduledFrameCount; + final start = FrameScheduler.instance.dispatchedFrameCount; // Yield to the event loop in small increments so the native scheduler // callbacks are delivered while real time elapses. while (sw.elapsed < window) { await Future.delayed(const Duration(milliseconds: 20)); } - final frames = FrameScheduler.instance.scheduledFrameCount - start; + final frames = FrameScheduler.instance.dispatchedFrameCount - start; final realSeconds = sw.elapsed.inMicroseconds / 1e6; return frames / realSeconds; } - testWidgets('setTargetFramerate lowers scheduled frames-per-second', + testWidgets('setTargetFramerate lowers dispatched frames-per-second', (tester) async { ThermionViewer? viewer; final sun = DirectLight.sun(direction: Vector3(0.7, -1, -0.8).normalized()); @@ -94,9 +94,9 @@ void main() { // Let the loop settle, then measure the 60 FPS rate. await Future.delayed(const Duration(seconds: 1)); - final defaultFps = await measureScheduledFps(const Duration(seconds: 2)); + final defaultFps = await measureDispatchedFps(const Duration(seconds: 2)); debugPrint( - 'Thermion scheduled FPS: default=${defaultFps.toStringAsFixed(1)}', + 'Thermion dispatched FPS: default=${defaultFps.toStringAsFixed(1)}', ); // Sanity: the loop is rendering continuously (not stalled). The absolute @@ -105,13 +105,13 @@ void main() { expect(defaultFps, greaterThanOrEqualTo(15), reason: 'loop should render continuously; got $defaultFps'); - // Cap well below the render-limited default and confirm the scheduled rate + // Cap well below the render-limited default and confirm the dispatched rate // drops to the cap. 5 FPS is unambiguously below any reasonable default. FilamentApp.instance!.setTargetFramerate(5); // Give the new interval a moment to take effect. await Future.delayed(const Duration(milliseconds: 500)); - final lowFps = await measureScheduledFps(const Duration(seconds: 2)); - debugPrint('Thermion scheduled FPS: limited=${lowFps.toStringAsFixed(1)}'); + final lowFps = await measureDispatchedFps(const Duration(seconds: 2)); + debugPrint('Thermion dispatched FPS: limited=${lowFps.toStringAsFixed(1)}'); expect(lowFps, lessThan(defaultFps / 2), reason: '5 FPS cap should schedule far fewer frames than the default ' @@ -126,9 +126,9 @@ void main() { await FrameScheduler.instance.start(); await Future.delayed(const Duration(milliseconds: 500)); final afterRestartFps = - await measureScheduledFps(const Duration(seconds: 2)); + await measureDispatchedFps(const Duration(seconds: 2)); debugPrint( - 'Thermion scheduled FPS: after restart=' + 'Thermion dispatched FPS: after restart=' '${afterRestartFps.toStringAsFixed(1)}', ); expect(afterRestartFps, closeTo(5, 3), @@ -139,9 +139,9 @@ void main() { // Restore and confirm the rate climbs back near the default. FilamentApp.instance!.setTargetFramerate(60); await Future.delayed(const Duration(milliseconds: 500)); - final restoredFps = await measureScheduledFps(const Duration(seconds: 2)); + final restoredFps = await measureDispatchedFps(const Duration(seconds: 2)); debugPrint( - 'Thermion scheduled FPS: restored=${restoredFps.toStringAsFixed(1)}', + 'Thermion dispatched FPS: restored=${restoredFps.toStringAsFixed(1)}', ); expect(restoredFps, greaterThan(lowFps * 2), reason: 'restoring 60 FPS should raise the rate well above the 5 FPS ' diff --git a/examples/flutter/quickstart/integration_test/texture_resize_test.dart b/examples/flutter/quickstart/integration_test/texture_resize_test.dart index 2f82e0c63..339ab1b45 100644 --- a/examples/flutter/quickstart/integration_test/texture_resize_test.dart +++ b/examples/flutter/quickstart/integration_test/texture_resize_test.dart @@ -91,7 +91,7 @@ void main() { () => !scheduler.isRendering, 'the current frame to finish', ); - scheduler.setOnFrame(() async { + scheduler.setFrameHandler(() async { await renderGate.future; await FilamentApp.instance?.render(); }); diff --git a/thermion_dart/ffigen/native.yaml b/thermion_dart/ffigen/native.yaml index d26b2aec8..f5b2dacce 100644 --- a/thermion_dart/ffigen/native.yaml +++ b/thermion_dart/ffigen/native.yaml @@ -6,6 +6,8 @@ headers: include-directives: - '../native/include/c_api/*.h' - '../native/include/c_api/**/*.h' +compiler-opts: + - '-DTHERMION_FFIGEN' ffi-native: asset-id: package:thermion_dart/thermion_dart.dart ignore-source-errors: true @@ -14,9 +16,9 @@ functions: include: - '.*' exclude: - # FrameScheduler_stop joins the scheduler thread and waits for the final - # render task to drain. Blocking functions must not be Dart FFI leaf - # calls. This excludes it only from isLeaf:true, not from the bindings. + # FrameScheduler_stop joins the scheduler callback thread. Blocking + # functions must not be Dart FFI leaf calls. This excludes it only from + # isLeaf:true, not from the bindings. - 'FrameScheduler_stop' enums: as-int: diff --git a/thermion_dart/ffigen/web.yaml b/thermion_dart/ffigen/web.yaml index 686cb20f5..e4b0542dc 100644 --- a/thermion_dart/ffigen/web.yaml +++ b/thermion_dart/ffigen/web.yaml @@ -10,6 +10,7 @@ headers: - '../native/include/c_api/**/*.h' compiler-opts: - '-D__EMSCRIPTEN__' + - '-DTHERMION_FFIGEN' structs: dependency-only: opaque exclude: @@ -22,4 +23,4 @@ ignore-source-errors: true enums: as-int: include: - - .* \ No newline at end of file + - .* diff --git a/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart b/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart index 3b0922ef9..ce0563476 100644 --- a/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart +++ b/thermion_dart/lib/src/bindings/src/thermion_dart_ffi.g.dart @@ -4605,27 +4605,12 @@ external void Scene_setIndirectLight(ffi.Pointer tScene, ffi.Pointer, ffi.Pointer)>(isLeaf: true) external void Scene_addFilamentAsset(ffi.Pointer tScene, ffi.Pointer asset); -@ffi.Native(isLeaf: true) -external void FrameScheduler_start(FrameCallback callback, int targetFps); +@ffi.Native(isLeaf: true) +external void FrameScheduler_startWithCallback(FrameTickCallback tickCallback, int targetFps); @ffi.Native() external void FrameScheduler_stop(); -@ffi.Native)>(isLeaf: true) -external void FrameScheduler_setRenderThread(ffi.Pointer renderThread); - -@ffi.Native)>(isLeaf: true) -external void FrameScheduler_setRenderManager(ffi.Pointer rm); - -@ffi.Native)>(isLeaf: true) -external void FrameScheduler_setPostRenderCallback(PostRenderCallback callback, ffi.Pointer userData); - -@ffi.Native(isLeaf: true) -external bool FrameScheduler_requestRender(int frameTimeNanos); - -@ffi.Native(isLeaf: true) -external void FrameScheduler_startNativeRenderLoop(int targetFps); - @ffi.Native)>(isLeaf: true) external int FrameScheduler_initDartApi(ffi.Pointer data); @@ -5734,12 +5719,9 @@ final class TShadowOptions extends ffi.Struct { typedef FilamentRenderCallbackFunction = ffi.Void Function(ffi.Pointer owner); typedef DartFilamentRenderCallbackFunction = void Function(ffi.Pointer owner); typedef FilamentRenderCallback = ffi.Pointer>; -typedef FrameCallbackFunction = ffi.Void Function(ffi.Uint64 frameTimeNanos); -typedef DartFrameCallbackFunction = void Function(int frameTimeNanos); -typedef FrameCallback = ffi.Pointer>; -typedef PostRenderCallbackFunction = ffi.Void Function(ffi.Pointer userData); -typedef DartPostRenderCallbackFunction = void Function(ffi.Pointer userData); -typedef PostRenderCallback = ffi.Pointer>; +typedef FrameTickCallbackFunction = ffi.Void Function(ffi.Uint64 frameTimeNanos); +typedef DartFrameTickCallbackFunction = void Function(int frameTimeNanos); +typedef FrameTickCallback = ffi.Pointer>; final class TMovementIntentCalculator extends ffi.Opaque {} diff --git a/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart b/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart index 16087b80e..64a05b7c8 100644 --- a/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart +++ b/thermion_dart/lib/src/bindings/src/thermion_dart_js_interop.g.dart @@ -2376,13 +2376,8 @@ extension type GeneratedBindings(NativeLibrary _) implements JSObject { external Pointer _Scene_getSkybox(Pointer tScene); external void _Scene_setIndirectLight(Pointer tScene, Pointer tIndirectLight); external void _Scene_addFilamentAsset(Pointer tScene, Pointer asset); - external void _FrameScheduler_start(FrameCallback callback, int targetFps); + external void _FrameScheduler_startWithCallback(FrameTickCallback tickCallback, int targetFps); external void _FrameScheduler_stop(); - external void _FrameScheduler_setRenderThread(Pointer renderThread); - external void _FrameScheduler_setRenderManager(Pointer rm); - external void _FrameScheduler_setPostRenderCallback(PostRenderCallback callback, Pointer userData); - external int _FrameScheduler_requestRender(JSBigInt frameTimeNanos); - external void _FrameScheduler_startNativeRenderLoop(int targetFps); external int _FrameScheduler_initDartApi(Pointer data); external void _FrameScheduler_startWithPort(JSBigInt port, int targetFps); external void _FrameScheduler_setTargetFps(int fps); @@ -8764,9 +8759,9 @@ void Scene_addFilamentAsset(Pointer tScene, Pointer asse return result; } -void FrameScheduler_start(DartFrameCallback callback, int targetFps) { - final result = GeneratedBindings.instance._FrameScheduler_start( - callback as Pointer>, +void FrameScheduler_startWithCallback(DartFrameTickCallback tickCallback, int targetFps) { + final result = GeneratedBindings.instance._FrameScheduler_startWithCallback( + tickCallback as Pointer>, targetFps, ); return result; @@ -8777,34 +8772,6 @@ void FrameScheduler_stop() { return result; } -void FrameScheduler_setRenderThread(Pointer renderThread) { - final result = GeneratedBindings.instance._FrameScheduler_setRenderThread(renderThread); - return result; -} - -void FrameScheduler_setRenderManager(Pointer rm) { - final result = GeneratedBindings.instance._FrameScheduler_setRenderManager(rm.cast()); - return result; -} - -void FrameScheduler_setPostRenderCallback(DartPostRenderCallback callback, Pointer userData) { - final result = GeneratedBindings.instance._FrameScheduler_setPostRenderCallback( - callback as Pointer>, - userData, - ); - return result; -} - -bool FrameScheduler_requestRender(BigInt frameTimeNanos) { - final result = GeneratedBindings.instance._FrameScheduler_requestRender(frameTimeNanos.toJSBigInt); - return result == 1; -} - -void FrameScheduler_startNativeRenderLoop(int targetFps) { - final result = GeneratedBindings.instance._FrameScheduler_startNativeRenderLoop(targetFps); - return result; -} - int FrameScheduler_initDartApi(Pointer data) { final result = GeneratedBindings.instance._FrameScheduler_initDartApi(data); return result; @@ -11292,14 +11259,10 @@ final class TSurfaceOrientation extends Struct { } } -typedef FrameCallback = Pointer>; -typedef DartFrameCallback = Pointer>; -typedef FrameCallbackFunction = void Function(JSBigInt frameTimeNanos); -typedef DartFrameCallbackFunction = void Function(BigInt frameTimeNanos); -typedef PostRenderCallback = Pointer>; -typedef DartPostRenderCallback = Pointer>; -typedef PostRenderCallbackFunction = void Function(Pointer userData); -typedef DartPostRenderCallbackFunction = void Function(Pointer userData); +typedef FrameTickCallback = Pointer>; +typedef DartFrameTickCallback = Pointer>; +typedef FrameTickCallbackFunction = void Function(JSBigInt frameTimeNanos); +typedef DartFrameTickCallbackFunction = void Function(BigInt frameTimeNanos); extension TMovementIntentExecutorExt on Pointer { TMovementIntentExecutor toDart() { diff --git a/thermion_dart/native/include/c_api/FrameSchedulerApi.h b/thermion_dart/native/include/c_api/FrameSchedulerApi.h index 25af24830..952e682a5 100644 --- a/thermion_dart/native/include/c_api/FrameSchedulerApi.h +++ b/thermion_dart/native/include/c_api/FrameSchedulerApi.h @@ -1,7 +1,12 @@ #pragma once #include "APIBoundaryTypes.h" + +// Preserve the historical generated-binding declaration order without making +// the production timing API depend on rendering types. +#ifdef THERMION_FFIGEN #include "TRenderManager.h" +#endif #ifdef __cplusplus namespace thermion @@ -9,18 +14,11 @@ namespace thermion extern "C" { #endif - typedef void (*FrameCallback)(uint64_t frameTimeNanos); - typedef void (*PostRenderCallback)(void* userData); + typedef void (*FrameTickCallback)(uint64_t frameTimeNanos); - EMSCRIPTEN_KEEPALIVE void FrameScheduler_start(FrameCallback callback, int targetFps); + EMSCRIPTEN_KEEPALIVE void FrameScheduler_startWithCallback(FrameTickCallback tickCallback, int targetFps); EMSCRIPTEN_KEEPALIVE void FrameScheduler_stop(); - EMSCRIPTEN_KEEPALIVE void FrameScheduler_setRenderThread(void* renderThread); - EMSCRIPTEN_KEEPALIVE void FrameScheduler_setRenderManager(TRenderManager* rm); - EMSCRIPTEN_KEEPALIVE void FrameScheduler_setPostRenderCallback(PostRenderCallback callback, void* userData); - EMSCRIPTEN_KEEPALIVE bool FrameScheduler_requestRender(uint64_t frameTimeNanos); - EMSCRIPTEN_KEEPALIVE void FrameScheduler_startNativeRenderLoop(int targetFps); - EMSCRIPTEN_KEEPALIVE int FrameScheduler_initDartApi(void* data); EMSCRIPTEN_KEEPALIVE void FrameScheduler_startWithPort(int64_t port, int targetFps); diff --git a/thermion_dart/native/include/rendering/FrameScheduler.hpp b/thermion_dart/native/include/rendering/FrameScheduler.hpp index 867fc3254..69289b523 100644 --- a/thermion_dart/native/include/rendering/FrameScheduler.hpp +++ b/thermion_dart/native/include/rendering/FrameScheduler.hpp @@ -53,16 +53,17 @@ namespace thermion { class FrameScheduler { public: /// Receives a monotonic frame timestamp and the pointer supplied to start(). - using Callback = void (*)(uint64_t frameTimeNanos, void* userData); + using TickCallback = void (*)(uint64_t frameTimeNanos, void* userData); virtual ~FrameScheduler() = default; /// Starts the frame source. Each source tick that passes rate gating invokes - /// `callback` with its timestamp and `userData`. + /// `tickCallback` with its timestamp and `userData`. /// - /// The scheduler does not own `userData`. The callback and `userData` must - /// remain valid until stop() returns. Call stop() before a second start(). - virtual void start(Callback callback, void* userData = nullptr) = 0; + /// The scheduler does not own `userData`. The tick callback and `userData` + /// must remain valid until stop() returns. Call stop() before a second + /// start(). + virtual void start(TickCallback tickCallback, void* userData = nullptr) = 0; /// Stops the frame source and waits for its callback context to stop. /// Work that the callback already posted can continue after this returns. @@ -84,8 +85,8 @@ class FrameScheduler { void setTargetFps(int fps); protected: - Callback _callback = nullptr; - void* _callbackUserData = nullptr; + TickCallback _tickCallback = nullptr; + void* _tickUserData = nullptr; // setTargetFps() can change _fpsLimit from another thread. The source // callback owns the other timing fields while the scheduler runs. @@ -109,7 +110,7 @@ class TimerFrameScheduler : public FrameScheduler { public: explicit TimerFrameScheduler(int targetFps) : _targetFps(targetFps) {} ~TimerFrameScheduler() override { stop(); } - void start(Callback callback, void* userData = nullptr) override; + void start(TickCallback tickCallback, void* userData = nullptr) override; void stop() override; private: void run(); @@ -122,7 +123,7 @@ class CADisplayLinkScheduler : public FrameScheduler { void* _wrapper = nullptr; public: ~CADisplayLinkScheduler() override { stop(); } - void start(Callback callback, void* userData = nullptr) override; + void start(TickCallback tickCallback, void* userData = nullptr) override; void stop() override; private: static void displayLinkCallback(uint64_t frameTimeNanos, void* context); @@ -137,7 +138,7 @@ class CVDisplayLinkScheduler : public FrameScheduler { mach_timebase_info_data_t _timebase{}; public: ~CVDisplayLinkScheduler() override { stop(); } - void start(Callback callback, void* userData = nullptr) override; + void start(TickCallback tickCallback, void* userData = nullptr) override; void stop() override; private: static CVReturn displayLinkCallback(CVDisplayLinkRef displayLink, @@ -155,7 +156,7 @@ class DXGIFrameScheduler : public FrameScheduler { public: explicit DXGIFrameScheduler(int targetFps) : _targetFps(targetFps) {} ~DXGIFrameScheduler() override { stop(); } - void start(Callback callback, void* userData = nullptr) override; + void start(TickCallback tickCallback, void* userData = nullptr) override; void stop() override; }; #endif @@ -172,7 +173,7 @@ class AChoreographerFrameScheduler : public FrameScheduler { uint64_t _nextSourceFrameNs = 0; public: ~AChoreographerFrameScheduler() override { stop(); } - void start(Callback callback, void* userData = nullptr) override; + void start(TickCallback tickCallback, void* userData = nullptr) override; void stop() override; private: void scheduleNextFrame(uint64_t lastFrameTimeNanos = 0); diff --git a/thermion_dart/native/src/c_api/FrameSchedulerApi.cpp b/thermion_dart/native/src/c_api/FrameSchedulerApi.cpp index d78f3e914..86eab003a 100644 --- a/thermion_dart/native/src/c_api/FrameSchedulerApi.cpp +++ b/thermion_dart/native/src/c_api/FrameSchedulerApi.cpp @@ -1,13 +1,8 @@ #include -#include #include -#include #include "c_api/FrameSchedulerApi.h" -#include "c_api/TRenderManager.h" -#include "rendering/RenderThread.hpp" -#include "rendering/RenderManager.hpp" #include "rendering/FrameScheduler.hpp" #ifndef __EMSCRIPTEN__ @@ -20,34 +15,25 @@ extern "C" { static thermion::FrameScheduler* _frameScheduler = nullptr; - static FrameCallback _scheduledCallback = nullptr; + static FrameTickCallback _tickCallback = nullptr; static int64_t _dartPort = 0; - static TRenderManager* _nativeRenderManager = nullptr; - static RenderThread* _renderThread = nullptr; - static std::atomic _nativeRenderInProgress{false}; - static PostRenderCallback _postRenderCallback = nullptr; - static void* _postRenderUserData = nullptr; - static std::atomic _targetFpsLimit{0}; - static int _requestAppliedFpsLimit = 0; - static uint64_t _nextRequestRenderNs = 0; - static void applyTargetFps(FrameScheduler* scheduler) { scheduler->setTargetFps(_targetFpsLimit.load(std::memory_order_relaxed)); } - static void forwardScheduledFrame( + static void forwardScheduledTick( uint64_t frameTimeNanos, void* userData) { - auto callback = *static_cast(userData); + auto callback = *static_cast(userData); if (callback) { callback(frameTimeNanos); } } #ifndef __EMSCRIPTEN__ - static void postScheduledFrameToDart( + static void postScheduledTickToDart( uint64_t frameTimeNanos, void* userData) { const int64_t port = *static_cast(userData); if (port == 0) return; @@ -59,7 +45,8 @@ extern "C" } #endif - EMSCRIPTEN_KEEPALIVE void FrameScheduler_start(FrameCallback callback, int targetFps) { + EMSCRIPTEN_KEEPALIVE void FrameScheduler_startWithCallback( + FrameTickCallback tickCallback, int targetFps) { #ifndef __EMSCRIPTEN__ if (_frameScheduler) { _frameScheduler->stop(); @@ -68,8 +55,8 @@ extern "C" } _frameScheduler = thermion::FrameScheduler::create(targetFps); applyTargetFps(_frameScheduler); - _scheduledCallback = callback; - _frameScheduler->start(forwardScheduledFrame, &_scheduledCallback); + _tickCallback = tickCallback; + _frameScheduler->start(forwardScheduledTick, &_tickCallback); #endif } @@ -80,16 +67,8 @@ extern "C" delete _frameScheduler; _frameScheduler = nullptr; } - _scheduledCallback = nullptr; + _tickCallback = nullptr; _dartPort = 0; - - while (_nativeRenderInProgress.load(std::memory_order_acquire)) { - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - _nativeRenderManager = nullptr; - _renderThread = nullptr; - _postRenderCallback = nullptr; - _postRenderUserData = nullptr; #endif } @@ -125,7 +104,7 @@ extern "C" _frameScheduler = thermion::FrameScheduler::create(targetFps); applyTargetFps(_frameScheduler); _dartPort = port; - _frameScheduler->start(postScheduledFrameToDart, &_dartPort); + _frameScheduler->start(postScheduledTickToDart, &_dartPort); #endif } @@ -139,102 +118,4 @@ extern "C" #endif } - EMSCRIPTEN_KEEPALIVE void FrameScheduler_setRenderThread(void* renderThread) { -#ifndef __EMSCRIPTEN__ - _renderThread = static_cast(renderThread); -#endif - } - - EMSCRIPTEN_KEEPALIVE void FrameScheduler_setRenderManager(TRenderManager* rm) { - _nativeRenderManager = rm; - } - - EMSCRIPTEN_KEEPALIVE void FrameScheduler_setPostRenderCallback(PostRenderCallback callback, void* userData) { - _postRenderCallback = callback; - _postRenderUserData = userData; - } - - static bool _nativeFrameCallback(uint64_t frameTimeNanos) { - auto* renderManager = _nativeRenderManager; - auto* renderThread = _renderThread; - auto postRenderCallback = _postRenderCallback; - auto* postRenderUserData = _postRenderUserData; - if (!renderManager || !renderThread) return false; - if (_nativeRenderInProgress.exchange(true, std::memory_order_acq_rel)) { - return false; - } - - renderThread->addDetachedTask([ - renderManager, - frameTimeNanos, - postRenderCallback, - postRenderUserData - ]() { - RenderManager_render(renderManager, frameTimeNanos); - - if (postRenderCallback) { - postRenderCallback(postRenderUserData); - } - - _nativeRenderInProgress.store(false, std::memory_order_release); - }); - return true; - } - - static void _nativeScheduledFrameCallback( - uint64_t frameTimeNanos, void*) { - _nativeFrameCallback(frameTimeNanos); - } - - EMSCRIPTEN_KEEPALIVE bool FrameScheduler_requestRender(uint64_t frameTimeNanos) { - int fps = _targetFpsLimit.load(std::memory_order_relaxed); - if (fps > 0) { - const uint64_t interval = std::max( - 1, 1000000000ULL / static_cast(fps)); - const uint64_t tolerance = 1000000ULL; - - if (_requestAppliedFpsLimit != fps || _nextRequestRenderNs == 0) { - _requestAppliedFpsLimit = fps; - _nextRequestRenderNs = frameTimeNanos; - } - - if (_nextRequestRenderNs > frameTimeNanos && - _nextRequestRenderNs - frameTimeNanos > tolerance) { - return false; - } - - if (!_nativeFrameCallback(frameTimeNanos)) { - return false; - } - - if (_nextRequestRenderNs <= frameTimeNanos) { - const uint64_t missedIntervals = - (frameTimeNanos - _nextRequestRenderNs) / interval + 1; - _nextRequestRenderNs += missedIntervals * interval; - } else { - _nextRequestRenderNs += interval; - } - return true; - } else { - _requestAppliedFpsLimit = 0; - _nextRequestRenderNs = 0; - } - return _nativeFrameCallback(frameTimeNanos); - } - - EMSCRIPTEN_KEEPALIVE void FrameScheduler_startNativeRenderLoop(int targetFps) { -#ifndef __EMSCRIPTEN__ - if (_frameScheduler) { - _frameScheduler->stop(); - delete _frameScheduler; - _frameScheduler = nullptr; - } - - _frameScheduler = new thermion::TimerFrameScheduler( - targetFps > 0 ? targetFps : 60); - applyTargetFps(_frameScheduler); - _frameScheduler->start(_nativeScheduledFrameCallback); -#endif - } - } diff --git a/thermion_dart/native/src/rendering/FrameScheduler.cpp b/thermion_dart/native/src/rendering/FrameScheduler.cpp index 9aa4785b4..51c122bcc 100644 --- a/thermion_dart/native/src/rendering/FrameScheduler.cpp +++ b/thermion_dart/native/src/rendering/FrameScheduler.cpp @@ -74,14 +74,14 @@ void FrameScheduler::handleSourceTick(uint64_t nanos) { _nextDispatchNs = 0; } - if (_callback) { - _callback(nanos, _callbackUserData); + if (_tickCallback) { + _tickCallback(nanos, _tickUserData); } } void FrameScheduler::resetState() { - _callback = nullptr; - _callbackUserData = nullptr; + _tickCallback = nullptr; + _tickUserData = nullptr; _appliedFpsLimit = 0; _dispatchIntervalNs = 0; _nextDispatchNs = 0; @@ -179,10 +179,10 @@ void TimerFrameScheduler::run() { } } -void TimerFrameScheduler::start(Callback callback, void* userData) { +void TimerFrameScheduler::start(TickCallback tickCallback, void* userData) { if (_running) return; - _callback = callback; - _callbackUserData = userData; + _tickCallback = tickCallback; + _tickUserData = userData; _running = true; _thread = new std::thread([this]() { run(); }); } @@ -212,10 +212,10 @@ void CADisplayLinkScheduler::displayLinkCallback(uint64_t frameTimeNanos, void* self->handleSourceTick(frameTimeNanos); } -void CADisplayLinkScheduler::start(Callback callback, void* userData) { +void CADisplayLinkScheduler::start(TickCallback tickCallback, void* userData) { stop(); - _callback = callback; - _callbackUserData = userData; + _tickCallback = tickCallback; + _tickUserData = userData; _wrapper = CADisplayLinkWrapper_create(displayLinkCallback, this); CADisplayLinkWrapper_setTargetFps( _wrapper, _fpsLimit.load(std::memory_order_relaxed)); @@ -244,10 +244,10 @@ void CADisplayLinkScheduler::stop() { #if __APPLE__ && TARGET_OS_OSX -void CVDisplayLinkScheduler::start(Callback callback, void* userData) { +void CVDisplayLinkScheduler::start(TickCallback tickCallback, void* userData) { stop(); - _callback = callback; - _callbackUserData = userData; + _tickCallback = tickCallback; + _tickUserData = userData; mach_timebase_info(&_timebase); CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink); @@ -286,10 +286,10 @@ CVReturn CVDisplayLinkScheduler::displayLinkCallback(CVDisplayLinkRef displayLin #ifdef _WIN32 -void DXGIFrameScheduler::start(Callback callback, void* userData) { +void DXGIFrameScheduler::start(TickCallback tickCallback, void* userData) { stop(); - _callback = callback; - _callbackUserData = userData; + _tickCallback = tickCallback; + _tickUserData = userData; _running = true; _thread = new std::thread([this]() { @@ -404,10 +404,10 @@ void AChoreographerFrameScheduler::scheduleNextFrame(uint64_t lastFrameTimeNanos } } -void AChoreographerFrameScheduler::start(Callback callback, void* userData) { +void AChoreographerFrameScheduler::start(TickCallback tickCallback, void* userData) { stop(); - _callback = callback; - _callbackUserData = userData; + _tickCallback = tickCallback; + _tickUserData = userData; _running = true; _thread = new std::thread([this]() { diff --git a/thermion_flutter/thermion_flutter/ARCHITECTURE.md b/thermion_flutter/thermion_flutter/ARCHITECTURE.md index edee5d290..4c05c876c 100644 --- a/thermion_flutter/thermion_flutter/ARCHITECTURE.md +++ b/thermion_flutter/thermion_flutter/ARCHITECTURE.md @@ -47,17 +47,15 @@ On macOS, iOS, Windows, Android, Thermion use the platform vsync signal to synch - `DXGIFrameScheduler` (DXGI `WaitForVBlank`) (Windows) - `AChoreographerFrameScheduler` (Android) -On Linux, vsync is not reliably available, so Thermion uses Flutter's own `SchedulerBinding`. +On Linux, vsync is not reliably available, so Thermion uses Flutter's own `SchedulerBinding`. Its persistent frame callback feeds the same Dart `FrameScheduler` frame-handler pipeline used by the other platforms. This keeps Thermion in lockstep with Flutter's Skia compositor without introducing a separate Linux render loop: request-frame hooks, `FilamentApp.render()`, and post-render texture notification run in the same order everywhere. -Instead, we use a platform-native `FrameScheduler` (`thermion_dart/native/src/c_api/FrameSchedulerApi.cpp`) to provide the vsync signal: - -On Linux, we need to use Flutter's persistent frame callback to drive a non-blocking `FrameScheduler_requestRender` on the native render thread (`_initializeNativeRenderLoop` in `thermion_flutter_plugin_native.dart`). This keeps Thermion in lockstep with Flutter's Skia compositor, which matters on Linux where there is no independent display-link API we can reliably use. - -The `FrameScheduler` dispatches its callback to Dart in one of two ways: +The native `FrameScheduler` only produces rate-limited timing ticks. It delivers accepted ticks to Dart in one of two ways: - **Release**: a raw C function pointer (`ffi.NativeCallable`) — minimum latency. - **Debug** (hot-restart safe): `Dart_PostCObject_DL` onto a `ReceivePort`. `Dart_PostCObject` silently drops messages to dead ports, so stale native schedulers created in a previous isolate can't crash the new one. +Linux does not need either native-to-Dart transport because Flutter invokes the persistent frame callback in Dart directly. It still uses the same active, pause, in-flight, diagnostics, and frame-handler logic after that platform-specific entry point. + Android intentionally keeps render admission on these Dart callback paths. Flutter's `ImageReaderSurfaceProducer` receives images through a listener on Android's main looper. A fully native producer loop can continue submitting @@ -71,7 +69,7 @@ By default the viewer renders on **every vsync** — i.e. at the display's nativ `FilamentApp.instance.setTargetFramerate(fps)` caps the rate *below* the display refresh by skipping vsyncs at the source, so dropped native frames never wake Dart. It's an engine-level API — the same native scheduler paces the headless CLI path — and it cannot raise the rate above the refresh. An absolute target deadline preserves the requested average on displays whose refresh is not an integer multiple of the target. For example, 60 fps on a 90 Hz display alternates one- and two-vsync presentation intervals rather than falling to 45 fps. That cadence necessarily has some judder; exact, evenly spaced 60 fps is physically impossible on a fixed 90 Hz presentation clock. -The cap is applied in two native places, both fed by `FrameScheduler_setTargetFps`: in `FrameScheduler::dispatchFrame` (the CVDisplayLink / CADisplayLink / AChoreographer / DXGI / timer schedulers), and in `FrameScheduler_requestRender` (the Linux Flutter-synced path, which bypasses `dispatchFrame`). The desired cap is process-wide and survives scheduler destruction/recreation during app lifecycle transitions. Web applies the same deadline algorithm directly in its `requestAnimationFrame` loop. +The native display-link sources apply the cap in `FrameScheduler::handleSourceTick`, fed by `FrameScheduler_setTargetFps`. Linux applies the same absolute-deadline algorithm to Flutter's frame timestamps before entering the common frame-handler pipeline. Web applies it directly in its `requestAnimationFrame` loop. Framerate is a property of the **shared render loop**, not of any one viewer. All viewers on the same engine are pace-locked to the same rate (one scheduler drives a single `renderManager.render()` that renders every attached view each tick), so there is no per-view pacing — the last `setTargetFramerate` call wins for all viewers. @@ -99,16 +97,15 @@ On each vsync tick, the following runs: 7. Back in `_renderFrame`, for each `PlatformTextureDescriptor` we call **`markTextureFrameAvailable()`**. 8. The Texture widget schedules a repaint; Flutter's compositor samples the updated hardware texture on its next frame. -Steps 3–6 are a single Dart `await`: the Flutter frame callback does not return until `endFrame` has completed on the render thread. The render does **not** run on the platform thread — it runs on the dedicated `RenderThread` — so the UI isolate isn't blocked on GPU work beyond the round-trip wait. +Steps 3–6 are represented by a single Dart `Future`. The timing callback starts that work without awaiting it; the `_rendering` guard skips later ticks until the future completes. Rendering runs on the dedicated `RenderThread`, not the platform thread or Dart isolate. ### `markTextureFrameAvailable` `markTextureFrameAvailable` is how we tell Flutter "the texture you imported has new contents; please sample it on the next compositor pass." It is called **after** `endFrame` returns on the render thread (i.e. after the callback has crossed back to the main thread). Implementations: - **Darwin**: direct Objective-C call — `SwiftThermionFlutterPluginObjCAPI.markTextureFrameAvailableWithFlutterTextureId_` (`darwin_platform_texture_descriptor.dart`). -- **Android/Linux/Windows**: platform channel — `channel.invokeMethod("markTextureFrameAvailable", flutterTextureId)` (`method_channel_platform_texture_descriptor.dart`). - -On Linux with `FrameScheduler_setPostRenderCallback`, the mark happens entirely in native code (`thermion_flutter_mark_textures`) without bouncing to Dart, removing one round-trip per frame. +- **Android/Windows**: platform channel — `channel.invokeMethod("markTextureFrameAvailable", flutterTextureId)` (`method_channel_platform_texture_descriptor.dart`). +- **Linux**: after the common Dart render future completes, one direct FFI call to `thermion_flutter_mark_textures` marks every registered external texture. This keeps texture presentation platform-specific without making rendering platform-specific. ### Composite path @@ -124,7 +121,7 @@ Compositing itself is Flutter's responsibility — the Texture widget participat **The invariant (both platforms): pause stops rendering only. The task queue keeps draining.** -`pauseFrameScheduler()` / `resumeFrameScheduler()` gate the **render pipeline** — on native, Dart's `_onFrame` short-circuits (so no `RenderManager_render` task is queued); on web, `RenderManager::tick()` skips `updateAnimationsAndPlugins` + the swapchain loop. Neither path touches `RenderThread::_tasks`, so every `*_RenderThread` FFI call (`setTransform`, `addEntity`, material/camera updates, etc.) continues to queue and execute exactly as it does when running. `await`-ing an FFI call during pause will not hang; state accumulated during pause is visible on the first render after resume. +`pauseFrameScheduler()` / `resumeFrameScheduler()` gate the **render pipeline** — on native, Dart declines to dispatch the frame handler (so no `RenderManager_render` task is queued); on web, `RenderManager::tick()` skips `updateAnimationsAndPlugins` + the swapchain loop. Neither path touches `RenderThread::_tasks`, so every `*_RenderThread` FFI call (`setTransform`, `addEntity`, material/camera updates, etc.) continues to queue and execute exactly as it does when running. `await`-ing an FFI call during pause will not hang; state accumulated during pause is visible on the first render after resume. App lifecycle suspension is separate from an explicit caller pause. `hidden`, `paused`, and `detached` suspend rendering; `inactive` does not, because the app diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/frame_scheduler.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/frame_scheduler.dart index 665c75300..7d044d2d7 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/frame_scheduler.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/frame_scheduler.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:ffi' as ffi; import 'dart:io'; import 'dart:isolate'; +import 'dart:math' as math; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/scheduler.dart'; @@ -9,29 +10,22 @@ import 'package:logging/logging.dart'; // ignore: implementation_imports import 'package:thermion_dart/src/bindings/src/thermion_dart_ffi.g.dart' show - FrameScheduler_start, + FrameScheduler_startWithCallback, FrameScheduler_stop, - FrameCallbackFunction, + FrameTickCallbackFunction, FrameScheduler_initDartApi, FrameScheduler_startWithPort, - FrameScheduler_setRenderThread, - FrameScheduler_setRenderManager, - FrameScheduler_setPostRenderCallback, - FrameScheduler_requestRender, - FrameScheduler_steadyClockUs, - PostRenderCallback, - TRenderManager; - -/// Drives per-frame callbacks off the native FrameScheduler (CVDisplayLink / -/// DXGI / AChoreographer / timer) via one of three modes: + FrameScheduler_steadyClockUs; + +/// Dispatches a frame handler from platform timing ticks via one of three +/// modes: /// -/// - **Direct callback** (release builds): native calls a Dart function +/// - **Direct tick** (release builds): native calls a Dart function /// pointer via [ffi.NativeCallable.listener]. /// - **Port mode** (debug builds): native posts messages to a [ReceivePort]. /// Hot-restart safe — messages to dead ports are silently dropped. -/// - **Flutter-synced** (Linux native render loop): Flutter's persistent -/// frame callback drives a non-blocking native render request. [onFrame] -/// is not invoked in this mode. +/// - **Flutter-synced** (Linux): Flutter's persistent frame callback drives +/// the same Dart callback pipeline as the native display-link sources. class FrameScheduler { FrameScheduler._(); @@ -40,12 +34,13 @@ class FrameScheduler { /// Returns the process-wide singleton. static FrameScheduler get instance => _instance; - /// Called each frame in direct/port mode. Not invoked in Flutter-synced mode. - Future Function()? _onFrameCallback; + /// Work dispatched once for each accepted timing tick. + Future Function()? _frameHandler; - /// Bind the per-frame callback. Must be called before [start]. - void setOnFrame(Future Function() onFrame) { - _onFrameCallback = onFrame; + /// Bind the work dispatched for each accepted tick. Must be called before + /// [start] or [startFlutterSynced]. + void setFrameHandler(Future Function() handler) { + _frameHandler = handler; } static final _logger = Logger("FrameScheduler"); @@ -61,10 +56,15 @@ class FrameScheduler { /// callback. In that mode the loop is driven by Flutter's frame clock /// and must be re-armed with [SchedulerBinding.scheduleFrame] on resume. bool _flutterSynced = false; + bool _flutterTickCallbackRegistered = false; - ffi.NativeCallable? _frameCallable; + ffi.NativeCallable? _tickCallable; ReceivePort? _framePort; + int Function()? _flutterTargetFps; + int _flutterAppliedFpsLimit = 0; + int _nextFlutterFrameUs = 0; + int _diagFrameCount = 0; int _diagDropCount = 0; int _diagJankCount = 0; @@ -79,16 +79,15 @@ class FrameScheduler { bool get isPaused => _paused; bool get isRendering => _rendering; - // Monotonic count of frames admitted past both the framerate throttle and - // in-flight guard. A scheduled frame is not necessarily rendered — the - // render itself can still fail — so this counts accepted work rather than - // completions. Never resets, unlike the rolling _diag* counters used for - // the periodic log line. - int _scheduledFrameCount = 0; + // Monotonic count of handlers dispatched past both the framerate throttle + // and in-flight guard. A dispatched handler is not necessarily a completed + // render, so this counts started work rather than completions. Never resets, + // unlike the rolling _diag* counters used for the periodic log line. + int _dispatchedFrameCount = 0; - /// Total number of frames scheduled (dispatched) since the scheduler was - /// created. Monotonic; sample deltas to measure scheduled frames-per-second. - int get scheduledFrameCount => _scheduledFrameCount; + /// Total number of frame handlers dispatched since the scheduler was + /// created. Monotonic; sample deltas to measure dispatched frames per second. + int get dispatchedFrameCount => _dispatchedFrameCount; /// Start the native scheduler. Picks port mode in debug builds on /// macOS/iOS/Android/Windows and a direct native callback otherwise. @@ -110,34 +109,35 @@ class FrameScheduler { if (usePortMode) { await _initializePortMode(); } else { - _frameCallable = ffi.NativeCallable.listener( - _onFrame, + _tickCallable = ffi.NativeCallable.listener( + _handleNativeFrameTick, ); - FrameScheduler_start(_frameCallable!.nativeFunction, 60); + FrameScheduler_startWithCallback(_tickCallable!.nativeFunction, 60); } } - /// Configure the native scheduler to run the render loop entirely in - /// native code, synchronized to Flutter's frame clock via a persistent - /// frame callback. [onFrame] is not invoked in this mode. - Future startFlutterSynced({ - required ffi.Pointer renderThreadHandle, - required ffi.Pointer renderManagerHandle, - required PostRenderCallback postRenderCallback, - required ffi.Pointer postRenderUserData, - }) async { + /// Drive the normal frame callback pipeline from Flutter's frame clock. + /// + /// Linux uses this because it has no reliable native display-link source + /// synchronized with Flutter's compositor. Rendering itself is still owned + /// by the handler installed with [setFrameHandler], just like every other + /// mode. + Future startFlutterSynced({required int Function() targetFps}) async { if (_active) return; _active = true; _flutterSynced = true; - - FrameScheduler_setRenderThread(renderThreadHandle); - FrameScheduler_setRenderManager(renderManagerHandle); - FrameScheduler_setPostRenderCallback( - postRenderCallback, - postRenderUserData, - ); - - SchedulerBinding.instance.addPersistentFrameCallback(_onFlutterFrame); + _flutterTargetFps = targetFps; + _flutterAppliedFpsLimit = 0; + _nextFlutterFrameUs = 0; + + // Persistent callbacks cannot be unregistered. Register exactly once for + // this isolate; stop/reset only deactivate it, and a later start reuses it. + if (!_flutterTickCallbackRegistered) { + SchedulerBinding.instance.addPersistentFrameCallback( + _handleFlutterFrameTick, + ); + _flutterTickCallbackRegistered = true; + } SchedulerBinding.instance.scheduleFrame(); _logger.info('Flutter-synced render loop started'); @@ -151,13 +151,16 @@ class FrameScheduler { void stop() { _active = false; _flutterSynced = false; + _flutterTargetFps = null; + _flutterAppliedFpsLimit = 0; + _nextFlutterFrameUs = 0; // Always stop native state even when Dart has just hot-restarted and no // longer remembers that the previous isolate started a scheduler. FrameScheduler_stop(); - _frameCallable?.close(); - _frameCallable = null; + _tickCallable?.close(); + _tickCallable = null; _framePort?.close(); _framePort = null; @@ -168,15 +171,15 @@ class FrameScheduler { stop(); _paused = false; _rendering = false; - _onFrameCallback = null; + _frameHandler = null; } void pause() => _paused = true; /// Clear the pause flag. In Flutter-synced mode this must also re-arm the - /// frame callback: [pause] stops [_onFlutterFrame] from scheduling the next - /// frame, so without an explicit [SchedulerBinding.scheduleFrame] the loop - /// would stay frozen after a background→foreground transition. + /// frame callback: [pause] stops [_handleFlutterFrameTick] from scheduling + /// the next frame, so without an explicit [SchedulerBinding.scheduleFrame] + /// the loop would stay frozen after a background→foreground transition. void resume() { _paused = false; if (_active && _flutterSynced) { @@ -215,9 +218,9 @@ class FrameScheduler { _diagTransitCount = 0; _diagTransitMax = 0; } - _onFrame(frameTimeNanos); + _handleNativeFrameTick(frameTimeNanos); } else { - _onFrame(message as int); + _handleNativeFrameTick(message as int); } }); @@ -227,27 +230,44 @@ class FrameScheduler { _logger.info('Frame scheduler started in port mode (hot restart safe)'); } - void _onFrame(int frameTimeNanos) { - if (!_active || _paused) return; + void _handleNativeFrameTick(int frameTimeNanos) { // Framerate throttling for this path happens at the native source - // (dispatchFrame skips dropped vsyncs before this is even called), so no - // Dart-side gate here — only the in-flight guard below. + // (handleSourceTick skips rejected ticks before this is even called), so no + // Dart-side gate here — only the handler in-flight guard below. + _tryDispatchFrame(); + } + + /// Applies the common active, pause, handler, and in-flight gates, then + /// dispatches one frame handler. An optional source-specific gate can reject + /// the tick before work starts (Linux uses it for target-FPS pacing). + bool _tryDispatchFrame({bool Function()? sourceGate}) { + if (!_active || _paused) return false; if (_rendering) { - // A vsync arrived while the previous frame is still rendering — - // count it as a drop and skip. (Previously uncounted.) + // Keep only one handler/render in flight. Ticks arriving while Dart + // or the render thread is still busy are dropped rather than queued. _diagDropCount++; - return; + return false; } - final callback = _onFrameCallback; - if (callback == null) { - throw StateError('FrameScheduler.setOnFrame must be called before start'); + final handler = _frameHandler; + if (handler == null) { + throw StateError( + 'FrameScheduler.setFrameHandler must be called before start', + ); } + if (sourceGate != null && !sourceGate()) return false; + + _runFrameHandler(handler); + return true; + } + + /// Runs a dispatched frame handler and records diagnostics. + void _runFrameHandler(Future Function() handler) { _rendering = true; - _scheduledFrameCount++; + _dispatchedFrameCount++; _diagStopwatch ..reset() ..start(); - callback() + handler() .then((_) { _diagStopwatch.stop(); _rendering = false; @@ -280,16 +300,46 @@ class FrameScheduler { }); } - void _onFlutterFrame(Duration timeStamp) { - if (!_active || _paused) return; - // Pacing for this path is native. Count only requests that pass both the - // native throttle and render-thread in-flight guard. - final scheduled = FrameScheduler_requestRender( - timeStamp.inMicroseconds * 1000, + void _handleFlutterFrameTick(Duration timeStamp) { + if (!_active || !_flutterSynced || _paused) return; + _tryDispatchFrame( + sourceGate: () => _admitFlutterTickAtTargetFps(timeStamp), ); - if (scheduled) { - _scheduledFrameCount++; - } SchedulerBinding.instance.scheduleFrame(); } + + /// Applies the same absolute-deadline pacing used by the native frame + /// sources. The deadline advances only when a frame can actually run, so a + /// slow render does not consume future frame slots while it is in flight. + bool _admitFlutterTickAtTargetFps(Duration timeStamp) { + final fps = _flutterTargetFps?.call() ?? 0; + if (fps <= 0) { + _flutterAppliedFpsLimit = 0; + _nextFlutterFrameUs = 0; + return true; + } + + final frameTimeUs = timeStamp.inMicroseconds; + final intervalUs = math.max(1, 1000000 ~/ fps); + const toleranceUs = 1000; + + if (_flutterAppliedFpsLimit != fps || _nextFlutterFrameUs == 0) { + _flutterAppliedFpsLimit = fps; + _nextFlutterFrameUs = frameTimeUs; + } + + if (_nextFlutterFrameUs > frameTimeUs && + _nextFlutterFrameUs - frameTimeUs > toleranceUs) { + return false; + } + + if (_nextFlutterFrameUs <= frameTimeUs) { + final missedIntervals = + (frameTimeUs - _nextFlutterFrameUs) ~/ intervalUs + 1; + _nextFlutterFrameUs += missedIntervals * intervalUs; + } else { + _nextFlutterFrameUs += intervalUs; + } + return true; + } } diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/native_rendering_lifecycle_controller.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/native_rendering_lifecycle_controller.dart index 1fbe1e6cd..090fe077f 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/native_rendering_lifecycle_controller.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/native_rendering_lifecycle_controller.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:ffi' as ffi; import 'dart:io'; import 'package:flutter/widgets.dart'; @@ -42,7 +41,7 @@ class NativeRenderingLifecycleController with WidgetsBindingObserver { /// Starts the appropriate frame scheduling mode for the current platform. Future start() async { - FrameScheduler.instance.setOnFrame(_renderFrame); + FrameScheduler.instance.setFrameHandler(_renderFrame); // Android must keep render admission on the Dart callback/port path. // SurfaceProducer consumes ImageReader frames from Android's main looper; @@ -115,24 +114,9 @@ class NativeRenderingLifecycleController with WidgetsBindingObserver { } Future _startFlutterSynced() async { - final dylib = ffi.DynamicLibrary.process(); - final getHandleFn = dylib - .lookupFunction< - ffi.Pointer Function(), - ffi.Pointer Function() - >('thermion_flutter_get_plugin_handle'); - final pluginHandle = getHandleFn(); - final markTexturesFnPtr = dylib - .lookup)>>( - 'thermion_flutter_mark_textures', - ); - final app = FilamentApp.instance as FFIFilamentApp; await FrameScheduler.instance.startFlutterSynced( - renderThreadHandle: app.renderThreadHandle, - renderManagerHandle: app.renderManager.getNativeHandle(), - postRenderCallback: markTexturesFnPtr, - postRenderUserData: pluginHandle, + targetFps: () => app.targetFramerate, ); } diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/native_texture_surface_manager.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/native_texture_surface_manager.dart index 4f91bfc48..aa4e7e165 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/native_texture_surface_manager.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/native_texture_surface_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ffi' as ffi; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -69,6 +70,13 @@ class NativeTextureSurfaceManager { final NativeOptions Function() _options; final NativePlatformTextureDescriptorRegistry registry; + /// Linux can notify every registered external texture with one direct FFI + /// call. Keep this presentation detail here, after the common Dart render + /// future completes, rather than coupling it to frame scheduling. + late final void Function()? _markLinuxTextures = Platform.isLinux + ? _createLinuxTextureMarker() + : null; + // The view can be redirected to an internal target in composite highlight // mode, so track the Flutter-facing render target independently. final _viewRenderTargets = {}; @@ -99,7 +107,12 @@ class NativeTextureSurfaceManager { /// Notifies live descriptors and reaps render targets deferred by Windows /// resize operations. Future onFrameRendered() async { - registry.markFrameAvailable(); + final markLinuxTextures = _markLinuxTextures; + if (markLinuxTextures != null) { + markLinuxTextures(); + } else { + registry.markFrameAvailable(); + } if (_deferredRenderTargets.isEmpty) return; @@ -121,6 +134,22 @@ class NativeTextureSurfaceManager { } } + static void Function() _createLinuxTextureMarker() { + final dylib = ffi.DynamicLibrary.process(); + final getPluginHandle = dylib + .lookupFunction< + ffi.Pointer Function(), + ffi.Pointer Function() + >('thermion_flutter_get_plugin_handle'); + final markTextures = dylib + .lookupFunction< + ffi.Void Function(ffi.Pointer), + void Function(ffi.Pointer) + >('thermion_flutter_mark_textures'); + + return () => markTextures(getPluginHandle()); + } + Future createAndBind( View view, int width, diff --git a/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc b/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc index a1636a245..cf22c5eb0 100644 --- a/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc +++ b/thermion_flutter/thermion_flutter/linux/thermion_flutter_plugin.cc @@ -881,7 +881,7 @@ static void method_call_cb(FlMethodChannel *channel, FlMethodCall *method_call, thermion_flutter_plugin_handle_method_call(plugin, method_call); } -// Global plugin instance for cross-library access (native render loop) +// Global plugin instance used by Dart's direct post-render texture notifier. static ThermionFlutterPlugin* g_plugin_instance = nullptr; void thermion_flutter_plugin_register_with_registrar(FlPluginRegistrar *registrar) @@ -907,16 +907,16 @@ void thermion_flutter_plugin_register_with_registrar(FlPluginRegistrar *registra g_object_unref(plugin); } -// === Exported symbols for cross-library native render loop === +// === Exported symbols for Dart post-render texture notification === extern "C" __attribute__((visibility("default"))) void* thermion_flutter_get_plugin_handle() { return g_plugin_instance; } -// Called from thermion_dart's native render loop (post-render callback) -// after each frame. Marks all textures as frame-available so Flutter -// picks up the new content on its next raster pass. +// Called from Dart after the common render future completes. Marks all +// textures as frame-available so Flutter picks up the new content on its next +// raster pass. extern "C" __attribute__((visibility("default"))) void thermion_flutter_mark_textures(void* pluginPtr) { auto* self = FLUTTER_FILAMENT_PLUGIN(pluginPtr); diff --git a/thermion_flutter/thermion_flutter/test/frame_scheduler_flutter_synced_test.dart b/thermion_flutter/thermion_flutter/test/frame_scheduler_flutter_synced_test.dart new file mode 100644 index 000000000..2a440cc55 --- /dev/null +++ b/thermion_flutter/thermion_flutter/test/frame_scheduler_flutter_synced_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:thermion_flutter/src/platform/src/frame_scheduler.dart'; + +/// Headless coverage for the Dart side of the Flutter-synchronized Linux +/// frame source. +/// +/// This remains one test because Flutter persistent frame callbacks cannot be +/// unregistered from the shared test binding. +void main() { + testWidgets('FrameScheduler dispatches handlers for accepted Linux ticks', ( + tester, + ) async { + final scheduler = FrameScheduler.instance; + scheduler.reset(); + final dispatchedAtStart = scheduler.dispatchedFrameCount; + + addTearDown(scheduler.reset); + + var handlerCalls = 0; + var targetFps = 0; + Completer? handlerGate; + scheduler.setFrameHandler(() async { + handlerCalls++; + await handlerGate?.future; + }); + + Future start() => + scheduler.startFlutterSynced(targetFps: () => targetFps); + + await start(); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + expect(handlerCalls, 2); + expect(scheduler.dispatchedFrameCount, dispatchedAtStart + 2); + + // A handler still in flight prevents another tick from dispatching + // work, matching the normal native tick path. + handlerGate = Completer(); + await tester.pump(const Duration(milliseconds: 16)); + expect(handlerCalls, 3); + expect(scheduler.isRendering, isTrue); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + expect(handlerCalls, 3); + + handlerGate.complete(); + handlerGate = null; + await tester.pump(); + expect(handlerCalls, 4); + + // Linux applies the shared target framerate before entering the common + // handler pipeline. + scheduler.stop(); + targetFps = 30; + await start(); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 10)); + expect(handlerCalls, 5); + await tester.pump(const Duration(milliseconds: 24)); + expect(handlerCalls, 6); + + // Pause stops requests and re-arming; resume explicitly re-arms Linux. + scheduler.pause(); + await tester.pump(const Duration(milliseconds: 16)); + expect(handlerCalls, 6); + scheduler.resume(); + await tester.pump(const Duration(milliseconds: 34)); + expect(handlerCalls, 7); + + scheduler.stop(); + await tester.pump(const Duration(milliseconds: 16)); + expect(handlerCalls, 7); + + // Persistent callbacks cannot be removed. Restarting must reuse the + // existing registration instead of producing duplicate frame requests. + await start(); + await tester.pump(const Duration(milliseconds: 16)); + expect(handlerCalls, 8); + expect(scheduler.dispatchedFrameCount, dispatchedAtStart + 8); + }); +}