diff --git a/android/src/new/java/com/rive/RiveFramePacer.kt b/android/src/new/java/com/rive/RiveFramePacer.kt new file mode 100644 index 00000000..3dc65e74 --- /dev/null +++ b/android/src/new/java/com/rive/RiveFramePacer.kt @@ -0,0 +1,63 @@ +package com.rive + +/** + * Copy of rive-android's internal `RiveFramePacer` (app/rive/RiveFrameRate.kt); keep the two in + * sync. Tracks frame-rate cap timing without owning platform frame callbacks. + * + * The caller should use [delayBeforeNextFrameNs] to sleep until close to the next target render + * time, then still await the platform frame clock before rendering. + * + * @param framesPerSecond Maximum frames per second, or null to render on every frame callback. + */ +internal class RiveFramePacer(framesPerSecond: Double?) { + private val periodNs = framesPerSecond + ?.takeIf { it.isFinite() && it > 0 } + ?.let { Math.round(1_000_000_000.0 / it).coerceAtLeast(1L) } + private var nextRenderTimeNs = 0L + + /** + * Returns how long to delay before requesting another platform frame callback, or 0 when no + * delay should be applied. + * + * @param nowNs Current monotonic time, in the same time base as frame callbacks. + */ + fun delayBeforeNextFrameNs(nowNs: Long): Long { + val activePeriodNs = periodNs ?: return 0L + if (nextRenderTimeNs == 0L) { + return 0L + } + + // Wake before the target time so the caller can await the next platform frame callback. + return (nextRenderTimeNs - nowNs - EARLY_WAKE_NS) + .coerceAtLeast(0L) + .coerceAtMost(activePeriodNs) + } + + /** Attempts to accept [frameTimeNs] as the next Rive frame; true when the caller should draw. */ + fun tryScheduleFrame(frameTimeNs: Long): Boolean { + val activePeriodNs = periodNs ?: return true + if (nextRenderTimeNs != 0L && frameTimeNs < nextRenderTimeNs) { + return false + } + + if (nextRenderTimeNs == 0L) { + nextRenderTimeNs = frameTimeNs + activePeriodNs + return true + } + + val periodsMissed = ((frameTimeNs - nextRenderTimeNs) / activePeriodNs) + 1L + nextRenderTimeNs += periodsMissed * activePeriodNs + + return true + } + + /** Restarts pacing so the next frame callback renders immediately. */ + fun reset() { + nextRenderTimeNs = 0L + } + + private companion object { + // Coarse wake headroom before the next target frame time. + const val EARLY_WAKE_NS = 4_000_000L + } +} diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index e8e5fd34..437d5b4c 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -58,11 +58,6 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { companion object { private const val TAG = "RiveReactNativeView" - // Half of a 120Hz vsync: lets a cap land on the nearest vsync multiple - // (e.g. every 4th frame at 120Hz for a 30fps cap) instead of drifting past - // it and halving the effective rate. - private const val CAP_TOLERANCE_NS = 4_000_000L - // Main thread only. private val liveViews = mutableSetOf() @@ -78,9 +73,12 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { var frameRate: Double? = null set(value) { field = value + framePacer = RiveFramePacer(value) updateFrameRateHint() } + private var framePacer = RiveFramePacer(null) + var onError: ((String) -> Unit)? = null private var settledJob: Job? = null @@ -183,16 +181,11 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { override fun doFrame(frameTimeNanos: Long) { frameScheduled = false if (disposed || !canRender() || !hasWork()) { - lastFrameTimeNs = 0L + resetTimebase() return } - val capPeriodNs = frameRate - ?.takeIf { it > 0 } - ?.let { (1_000_000_000.0 / it).toLong() } - if (capPeriodNs != null && lastFrameTimeNs != 0L && - frameTimeNanos - lastFrameTimeNs < capPeriodNs - CAP_TOLERANCE_NS - ) { + if (!framePacer.tryScheduleFrame(frameTimeNanos)) { // Skip without touching lastFrameTimeNs: the eventual advance must // cover the full elapsed time so capped playback keeps wall-clock speed. requestFrame() @@ -204,7 +197,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { val sm = stateMachineHandle val rs = riveSurface if (worker == null || art == null || sm == null || rs == null) { - lastFrameTimeNs = 0L + resetTimebase() return } @@ -233,7 +226,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (hasWork()) { requestFrame() } else { - lastFrameTimeNs = 0L + resetTimebase() } } } @@ -250,7 +243,13 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private fun postFrame() { if (disposed || frameScheduled || !canRender() || !hasWork()) return frameScheduled = true - Choreographer.getInstance().postFrameCallback(renderCallback) + val delayMs = framePacer.delayBeforeNextFrameNs(System.nanoTime()) / 1_000_000 + Choreographer.getInstance().postFrameCallbackDelayed(renderCallback, delayMs) + } + + private fun resetTimebase() { + lastFrameTimeNs = 0L + framePacer.reset() } private fun cancelFrame() { @@ -258,7 +257,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { Choreographer.getInstance().removeFrameCallback(renderCallback) frameScheduled = false } - lastFrameTimeNs = 0L + resetTimebase() } private fun onVisibilityMaybeChanged() { diff --git a/example/src/reproducers/FrameRateCap.tsx b/example/src/reproducers/FrameRateCap.tsx index 6de1cce8..e0ed883a 100644 --- a/example/src/reproducers/FrameRateCap.tsx +++ b/example/src/reproducers/FrameRateCap.tsx @@ -27,6 +27,8 @@ type FrameRateOption = { const OPTIONS: FrameRateOption[] = [ { label: 'Uncapped', value: undefined }, + // Not a divisor of 60/120 Hz, like 60 fps on a 90 Hz display. + { label: '45 fps', value: 45 }, { label: '30 fps', value: 30 }, { label: '15 fps', value: 15 }, { label: '5 fps', value: 5 },