Skip to content

feat: frameRate prop to cap the render loop on the experimental backends#347

Merged
mfazekas merged 4 commits into
feat/rive-ios-experimentalfrom
claude/framerate-render-cap-64be50
Jul 17, 2026
Merged

feat: frameRate prop to cap the render loop on the experimental backends#347
mfazekas merged 4 commits into
feat/rive-ios-experimentalfrom
claude/framerate-render-cap-64be50

Conversation

@mfazekas

Copy link
Copy Markdown
Collaborator

Adds an optional frameRate view prop (issue #332): a number caps the render loop at that many frames per second, and a { minimum, maximum, preferred? } range maps to CAFrameRateRange on iOS; Android honors a range best-effort as a cap at preferred ?? 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 (RiveFramePacer is 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 platform requestedFrameRate hint. 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 frameRate back 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):

frameRate before after
undefined 37% 36%
30 no effect 17%
15 no effect 9%
5 37% 6%
paused 36% 3%

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 gfxinfo counts 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 at frameRate={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%).

mfazekas added 4 commits July 17, 2026 11:04
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.
@mfazekas
mfazekas merged commit 19f3350 into feat/rive-ios-experimental Jul 17, 2026
15 checks passed
@mfazekas
mfazekas deleted the claude/framerate-render-cap-64be50 branch July 17, 2026 11:12
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.

1 participant