From a51b1435806462092e31a03651b72b6ec30738ef Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 27 Feb 2026 12:46:13 -0800 Subject: [PATCH 1/5] Add dirty flag to SharedRenderTexture for skip-frame optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When dirtyTrackingEnabled is true, _paintShared() skips the expensive clear→paint→flush cycle on frames where no painter called markDirty(). Defaults to false so upstream behavior is preserved unless opted in. --- lib/src/widgets/inherited_widgets.dart | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/src/widgets/inherited_widgets.dart b/lib/src/widgets/inherited_widgets.dart index 58f982fb..dca95224 100644 --- a/lib/src/widgets/inherited_widgets.dart +++ b/lib/src/widgets/inherited_widgets.dart @@ -19,6 +19,14 @@ class SharedRenderTexture { final List painters = []; final GlobalKey panelKey; + bool _dirty = true; + bool _scheduled = false; + + /// When true, [_paintShared] skips the clear→paint→flush cycle if no + /// painter called [markDirty] since the last flush. When false (default), + /// every scheduled paint runs the full cycle — identical to upstream. + bool dirtyTrackingEnabled = false; + SharedRenderTexture({ required this.texture, required this.devicePixelRatio, @@ -26,19 +34,24 @@ class SharedRenderTexture { required this.panelKey, }); + /// Mark the texture as needing a repaint on the next scheduled frame. + void markDirty() { + _dirty = true; + } + /// Paint the shared render texture. void _paintShared(_) { + _scheduled = false; + if (dirtyTrackingEnabled && !_dirty) return; + texture.clear(backgroundColor); for (final painter in painters) { painter.paintIntoSharedTexture(texture); } texture.flush(devicePixelRatio); - - _scheduled = false; + _dirty = false; } - bool _scheduled = false; - /// Schedule a paint of the shared render texture. void schedulePaint() { if (_scheduled) { @@ -52,11 +65,13 @@ class SharedRenderTexture { void addPainter(SharedTexturePainter painter) { painters.add(painter); painters.sort((a, b) => a.sharedDrawOrder.compareTo(b.sharedDrawOrder)); + markDirty(); } /// Remove a painter from the shared render texture. void removePainter(SharedTexturePainter painter) { painters.remove(painter); + markDirty(); } } From f5b8010043423d5a35110250701e6af62eb53fc4 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 27 Feb 2026 12:50:49 -0800 Subject: [PATCH 2/5] Add .lfsconfig to skip LFS fetch for forked test assets Upstream LFS objects aren't replicated to the fork. This config prevents LFS smudge failures during flutter pub get. --- .lfsconfig | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .lfsconfig diff --git a/.lfsconfig b/.lfsconfig new file mode 100644 index 00000000..2cf1bce1 --- /dev/null +++ b/.lfsconfig @@ -0,0 +1,2 @@ +[lfs] + fetchexclude = * From 11aeabd3cf0ca003ec8f2e8164c840d727116dff Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 27 Feb 2026 13:58:28 -0800 Subject: [PATCH 3/5] decouple ticker from state machine advance in dirty tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ticker keeps running every frame (frameCallback → schedulePaint), but the state machine only advances when enough wall-clock time has accumulated (advanceInterval). This avoids the deadlock where _paintShared skipped everything when not dirty, which prevented paintIntoSharedTexture from being called, which prevented the advance, which prevented markDirty. Flow: - frameCallback accumulates elapsedSeconds each tick - When accumulated >= advanceInterval, marks texture dirty - _paintShared runs full cycle only on dirty frames - paintIntoSharedTexture receives accumulated elapsed time so the controller gets the correct wall-clock delta --- lib/src/widgets/inherited_widgets.dart | 36 +++++++++++++++++++++++- lib/src/widgets/shared_texture_view.dart | 24 +++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/lib/src/widgets/inherited_widgets.dart b/lib/src/widgets/inherited_widgets.dart index dca95224..f8afec85 100644 --- a/lib/src/widgets/inherited_widgets.dart +++ b/lib/src/widgets/inherited_widgets.dart @@ -1,3 +1,4 @@ +import 'dart:developer' as developer; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:rive_native/rive_native.dart' as rive; @@ -27,6 +28,12 @@ class SharedRenderTexture { /// every scheduled paint runs the full cycle — identical to upstream. bool dirtyTrackingEnabled = false; + /// When > 0 and [dirtyTrackingEnabled] is true, the render object's + /// ticker automatically calls [markDirty] after this many seconds of + /// accumulated frame time, so the state machine advances at the desired + /// rate without being called every frame. + double advanceInterval = 0; + SharedRenderTexture({ required this.texture, required this.devicePixelRatio, @@ -39,10 +46,37 @@ class SharedRenderTexture { _dirty = true; } + int _paintCount = 0; + int _skipCount = 0; + /// Paint the shared render texture. + /// + /// When [dirtyTrackingEnabled] is true and the texture is clean, the entire + /// clear→paint→flush cycle is skipped. The render-object ticker stays alive + /// independently and calls [markDirty] when [advanceInterval] elapses, so + /// the state machine still advances at the desired rate. void _paintShared(_) { _scheduled = false; - if (dirtyTrackingEnabled && !_dirty) return; + if (dirtyTrackingEnabled && !_dirty) { + _skipCount++; + if (_skipCount % 60 == 1) { + developer.log( + '[DirtyTrack] _paintShared SKIPPED #$_skipCount ' + '(painters=${painters.length})', + name: 'SharedRenderTexture', + ); + } + return; + } + + _paintCount++; + if (dirtyTrackingEnabled && _paintCount % 30 == 1) { + developer.log( + '[DirtyTrack] _paintShared PAINTING #$_paintCount ' + '(dirty=$_dirty, painters=${painters.length}, skips=$_skipCount)', + name: 'SharedRenderTexture', + ); + } texture.clear(backgroundColor); for (final painter in painters) { diff --git a/lib/src/widgets/shared_texture_view.dart b/lib/src/widgets/shared_texture_view.dart index 964e75ab..4269183f 100644 --- a/lib/src/widgets/shared_texture_view.dart +++ b/lib/src/widgets/shared_texture_view.dart @@ -107,6 +107,10 @@ class SharedTextureViewRenderObject extends RiveNativeRenderBox int drawOrder = 1; + /// Accumulated elapsed seconds across frames while dirty tracking skips + /// the paint cycle. Reset to 0 after each [paintIntoSharedTexture] call. + double _accumulatedElapsed = 0; + SharedRenderTexture get shared => _shared; set shared(SharedRenderTexture value) { if (_shared == value) { @@ -182,6 +186,12 @@ class SharedTextureViewRenderObject extends RiveNativeRenderBox Offset panelPosition = renderBox.localToGlobal(Offset.zero); Offset globalPosition = localToGlobal(Offset.zero) - panelPosition; + // When dirty tracking is enabled, use accumulated elapsed time so the + // controller receives the full wall-clock delta since the last advance. + final effectiveElapsed = + _shared.dirtyTrackingEnabled ? _accumulatedElapsed : elapsedSeconds; + _accumulatedElapsed = 0; + final renderer = texture.renderer; renderer.save(); @@ -195,7 +205,7 @@ class SharedTextureViewRenderObject extends RiveNativeRenderBox texture, devicePixelRatio, size, - elapsedSeconds, + effectiveElapsed, ) ?? false; if (_shouldAdvance) { @@ -220,6 +230,18 @@ class SharedTextureViewRenderObject extends RiveNativeRenderBox @override void frameCallback(Duration duration) { super.frameCallback(duration); + _accumulatedElapsed += elapsedSeconds; + + // When dirty tracking with an advance interval is active, mark the + // texture dirty once enough wall-clock time has accumulated. This + // decouples the ticker (which keeps running) from the state-machine + // advance (which only runs on dirty frames). + if (_shared.dirtyTrackingEnabled && _shared.advanceInterval > 0) { + if (_accumulatedElapsed >= _shared.advanceInterval) { + _shared.markDirty(); + } + } + _shared.schedulePaint(); } From 98b634d1ae361560b5337f8186272880f0f5a7e6 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 27 Feb 2026 14:03:02 -0800 Subject: [PATCH 4/5] remove debug logging from dirty tracking --- lib/src/widgets/inherited_widgets.dart | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/lib/src/widgets/inherited_widgets.dart b/lib/src/widgets/inherited_widgets.dart index f8afec85..c887264d 100644 --- a/lib/src/widgets/inherited_widgets.dart +++ b/lib/src/widgets/inherited_widgets.dart @@ -1,4 +1,3 @@ -import 'dart:developer' as developer; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:rive_native/rive_native.dart' as rive; @@ -46,9 +45,6 @@ class SharedRenderTexture { _dirty = true; } - int _paintCount = 0; - int _skipCount = 0; - /// Paint the shared render texture. /// /// When [dirtyTrackingEnabled] is true and the texture is clean, the entire @@ -57,26 +53,7 @@ class SharedRenderTexture { /// the state machine still advances at the desired rate. void _paintShared(_) { _scheduled = false; - if (dirtyTrackingEnabled && !_dirty) { - _skipCount++; - if (_skipCount % 60 == 1) { - developer.log( - '[DirtyTrack] _paintShared SKIPPED #$_skipCount ' - '(painters=${painters.length})', - name: 'SharedRenderTexture', - ); - } - return; - } - - _paintCount++; - if (dirtyTrackingEnabled && _paintCount % 30 == 1) { - developer.log( - '[DirtyTrack] _paintShared PAINTING #$_paintCount ' - '(dirty=$_dirty, painters=${painters.length}, skips=$_skipCount)', - name: 'SharedRenderTexture', - ); - } + if (dirtyTrackingEnabled && !_dirty) return; texture.clear(backgroundColor); for (final painter in painters) { From a7579238b53e44b7075160351d897ba5e4234952 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 27 Feb 2026 14:23:50 -0800 Subject: [PATCH 5/5] replace advanceInterval with onFrameTick callback The SDK no longer needs to know about throttle intervals. Instead, SharedRenderTexture exposes a generic onFrameTick callback that the render object calls each frame with elapsedSeconds. App code wires its own timing logic to call markDirty() when an advance is needed. --- lib/src/widgets/inherited_widgets.dart | 13 ++++++------- lib/src/widgets/shared_texture_view.dart | 12 +----------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/lib/src/widgets/inherited_widgets.dart b/lib/src/widgets/inherited_widgets.dart index c887264d..8b54d6c5 100644 --- a/lib/src/widgets/inherited_widgets.dart +++ b/lib/src/widgets/inherited_widgets.dart @@ -27,11 +27,10 @@ class SharedRenderTexture { /// every scheduled paint runs the full cycle — identical to upstream. bool dirtyTrackingEnabled = false; - /// When > 0 and [dirtyTrackingEnabled] is true, the render object's - /// ticker automatically calls [markDirty] after this many seconds of - /// accumulated frame time, so the state machine advances at the desired - /// rate without being called every frame. - double advanceInterval = 0; + /// Called every frame by the render object's ticker with the frame's + /// elapsed seconds. Listeners can accumulate time and call [markDirty] + /// when a state-machine advance is needed. + void Function(double elapsedSeconds)? onFrameTick; SharedRenderTexture({ required this.texture, @@ -49,8 +48,8 @@ class SharedRenderTexture { /// /// When [dirtyTrackingEnabled] is true and the texture is clean, the entire /// clear→paint→flush cycle is skipped. The render-object ticker stays alive - /// independently and calls [markDirty] when [advanceInterval] elapses, so - /// the state machine still advances at the desired rate. + /// independently and invokes [onFrameTick] each frame so external code can + /// call [markDirty] when a state-machine advance is needed. void _paintShared(_) { _scheduled = false; if (dirtyTrackingEnabled && !_dirty) return; diff --git a/lib/src/widgets/shared_texture_view.dart b/lib/src/widgets/shared_texture_view.dart index 4269183f..f6f1902b 100644 --- a/lib/src/widgets/shared_texture_view.dart +++ b/lib/src/widgets/shared_texture_view.dart @@ -231,17 +231,7 @@ class SharedTextureViewRenderObject extends RiveNativeRenderBox void frameCallback(Duration duration) { super.frameCallback(duration); _accumulatedElapsed += elapsedSeconds; - - // When dirty tracking with an advance interval is active, mark the - // texture dirty once enough wall-clock time has accumulated. This - // decouples the ticker (which keeps running) from the state-machine - // advance (which only runs on dirty frames). - if (_shared.dirtyTrackingEnabled && _shared.advanceInterval > 0) { - if (_accumulatedElapsed >= _shared.advanceInterval) { - _shared.markDirty(); - } - } - + _shared.onFrameTick?.call(elapsedSeconds); _shared.schedulePaint(); }