Skip to content

Commit

Permalink
fix: Use effective tpf value in LoopRunner (onUpdate)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Jean-René Lavoie <>
  • Loading branch information
DeathPhoenix22 authored Dec 17, 2024
1 parent e2973ef commit b8f8d02
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 31 deletions.
2 changes: 1 addition & 1 deletion fxgl/src/main/kotlin/com/almasb/fxgl/app/Engine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ internal class Engine(val settings: ReadOnlyGameSettings) {

private val log = Logger.get(javaClass)

private val loop = LoopRunner(settings.ticksPerSecond) { loop(it) }
private val loop = LoopRunner(settings.ticksPerSecond, settings.fpsRefreshRate) { loop(it) }

val tpf: Double
get() = loop.tpf
Expand Down
46 changes: 28 additions & 18 deletions fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package com.almasb.fxgl.app
import com.almasb.fxgl.logging.Logger
import javafx.animation.AnimationTimer
import javafx.application.Platform
import javafx.util.Duration
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.system.measureNanoTime
Expand All @@ -29,6 +30,8 @@ internal class LoopRunner(
*/
private val ticksPerSecond: Int = -1,

private val fpsRefreshRate: Duration = Duration.millis(500.0),

private val runnable: (Double) -> Unit) {

private val log = Logger.get<LoopRunner>()
Expand All @@ -47,7 +50,8 @@ internal class LoopRunner(
private set

private var lastFPSUpdateNanos = 0L
private var fpsBuffer2sec = 0
private var fpsSamplingCount = 0
private var lastFrameNanos = 0L

private val impl by lazy {
if (ticksPerSecond <= 0) {
Expand Down Expand Up @@ -76,15 +80,14 @@ internal class LoopRunner(
fun resume() {
log.debug("Resuming loop")

lastFrameNanos = 0
impl.resume()
}

fun pause() {
log.debug("Pausing loop")

impl.pause()

lastFPSUpdateNanos = 0L
}

fun stop() {
Expand All @@ -94,29 +97,36 @@ internal class LoopRunner(
}

private fun frame(now: Long) {
if (lastFPSUpdateNanos == 0L) {
lastFPSUpdateNanos = now
fpsBuffer2sec = 0
val ticksPerSecond = if (ticksPerSecond < 0) 60 else ticksPerSecond // When unknown, default to 60 fps

if (lastFrameNanos == 0L) {
lastFrameNanos = now - (1_000_000_000.0 / ticksPerSecond).toLong()
lastFPSUpdateNanos = lastFrameNanos
fpsSamplingCount = 1
}

cpuNanoTime = measureNanoTime {
runnable(tpf)
tpf = (now - lastFrameNanos).toDouble() / 1_000_000_000

// The "executor" will call 60 times per seconds even if the game runs under 60 fps.
// If it's not even "half" a tick long, skip
if(tpf < (1_000_000_000 / (ticksPerSecond * 1.5)) / 1_000_000_000 ) {
return
}

fpsBuffer2sec++
fpsSamplingCount++

// if 2 seconds have passed
if (now - lastFPSUpdateNanos >= 2_000_000_000) {
// Update the FPS value based on provided refresh rate
val timeSinceLastFPSUpdateNanos = now - lastFPSUpdateNanos;
if (timeSinceLastFPSUpdateNanos >= fpsRefreshRate.toMillis() * 1_000_000) {
lastFPSUpdateNanos = now
fps = fpsBuffer2sec / 2
fpsBuffer2sec = 0
fps = (fpsSamplingCount.toLong() * 1_000_000_000 / timeSinceLastFPSUpdateNanos).toInt()
fpsSamplingCount = 0
}

// tweak potentially erroneous reads
if (fps < 5)
fps = 60
lastFrameNanos = now

// update tpf for the next 2 seconds
tpf = 1.0 / fps
cpuNanoTime = measureNanoTime {
runnable(tpf)
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions fxgl/src/main/kotlin/com/almasb/fxgl/app/Settings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import javafx.beans.property.*
import javafx.scene.input.KeyCode
import javafx.scene.paint.Color
import javafx.stage.StageStyle
import javafx.util.Duration
import java.util.*
import java.util.Collections.unmodifiableList
import kotlin.math.roundToInt
Expand Down Expand Up @@ -269,6 +270,12 @@ class GameSettings(
*/
var ticksPerSecond: Int = -1,

/**
* Rate (time) between each FPS sampling update.
* Default value is 500 millis
*/
var fpsRefreshRate: Duration = Duration.millis(500.0),

/**
* How fast the 3D mouse movements are (example, rotating the camera).
*/
Expand Down Expand Up @@ -402,6 +409,7 @@ class GameSettings(
secondsIn24h,
randomSeed,
ticksPerSecond,
fpsRefreshRate,
userAppClass,
mouseSensitivity,
defaultLanguage,
Expand Down Expand Up @@ -583,6 +591,8 @@ class ReadOnlyGameSettings internal constructor(

val ticksPerSecond: Int,

val fpsRefreshRate: Duration,

val userAppClass: Class<*>,

/**
Expand Down
2 changes: 2 additions & 0 deletions fxgl/src/test/kotlin/com/almasb/fxgl/app/GameSettingsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.almasb.fxgl.core.util.Platform
import com.almasb.fxgl.test.RunWithFX
import javafx.scene.input.KeyCode
import javafx.stage.Stage
import javafx.util.Duration
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.CoreMatchers.hasItems
import org.hamcrest.MatcherAssert.assertThat
Expand Down Expand Up @@ -87,6 +88,7 @@ class GameSettingsTest {
assertThat(settings.menuKey, `is`(KeyCode.ENTER))
assertThat(settings.credits, hasItems("TestCredit1", "TestCredit2"))
assertThat(settings.applicationMode, `is`(ApplicationMode.RELEASE))
assertThat(settings.fpsRefreshRate, `is`(Duration.millis(500.0)))

assertTrue(settings.isDesktop)
assertFalse(settings.isBrowser)
Expand Down
69 changes: 57 additions & 12 deletions fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,44 +106,89 @@ class LoopRunnerTest {
fun `LoopRunner resets ticks after pause`() {
var count1 = 0.0
var count2 = 0.0
val frameTime = 1_000L / 60

listOf(
// run with a given ticks per second (via scheduled service tick)
LoopRunner(60) {

count1 += it
},

// run with display refresh rate (via JavaFX pulse tick)
LoopRunner {

count2 += it
}
).forEach {
it.start()

// 16.6 per frame, so 10 frames
Thread.sleep(166)
// 16.6 per frame, so 50 frames
Thread.sleep(frameTime * 50)

it.pause()

// sleep for 150 frames = 2.5 sec
Thread.sleep(166 * 15)
Thread.sleep(frameTime * 150)

it.resume()

// 16.6 per frame, so 10 frames
Thread.sleep(166)
// 16.6 per frame, so 50 frames
Thread.sleep(frameTime * 50)

it.stop()
}

// in total we should have computed 20 frames, ~20 * 0.017 = ~0.34
// We processed approximately 100 frames (150 where in Pause)
assertThat(count1, greaterThan((90 * frameTime).toDouble() / 1_000))
assertThat(count1, lessThan((110 * frameTime).toDouble() / 1_000))

assertThat(count1, greaterThan(0.0))
assertThat(count1, lessThan(0.75))
assertThat(count2, greaterThan((90 * frameTime).toDouble() / 1_000))
assertThat(count2, lessThan((110 * frameTime).toDouble() / 1_000))
}

assertThat(count2, greaterThan(0.0))
assertThat(count2, lessThan(0.75))
@Test
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
fun `Lag Recovery`() {
var t = 0.0
var lag = 200L

listOf(
// run with a given ticks per second (via scheduled service tick)
LoopRunner(60) { t += it; Thread.sleep(lag) }
).forEach { loop ->
t = 0.0

loop.start()

Thread.sleep(2500) // Sample for more than 2 seconds, to cover the 2SecsBuffer case

loop.pause()

// We know that a single tick will take at least "lag" millis, so TPFs should be around 200 millis
assertThat(loop.tpf, closeTo(lag.toDouble() / 1000.0, 0.02))
assertThat(loop.fps.toDouble(), closeTo(5.0, 1.0))

// The game loop should have completed 2.5 seconds of game time at this stage
assertThat(t, closeTo(2.5, 0.2))

lag = 1L // Stop Lag

loop.resume()

Thread.sleep(1000) // Need to wait at least 2 seconds for the FPS sampling to recalculate

loop.stop()

// The 2 seconds Buffer shouldn't cause tpf to be 200 millis anymore
assertThat(loop.tpf, closeTo(0.016, 0.09))

assertThat(t, closeTo(3.5, 0.4))

// shouldn't change anything since loop is stopped
Thread.sleep(300)

assertThat(loop.tpf, closeTo(0.016, 0.09))

assertThat(t, closeTo(3.5, 0.4))
}
}
}

0 comments on commit b8f8d02

Please sign in to comment.