From b8f8d02b497a2acd94ca7f7335607420335b67b5 Mon Sep 17 00:00:00 2001 From: DeathPhoenix22 <122117914+DeathPhoenix22@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:31:25 -0500 Subject: [PATCH] fix: Use effective tpf value in LoopRunner (onUpdate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --------- Co-authored-by: Jean-ReneĢ Lavoie <> --- .../main/kotlin/com/almasb/fxgl/app/Engine.kt | 2 +- .../kotlin/com/almasb/fxgl/app/LoopRunner.kt | 46 ++++++++----- .../kotlin/com/almasb/fxgl/app/Settings.kt | 10 +++ .../com/almasb/fxgl/app/GameSettingsTest.kt | 2 + .../com/almasb/fxgl/app/LoopRunnerTest.kt | 69 +++++++++++++++---- 5 files changed, 98 insertions(+), 31 deletions(-) diff --git a/fxgl/src/main/kotlin/com/almasb/fxgl/app/Engine.kt b/fxgl/src/main/kotlin/com/almasb/fxgl/app/Engine.kt index a136a50562..17729a1c35 100644 --- a/fxgl/src/main/kotlin/com/almasb/fxgl/app/Engine.kt +++ b/fxgl/src/main/kotlin/com/almasb/fxgl/app/Engine.kt @@ -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 diff --git a/fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt b/fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt index bd97683176..bc3ebc6f0e 100644 --- a/fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt +++ b/fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt @@ -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 @@ -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() @@ -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) { @@ -76,6 +80,7 @@ internal class LoopRunner( fun resume() { log.debug("Resuming loop") + lastFrameNanos = 0 impl.resume() } @@ -83,8 +88,6 @@ internal class LoopRunner( log.debug("Pausing loop") impl.pause() - - lastFPSUpdateNanos = 0L } fun stop() { @@ -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) } } } diff --git a/fxgl/src/main/kotlin/com/almasb/fxgl/app/Settings.kt b/fxgl/src/main/kotlin/com/almasb/fxgl/app/Settings.kt index 1d1b32de48..cccb706595 100644 --- a/fxgl/src/main/kotlin/com/almasb/fxgl/app/Settings.kt +++ b/fxgl/src/main/kotlin/com/almasb/fxgl/app/Settings.kt @@ -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 @@ -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). */ @@ -402,6 +409,7 @@ class GameSettings( secondsIn24h, randomSeed, ticksPerSecond, + fpsRefreshRate, userAppClass, mouseSensitivity, defaultLanguage, @@ -583,6 +591,8 @@ class ReadOnlyGameSettings internal constructor( val ticksPerSecond: Int, + val fpsRefreshRate: Duration, + val userAppClass: Class<*>, /** diff --git a/fxgl/src/test/kotlin/com/almasb/fxgl/app/GameSettingsTest.kt b/fxgl/src/test/kotlin/com/almasb/fxgl/app/GameSettingsTest.kt index e2320c14c7..71dfd4ef1f 100644 --- a/fxgl/src/test/kotlin/com/almasb/fxgl/app/GameSettingsTest.kt +++ b/fxgl/src/test/kotlin/com/almasb/fxgl/app/GameSettingsTest.kt @@ -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 @@ -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) diff --git a/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt b/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt index 44c38598cc..d6cfe6fcc0 100644 --- a/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt +++ b/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt @@ -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)) + } } } \ No newline at end of file