Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions android/src/new/java/com/rive/RiveFramePacer.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
31 changes: 15 additions & 16 deletions android/src/new/java/com/rive/RiveReactNativeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<RiveReactNativeView>()

Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
}

Expand Down Expand Up @@ -233,7 +226,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
if (hasWork()) {
requestFrame()
} else {
lastFrameTimeNs = 0L
resetTimebase()
}
}
}
Expand All @@ -250,15 +243,21 @@ 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() {
if (frameScheduled) {
Choreographer.getInstance().removeFrameCallback(renderCallback)
frameScheduled = false
}
lastFrameTimeNs = 0L
resetTimebase()
}

private fun onVisibilityMaybeChanged() {
Expand Down
2 changes: 2 additions & 0 deletions example/src/reproducers/FrameRateCap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading