Skip to content

perf: schedule engine frames at the composition frame rate (throttled ticker) - #426

Merged
xvrh merged 3 commits into
masterfrom
throttled-ticker
Aug 21, 2026
Merged

perf: schedule engine frames at the composition frame rate (throttled ticker)#426
xvrh merged 3 commits into
masterfrom
throttled-ticker

Conversation

@xvrh

@xvrh xvrh commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Follow-up to the discussion in #420 (and #419): as measured there, the rebuild gate from #423 was not enough — the auto-animation's ticker still re-armed a frame callback on every vsync, so the engine kept running the full pipeline (frame scheduling, build/paint flush, scene submission, raster) at the display refresh rate. Gating rebuilds alone recovered only ~2% CPU because that per-frame engine overhead dominates.

Approach

Drive the auto-animation with a throttled Ticker: after each tick, re-arming the vsync callback is delayed with a one-shot timer aimed at the last vsync preceding the next composition-frame boundary (each tick timestamp is a vsync timestamp, so the vsync phase is known). Timers never fire early, so the frame request goes out one vsync ahead and the tick lands on the first vsync at or after the boundary:

  • no skipped frames when the rates don't divide (24/25fps content on a 60Hz display),
  • no slipped frames when they match (60fps content on 60Hz stays at 60, the throttle becomes a no-op),
  • elapsed time still comes from frame timestamps, so a late timer drops frames instead of slowing the animation down.

Because the ticker parks on a timer between composition frames, no engine frame is even scheduled in between — the whole pipeline runs at the composition rate, which is where the savings come from. This achieves the gains of the Timer-drive proposal in #420 while keeping AnimationController semantics (statuses, wall-clock accuracy, curves), TickerMode (including forceFrames), and app-lifecycle behavior (in the background the timer chain parks itself after a single fire), with no new public controller API.

Measured results (macOS profile build, 120Hz display, composition in a ListView)

Engine frames/s Process CPU
master 120 13.8%
this PR 30 (LottieLogo1, 30fps) 6.3%

Composition-rate accuracy across assets: 24fps → 24 frames/s, 25fps → ~25, 30fps → ~30, 60fps → ~60.

Behavior notes

  • External AnimationControllers are unaffected — they keep the vsync-driven path (plus the perf: throttle auto-animation rebuilds to composition frame rate #423 gate).
  • flutter test is unaffected: the throttle is inactive under the test runner so tester.pumpAndSettle() keeps observing the animation exactly as before. debugThrottleAnimationsInTests re-enables it (used by this package's own throttle tests).
  • FrameRate.max keeps rendering every display frame; frameRate: FrameRate(x) throttles to x.
  • The vsync period is resolved from the widget's own View (multi-view safe, 60Hz fallback).

Extras

  • FrameRate.resolveFps — single resolver for the composition/max sentinels (previously duplicated in roundProgress and the widget).
  • example/lib/frame_rate_demo.dart — interactive test page with live engine-fps/build/raster metrics, asset & frame-rate pickers, TickerMode/lifecycle/external-controller toggles, concurrent staggered animations, and a side-by-side pacing comparison (flutter run -t lib/frame_rate_demo.dart --profile).
  • example/lib/bench_main.dart — headless benchmark entrypoint (--dart-define=ASSET=...).

Testing

9 dedicated widget tests in test/frame_rate_throttle_test.dart (throttled rebuild rate, no engine frame scheduled between composition frames, no-op at/above display rate, wall-clock correctness, no skipped frames at 25fps-on-60Hz, TickerMode pause/resume, zero frames when mounted under a disabled TickerMode, pumpAndSettle compatibility). Full suite: 1025 tests pass, goldens unchanged.

cc @OuHT — this should deliver the same savings your Perfetto traces showed for Timer drive; I'd be very interested in your CPU-cycle numbers for this branch with your Android measurement setup.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.94595% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.09%. Comparing base (91ce46f) to head (01f0c7c).

Files with missing lines Patch % Lines
lib/src/lottie.dart 95.58% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #426      +/-   ##
==========================================
+ Coverage   83.96%   84.09%   +0.13%     
==========================================
  Files         163      164       +1     
  Lines        6672     6741      +69     
==========================================
+ Hits         5602     5669      +67     
- Misses       1070     1072       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

ezamagni-tg pushed a commit to ezamagni-tg/lottie-flutter that referenced this pull request Aug 6, 2026
perf: schedule engine frames at the composition frame rate (throttled ticker)
@ezamagni

Copy link
Copy Markdown
Contributor

This performance improvements are very much needed! Hope they can be released in an upcoming version soon

@xvrh

xvrh commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

@ezamagni I'm looking for feedback on this work. If you can validate that it works for you I will merge it.

@ezamagni

Copy link
Copy Markdown
Contributor

@xvrh I'd love to but I'm not quite expert on performance profiling, Perfetto and stuff like that.
How can I validate it with actual numbers?
Also, is this PR still evaluated against #420? Should I compare that branch as well?

@xvrh

xvrh commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

This is an alternative implementation to achieve the same gain as #420 and any feedback on real world usage is welcome.

xvrh and others added 3 commits August 21, 2026 20:00
The #423 gate stopped the Lottie subtree from rebuilding on every vsync,
but the auto-animation's ticker still re-armed a frame callback each
display frame, so the engine kept running the full pipeline (scheduling,
build/paint flush, scene submission, raster) at the display refresh
rate. As measured in the #420 discussion, that per-frame overhead
dominates: gating rebuilds alone recovered only ~2% CPU.

Drive the auto-animation with a throttled Ticker instead: after each
tick, re-arming the vsync callback is delayed with a one-shot timer
aimed at the last vsync preceding the next composition-frame boundary
(tick timestamps are vsync timestamps, so the vsync phase is known).
Timers never fire early, so the frame request goes out one vsync ahead
and the tick lands on the first vsync at or after the boundary — frames
are neither skipped (24fps content on a 60Hz display) nor slipped
(60fps content rendering at half rate). Elapsed time still comes from
frame timestamps, so a late timer drops frames instead of slowing the
animation down.

Because the ticker parks on a timer between frames, the whole pipeline
now runs at the composition rate. Benchmarked on macOS (profile,
120Hz display, LottieLogo1 30fps in a ListView): 120 -> 30 engine
frames/s, 13.8% -> 6.3% process CPU. 24/25/30/60fps compositions all
land exactly on their composition rate.

Behavior notes:
- External AnimationControllers keep the previous vsync-driven path.
- TickerMode (including forceFrames) and app lifecycle keep working:
  muting cancels the pending timer, and in the background the chain
  parks itself after a single timer fire.
- The throttle is inactive under `flutter test` so that
  tester.pumpAndSettle() keeps observing the animation;
  debugThrottleAnimationsInTests re-enables it (used by this package's
  own tests).
- FrameRate.max keeps rendering every display frame.

Also add FrameRate.resolveFps as the single place that resolves the
composition/max sentinels, an interactive test page
(example/lib/frame_rate_demo.dart) with live engine-fps metrics, and a
headless benchmark entrypoint (example/lib/bench_main.dart).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…le 8.7, AGP 8.6, declarative plugins)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@xvrh
xvrh force-pushed the throttled-ticker branch from 51ae9b7 to 01f0c7c Compare August 21, 2026 18:02
@xvrh
xvrh merged commit 3fa648b into master Aug 21, 2026
5 checks passed
@xvrh
xvrh deleted the throttled-ticker branch August 21, 2026 21:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants