From 907514f2d631cd6335a70eff0a5ba56395ff310f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Rene=CC=81=20Lavoie?= <> Date: Tue, 10 Oct 2023 14:08:28 -0400 Subject: [PATCH 1/5] fix: Use effective tpf value in LoopRunner (onUpdate) --- .../kotlin/com/almasb/fxgl/app/LoopRunner.kt | 34 +++++-------- .../com/almasb/fxgl/app/LoopRunnerTest.kt | 49 +++++++++++++++++++ 2 files changed, 61 insertions(+), 22 deletions(-) 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 bd9768317..3535058e2 100644 --- a/fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt +++ b/fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt @@ -46,8 +46,7 @@ internal class LoopRunner( var cpuNanoTime = 0L private set - private var lastFPSUpdateNanos = 0L - private var fpsBuffer2sec = 0 + private var lastFrameNanos = 0L private val impl by lazy { if (ticksPerSecond <= 0) { @@ -83,8 +82,6 @@ internal class LoopRunner( log.debug("Pausing loop") impl.pause() - - lastFPSUpdateNanos = 0L } fun stop() { @@ -94,29 +91,22 @@ internal class LoopRunner( } private fun frame(now: Long) { - if (lastFPSUpdateNanos == 0L) { - lastFPSUpdateNanos = now - fpsBuffer2sec = 0 - } - - cpuNanoTime = measureNanoTime { - runnable(tpf) + if (lastFrameNanos == 0L) { + lastFrameNanos = now } - fpsBuffer2sec++ + tpf = (now - lastFrameNanos).toDouble() / 1_000_000_000 - // if 2 seconds have passed - if (now - lastFPSUpdateNanos >= 2_000_000_000) { - lastFPSUpdateNanos = now - fps = fpsBuffer2sec / 2 - fpsBuffer2sec = 0 + // 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 + } - // 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/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt b/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt index 44c38598c..a5193ee03 100644 --- a/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt +++ b/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt @@ -146,4 +146,53 @@ class LoopRunnerTest { 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 = 250L + + listOf( + // run with a given ticks per second (via scheduled service tick) + LoopRunner(60) { t += it; Thread.sleep(lag) }, + + // run with display refresh rate (via JavaFX pulse tick) + LoopRunner { 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)) + + // 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) + + 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 From 3219c923f55f4e200450e3fc58c3d5a42dc66bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Rene=CC=81=20Lavoie?= <> Date: Tue, 10 Oct 2023 15:54:45 -0400 Subject: [PATCH 2/5] fix: Frame rates calculation accuracy --- .../kotlin/com/almasb/fxgl/app/LoopRunner.kt | 15 ++++++++++ .../com/almasb/fxgl/app/LoopRunnerTest.kt | 30 ++++++++----------- 2 files changed, 28 insertions(+), 17 deletions(-) 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 3535058e2..dd24aee49 100644 --- a/fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt +++ b/fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt @@ -46,6 +46,8 @@ internal class LoopRunner( var cpuNanoTime = 0L private set + private var lastFPSUpdateNanos = 0L + private var fpsSamplingCount = 0 private var lastFrameNanos = 0L private val impl by lazy { @@ -93,16 +95,29 @@ internal class LoopRunner( private fun frame(now: Long) { if (lastFrameNanos == 0L) { lastFrameNanos = now + lastFPSUpdateNanos = now + fpsSamplingCount = 0 } tpf = (now - lastFrameNanos).toDouble() / 1_000_000_000 + val ticksPerSecond = if (ticksPerSecond < 0) 60 else ticksPerSecond // For JavaFX loops, cap at 60fps too // 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 } + fpsSamplingCount++ + + // Update the FPS value every 500 millis + val timeSinceLastFPSUpdateNanos = now - lastFPSUpdateNanos; + if (timeSinceLastFPSUpdateNanos >= 500_000_000) { + lastFPSUpdateNanos = now + fps = (fpsSamplingCount.toLong() * 1_000_000_000 / timeSinceLastFPSUpdateNanos).toInt() + fpsSamplingCount = 0 + } + lastFrameNanos = now cpuNanoTime = measureNanoTime { 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 a5193ee03..4a4385e9a 100644 --- a/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt +++ b/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt @@ -106,59 +106,54 @@ 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) + Thread.sleep(frameTime * 10) 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) + Thread.sleep(frameTime * 10) it.stop() } - // in total we should have computed 20 frames, ~20 * 0.017 = ~0.34 - - assertThat(count1, greaterThan(0.0)) - assertThat(count1, lessThan(0.75)) + // We processed 170 frames + assertThat(count1, greaterThan((169 * frameTime).toDouble() / 1_000)) + assertThat(count1, lessThan((171 * frameTime).toDouble() / 1_000)) - assertThat(count2, greaterThan(0.0)) - assertThat(count2, lessThan(0.75)) + assertThat(count2, greaterThan((169 * frameTime).toDouble() / 1_000)) + assertThat(count2, lessThan((171 * frameTime).toDouble() / 1_000)) } @Test @EnabledIfEnvironmentVariable(named = "CI", matches = "true") fun `Lag Recovery`() { var t = 0.0 - var lag = 250L + var lag = 200L listOf( // run with a given ticks per second (via scheduled service tick) - LoopRunner(60) { t += it; Thread.sleep(lag) }, - - // run with display refresh rate (via JavaFX pulse tick) - LoopRunner { t += it; Thread.sleep(lag) } + LoopRunner(60) { t += it; Thread.sleep(lag) } ).forEach { loop -> t = 0.0 @@ -170,6 +165,7 @@ class LoopRunnerTest { // 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)) @@ -178,7 +174,7 @@ class LoopRunnerTest { loop.resume() - Thread.sleep(1000) + Thread.sleep(1000) // Need to wait at least 2 seconds for the FPS sampling to recalculate loop.stop() From fd7b943ab8010439a4049295b24028d89e0e8271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Rene=CC=81=20Lavoie?= <> Date: Mon, 6 Nov 2023 21:16:57 -0500 Subject: [PATCH 3/5] fix: Prevent accumulate of tpf time when pausing from the GameController --- .../kotlin/com/almasb/fxgl/app/LoopRunner.kt | 10 ++++++---- .../com/almasb/fxgl/app/LoopRunnerTest.kt | 18 +++++++++--------- 2 files changed, 15 insertions(+), 13 deletions(-) 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 dd24aee49..3d0a7cc7c 100644 --- a/fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt +++ b/fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt @@ -77,6 +77,7 @@ internal class LoopRunner( fun resume() { log.debug("Resuming loop") + lastFrameNanos = 0 impl.resume() } @@ -93,15 +94,16 @@ internal class LoopRunner( } private fun frame(now: Long) { + val ticksPerSecond = if (ticksPerSecond < 0) 60 else ticksPerSecond // For JavaFX loops, cap at 60fps too + if (lastFrameNanos == 0L) { - lastFrameNanos = now - lastFPSUpdateNanos = now - fpsSamplingCount = 0 + lastFrameNanos = now - (1_000_000_000.0 / ticksPerSecond).toLong() + lastFPSUpdateNanos = lastFrameNanos + fpsSamplingCount = 1 } tpf = (now - lastFrameNanos).toDouble() / 1_000_000_000 - val ticksPerSecond = if (ticksPerSecond < 0) 60 else ticksPerSecond // For JavaFX loops, cap at 60fps too // 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 ) { 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 4a4385e9a..971c6640f 100644 --- a/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt +++ b/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt @@ -121,8 +121,8 @@ class LoopRunnerTest { ).forEach { it.start() - // 16.6 per frame, so 10 frames - Thread.sleep(frameTime * 10) + // 16.6 per frame, so 50 frames + Thread.sleep(frameTime * 50) it.pause() @@ -131,18 +131,18 @@ class LoopRunnerTest { it.resume() - // 16.6 per frame, so 10 frames - Thread.sleep(frameTime * 10) + // 16.6 per frame, so 50 frames + Thread.sleep(frameTime * 50) it.stop() } - // We processed 170 frames - assertThat(count1, greaterThan((169 * frameTime).toDouble() / 1_000)) - assertThat(count1, lessThan((171 * frameTime).toDouble() / 1_000)) + // We processed approximately 100 frames (150 where in Pause) + assertThat(count1, greaterThan((98 * frameTime).toDouble() / 1_000)) + assertThat(count1, lessThan((102 * frameTime).toDouble() / 1_000)) - assertThat(count2, greaterThan((169 * frameTime).toDouble() / 1_000)) - assertThat(count2, lessThan((171 * frameTime).toDouble() / 1_000)) + assertThat(count2, greaterThan((98 * frameTime).toDouble() / 1_000)) + assertThat(count2, lessThan((102 * frameTime).toDouble() / 1_000)) } @Test From d718b8899aa2ed435841fe03b6bb33e6a96e9491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Rene=CC=81=20Lavoie?= <> Date: Thu, 21 Mar 2024 11:10:47 -0400 Subject: [PATCH 4/5] feat: Allow settings for FPS sampling refresh rate --- fxgl/src/main/kotlin/com/almasb/fxgl/app/Engine.kt | 2 +- fxgl/src/main/kotlin/com/almasb/fxgl/app/LoopRunner.kt | 9 ++++++--- fxgl/src/main/kotlin/com/almasb/fxgl/app/Settings.kt | 10 ++++++++++ .../kotlin/com/almasb/fxgl/app/GameSettingsTest.kt | 2 ++ 4 files changed, 19 insertions(+), 4 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 5bef1010b..7bc745146 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 3d0a7cc7c..bc3ebc6f0 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() @@ -94,7 +97,7 @@ internal class LoopRunner( } private fun frame(now: Long) { - val ticksPerSecond = if (ticksPerSecond < 0) 60 else ticksPerSecond // For JavaFX loops, cap at 60fps too + 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() @@ -112,9 +115,9 @@ internal class LoopRunner( fpsSamplingCount++ - // Update the FPS value every 500 millis + // Update the FPS value based on provided refresh rate val timeSinceLastFPSUpdateNanos = now - lastFPSUpdateNanos; - if (timeSinceLastFPSUpdateNanos >= 500_000_000) { + if (timeSinceLastFPSUpdateNanos >= fpsRefreshRate.toMillis() * 1_000_000) { lastFPSUpdateNanos = now fps = (fpsSamplingCount.toLong() * 1_000_000_000 / timeSinceLastFPSUpdateNanos).toInt() fpsSamplingCount = 0 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 1d1b32de4..cccb70659 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 e2320c14c..71dfd4ef1 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) From c21af1e1d6ed6b73a97d5e4a77b8376b599faab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Rene=CC=81=20Lavoie?= <> Date: Thu, 21 Mar 2024 11:19:15 -0400 Subject: [PATCH 5/5] test: Allow CI test latency --- .../src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 971c6640f..d6cfe6fcc 100644 --- a/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt +++ b/fxgl/src/test/kotlin/com/almasb/fxgl/app/LoopRunnerTest.kt @@ -138,11 +138,11 @@ class LoopRunnerTest { } // We processed approximately 100 frames (150 where in Pause) - assertThat(count1, greaterThan((98 * frameTime).toDouble() / 1_000)) - assertThat(count1, lessThan((102 * frameTime).toDouble() / 1_000)) + assertThat(count1, greaterThan((90 * frameTime).toDouble() / 1_000)) + assertThat(count1, lessThan((110 * frameTime).toDouble() / 1_000)) - assertThat(count2, greaterThan((98 * frameTime).toDouble() / 1_000)) - assertThat(count2, lessThan((102 * frameTime).toDouble() / 1_000)) + assertThat(count2, greaterThan((90 * frameTime).toDouble() / 1_000)) + assertThat(count2, lessThan((110 * frameTime).toDouble() / 1_000)) } @Test