From e3ce32c44ee2ba3a8977f3420e8e18007a413ce8 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 27 Feb 2026 12:46:13 -0800 Subject: [PATCH 1/9] 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 b687a66730c0a1e0e9e7d4826cccb5dbffae0770 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 27 Feb 2026 12:50:49 -0800 Subject: [PATCH 2/9] 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 c91737cf5c56a9fd566d7a3d045fdeb1b99c35e6 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 27 Feb 2026 13:58:28 -0800 Subject: [PATCH 3/9] 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 7b732d86cc095c75c8cd20e5d048029350b6fee2 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 27 Feb 2026 14:03:02 -0800 Subject: [PATCH 4/9] 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 545c7f4363c4eedd7d4c4d9540120f3d92a8d715 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 27 Feb 2026 14:23:50 -0800 Subject: [PATCH 5/9] 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(); } From 0ccd246292f9985fb7d1d6e6f6e7f20136c771f6 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 27 Mar 2026 22:52:51 -0700 Subject: [PATCH 6/9] add per-artboard advance profiling behind global toggle Adds riveAdvanceProfilingEnabled flag and Stopwatch-based instrumentation to RiveWidgetController.advance(). When enabled, emits Timeline.instantSync events with artboard name, advance duration (us), and whether the state machine changed. --- lib/src/painters/widget_controller.dart | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/lib/src/painters/widget_controller.dart b/lib/src/painters/widget_controller.dart index e03542fc..c088557a 100644 --- a/lib/src/painters/widget_controller.dart +++ b/lib/src/painters/widget_controller.dart @@ -1,6 +1,18 @@ +import 'dart:developer' as developer; + import 'package:flutter/gestures.dart'; import 'package:rive/rive.dart'; +/// Global toggle for per-artboard advance profiling. +/// +/// When enabled, each [RiveWidgetController.advance] call emits a +/// `dart:developer` [developer.Timeline] sync event named +/// `Rive.advance:` with the advance duration in microseconds +/// and whether the state machine reported a change. +/// +/// Set to `true` from app code (e.g. behind a feature flag) to activate. +bool riveAdvanceProfilingEnabled = false; + /// {@template rive_controller} /// This controller builds on top of the concept of a Rive painter, but /// provides a more convenient API for building Rive widgets. @@ -19,6 +31,9 @@ base class RiveWidgetController extends BasicArtboardPainter /// The state machine that the [RiveWidgetController] is using. late final StateMachine stateMachine; + /// Cached label for profiling (avoids string allocation per frame). + late final String _profilingLabel; + /// {@macro rive_controller} /// - The [file] parameter is the Rive file to paint. /// - The [artboardSelector] parameter specifies which artboard to use. @@ -30,6 +45,7 @@ base class RiveWidgetController extends BasicArtboardPainter }) { artboard = _createArtboard(file, artboardSelector); stateMachine = _createStateMachine(artboard, stateMachineSelector); + _profilingLabel = 'Rive.advance:${artboard.name}'; } /// Whether the state machine has been scheduled for repaint. @@ -198,9 +214,30 @@ base class RiveWidgetController extends BasicArtboardPainter } } + static final Stopwatch _profilingStopwatch = Stopwatch(); + @override bool advance(double elapsedSeconds) { _repaintScheduled = false; + + if (riveAdvanceProfilingEnabled) { + _profilingStopwatch.reset(); + _profilingStopwatch.start(); + final didAdvance = stateMachine.advanceAndApply(elapsedSeconds); + _profilingStopwatch.stop(); + + developer.Timeline.instantSync( + _profilingLabel, + arguments: { + 'us': _profilingStopwatch.elapsedMicroseconds.toString(), + 'didAdvance': didAdvance.toString(), + 'elapsed': elapsedSeconds.toString(), + }, + ); + + return didAdvance && active; + } + final didAdvance = stateMachine.advanceAndApply(elapsedSeconds); return didAdvance && active; } From 2b2144a59685b7e44cbe67ca23a5738c659dd2d6 Mon Sep 17 00:00:00 2001 From: Tamir Date: Mon, 11 May 2026 15:40:45 -0700 Subject: [PATCH 7/9] fix: skip LFS smudge for forked test assets fetchexclude alone only filters `lfs fetch`; checkout still triggers smudge which fails because the bare cache lacks LFS objects (forked test assets aren't shipped to GitHub LFS). Override the smudge filter so pub clones of this ref succeed without GIT_LFS_SKIP_SMUDGE=1. --- .lfsconfig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.lfsconfig b/.lfsconfig index 2cf1bce1..137efb97 100644 --- a/.lfsconfig +++ b/.lfsconfig @@ -1,2 +1,5 @@ [lfs] fetchexclude = * +[filter "lfs"] + smudge = git-lfs smudge --skip -- %f + process = git-lfs filter-process --skip From e477e46ba6d77abf874ebb2bb99d24bc92b32973 Mon Sep 17 00:00:00 2001 From: Tamir Date: Mon, 11 May 2026 15:42:36 -0700 Subject: [PATCH 8/9] fix: remove LFS attributes so pub clones don't trigger smudge LFS-tracked test assets aren't shipped to GitHub LFS, so smudge during `pub get` checkout fails ('remote missing object'). `.lfsconfig` only takes effect after checkout, not during it. Removing the LFS rule from .gitattributes makes the pointer files check out as plain (128-byte) blobs. Client doesn't consume the rive package's tests, so the pointer-only test assets are harmless. --- .gitattributes | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitattributes b/.gitattributes index 292edff7..e69de29b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +0,0 @@ -test/assets/** filter=lfs diff=lfs merge=lfs -text -test/goldens/**/images/*.png filter=lfs diff=lfs merge=lfs -text \ No newline at end of file From fb7944efd24d492fe02cbc85eb869ce3ce13ee18 Mon Sep 17 00:00:00 2001 From: Tamir Date: Mon, 11 May 2026 15:45:05 -0700 Subject: [PATCH 9/9] fix: remove LFS-pointer test asset to unblock pub clones This .riv file is only a 128-byte LFS pointer (real content never pushed to GitHub LFS). `pub get` fails because `git clone` of the bare pub cache checks out the upstream HEAD with LFS rules before landing on our ref, triggering smudge on a missing remote object. The asset is only consumed by rive package's own tests, which we don't run from the client. --- test/assets/rive_file_controller_test.riv | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 test/assets/rive_file_controller_test.riv diff --git a/test/assets/rive_file_controller_test.riv b/test/assets/rive_file_controller_test.riv deleted file mode 100644 index c4d26b64..00000000 --- a/test/assets/rive_file_controller_test.riv +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:adf0dd0ab7dff250ba939403cba5b0bf6004a09c735b183fbc623213eb28249a -size 693