feat: frameRate prop to cap the render loop on the experimental backends#347
Merged
mfazekas merged 4 commits intoJul 17, 2026
Merged
Conversation
Fabric's prop diff sends null (not undefined) when a prop is removed, but Nitro's JSIConverter<std::optional<T>> only maps undefined to nullopt, so clearing any optional view prop threw during commit (upstream mrousavy/nitro#1184). Post-process the generated prop parser to treat null like undefined. Mirrors open PR #326; needed here so frameRate can be cleared back to uncapped. The regenerated output lands with the frameRate commit.
Adds an optional frameRate view prop (issue #332): a number caps rendering at that many frames per second, and a { minimum, maximum, preferred? } range maps to CAFrameRateRange. Capping limits frame production, not animation time — playback still advances by the real elapsed time. iOS forwards it to RiveUIView.frameRate (.fps / .range / .default, https://rive.app/docs/runtimes/apple/apple#frame-rate). Android has no public per-view API on its new runtime (RiveFramePacer is internal and only drives the Compose loop), so our Choreographer loop skips vsyncs until the cap period has elapsed — same math as upstream's pacer, with half a 120Hz vsync of tolerance so caps land on vsync multiples — and honors a range best-effort as a cap at preferred ?? maximum. On API 35+ the cap is also applied as the platform requestedFrameRate hint while actively drawing, mirroring upstream. While paused, the Android loop now only draws when content changed (initial frame, resize, rebinding) instead of re-rendering identical frames every vsync. The legacy backends accept the prop and ignore it. Measured via dumpsys gfxinfo (Android emulator) and simctl recordVideo + mpdecimate (iOS simulator) on a looping animation: uncapped ~57/58 fps, cap=5 -> 5 fps, range 10-20 preferred 15 -> 15 fps on both platforms, paused -> 0 frames on Android.
Looping rewards animation with Uncapped / 30 / 15 / 5 fps / range buttons, plus play-pause and remount. Lower caps visibly step while wall-clock playback speed is unchanged; used to verify the cap on both platforms.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an optional
frameRateview prop (issue #332): a number caps the render loop at that many frames per second, and a{ minimum, maximum, preferred? }range maps toCAFrameRateRangeon iOS; Android honors a range best-effort as a cap atpreferred ?? maximum. Capping limits frame production, not animation time — playback still advances by the real elapsed time, so wall-clock speed is unchanged.iOS forwards the prop to
RiveUIView.frameRate(https://rive.app/docs/runtimes/apple/apple#frame-rate). Android's new runtime has no public per-view API (RiveFramePaceris internal and only drives the Compose loop), so our Choreographer loop skips vsyncs itself — same math as upstream's pacer — and on API 35+ also applies the cap as the platformrequestedFrameRatehint. While paused, the Android loop now only draws when content changed (initial frame, resize, rebinding) instead of re-rendering identical frames every vsync. The legacy backends accept the prop and ignore it.Also includes the null-prop postprocess fix from #326 (clearing
frameRateback to undefined throws without it); dedupes trivially with whichever lands first.Before/after on a looping animation (the Frame Rate Cap example page added here), app process CPU on a Pixel 6 emulator (API 34, % of one core, 12s windows, cold start, idle host):
The paused row is the paused-draw fix: before, a paused view kept rendering identical frames at the display refresh rate. Effective frame production was verified independently:
dumpsys gfxinfocounts 285/151/75/25 frames per 5s for uncapped/30/15/5 and 0 while paused; on the iOS simulator, unique frames in a screen recording measured ~58 fps uncapped, 5 fps atframeRate={5}and 15 fps for{minimum: 10, maximum: 20, preferred: 15}. Earlier real-device measurements of the same cap logic (OnePlus Dimensity 8350, debug build): uncapped 133% of a core, cap=30 111%, cap=15 68%, paused 14% ≈ the app's cost with Rive unmounted (13%).