From 2845c9e078d799b57b05ca01dcbf7caa9a3d3ef0 Mon Sep 17 00:00:00 2001 From: Jason Mitchell Date: Tue, 8 Sep 2026 23:01:21 -0700 Subject: [PATCH] FPS pacer work (#2086) --- glsm/build.gradle.kts | 1 + .../angelica/glsm/GLStateManager.java | 48 +- .../glsm/backend/Lwjgl2GLRenderBackend.java | 6 +- .../angelica/glsm/backend/MainThreadPump.java | 112 ++++ .../angelica/glsm/backend/RenderBackend.java | 138 +++- .../glsm/backend/BackendPolicyTest.java | 48 +- .../glsm/backend/GateRecorderTest.java | 72 ++ .../glsm/backend/MainThreadPumpTest.java | 153 +++++ lwjgl3-backend/build.gradle.kts | 1 + .../lwjgl3/Lwjgl3GLRenderBackend.java | 52 +- .../angelica/sdlgpu/SDLGPURenderBackend.java | 35 +- .../angelica/sdlgpu/device/Device.java | 10 +- .../angelica/sdlgpu/frame/FrameManager.java | 55 +- .../angelica/sdlgpu/frame/Presenter.java | 25 +- .../angelica/sdlgpu/frame/SdlFramePlots.java | 2 + .../angelica/sdlgpu/frame/PresenterTest.java | 16 + .../angelica/config/AngelicaConfig.java | 5 - .../gtnewhorizons/angelica/mixins/Mixins.java | 9 + .../angelica/profiling/TracyFramePlots.java | 24 + .../angelica/proxy/ClientProxy.java | 2 + .../angelica/rendering/FpsReducer.java | 173 +++++ .../angelica/rendering/FramePacer.java | 399 ++++++------ .../angelica/rendering/PacerCore.java | 374 +++++++++++ .../angelica/rendering/PacerSleeper.java | 38 +- .../rendering/ReducerStateMachine.java | 126 ++++ .../sodium/client/gui/FrameRateOptions.java | 5 +- .../client/gui/SodiumGameOptionPages.java | 94 +++ .../sodium/client/gui/SodiumGameOptions.java | 12 + .../sodium/client/gui/SodiumOptionsGUI.java | 1 + .../control/ControlValueFormatter.java | 3 +- .../resources/assets/angelica/lang/en_US.lang | 23 + .../mixins/early/angelica/MixinMinecraft.java | 22 +- .../angelica/MixinMinecraft_FrameHook.java | 6 +- .../angelica/MixinMinecraft_IconifyGuard.java | 4 +- .../angelica/debug/MixinMinecraft_FPSCap.java | 10 +- .../MixinGameSettings_FpsReducerVolume.java | 18 + .../MixinGuiScreen_FpsReducerInput.java | 20 + .../MixinMinecraft_FpsReducerInput.java | 20 + ...inSoundManagerLoader_FpsReducerVolume.java | 16 + .../angelica/rendering/FramePacerTest.java | 322 +++++---- .../angelica/rendering/PacerCoreSimTest.java | 614 ++++++++++++++++++ .../angelica/rendering/PacerCoreTest.java | 420 ++++++++++++ .../angelica/rendering/PacerSleeperTest.java | 82 ++- .../rendering/ReducerStateMachineTest.java | 179 +++++ 44 files changed, 3304 insertions(+), 491 deletions(-) create mode 100644 glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/MainThreadPump.java create mode 100644 glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/GateRecorderTest.java create mode 100644 glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/MainThreadPumpTest.java create mode 100644 src/main/java/com/gtnewhorizons/angelica/rendering/FpsReducer.java create mode 100644 src/main/java/com/gtnewhorizons/angelica/rendering/PacerCore.java create mode 100644 src/main/java/com/gtnewhorizons/angelica/rendering/ReducerStateMachine.java create mode 100644 src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinGameSettings_FpsReducerVolume.java create mode 100644 src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinGuiScreen_FpsReducerInput.java create mode 100644 src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinMinecraft_FpsReducerInput.java create mode 100644 src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinSoundManagerLoader_FpsReducerVolume.java create mode 100644 src/test/java/com/gtnewhorizons/angelica/rendering/PacerCoreSimTest.java create mode 100644 src/test/java/com/gtnewhorizons/angelica/rendering/PacerCoreTest.java create mode 100644 src/test/java/com/gtnewhorizons/angelica/rendering/ReducerStateMachineTest.java diff --git a/glsm/build.gradle.kts b/glsm/build.gradle.kts index 9c2c8e1ef..516e13d20 100644 --- a/glsm/build.gradle.kts +++ b/glsm/build.gradle.kts @@ -114,6 +114,7 @@ dependencies { testRuntimeOnly(libs.lwjgl3) { artifact { classifier = lwjglNatives } } // @Lwjgl3Aware annotation compileOnly(libs.lwjgl3ify) { artifact { classifier = "dev" }; isTransitive = false } + compileOnly(libs.retrofuturabootstrap) { isTransitive = false } compileOnly(libs.lombok) { isTransitive = false } annotationProcessor(libs.lombok) diff --git a/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/GLStateManager.java b/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/GLStateManager.java index 48f9d5cb1..f167305b0 100644 --- a/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/GLStateManager.java +++ b/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/GLStateManager.java @@ -3534,6 +3534,9 @@ public class GLStateManager { Display.swapBuffers(); } + private static boolean firstClearPending; + private static long firstClearBlockNanos; + public static void updateDisplay() throws LWJGLException { updateDisplay(true); } @@ -3541,17 +3544,19 @@ public class GLStateManager { public static void updateDisplay(boolean processMessages) throws LWJGLException { if (Thread.currentThread() != MainThread) { swapBuffers(); - if (processMessages) Display.processMessages(); + if (processMessages) pumpDisplayMessages(); return; } + if (processMessages) pumpDisplayMessages(); if (!RENDER_BACKEND.wantsDisplayUpdateGateTiming()) { - Display.update(processMessages); + Display.update(false); return; } final long start = System.nanoTime(); - Display.update(processMessages); + Display.update(false); final long end = System.nanoTime(); - RENDER_BACKEND.recordFrameGate(end - start, end); + RENDER_BACKEND.recordGate(end - start, end); + firstClearPending = true; } /** @@ -3902,9 +3907,26 @@ public class GLStateManager { return; } } + if (firstClearPending && Thread.currentThread() == MainThread && getDrawFramebuffer() == 0) { + firstClearPending = false; + final long start = System.nanoTime(); + RENDER_BACKEND.clear(mask); + final long end = System.nanoTime(); + if (end - start > 0) { + firstClearBlockNanos += end - start; + RENDER_BACKEND.recordGate(end - start, end); + } + return; + } RENDER_BACKEND.clear(mask); } + public static long takeFirstClearBlockNanos() { + final long value = firstClearBlockNanos; + firstClearBlockNanos = 0L; + return value; + } + public static void glPushAttrib(int mask) { final RecordMode mode = DisplayListManager.getRecordMode(); if (mode != RecordMode.NONE) { @@ -5246,20 +5268,8 @@ public class GLStateManager { return RENDER_BACKEND.getEffectiveVSyncMode(); } - public static int getDisplayRefreshRateHz() { - return RENDER_BACKEND.getDisplayRefreshRateHz(); - } - - public static boolean gateAnchorsNextFrameStart() { - return RENDER_BACKEND.gateAnchorsNextFrameStart(); - } - - public static long lastFrameGateNanos() { - return RENDER_BACKEND.lastFrameGateNanos(); - } - - public static long lastFrameGateEndNanos() { - return RENDER_BACKEND.lastFrameGateEndNanos(); + public static void setPresentSuppressed(boolean suppressed) { + RENDER_BACKEND.setPresentSuppressed(suppressed); } public static boolean hasSwapchainBackpressure() { @@ -5267,7 +5277,7 @@ public class GLStateManager { } public static void pumpDisplayMessages() { - Display.processMessages(); + RENDER_BACKEND.pumpDisplayMessages(); } public static int glGetError() { diff --git a/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/Lwjgl2GLRenderBackend.java b/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/Lwjgl2GLRenderBackend.java index c5bc8aa7a..05d110329 100644 --- a/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/Lwjgl2GLRenderBackend.java +++ b/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/Lwjgl2GLRenderBackend.java @@ -122,11 +122,11 @@ public final class Lwjgl2GLRenderBackend extends RenderBackend { } @Override - protected int queryDisplayRefreshRateHz() { + protected long queryRefreshPeriodNanos() { final DisplayMode mode = Display.getDisplayMode(); - if (mode != null && mode.getFrequency() > 0) return mode.getFrequency(); + if (mode != null && mode.getFrequency() > 0) return periodFromIntegerHz(mode.getFrequency()); final DisplayMode desktop = Display.getDesktopDisplayMode(); - return desktop == null ? 0 : desktop.getFrequency(); + return desktop == null ? 0L : periodFromIntegerHz(desktop.getFrequency()); } @Override diff --git a/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/MainThreadPump.java b/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/MainThreadPump.java new file mode 100644 index 000000000..c0508a4d0 --- /dev/null +++ b/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/MainThreadPump.java @@ -0,0 +1,112 @@ +package com.gtnewhorizons.angelica.glsm.backend; + +import com.gtnewhorizons.angelica.glsm.profiling.Tracy; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.lwjgl.input.Keyboard; +import org.lwjgl.input.Mouse; + +import java.util.concurrent.Executor; +import java.util.function.BooleanSupplier; + +public final class MainThreadPump { + + private static final Logger LOGGER = LogManager.getLogger("MainThreadPump"); + + private static final Tracy.ZoneId Z_DISPLAY_MESSAGES = Tracy.zoneId("displayMessages", Tracy.COLOR_CLIENT); + + private static final long PARK_MILLIS = 20L; + private static final long STALL_WARN_NANOS = 5_000_000_000L; + + public static void pollInput() { + Keyboard.poll(); + Mouse.poll(); + } + + private final Executor mainExecutor; + private final BooleanSupplier isMainThread; + private final Runnable pumpBody; + private final Runnable pollBody; + private final Runnable task = this::runPump; + private final Object lock = new Object(); + + private boolean done; + private Throwable failure; + private ClassLoader callerLoader; + + public MainThreadPump(Executor mainExecutor, BooleanSupplier isMainThread, Runnable pumpBody, Runnable pollBody) { + this.mainExecutor = mainExecutor; + this.isMainThread = isMainThread; + this.pumpBody = pumpBody; + this.pollBody = pollBody; + } + + public void pumpMessages() { + Tracy.beginZone(Z_DISPLAY_MESSAGES); + try { + if (isMainThread.getAsBoolean()) { + pumpBody.run(); + } else { + synchronized (lock) { + runPumpOnMainThread(); + } + } + pollBody.run(); + } finally { + Tracy.endZone(); + } + } + + private void runPumpOnMainThread() { + final Thread self = Thread.currentThread(); + callerLoader = self.getContextClassLoader(); + failure = null; + done = false; + mainExecutor.execute(task); + boolean interrupted = false; + boolean stallReported = false; + final long stallAt = System.nanoTime() + STALL_WARN_NANOS; + while (!done) { + try { + lock.wait(PARK_MILLIS); + } catch (InterruptedException e) { + interrupted = true; + } + if (!done && !stallReported && System.nanoTime() - stallAt >= 0L) { + stallReported = true; + LOGGER.warn("Display message pump has been waiting on the RFB main thread for over {} ms", STALL_WARN_NANOS / 1_000_000L); + } + } + if (interrupted) self.interrupt(); + final Throwable t = failure; + if (t != null) { + throw asUnchecked(t); + } + } + + private void runPump() { + final Thread self = Thread.currentThread(); + final ClassLoader saved = self.getContextClassLoader(); + try { + self.setContextClassLoader(callerLoader); + pumpBody.run(); + } catch (Throwable t) { + failure = t; + } finally { + try { + self.setContextClassLoader(saved); + } finally { + synchronized (lock) { + done = true; + lock.notifyAll(); + } + } + } + } + + private static RuntimeException asUnchecked(Throwable t) { + if (t instanceof RuntimeException runtime) return runtime; + if (t instanceof Error error) throw error; + return new RuntimeException(t); + } +} diff --git a/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/RenderBackend.java b/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/RenderBackend.java index 71259c55f..82e63305a 100644 --- a/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/RenderBackend.java +++ b/glsm/src/main/java/com/gtnewhorizons/angelica/glsm/backend/RenderBackend.java @@ -3,6 +3,7 @@ package com.gtnewhorizons.angelica.glsm.backend; import com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities; import com.gtnewhorizons.angelica.glsm.GLStateManager; import com.gtnewhorizons.angelica.glsm.RenderSystem; +import org.lwjgl.opengl.Display; import org.lwjgl.opengl.GL20; import java.nio.ByteBuffer; @@ -12,6 +13,7 @@ import java.nio.IntBuffer; import java.nio.ShortBuffer; import java.util.Collections; import java.util.List; +import java.util.concurrent.locks.LockSupport; import java.util.function.Predicate; /** @@ -72,7 +74,9 @@ public abstract class RenderBackend { public final void setVSyncMode(VSyncMode preferred) { setVSyncPreference(preferred); - publishVSyncMode(applyVSyncMode(preferred)); + final VSyncMode mode = applyVSyncMode(preferred); + vsyncHonored = applyHonored(); + publishVSyncMode(mode); } public final void setVSyncPreference(VSyncMode preferred) { @@ -90,50 +94,146 @@ public abstract class RenderBackend { protected abstract VSyncMode applyVSyncMode(VSyncMode preferred); + protected boolean applyHonored() { return true; } + + private boolean vsyncHonored = true; + + public final boolean vsyncHonored() { return vsyncHonored; } + public boolean supportsVSyncMode(VSyncMode mode) { return mode != VSyncMode.MAILBOX; } - protected final void publishVSyncMode(VSyncMode mode) { effectiveVSyncMode = mode; } + protected final void publishVSyncMode(VSyncMode mode) { + effectiveVSyncMode = mode; + bumpDisplayGeneration(); + } public final VSyncMode getEffectiveVSyncMode() { return effectiveVSyncMode; } + private int displayGeneration; + + public final int displayGeneration() { return displayGeneration; } + + protected final void bumpDisplayGeneration() { displayGeneration++; } + private static final long REFRESH_POLL_NANOS = 1_000_000_000L; + private static final long MIN_PLAUSIBLE_PERIOD_NANOS = 1_000_000L; + private static final long MAX_PLAUSIBLE_PERIOD_NANOS = 50_000_000L; private long refreshPollNanos; - private int cachedRefreshHz; + private long cachedRefreshPeriodNanos; - public final int getDisplayRefreshRateHz() { + public final long refreshPeriodNanos() { final long now = System.nanoTime(); if (refreshPollNanos == 0L || now - refreshPollNanos >= REFRESH_POLL_NANOS) { refreshPollNanos = now; - cachedRefreshHz = queryDisplayRefreshRateHz(); + final long fresh = plausiblePeriodNanos(queryRefreshPeriodNanos()); + if (fresh != cachedRefreshPeriodNanos) bumpDisplayGeneration(); + cachedRefreshPeriodNanos = fresh; } - return cachedRefreshHz; + return cachedRefreshPeriodNanos; } - protected int queryDisplayRefreshRateHz() { return 0; } + protected long queryRefreshPeriodNanos() { return 0L; } - public static int refreshHzFrom(int numerator, int denominator, float fallbackHz) { - if (numerator > 0 && denominator > 0) return (numerator + denominator - 1) / denominator; - return fallbackHz > 0.0f ? (int) Math.ceil(fallbackHz) : 0; + static long plausiblePeriodNanos(long periodNanos) { + return (periodNanos >= MIN_PLAUSIBLE_PERIOD_NANOS && periodNanos <= MAX_PLAUSIBLE_PERIOD_NANOS) ? periodNanos : 0L; + } + + public static long periodFromRational(int numerator, int denominator, float fallbackHz) { + if (numerator > 0 && denominator > 0) { + final long scaledDenominator = 1_000_000_000L * denominator; + return (scaledDenominator + numerator / 2) / numerator; + } + return fallbackHz > 0.0f ? Math.round(1_000_000_000.0 / fallbackHz) : 0L; + } + + private static final int[] TRUNCATED_NTSC_HZ = {23, 29, 59, 71, 119, 143, 239}; + + private static boolean isTruncatedNtscRate(int hz) { + for (int candidate : TRUNCATED_NTSC_HZ) { + if (candidate == hz) return true; + } + return false; + } + + public static long periodFromIntegerHz(int hz) { + if (hz <= 0) return 0L; + if (isTruncatedNtscRate(hz)) { + final long denominator = (hz + 1) * 1000L; + return Math.round(1_000_000_000.0 * 1001 / denominator); + } + return Math.round(1_000_000_000.0 / hz); + } + + public static int hzFromPeriod(long periodNanos) { + return periodNanos <= 0L ? 0 : (int) Math.round(1_000_000_000.0 / periodNanos); } public boolean hasSwapchainBackpressure() { return false; } - private long frameGateNanos; - private long frameGateEndNanos; + public static final class GateSample { + public long durationNanos; + public long endNanos; + public long gpuWaitNanos; + } - public boolean gateAnchorsNextFrameStart() { return false; } + static final class GateRecorder { + + private long gateDurationNanos; + private long gateEndNanos; + private long gpuWaitNanos; + + synchronized void recordGate(long durationNanos, long endNanos) { + if (gateEndNanos != 0L && gateDurationNanos >= durationNanos) return; + gateDurationNanos = durationNanos; + gateEndNanos = endNanos; + } + + synchronized void recordGpuWait(long nanos) { + gpuWaitNanos += nanos; + } + + synchronized void readGateSample(GateSample out) { + final long end = gateEndNanos; + out.durationNanos = end == 0L ? 0L : gateDurationNanos; + out.endNanos = end; + out.gpuWaitNanos = gpuWaitNanos; + gpuWaitNanos = 0L; + gateEndNanos = 0L; + } + } + + private final GateRecorder gateRecorder = new GateRecorder(); public boolean wantsDisplayUpdateGateTiming() { return true; } - public void recordFrameGate(long durationNanos, long endNanos) { - frameGateNanos = durationNanos; - frameGateEndNanos = endNanos; + public final void recordGate(long durationNanos, long endNanos) { + gateRecorder.recordGate(durationNanos, endNanos); } - public long lastFrameGateNanos() { return frameGateNanos; } + public final void recordGpuWait(long nanos) { + gateRecorder.recordGpuWait(nanos); + } - public long lastFrameGateEndNanos() { return frameGateEndNanos; } + public final void readGateSample(GateSample out) { + gateRecorder.readGateSample(out); + } + + public void awaitPresent() {} + + public void parkNanos(long nanos) { + LockSupport.parkNanos(nanos); + } + + public void pumpDisplayMessages() { + Display.processMessages(); + } + + private boolean presentSuppressed; + + public final void setPresentSuppressed(boolean suppressed) { presentSuppressed = suppressed; } + + public final boolean isPresentSuppressed() { return presentSuppressed; } public void onFrameBegin() { if (GLStateManager.takeStateSeedPending()) { @@ -144,7 +244,7 @@ public abstract class RenderBackend { public void onFrameEnd() {} /** Fired by lwjgl3ify Display before a mutation that recreates the swapchain. */ - public void onPreSwapchainInvalidatingChange(Object change) {} + public void onPreSwapchainInvalidatingChange(Object change) { bumpDisplayGeneration(); } public boolean handleMakeCurrent(Object drawable) { return false; } public boolean handleReleaseContext(Object drawable) { return false; } diff --git a/glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/BackendPolicyTest.java b/glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/BackendPolicyTest.java index 2af498644..393e4356d 100644 --- a/glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/BackendPolicyTest.java +++ b/glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/BackendPolicyTest.java @@ -44,22 +44,48 @@ class BackendPolicyTest { } @Test - void refreshPrefersTheExactRational() { - assertEquals(144, RenderBackend.refreshHzFrom(144, 1, 0.0f)); - assertEquals(60, RenderBackend.refreshHzFrom(60000, 1001, 0.0f)); + void periodPrefersTheExactRational() { + assertEquals(16_683_333L, RenderBackend.periodFromRational(60000, 1001, 0.0f)); + assertEquals(6_944_444L, RenderBackend.periodFromRational(144, 1, 0.0f)); } @Test - void refreshRoundsUp() { - assertEquals(144, RenderBackend.refreshHzFrom(0, 0, 143.98f)); - assertEquals(60, RenderBackend.refreshHzFrom(0, 0, 59.94f)); - assertEquals(60, RenderBackend.refreshHzFrom(0, 0, 60.0f)); + void periodFallsBackToTheFloatRate() { + assertEquals(16_683_350L, RenderBackend.periodFromRational(0, 0, 59.94f)); + assertEquals(16_666_667L, RenderBackend.periodFromRational(0, 0, 60.0f)); } @Test - void refreshIsZeroWhenUnknown() { - assertEquals(0, RenderBackend.refreshHzFrom(0, 0, 0.0f)); - assertEquals(0, RenderBackend.refreshHzFrom(-1, -1, -1.0f)); - assertEquals(0, RenderBackend.refreshHzFrom(144, 0, 0.0f)); + void periodIsZeroWhenUnknown() { + assertEquals(0L, RenderBackend.periodFromRational(0, 0, 0.0f)); + assertEquals(0L, RenderBackend.periodFromRational(-1, -1, -1.0f)); + assertEquals(0L, RenderBackend.periodFromRational(144, 0, 0.0f)); + } + + @Test + void integerNtscRatesAreAdjusted() { + assertEquals(16_683_333L, RenderBackend.periodFromIntegerHz(59)); + assertEquals(6_951_389L, RenderBackend.periodFromIntegerHz(143)); + assertEquals(16_666_667L, RenderBackend.periodFromIntegerHz(60)); + assertEquals(6_944_444L, RenderBackend.periodFromIntegerHz(144)); + assertEquals(0L, RenderBackend.periodFromIntegerHz(0)); + assertEquals(0L, RenderBackend.periodFromIntegerHz(-60)); + assertEquals(60, RenderBackend.hzFromPeriod(RenderBackend.periodFromIntegerHz(59))); + } + + @Test + void hzFromPeriodRoundsToNearest() { + assertEquals(60, RenderBackend.hzFromPeriod(16_666_667L)); + assertEquals(144, RenderBackend.hzFromPeriod(6_944_444L)); + assertEquals(0, RenderBackend.hzFromPeriod(0L)); + assertEquals(0, RenderBackend.hzFromPeriod(-1L)); + } + + @Test + void plausiblePeriodFilterKeeps20To1000Hz() { + assertEquals(0L, RenderBackend.plausiblePeriodNanos(999_000L)); + assertEquals(1_000_000L, RenderBackend.plausiblePeriodNanos(1_000_000L)); + assertEquals(50_000_000L, RenderBackend.plausiblePeriodNanos(50_000_000L)); + assertEquals(0L, RenderBackend.plausiblePeriodNanos(51_000_000L)); } } diff --git a/glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/GateRecorderTest.java b/glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/GateRecorderTest.java new file mode 100644 index 000000000..5d9ae57c8 --- /dev/null +++ b/glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/GateRecorderTest.java @@ -0,0 +1,72 @@ +package com.gtnewhorizons.angelica.glsm.backend; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GateRecorderTest { + + @Test + void theLongestBlockWithinAFrameWinsRegardlessOfOrder() { + final RenderBackend.GateRecorder longestFirst = new RenderBackend.GateRecorder(); + longestFirst.recordGate(5_000_000L, 100L); + longestFirst.recordGate(1_000_000L, 200L); + + final RenderBackend.GateSample out = new RenderBackend.GateSample(); + longestFirst.readGateSample(out); + assertEquals(5_000_000L, out.durationNanos); + assertEquals(100L, out.endNanos); + + final RenderBackend.GateRecorder shortestFirst = new RenderBackend.GateRecorder(); + shortestFirst.recordGate(1_000_000L, 10L); + shortestFirst.recordGate(4_000_000L, 20L); + + shortestFirst.readGateSample(out); + assertEquals(4_000_000L, out.durationNanos); + assertEquals(20L, out.endNanos); + } + + @Test + void aReadConsumesTheSample() { + final RenderBackend.GateRecorder recorder = new RenderBackend.GateRecorder(); + recorder.recordGate(2_000_000L, 50L); + + final RenderBackend.GateSample out = new RenderBackend.GateSample(); + recorder.readGateSample(out); + + recorder.readGateSample(out); + assertEquals(0L, out.endNanos); + assertEquals(0L, out.durationNanos); + } + + @Test + void gpuWaitAccumulatesAndResetsOnRead() { + final RenderBackend.GateRecorder recorder = new RenderBackend.GateRecorder(); + recorder.recordGate(1_000_000L, 10L); + recorder.recordGpuWait(300L); + recorder.recordGpuWait(700L); + + final RenderBackend.GateSample out = new RenderBackend.GateSample(); + recorder.readGateSample(out); + assertEquals(1_000L, out.gpuWaitNanos); + + recorder.recordGate(1_000_000L, 11L); + recorder.readGateSample(out); + assertEquals(0L, out.gpuWaitNanos); + } + + @Test + void gpuWaitIsDrainedOnASampleFreeRead() { + final RenderBackend.GateRecorder recorder = new RenderBackend.GateRecorder(); + recorder.recordGpuWait(450L); + + final RenderBackend.GateSample out = new RenderBackend.GateSample(); + recorder.readGateSample(out); + + assertEquals(0L, out.endNanos); + assertEquals(450L, out.gpuWaitNanos); + + recorder.readGateSample(out); + assertEquals(0L, out.gpuWaitNanos); + } +} diff --git a/glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/MainThreadPumpTest.java b/glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/MainThreadPumpTest.java new file mode 100644 index 000000000..8434ff575 --- /dev/null +++ b/glsm/src/test/java/com/gtnewhorizons/angelica/glsm/backend/MainThreadPumpTest.java @@ -0,0 +1,153 @@ +package com.gtnewhorizons.angelica.glsm.backend; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(10) +class MainThreadPumpTest { + + private static final class DeferredExecutor implements Executor { + final Deque tasks = new ConcurrentLinkedDeque<>(); + + @Override + public void execute(Runnable command) { + tasks.add(command); + } + + void runNext() { + tasks.removeFirst().run(); + } + + void awaitTask() { + while (tasks.isEmpty()) { + Thread.onSpinWait(); + } + } + } + + private static final class InlineExecutor implements Executor { + final List submitted = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void execute(Runnable command) { + submitted.add(command); + command.run(); + } + } + + private static void awaitWaiting(Thread t) { + while (t.getState() != Thread.State.WAITING && t.getState() != Thread.State.TIMED_WAITING) { + assertTrue(t.isAlive(), "thread died instead of blocking"); + Thread.onSpinWait(); + } + } + + private static MainThreadPump pump(Executor executor, Runnable pumpBody, Runnable pollBody) { + return new MainThreadPump(executor, () -> false, pumpBody, pollBody); + } + + @Test + void repeatedPumpsSubmitTheSameRunnable() { + final InlineExecutor executor = new InlineExecutor(); + final AtomicInteger pumps = new AtomicInteger(); + final AtomicInteger polls = new AtomicInteger(); + final MainThreadPump p = pump(executor, pumps::incrementAndGet, polls::incrementAndGet); + + for (int i = 0; i < 16; i++) { + p.pumpMessages(); + } + + assertEquals(16, executor.submitted.size()); + assertEquals(16, pumps.get()); + assertEquals(16, polls.get()); + final Runnable first = executor.submitted.get(0); + for (Runnable r : executor.submitted) { + assertSame(first, r, "pump must not allocate a new task per call"); + } + } + + @Test + void pumpBlocksUntilTheMainThreadRunsTheTask() throws Exception { + final DeferredExecutor executor = new DeferredExecutor(); + final AtomicInteger polls = new AtomicInteger(); + final MainThreadPump p = pump(executor, () -> {}, polls::incrementAndGet); + + final Thread caller = new Thread(p::pumpMessages, "pump-caller"); + caller.start(); + awaitWaiting(caller); + executor.awaitTask(); + assertEquals(1, executor.tasks.size()); + assertEquals(0, polls.get()); + + executor.runNext(); + caller.join(); + assertEquals(1, polls.get()); + } + + @Test + void aThrowingBodyPropagatesToTheCallerAndDoesNotLeaveItParked() { + final InlineExecutor executor = new InlineExecutor(); + final AtomicInteger polls = new AtomicInteger(); + final AtomicInteger pumps = new AtomicInteger(); + final MainThreadPump p = pump(executor, () -> { + if (pumps.incrementAndGet() == 1) throw new IllegalStateException("boom"); + }, polls::incrementAndGet); + + final IllegalStateException thrown = assertThrows(IllegalStateException.class, p::pumpMessages); + assertEquals("boom", thrown.getMessage()); + assertEquals(0, polls.get(), "poll must not run when the pump body failed"); + + p.pumpMessages(); + assertEquals(1, polls.get(), "a failed pump must not poison the next one"); + } + + @Test + void mainThreadCallersRunInlineWithoutTheExecutor() { + final InlineExecutor executor = new InlineExecutor(); + final AtomicInteger pumps = new AtomicInteger(); + final AtomicInteger polls = new AtomicInteger(); + final MainThreadPump p = new MainThreadPump(executor, () -> true, pumps::incrementAndGet, + polls::incrementAndGet); + + p.pumpMessages(); + + assertTrue(executor.submitted.isEmpty(), "the main thread must not hop through the executor"); + assertEquals(1, pumps.get()); + assertEquals(1, polls.get()); + } + + @Test + void anInterruptedCallerCompletesAndKeepsItsInterruptFlag() throws Exception { + final DeferredExecutor executor = new DeferredExecutor(); + final MainThreadPump p = pump(executor, () -> {}, () -> {}); + final AtomicInteger stillInterrupted = new AtomicInteger(-1); + + final Thread caller = new Thread(() -> { + p.pumpMessages(); + stillInterrupted.set(Thread.currentThread().isInterrupted() ? 1 : 0); + }, "pump-interrupted-caller"); + caller.start(); + awaitWaiting(caller); + executor.awaitTask(); + caller.interrupt(); + assertFalse(executor.tasks.isEmpty()); + + executor.runNext(); + caller.join(); + assertEquals(1, stillInterrupted.get()); + } +} diff --git a/lwjgl3-backend/build.gradle.kts b/lwjgl3-backend/build.gradle.kts index a5e2888f3..bcd7b4327 100644 --- a/lwjgl3-backend/build.gradle.kts +++ b/lwjgl3-backend/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { api(project(":glsm")) compileOnly(libs.lwjgl3ify) { artifact { classifier = "dev" }; isTransitive = false } + compileOnly(libs.retrofuturabootstrap) { isTransitive = false } compileOnly(libs.celeritas.common) { isTransitive = false } // LWJGL3 diff --git a/lwjgl3-backend/src/main/java/com/gtnewhorizons/angelica/lwjgl3/Lwjgl3GLRenderBackend.java b/lwjgl3-backend/src/main/java/com/gtnewhorizons/angelica/lwjgl3/Lwjgl3GLRenderBackend.java index 8707cffc8..c20144973 100644 --- a/lwjgl3-backend/src/main/java/com/gtnewhorizons/angelica/lwjgl3/Lwjgl3GLRenderBackend.java +++ b/lwjgl3-backend/src/main/java/com/gtnewhorizons/angelica/lwjgl3/Lwjgl3GLRenderBackend.java @@ -4,9 +4,13 @@ import com.gtnewhorizons.angelica.glsm.GLStateManager; import com.gtnewhorizons.angelica.glsm.GLESCaps; import com.gtnewhorizons.angelica.glsm.RenderSystem; import com.gtnewhorizons.angelica.glsm.backend.DebugMessageHandler; +import com.gtnewhorizons.angelica.glsm.backend.MainThreadPump; +import com.gtnewhorizons.retrofuturabootstrap.MainStartOnFirstThread; import com.gtnewhorizons.angelica.glsm.backend.RenderBackend; import com.gtnewhorizons.angelica.glsm.backend.VSyncMode; import me.eigenraven.lwjgl3ify.api.Lwjgl3Aware; +import me.eigenraven.lwjgl3ify.client.MainThreadExec; +import org.lwjglx.Lwjgl3ifyEventLoop; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.lwjgl.opengl.ARBClearTexture; @@ -36,7 +40,9 @@ import org.lwjgl.opengl.GLDebugMessageCallback; import org.lwjgl.sdl.SDL_DisplayMode; import org.lwjgl.sdl.SDL_DropEvent; import org.lwjgl.sdl.SDL_EventFilter; +import org.lwjgl.sdl.SDLError; import org.lwjgl.sdl.SDLEvents; +import org.lwjgl.sdl.SDLTimer; import org.lwjgl.sdl.SDLVideo; import org.lwjgl.system.JNI; import org.lwjgl.system.MemoryStack; @@ -71,6 +77,7 @@ public final class Lwjgl3GLRenderBackend extends RenderBackend { private SDL_EventFilter dropEventFilter; private final ConcurrentLinkedQueue droppedFiles = new ConcurrentLinkedQueue<>(); private volatile boolean watchingDrops; + private boolean swapIntervalHonored = true; @Override public void init() { @@ -173,19 +180,50 @@ public final class Lwjgl3GLRenderBackend extends RenderBackend { @Override protected VSyncMode applyVSyncMode(VSyncMode preferred) { - final boolean enabled = preferred.tearFree(); - SDLVideo.SDL_GL_SetSwapInterval(enabled ? 1 : 0); - return enabled ? VSyncMode.ON : VSyncMode.OFF; + final int want = preferred.tearFree() ? 1 : 0; + final boolean setOk = SDLVideo.SDL_GL_SetSwapInterval(want); + final String setError = setOk ? null : SDLError.SDL_GetError(); + int got = want; + try (MemoryStack stack = MemoryStack.stackPush()) { + final IntBuffer readback = stack.callocInt(1); + if (SDLVideo.SDL_GL_GetSwapInterval(readback)) got = readback.get(0); + } + if (!setOk) { + LOGGER.warn("SDL_GL_SetSwapInterval({}) failed: {}", want, setError); + } else if (got != want && !(want == 1 && got == -1)) { + LOGGER.warn("Requested swap interval {} but the driver reports {}", want, got); + } + swapIntervalHonored = want == 0 || got != 0; + return want != 0 && swapIntervalHonored ? VSyncMode.ON : VSyncMode.OFF; } @Override - protected int queryDisplayRefreshRateHz() { + protected boolean applyHonored() { + return swapIntervalHonored; + } + + @Override + protected long queryRefreshPeriodNanos() { final long window = Display.getWindow(); final int displayId = window == 0 ? SDLVideo.SDL_GetPrimaryDisplay() : SDLVideo.SDL_GetDisplayForWindow(window); - if (displayId == 0) return 0; + if (displayId == 0) return 0L; final SDL_DisplayMode mode = SDLVideo.SDL_GetCurrentDisplayMode(displayId); - if (mode == null) return 0; - return refreshHzFrom(mode.refresh_rate_numerator(), mode.refresh_rate_denominator(), mode.refresh_rate()); + if (mode == null) return 0L; + return periodFromRational(mode.refresh_rate_numerator(), mode.refresh_rate_denominator(), mode.refresh_rate()); + } + + @Override + public void parkNanos(long nanos) { + SDLTimer.SDL_DelayNS(nanos); + } + + private static final class Pump { + static final MainThreadPump INSTANCE = new MainThreadPump(MainStartOnFirstThread.instance(), MainThreadExec::isMainThread, Lwjgl3ifyEventLoop::pumpEvents, MainThreadPump::pollInput); + } + + @Override + public void pumpDisplayMessages() { + Pump.INSTANCE.pumpMessages(); } @Override diff --git a/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/SDLGPURenderBackend.java b/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/SDLGPURenderBackend.java index c32abad0a..43ddcc13c 100644 --- a/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/SDLGPURenderBackend.java +++ b/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/SDLGPURenderBackend.java @@ -7,6 +7,9 @@ import com.gtnewhorizons.angelica.glsm.CaptureGate; import com.gtnewhorizons.angelica.glsm.GLStateManager; import com.gtnewhorizons.angelica.glsm.profiling.Tracy; import com.gtnewhorizons.angelica.glsm.backend.GLDebugMessageListener; +import com.gtnewhorizons.angelica.glsm.backend.MainThreadPump; +import me.eigenraven.lwjgl3ify.client.MainThreadExec; +import org.lwjglx.Lwjgl3ifyEventLoop; import com.gtnewhorizons.angelica.glsm.backend.VSyncMode; import com.gtnewhorizons.angelica.glsm.backend.RenderBackend; import com.gtnewhorizons.angelica.glsm.hooks.GLSMConfig; @@ -89,6 +92,7 @@ import java.util.function.BiConsumer; import org.lwjgl.sdl.SDLGPU; import org.lwjgl.sdl.SDL_GPUBlitInfo; +import org.lwjgl.sdl.SDLTimer; import org.lwjgl.sdl.SDL_GPUBufferLocation; import org.lwjgl.sdl.SDL_GPUTextureLocation; import org.lwjgl.system.MemoryStack; @@ -398,26 +402,28 @@ public class SDLGPURenderBackend extends RenderBackend { } } - @Override protected int queryDisplayRefreshRateHz() { - return device.getDisplayRefreshRateHz(); + @Override protected long queryRefreshPeriodNanos() { + return device.getDisplayRefreshPeriodNanos(); } - @Override public boolean gateAnchorsNextFrameStart() { return true; } - @Override public boolean wantsDisplayUpdateGateTiming() { return false; } - @Override public long lastFrameGateNanos() { - if (presenter != null) return frameManager.windowFrame().lastFrameGateNanos; - return frameManager.lastFrameGateNanos(); - } - - @Override public long lastFrameGateEndNanos() { - if (presenter != null) return frameManager.windowFrame().lastFrameGateEndNanos; - return frameManager.lastFrameGateEndNanos(); - } - @Override public boolean hasSwapchainBackpressure() { return true; } + @Override public void awaitPresent() { + if (presenter != null) presenter.drain(); + } + + @Override public void parkNanos(long nanos) { + SDLTimer.SDL_DelayNS(nanos); + } + + private static final class Pump { + static final MainThreadPump INSTANCE = new MainThreadPump(MainStartOnFirstThread.instance(), MainThreadExec::isMainThread, Lwjgl3ifyEventLoop::pumpEvents, MainThreadPump::pollInput); + } + + @Override public void pumpDisplayMessages() { Pump.INSTANCE.pumpMessages(); } + @Override public void shutdown() { if (shutdown) return; shutdown = true; @@ -675,6 +681,7 @@ public class SDLGPURenderBackend extends RenderBackend { } @Override public void onPreSwapchainInvalidatingChange(Object change) { + super.onPreSwapchainInvalidatingChange(change); if (presenter != null) presenter.drain(); endFrameUploadFlush(); if (frameManager.isFrameActive()) { diff --git a/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/device/Device.java b/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/device/Device.java index b54a35fe1..ddb25cd78 100644 --- a/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/device/Device.java +++ b/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/device/Device.java @@ -335,7 +335,7 @@ public final class Device { } vsyncMode = candidate; LOG.info("SDL present mode {} (preferred={}), refresh {}Hz, {}, video={}, framesInFlight={}, supported: vsync={} immediate={} mailbox={}", - candidate, preferred, getDisplayRefreshRateHz(), + candidate, preferred, RenderBackend.hzFromPeriod(getDisplayRefreshPeriodNanos()), (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0 ? "fullscreen" : "windowed", SDL_GetCurrentVideoDriver(), FRAMES_IN_FLIGHT, supportsVSyncMode(VSyncMode.ON), supportsVSyncMode(VSyncMode.OFF), supportsVSyncMode(VSyncMode.MAILBOX)); @@ -376,13 +376,13 @@ public final class Device { }; } - public int getDisplayRefreshRateHz() { + public long getDisplayRefreshPeriodNanos() { final long window = Display.getWindow(); final int displayId = window == 0 ? SDL_GetPrimaryDisplay() : SDL_GetDisplayForWindow(window); - if (displayId == 0) return 0; + if (displayId == 0) return 0L; final SDL_DisplayMode mode = SDL_GetCurrentDisplayMode(displayId); - if (mode == null) return 0; - return RenderBackend.refreshHzFrom(mode.refresh_rate_numerator(), mode.refresh_rate_denominator(), mode.refresh_rate()); + if (mode == null) return 0L; + return RenderBackend.periodFromRational(mode.refresh_rate_numerator(), mode.refresh_rate_denominator(), mode.refresh_rate()); } public int getSwapchainTextureFormat() { diff --git a/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/FrameManager.java b/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/FrameManager.java index 7951d6872..7b00a0404 100644 --- a/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/FrameManager.java +++ b/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/FrameManager.java @@ -1,6 +1,7 @@ package com.gtnewhorizons.angelica.sdlgpu.frame; import com.gtnewhorizons.angelica.config.SystemProperties; +import com.gtnewhorizons.angelica.glsm.backend.BackendManager; import com.gtnewhorizons.angelica.glsm.profiling.Tracy; import com.gtnewhorizons.angelica.sdlgpu.device.Device; import com.gtnewhorizons.angelica.sdlgpu.resource.ResourceManager; @@ -43,6 +44,7 @@ public final class FrameManager { private static final Tracy.ZoneId Z_SDL_SUBMIT = Tracy.zoneId("sdlSubmit", Tracy.COLOR_SWAP); private static final Tracy.ZoneId Z_SDL_ACQUIRE_WAIT = Tracy.zoneId("sdlAcquireWait", Tracy.COLOR_SWAP); + private static final Tracy.ZoneId Z_SDL_GPU_WAIT = Tracy.zoneId("sdlGpuWait", Tracy.COLOR_SWAP); private final Device device; private ResourceManager resourceManager; @@ -83,8 +85,7 @@ public final class FrameManager { public int batchBreakSamplerThisFrame; public int stateAppliesThisFrame; public long acquireWaitNanosThisFrame; - public volatile long lastFrameGateNanos; - public volatile long lastFrameGateEndNanos; + public long gpuWaitNanosThisFrame; public int midFrameSubmitsThisFrame; public int mipGensThisFrame; public int mipGenSubmitsThisFrame; @@ -597,6 +598,7 @@ public final class FrameManager { f.stateAppliesThisFrame = 0; f.acquireWaitNanosThisFrame = 0; f.mipGensThisFrame = 0; + f.gpuWaitNanosThisFrame = 0; f.mipGenSubmitsThisFrame = 0; f.uploadFlushSubmitsThisFrame = 0; f.blitsThisFrame = 0; @@ -873,14 +875,6 @@ public final class FrameManager { registeredFrames.clear(); } - public long lastFrameGateNanos() { - return frame().lastFrameGateNanos; - } - - public long lastFrameGateEndNanos() { - return frame().lastFrameGateEndNanos; - } - private volatile Presenter presenter; public void setPresenter(Presenter presenter) { @@ -894,6 +888,7 @@ public final class FrameManager { } public void presentFinalTarget() { + if (BackendManager.RENDER_BACKEND.isPresentSuppressed()) return; if (!finalTarget.hasContent()) return; present(finalTarget.colorTexture(), finalTarget.width(), finalTarget.height(), SDL_FLIP_NONE, false); } @@ -916,16 +911,13 @@ public final class FrameManager { } private final AtomicLong windowAcquireWaitNanos = new AtomicLong(); + private final AtomicLong windowGpuWaitNanos = new AtomicLong(); private final AtomicInteger windowPresentSkips = new AtomicInteger(); private void drainWindowPresentCounters(FrameState f) { if (f == windowFrame) return; - final long acquire = windowAcquireWaitNanos.getAndSet(0L); - if (acquire != 0L) { - f.acquireWaitNanosThisFrame += acquire; - f.lastFrameGateNanos = windowFrame.lastFrameGateNanos; - f.lastFrameGateEndNanos = windowFrame.lastFrameGateEndNanos; - } + f.acquireWaitNanosThisFrame += windowAcquireWaitNanos.getAndSet(0L); + f.gpuWaitNanosThisFrame += windowGpuWaitNanos.getAndSet(0L); f.presentSkipsThisFrame += windowPresentSkips.getAndSet(0); } @@ -934,6 +926,11 @@ public final class FrameManager { else f.acquireWaitNanosThisFrame += waitNanos; } + private void noteGpuWait(FrameState f, long waitNanos) { + if (f == windowFrame) windowGpuWaitNanos.addAndGet(waitNanos); + else f.gpuWaitNanosThisFrame += waitNanos; + } + private void notePresentSkip(FrameState f) { if (f == windowFrame) windowPresentSkips.incrementAndGet(); else f.presentSkipsThisFrame++; @@ -962,13 +959,31 @@ public final class FrameManager { final int w; final int h; final boolean callOk; + final boolean swapchainReady; + Tracy.beginZone(Z_SDL_GPU_WAIT); + try { + swapchainReady = SDL_WaitForGPUSwapchain(device.getDevice(), window); + } finally { + Tracy.endZone(); + } + final long t1 = System.nanoTime(); + BackendManager.RENDER_BACKEND.recordGpuWait(t1 - t0); + noteGpuWait(f, t1 - t0); + + if (!swapchainReady) { + SDL_CancelGPUCommandBuffer(cb); + f.swapchainUnavailable = true; + notePresentSkip(f); + return false; + } + try (var stack = stackPush()) { final PointerBuffer pTexture = stack.pointers(0); final IntBuffer pWidth = stack.ints(0); final IntBuffer pHeight = stack.ints(0); Tracy.beginZone(Z_SDL_ACQUIRE_WAIT); try { - callOk = SDL_WaitAndAcquireGPUSwapchainTexture(cb, window, pTexture, pWidth, pHeight); + callOk = SDL_AcquireGPUSwapchainTexture(cb, window, pTexture, pWidth, pHeight); } finally { Tracy.endZone(); } @@ -981,9 +996,6 @@ public final class FrameManager { throw re; } final long tEnd = System.nanoTime(); - f.lastFrameGateNanos = tEnd - t0; - f.lastFrameGateEndNanos = tEnd; - noteAcquireWait(f, tEnd - t0); if (!callOk || tex == 0) { SDL_CancelGPUCommandBuffer(cb); @@ -992,6 +1004,9 @@ public final class FrameManager { return false; } + BackendManager.RENDER_BACKEND.recordGate(tEnd - t0, tEnd); + noteAcquireWait(f, tEnd - t1); + try (MemoryStack stack = stackPush()) { final long info = stack.ncalloc(SDL_GPUBlitInfo.ALIGNOF, 1, SDL_GPUBlitInfo.SIZEOF); writeBlitRegion(info + SDL_GPUBlitInfo.SOURCE, srcTexture, srcW, srcH); diff --git a/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/Presenter.java b/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/Presenter.java index e6d6e0230..054b2eb7c 100644 --- a/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/Presenter.java +++ b/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/Presenter.java @@ -8,23 +8,34 @@ public final class Presenter { private final FrameManager frameManager; private final Executor windowThreadExecutor; private final Semaphore pending = new Semaphore(1); + private final Runnable task; + + private long srcTexture; + private int srcW; + private int srcH; + private int flipMode; public Presenter(FrameManager frameManager, Executor windowThreadExecutor) { this.frameManager = frameManager; this.windowThreadExecutor = windowThreadExecutor; + this.task = () -> { + try { + frameManager.presentOnWindowThread(srcTexture, srcW, srcH, flipMode); + } finally { + pending.release(); + } + }; } public void requestPresent(long srcTexture, int srcW, int srcH, int flipMode) { pending.acquireUninterruptibly(); + this.srcTexture = srcTexture; + this.srcW = srcW; + this.srcH = srcH; + this.flipMode = flipMode; boolean submitted = false; try { - windowThreadExecutor.execute(() -> { - try { - frameManager.presentOnWindowThread(srcTexture, srcW, srcH, flipMode); - } finally { - pending.release(); - } - }); + windowThreadExecutor.execute(task); submitted = true; } finally { if (!submitted) pending.release(); diff --git a/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/SdlFramePlots.java b/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/SdlFramePlots.java index d404ccd40..6704d3577 100644 --- a/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/SdlFramePlots.java +++ b/sdl-gpu/src/main/java/com/gtnewhorizons/angelica/sdlgpu/frame/SdlFramePlots.java @@ -37,6 +37,7 @@ final class SdlFramePlots { private static final long PLOT_PER_PASS_BLOCK_FLUSHES = Tracy.plotHandle("sdl.perPassBlockFlushes"); private static final long PLOT_PER_PASS_BLOCK_BYTES = Tracy.plotHandle("sdl.perPassBlockBytes", TracyBackend.PLOT_FORMAT_MEMORY); private static final long PLOT_ACQUIRE_WAIT_US = Tracy.plotHandle("sdl.acquireWaitUs"); + private static final long PLOT_GPU_WAIT_US = Tracy.plotHandle("sdl.gpuWaitUs"); private static final long PLOT_PRESENT_TO_PRESENT_US = Tracy.plotHandle("sdl.presentToPresentUs"); private static final long PLOT_FRAMES_IN_FLIGHT = Tracy.plotHandle("sdl.framesInFlight"); private static final long PLOT_MIP_GENS = Tracy.plotHandle("sdl.mipGens"); @@ -97,6 +98,7 @@ final class SdlFramePlots { Tracy.plotInt(PLOT_PER_PASS_BLOCK_FLUSHES, f.uniformBlockFlushesThisFrame[ShaderManager.BLOCK_PER_PASS]); Tracy.plotInt(PLOT_PER_PASS_BLOCK_BYTES, f.uniformBlockBytesThisFrame[ShaderManager.BLOCK_PER_PASS]); Tracy.plotInt(PLOT_ACQUIRE_WAIT_US, f.acquireWaitNanosThisFrame / 1000); + Tracy.plotInt(PLOT_GPU_WAIT_US, f.gpuWaitNanosThisFrame / 1000); final long nowNanos = System.nanoTime(); if (f.lastEndFrameNanos != 0) Tracy.plotInt(PLOT_PRESENT_TO_PRESENT_US, (nowNanos - f.lastEndFrameNanos) / 1000); f.lastEndFrameNanos = nowNanos; diff --git a/sdl-gpu/src/test/java/com/gtnewhorizons/angelica/sdlgpu/frame/PresenterTest.java b/sdl-gpu/src/test/java/com/gtnewhorizons/angelica/sdlgpu/frame/PresenterTest.java index ccbbaddbe..5e9e9d338 100644 --- a/sdl-gpu/src/test/java/com/gtnewhorizons/angelica/sdlgpu/frame/PresenterTest.java +++ b/sdl-gpu/src/test/java/com/gtnewhorizons/angelica/sdlgpu/frame/PresenterTest.java @@ -9,6 +9,7 @@ import java.util.Deque; import java.util.concurrent.Executor; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.lwjgl.sdl.SDLSurface.SDL_FLIP_NONE; @@ -72,6 +73,21 @@ class PresenterTest { drainer.join(); } + @Test + void requestReusesOneRunnableAcrossCalls() { + final DeferredExecutor executor = new DeferredExecutor(); + final Presenter p = presenter(executor); + + p.requestPresent(1L, 10, 10, SDL_FLIP_NONE); + final Runnable first = executor.tasks.peekFirst(); + executor.runNext(); + + p.requestPresent(2L, 10, 10, SDL_FLIP_NONE); + final Runnable second = executor.tasks.peekFirst(); + + assertSame(first, second, "Presenter must submit the same preallocated Runnable on every request"); + } + @Test void anInlineExecutorReleasesTheSlotImmediately() { final Presenter p = new Presenter(new FrameManager(new Device()), Runnable::run); diff --git a/src/main/java/com/gtnewhorizons/angelica/config/AngelicaConfig.java b/src/main/java/com/gtnewhorizons/angelica/config/AngelicaConfig.java index 94e4068f1..23794e0a7 100644 --- a/src/main/java/com/gtnewhorizons/angelica/config/AngelicaConfig.java +++ b/src/main/java/com/gtnewhorizons/angelica/config/AngelicaConfig.java @@ -261,11 +261,6 @@ public class AngelicaConfig { @Config.RangeDouble(min = 16D, max = 64D) public static double mobSpawnerRenderDistance; - @Config.Comment("Switches to an alternate FPS limiter that gives more stable frametimes, in exchange for slightly " + - "more latency. Will never introduce more than one frame of latency, and has a lower impact at higher framerates.") - @Config.DefaultBoolean(false) - public static boolean sleepBeforeSwap; - @Config.Comment("Allows unicode languages to use an odd gui scale") @Config.DefaultBoolean(true) public static boolean removeUnicodeEvenScaling; diff --git a/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java b/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java index 2f972643e..2d4795963 100644 --- a/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java +++ b/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java @@ -528,6 +528,15 @@ public enum Mixins implements IMixins { "angelica.zoom.MixinMouseFilter" )), + ANGELICA_FPS_REDUCER(new MixinBuilder("Reduce frame rate and volume while unfocused, minimized, or idle") + .setPhase(Phase.EARLY) + .addClientMixins( + "angelica.fpsreducer.MixinGameSettings_FpsReducerVolume", + "angelica.fpsreducer.MixinGuiScreen_FpsReducerInput", + "angelica.fpsreducer.MixinMinecraft_FpsReducerInput", + "angelica.fpsreducer.MixinSoundManagerLoader_FpsReducerVolume" + )), + HUD_CACHING(new MixinBuilder() .setPhase(Phase.EARLY) .setApplyIf(() -> AngelicaConfig.enableHudCaching) diff --git a/src/main/java/com/gtnewhorizons/angelica/profiling/TracyFramePlots.java b/src/main/java/com/gtnewhorizons/angelica/profiling/TracyFramePlots.java index 011efeba5..26864b04c 100644 --- a/src/main/java/com/gtnewhorizons/angelica/profiling/TracyFramePlots.java +++ b/src/main/java/com/gtnewhorizons/angelica/profiling/TracyFramePlots.java @@ -10,6 +10,7 @@ import com.gtnewhorizons.angelica.glsm.profiling.Tracy; import com.gtnewhorizons.angelica.glsm.profiling.TracyBackend; import com.gtnewhorizons.angelica.glsm.streaming.TessellatorStreamingDrawer; import com.gtnewhorizons.angelica.rendering.AngelicaRenderQueue; +import com.gtnewhorizons.angelica.rendering.FramePacer; import com.gtnewhorizons.angelica.rendering.celeritas.CeleritasWorldRenderer; import com.gtnewhorizons.angelica.rendering.celeritas.TerrainDrawStats; import com.gtnewhorizons.angelica.rendering.culling.GpuCulling; @@ -121,6 +122,17 @@ public final class TracyFramePlots { private TracyFramePlots() {} + private static final long P_FRAME_GATE_US = Tracy.plotHandle("frameGateUs"); + private static final long P_PACER_GPU_WAIT_US = Tracy.plotHandle("pacer.gpuWaitUs"); + private static final long P_PACER_SLACK_US = Tracy.plotHandle("pacer.slackUs"); + private static final long P_PACER_SPIN_US = Tracy.plotHandle("pacer.spinUs"); + private static final long P_PACER_CEILING_HZ = Tracy.plotHandle("pacer.ceilingHz"); + private static final long P_PACER_MARGIN_US = Tracy.plotHandle("pacer.marginUs"); + private static final long P_PACER_LOCKED = Tracy.plotHandle("pacer.locked"); + private static final long P_PACER_PROBING = Tracy.plotHandle("pacer.probing"); + private static final long P_PACER_PRESENT_INTERVAL_US = Tracy.plotHandle("pacer.presentIntervalUs"); + private static final long P_PACER_IDLE_OVERSHOOT_US = Tracy.plotHandle("pacer.idleOvershootUs"); + public static void onFrame(Minecraft mc) { if (!Tracy.ENABLED) return; @@ -132,6 +144,18 @@ public final class TracyFramePlots { final Runtime runtime = Runtime.getRuntime(); Tracy.plotInt(P_HEAP_USED, runtime.totalMemory() - runtime.freeMemory()); } + + Tracy.plotInt(P_FRAME_GATE_US, FramePacer.gateDurationNanos() / 1000L); + Tracy.plotInt(P_PACER_GPU_WAIT_US, FramePacer.gpuWaitNanos() / 1000L); + Tracy.plotInt(P_PACER_SLACK_US, FramePacer.slackNanos() / 1000L); + Tracy.plotInt(P_PACER_SPIN_US, FramePacer.spinNanos() / 1000L); + Tracy.plotInt(P_PACER_MARGIN_US, FramePacer.marginNanos() / 1000L); + Tracy.plotInt(P_PACER_PRESENT_INTERVAL_US, FramePacer.presentIntervalNanos() / 1000L); + Tracy.plotInt(P_PACER_IDLE_OVERSHOOT_US, FramePacer.idleOvershootNanos() / 1000L); + Tracy.plotInt(P_PACER_CEILING_HZ, FramePacer.effectiveCapHz()); + Tracy.plotInt(P_PACER_LOCKED, FramePacer.locked() ? 1 : 0); + Tracy.plotInt(P_PACER_PROBING, FramePacer.probing() ? 1 : 0); + final int chunksUpdated = WorldRenderer.chunksUpdated; Tracy.plotInt(P_CHUNK_UPDATES, chunksUpdated - lastChunkUpdates); lastChunkUpdates = chunksUpdated; diff --git a/src/main/java/com/gtnewhorizons/angelica/proxy/ClientProxy.java b/src/main/java/com/gtnewhorizons/angelica/proxy/ClientProxy.java index d7df082ae..6823f6757 100644 --- a/src/main/java/com/gtnewhorizons/angelica/proxy/ClientProxy.java +++ b/src/main/java/com/gtnewhorizons/angelica/proxy/ClientProxy.java @@ -63,6 +63,7 @@ import com.gtnewhorizons.angelica.mixins.interfaces.IGameSettingsExt; import com.gtnewhorizons.angelica.render.CloudRenderer; import com.gtnewhorizons.angelica.render.EmissiveTextureAutoloader; import com.gtnewhorizons.angelica.rendering.AngelicaBlockSafetyRegistry; +import com.gtnewhorizons.angelica.rendering.FpsReducer; import com.gtnewhorizons.angelica.rendering.FramePacer; import com.gtnewhorizons.angelica.rendering.celeritas.CeleritasDebugScreenHandler; import com.gtnewhorizons.angelica.rendering.celeritas.CeleritasSetup; @@ -187,6 +188,7 @@ public final class ClientProxy extends CommonProxy { if (AngelicaConfig.enableZoom) { Zoom.init(); } + FpsReducer.init(); AngelicaConfig.applyGpuCullingMode(); if (AngelicaConfig.enableDynamicLights) { EntityLightConfig.init(new java.io.File(mc.mcDataDir, "config")); diff --git a/src/main/java/com/gtnewhorizons/angelica/rendering/FpsReducer.java b/src/main/java/com/gtnewhorizons/angelica/rendering/FpsReducer.java new file mode 100644 index 000000000..ae1a9c9b6 --- /dev/null +++ b/src/main/java/com/gtnewhorizons/angelica/rendering/FpsReducer.java @@ -0,0 +1,173 @@ +package com.gtnewhorizons.angelica.rendering; + +import com.gtnewhorizons.angelica.rendering.ReducerStateMachine.State; + +import com.gtnewhorizons.angelica.AngelicaMod; +import com.gtnewhorizons.angelica.glsm.GLStateManager; +import com.gtnewhorizons.angelica.proxy.ClientProxy; +import cpw.mods.fml.client.registry.ClientRegistry; +import cpw.mods.fml.common.FMLCommonHandler; +import cpw.mods.fml.common.eventhandler.SubscribeEvent; +import cpw.mods.fml.common.gameevent.TickEvent; +import me.jellysquid.mods.sodium.client.gui.SodiumGameOptions; +import net.minecraft.client.Minecraft; +import net.minecraft.client.audio.SoundCategory; +import net.minecraft.client.audio.SoundHandler; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.client.resources.I18n; +import net.minecraft.client.settings.KeyBinding; +import net.minecraftforge.client.event.GuiScreenEvent; +import net.minecraftforge.client.event.RenderGameOverlayEvent; +import net.minecraftforge.common.MinecraftForge; +import org.lwjgl.input.Mouse; +import org.lwjgl.opengl.Display; + +public final class FpsReducer { + + private static final ReducerStateMachine MACHINE = new ReducerStateMachine(System::nanoTime); + private static final FpsReducer INSTANCE = new FpsReducer(); + + private static KeyBinding forceKey; + private static KeyBinding disableKey; + + private static State applied; + private static int appliedVolumePercent = 100; + private static boolean configDirty; + private static String hudText; + private static long lastActiveMillis = Minecraft.getSystemTime(); + private static volatile int volumePercent = 100; + + private FpsReducer() {} + + public static void init() { + forceKey = new KeyBinding("angelica.keybind.fps_reducer_force", 0, "key.categories.misc"); + disableKey = new KeyBinding("angelica.keybind.fps_reducer_disable", 0, "key.categories.misc"); + ClientRegistry.registerKeyBinding(forceKey); + ClientRegistry.registerKeyBinding(disableKey); + FMLCommonHandler.instance().bus().register(INSTANCE); + MinecraftForge.EVENT_BUS.register(INSTANCE); + } + + public static void evaluateFrame() { + final SodiumGameOptions.ReducerSettings s = ClientProxy.options().reducer; + final boolean created = s.enabled && Display.isCreated(); + final boolean visible = !created || Display.isVisible(); + final boolean active = !created || Display.isActive(); + final boolean changed = MACHINE.evaluate(s.enabled, visible, active, s.idleTimeoutMinutes * 60_000_000_000L); + if (changed || configDirty) { + applyTransition(s); + } + } + + private static void applyTransition(SodiumGameOptions.ReducerSettings s) { + final State prev = applied; + final State next = MACHINE.state(); + applied = next; + configDirty = false; + + if (prev != null && prev != next) { + AngelicaMod.LOGGER.info("FPS reducer: {} -> {}", prev, next); + } + + final int percent = ReducerStateMachine.stateVolume(next, s.unfocusedVolume, s.minimizedVolume, s.idleVolume); + if (percent != appliedVolumePercent) { + appliedVolumePercent = percent; + applyVolume(percent); + } + + if (next == State.FORCED) { + hudText = I18n.format("angelica.fps_reducer.hud.forced"); + } else if (MACHINE.keybindDisabled()) { + hudText = I18n.format("angelica.fps_reducer.hud.disabled"); + } else { + hudText = null; + } + + GLStateManager.setPresentSuppressed(next == State.MINIMIZED); + } + + public static int effectiveCap(int userLimit, boolean inMenu) { + final SodiumGameOptions.ReducerSettings s = ClientProxy.options().reducer; + final int stateCap = ReducerStateMachine.stateCap(MACHINE.state(), s.unfocusedFpsLimit, s.idleFpsLimit); + return ReducerStateMachine.mergeCap(userLimit, stateCap, inMenu, s.limitMenuFrameRate); + } + + public static boolean skipRender() { + return MACHINE.state() == State.MINIMIZED; + } + + public static void onRenderSkipped() { + final Minecraft mc = Minecraft.getMinecraft(); + if (Display.isActive()) { + lastActiveMillis = Minecraft.getSystemTime(); + return; + } + if (mc.theWorld == null || mc.currentScreen != null || !mc.gameSettings.pauseOnLostFocus) return; + if (mc.gameSettings.touchscreen && Mouse.isButtonDown(1)) return; + if (Minecraft.getSystemTime() - lastActiveMillis > 500L) { + mc.displayInGameMenu(); + } + } + + public static float scaleVolume(float user) { + return ReducerStateMachine.masterVolume(user, volumePercent); + } + + private static void applyVolume(int percent) { + volumePercent = percent; + final Minecraft mc = Minecraft.getMinecraft(); + final SoundHandler handler = mc.getSoundHandler(); + if (handler == null || mc.gameSettings == null) return; + final float user = mc.gameSettings.getSoundLevel(SoundCategory.MASTER); + handler.setSoundLevel(SoundCategory.MASTER, scaleVolume(user)); + } + + public static void markConfigChanged() { + configDirty = true; + } + + public static String debugTag() { + return applied == null ? null : applied.debugTag(); + } + + public static void onInput() { + MACHINE.onInput(); + } + + @SubscribeEvent + public void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + + if (forceKey != null) { + while (forceKey.isPressed()) { + MACHINE.toggleForced(); + configDirty = true; + } + } + if (disableKey != null) { + while (disableKey.isPressed()) { + MACHINE.toggleKeybindDisabled(); + configDirty = true; + } + } + } + + private static void drawHud(boolean requireNoWorld) { + final Minecraft mc = Minecraft.getMinecraft(); + if (mc.gameSettings.showDebugInfo) return; + if (requireNoWorld && mc.theWorld != null) return; + mc.fontRenderer.drawStringWithShadow(hudText, 2, 2, 0xFFFFFF); + } + + @SubscribeEvent + public void onRenderOverlay(RenderGameOverlayEvent.Post event) { + if (event.type != RenderGameOverlayEvent.ElementType.ALL || hudText == null) return; + drawHud(false); + } + + @SubscribeEvent + public void onDrawScreen(GuiScreenEvent.DrawScreenEvent.Post event) { + if (hudText == null || !(event.gui instanceof GuiMainMenu)) return; + drawHud(true); + } +} diff --git a/src/main/java/com/gtnewhorizons/angelica/rendering/FramePacer.java b/src/main/java/com/gtnewhorizons/angelica/rendering/FramePacer.java index df8b7a990..3e9d9506b 100644 --- a/src/main/java/com/gtnewhorizons/angelica/rendering/FramePacer.java +++ b/src/main/java/com/gtnewhorizons/angelica/rendering/FramePacer.java @@ -2,163 +2,199 @@ package com.gtnewhorizons.angelica.rendering; import com.gtnewhorizons.angelica.AngelicaMod; import com.gtnewhorizons.angelica.glsm.GLStateManager; +import com.gtnewhorizons.angelica.glsm.backend.RenderBackend.GateSample; +import com.gtnewhorizons.angelica.glsm.backend.RenderBackend; import com.gtnewhorizons.angelica.glsm.backend.VSyncMode; import com.gtnewhorizons.angelica.glsm.profiling.Tracy; import com.gtnewhorizons.angelica.glsm.profiling.TracyBackend; -import java.util.concurrent.locks.LockSupport; +import java.util.function.LongConsumer; + +import static com.gtnewhorizons.angelica.glsm.backend.BackendManager.RENDER_BACKEND; public final class FramePacer { - public interface IdleWork { - void run(long deadlineNanos); + interface Backend { + VSyncMode effectiveVSyncMode(); + long refreshPeriodNanos(); + boolean vsyncHonored(); + int displayGeneration(); + void awaitPresent(); + void readGateSample(GateSample out); + void pumpDisplayMessages(); + void parkNanos(long nanos); + long nanoTime(); } - private static final int MIN_PLAUSIBLE_REFRESH_HZ = 20; - private static final int MAX_PLAUSIBLE_REFRESH_HZ = 1000; - private static final long IDLE_WORK_MIN_BUDGET_NANOS = 500_000L; - private static final long GATE_BLOCK_MIN_NANOS = 100_000L; - private static final int GATE_STREAK_FRAMES = 60; - static final int GATE_SETTLE_FRAMES = 120; - static final int PACING_HEADROOM_PERCENT = 5; + private static final class RenderBackendAdapter implements Backend { + @Override public VSyncMode effectiveVSyncMode() { return RENDER_BACKEND.getEffectiveVSyncMode(); } + @Override public long refreshPeriodNanos() { return RENDER_BACKEND.refreshPeriodNanos(); } + @Override public boolean vsyncHonored() { return RENDER_BACKEND.vsyncHonored(); } + @Override public int displayGeneration() { return RENDER_BACKEND.displayGeneration(); } + @Override public void awaitPresent() { RENDER_BACKEND.awaitPresent(); } + @Override public void readGateSample(GateSample out) { RENDER_BACKEND.readGateSample(out); } + @Override public void pumpDisplayMessages() { GLStateManager.pumpDisplayMessages(); } + @Override public void parkNanos(long nanos) { RENDER_BACKEND.parkNanos(nanos); } + @Override public long nanoTime() { return System.nanoTime(); } + } private static final Tracy.ZoneId Z_PACER_WAIT = Tracy.zoneId("pacerBackpressure", Tracy.COLOR_SWAP); - private static final Tracy.ZoneId Z_PACER_IDLE = Tracy.zoneId("pacerIdleWork", Tracy.COLOR_CLIENT); - private static final Tracy.ZoneId Z_PACER_SLEEP = Tracy.zoneId("pacerSleep", Tracy.COLOR_SWAP); - private static final long P_FRAME_GATE_US = Tracy.plotHandle("frameGateUs"); - private static final long P_SLACK_US = Tracy.plotHandle("pacer.slackUs"); - private static final long P_SPIN_US = Tracy.plotHandle("pacer.spinUs"); - private static final long P_CEILING_HZ = Tracy.plotHandle("pacer.ceilingHz"); - private static final long P_GATE_ACTIVE = Tracy.plotHandle("pacer.gateActive"); + private static final Tracy.ZoneId Z_PACER_BEGIN = Tracy.zoneId("pacerBegin", Tracy.COLOR_CLIENT); + private static final Tracy.ZoneId Z_PACER_PRESENT_SLEEP = Tracy.zoneId("pacerPresentSleep", Tracy.COLOR_SWAP); - private static long deadline; - private static long lastFrameTime; + + private static Backend BACKEND = new RenderBackendAdapter(); + + private static void parkNanos(long nanos) { + BACKEND.parkNanos(nanos); + } + + private static long nanoTime() { + return BACKEND.nanoTime(); + } + + private static PacerSleeper sleeper = new PacerSleeper(FramePacer::nanoTime, FramePacer::parkNanos, true); + static PacerCore core = new PacerCore(FramePacer::nanoTime, sleeper); + private static final GateSample sample = new GateSample(); private static boolean warnedUncapped; - private static boolean pacedLastFrame; - private static boolean statsActive; + private static int lastDisplayGeneration; - private static VSyncMode lastVSyncMode = VSyncMode.ON; - private static int lastCapHz; - private static int lastRefreshHz; + private static VSyncMode currentMode = VSyncMode.ON; + private static int currentRefreshHz; + private static int currentCapHz; + private static int currentEffectiveCapHz; - private static IdleWork idleWork; - private static PacerSleeper sleeper = newSleeper(); - private static int gateStreak; - private static Boolean settledBlocking; - private static boolean candidateBlocking; - private static int candidateFrames; - private static Boolean loggedBlocking; private static VSyncMode loggedMode; private static int loggedRefreshHz; private static int loggedCapHz; - - private static long lastSeenGateEnd; + private static int loggedEffectiveCapHz; + private static boolean loggedLocked; + private static boolean loggedProbing; private FramePacer() {} - private static PacerSleeper newSleeper() { - return new PacerSleeper(System::nanoTime, LockSupport::parkNanos, true); + public static void setIdleWork(LongConsumer work) { + core.setIdleWork(work); } - public static void setIdleWork(IdleWork work) { - idleWork = work; + public static void invalidate() { + core.invalidate(); } public static boolean pacedLastFrame() { - return pacedLastFrame; + return core.paced(); } public static void beginStats() { sleeper.resetStats(); - statsActive = true; } public static String endStats() { - statsActive = false; final String summary = sleeper.summary(configLine()); Tracy.message(summary); return summary; } - static boolean statsActive() { - return statsActive; - } - - static String configLine(VSyncMode mode, int refreshHz, int capHz, int ceilingHz) { + private static String configLine(VSyncMode mode, int refreshHz, int capHz, int ceilingHz) { return "cfg=[" + mode + " refresh=" + refreshHz + "Hz cap=" + capHz + " ceiling=" + ceilingHz + "Hz]"; } private static String configLine() { - return configLine(lastVSyncMode, lastRefreshHz, lastCapHz, - pacingCeilingHz(lastVSyncMode, lastCapHz, lastRefreshHz, gateActive())); + return configLine(currentMode, currentRefreshHz, currentCapHz, currentEffectiveCapHz); } - public static int plausibleRefreshHz(int hz) { - return hz >= MIN_PLAUSIBLE_REFRESH_HZ && hz <= MAX_PLAUSIBLE_REFRESH_HZ ? hz : 0; - } - - public static int withHeadroom(int hz, int percent) { - if (hz <= 0) return 0; - return (int) Math.max(hz + 1L, Math.round(hz * (100 + percent) / 100.0)); - } - - public static int pacingCeilingHz(VSyncMode mode, int capHz, int refreshHz, boolean gateActive) { + public static long endFrame(int capHz, Runnable renderAheadWait) { + final VSyncMode mode = BACKEND.effectiveVSyncMode(); final int cap = Math.max(capHz, 0); - final int backstop = backstopHz(mode, refreshHz, gateActive); - if (cap == 0) return backstop; - if (backstop == 0) return cap; - return Math.min(cap, backstop); - } + final long refreshPeriodNanos = BACKEND.refreshPeriodNanos(); + final boolean honored = BACKEND.vsyncHonored(); + final int refreshHz = refreshHz(mode, cap, refreshPeriodNanos); - static int backstopHz(VSyncMode mode, int refreshHz, boolean gateActive) { - if (!mode.tearFree()) return 0; - final int refresh = plausibleRefreshHz(refreshHz); - if (mode == VSyncMode.ON && gateActive) return withHeadroom(refresh, PACING_HEADROOM_PERCENT); - return refresh; - } + core.setRefreshPeriodNanos(refreshPeriodNanos); + core.setTearFree(mode.tearFree()); + core.setVsyncOn(mode == VSyncMode.ON && honored); + core.setCap(cap); - static boolean observeGate(long gateNanos, long thresholdNanos) { - if (gateNanos > thresholdNanos) gateStreak = GATE_STREAK_FRAMES; - else if (gateStreak > 0) gateStreak--; - return gateStreak > 0; - } - - static boolean gateActive() { - return gateStreak > 0; - } - - static Boolean settleGateBlocking(boolean observed) { - if (settledBlocking != null && observed == settledBlocking) { - candidateFrames = 0; - } else if (candidateFrames == 0 || observed != candidateBlocking) { - candidateBlocking = observed; - candidateFrames = 1; - } else if (++candidateFrames >= GATE_SETTLE_FRAMES) { - settledBlocking = observed; - candidateFrames = 0; + final int generation = BACKEND.displayGeneration(); + if (generation != lastDisplayGeneration) { + lastDisplayGeneration = generation; + core.invalidate(); } - return settledBlocking; - } - static boolean vsyncNotHonoured(VSyncMode mode, boolean gateBlocking) { - return mode == VSyncMode.ON && !gateBlocking; - } - - static boolean pacingStatusChanged(boolean blocking, VSyncMode mode, int refreshHz, int capHz) { - final Boolean settled = settleGateBlocking(blocking); - if (settled == null) return false; - if (settled.equals(loggedBlocking) && mode == loggedMode && refreshHz == loggedRefreshHz && capHz == loggedCapHz) { - return false; + if (mode == VSyncMode.ON && honored) { + BACKEND.awaitPresent(); + } + BACKEND.readGateSample(sample); + core.onGateSample(sample.durationNanos, sample.endNanos); + core.onGpuWait(sample.gpuWaitNanos); + + Tracy.beginZone(Z_PACER_WAIT); + try { + if (renderAheadWait != null) { + final long waitStart = nanoTime(); + renderAheadWait.run(); + core.onGpuWait(nanoTime() - waitStart); + } + } finally { + Tracy.endZone(); + } + + Tracy.beginZone(Z_PACER_BEGIN); + try { + core.beginFrame(nanoTime()); + } finally { + Tracy.endZone(); + } + + BACKEND.pumpDisplayMessages(); + core.markFrameStart(nanoTime()); + + final int effectiveCapHz = core.effectiveCapHz(); + final boolean locked = core.locked(); + final boolean probing = core.probing(); + + currentMode = mode; + currentRefreshHz = refreshHz; + currentCapHz = cap; + currentEffectiveCapHz = effectiveCapHz; + + maybeLog(mode, refreshHz, cap, effectiveCapHz, locked, probing); + + return core.lastPresentIntervalNanos(); + } + + public static void beforePresent() { + final long firstClearBlockNanos = GLStateManager.takeFirstClearBlockNanos(); + Tracy.beginZone(Z_PACER_PRESENT_SLEEP); + try { + core.beforePresent(nanoTime(), firstClearBlockNanos); + } finally { + Tracy.endZone(); + } + } + + + private static void maybeLog(VSyncMode mode, int refreshHz, int capHz, int effectiveCapHz, boolean locked, boolean probing) { + if (mode == loggedMode && refreshHz == loggedRefreshHz && capHz == loggedCapHz + && effectiveCapHz == loggedEffectiveCapHz && locked == loggedLocked && probing == loggedProbing) { + return; } - loggedBlocking = settled; loggedMode = mode; loggedRefreshHz = refreshHz; loggedCapHz = capHz; - return true; + loggedEffectiveCapHz = effectiveCapHz; + loggedLocked = locked; + loggedProbing = probing; + + final String line = pacingLine(mode, refreshHz, capHz, effectiveCapHz, locked, probing); + final boolean wallClock = mode == VSyncMode.ON && !locked && !probing; + if (wallClock) AngelicaMod.LOGGER.warn(line); + else AngelicaMod.LOGGER.info(line); + Tracy.message(line, wallClock ? TracyBackend.SEVERITY_WARNING : TracyBackend.SEVERITY_INFO); } - static String pacingLine(VSyncMode mode, int refreshHz, int capHz, boolean gateBlocking) { + static String pacingLine(VSyncMode mode, int refreshHz, int capHz, int effectiveCapHz, boolean locked, boolean probing) { final StringBuilder sb = new StringBuilder("Frame pacing: "); sb.append(switch (mode) { case OFF -> "vsync off"; @@ -166,141 +202,68 @@ public final class FramePacer { case ON -> "vsync"; }); if (mode.tearFree() && refreshHz > 0) sb.append(' ').append(refreshHz).append("Hz"); - if (capHz > 0) sb.append(", cap ").append(capHz); - if (vsyncNotHonoured(mode, gateBlocking)) sb.append(", driver not blocking, paced by wall clock"); - else if (gateBlocking) sb.append(", hardware paced"); + if (mode == VSyncMode.ON) { + if (locked) sb.append(", hardware paced"); + else if (probing) sb.append(", probing"); + else sb.append(", driver not blocking, paced by wall clock"); + } + if (capHz > 0) { + sb.append(", cap ").append(capHz); + if (effectiveCapHz > 0 && effectiveCapHz != capHz) sb.append(" -> ").append(effectiveCapHz); + } return sb.toString(); } - static long gateBlockThresholdNanos(long targetNanos) { - return Math.max(GATE_BLOCK_MIN_NANOS, targetNanos / 32); - } - - public static long nextDeadline(long previousDeadline, long now, long targetNanos) { - if (previousDeadline == 0L || now - previousDeadline > 2 * targetNanos) return now + targetNanos; - return previousDeadline + targetNanos; - } - - public static long endFrame(int capHz, Runnable renderAheadWait) { - lastVSyncMode = GLStateManager.getEffectiveVSyncMode(); - lastCapHz = Math.max(capHz, 0); - lastRefreshHz = refreshHz(); - return pace(pacingCeilingHz(lastVSyncMode, lastCapHz, lastRefreshHz, gateActive()), renderAheadWait); - } - - public static long pace(int ceilingHz, Runnable renderAheadWait) { - long now = System.nanoTime(); - long phaseEnd; - - final long targetNanos = ceilingHz > 0 ? 1_000_000_000L / ceilingHz : 0L; - final long thresholdNanos = gateBlockThresholdNanos(targetNanos); - long frameGateNanos = 0L; - - pacedLastFrame = ceilingHz > 0; - Tracy.plotInt(P_CEILING_HZ, ceilingHz); - - if (ceilingHz > 0) { - frameGateNanos = GLStateManager.lastFrameGateNanos(); - final long gateEnd = GLStateManager.lastFrameGateEndNanos(); - Tracy.plotInt(P_FRAME_GATE_US, frameGateNanos / 1000); - - if (gateEnd != lastSeenGateEnd) { - lastSeenGateEnd = gateEnd; - if (frameGateNanos > thresholdNanos && GLStateManager.gateAnchorsNextFrameStart()) { - deadline = gateEnd; - } - } - deadline = nextDeadline(deadline, now, targetNanos); - - final IdleWork work = idleWork; - if (work != null && deadline - now > IDLE_WORK_MIN_BUDGET_NANOS) { - Tracy.beginZone(Z_PACER_IDLE); - try { - work.run(deadline); - } finally { - Tracy.endZone(); - } - phaseEnd = System.nanoTime(); - now = phaseEnd; - } - - final long slackNanos = deadline - now; - Tracy.plotInt(P_SLACK_US, slackNanos / 1000); - - final boolean slept = now < deadline; - if (slept) { - Tracy.beginZone(Z_PACER_SLEEP); - try { - now = sleeper.sleepUntil(deadline, now); - } finally { - Tracy.endZone(); - } - } - if (Tracy.ENABLED || statsActive) { - sleeper.noteFrame(slackNanos, slept ? now - deadline : 0L, slept); - Tracy.plotInt(P_SPIN_US, sleeper.lastSpinNanos / 1000); - } - } else { - deadline = 0L; - } - - Tracy.beginZone(Z_PACER_WAIT); - try { - if (renderAheadWait != null) renderAheadWait.run(); - } finally { - Tracy.endZone(); - } - phaseEnd = System.nanoTime(); - final long waitNanos = phaseEnd - now; - now = phaseEnd; - if (ceilingHz > 0 && waitNanos > thresholdNanos) deadline = now; - final boolean gateBlocking = observeGate(ceilingHz > 0 ? Math.max(frameGateNanos, waitNanos) : 0L, thresholdNanos); - Tracy.plotInt(P_GATE_ACTIVE, gateBlocking ? 1 : 0); - - GLStateManager.pumpDisplayMessages(); - - if (pacingStatusChanged(gateBlocking, lastVSyncMode, lastRefreshHz, lastCapHz)) { - final boolean blocking = settledBlocking; - final String line = pacingLine(lastVSyncMode, lastRefreshHz, lastCapHz, blocking); - final boolean notHonoured = vsyncNotHonoured(lastVSyncMode, blocking); - if (notHonoured) AngelicaMod.LOGGER.warn(line); - else AngelicaMod.LOGGER.info(line); - Tracy.message(line, notHonoured ? TracyBackend.SEVERITY_WARNING : TracyBackend.SEVERITY_INFO); - } - - final long period = lastFrameTime == 0L ? 0L : now - lastFrameTime; - lastFrameTime = now; - return period; - } - - private static int refreshHz() { - final int reported = GLStateManager.getDisplayRefreshRateHz(); - final int hz = plausibleRefreshHz(reported); - - if (hz == 0 && lastCapHz == 0 && lastVSyncMode.tearFree() && !warnedUncapped) { + private static int refreshHz(VSyncMode mode, int capHz, long refreshPeriodNanos) { + final int hz = RenderBackend.hzFromPeriod(refreshPeriodNanos); + if (hz == 0 && capHz == 0 && mode.tearFree() && !warnedUncapped) { warnedUncapped = true; - AngelicaMod.LOGGER.warn("Display refresh rate reported as {}Hz, so the frame rate cannot be bounded if the driver ignores vsync. Set Max Framerate to bound it.", reported); + AngelicaMod.LOGGER.warn("Display refresh rate is unknown, so the frame rate cannot be bounded if the driver ignores vsync. Set Max Framerate to bound it."); } return hz; } - public static String debugIndicator(VSyncMode mode, int refreshHz, int capHz, boolean gateActive) { + static String debugIndicator(VSyncMode mode, int refreshHz, int capHz, int effectiveCapHz, boolean locked, boolean probing) { final int cap = Math.max(capHz, 0); - final boolean showCap = cap > 0 && cap == pacingCeilingHz(mode, cap, refreshHz, gateActive); + final boolean showCap = cap > 0; if (!mode.tearFree() && !showCap) return null; final StringBuilder sb = new StringBuilder(" ["); if (mode.tearFree()) { sb.append(mode == VSyncMode.MAILBOX ? "mailbox" : "vsync"); - final int refresh = plausibleRefreshHz(refreshHz); - if (refresh > 0) sb.append(' ').append(refresh); - if (showCap) sb.append(", "); + if (refreshHz > 0) sb.append(' ').append(refreshHz); + if (mode == VSyncMode.ON) { + if (probing) sb.append(", probing"); + else if (!locked) sb.append(", wall clock"); + } + if (showCap) sb.append(", cap ").append(cap).append(" -> ").append(effectiveCapHz); + } else { + sb.append("cap ").append(cap); } - if (showCap) sb.append("cap ").append(cap); return sb.append(']').toString(); } + public static long gateDurationNanos() { return sample.durationNanos; } + + public static long gpuWaitNanos() { return core.lastGpuWaitNanos(); } + + public static long slackNanos() { return core.slackNanos(); } + + public static long spinNanos() { return sleeper.lastFrameSpinNanos; } + + public static long marginNanos() { return core.marginNanos(); } + + public static long presentIntervalNanos() { return core.lastPresentIntervalNanos(); } + + public static long idleOvershootNanos() { return core.lastIdleOvershootNanos(); } + + public static int effectiveCapHz() { return core.effectiveCapHz(); } + + public static boolean locked() { return core.locked(); } + + public static boolean probing() { return core.probing(); } + public static String debugIndicator() { - return debugIndicator(lastVSyncMode, lastRefreshHz, lastCapHz, gateActive()); + return debugIndicator(currentMode, currentRefreshHz, currentCapHz, currentEffectiveCapHz, core.locked(), core.probing()); } } diff --git a/src/main/java/com/gtnewhorizons/angelica/rendering/PacerCore.java b/src/main/java/com/gtnewhorizons/angelica/rendering/PacerCore.java new file mode 100644 index 000000000..d616847fc --- /dev/null +++ b/src/main/java/com/gtnewhorizons/angelica/rendering/PacerCore.java @@ -0,0 +1,374 @@ +package com.gtnewhorizons.angelica.rendering; + +import com.google.common.primitives.Longs; +import com.gtnewhorizons.angelica.glsm.backend.RenderBackend; + +import java.util.function.LongConsumer; +import java.util.function.LongSupplier; + +final class PacerCore { + + private static final long NANOS_PER_SECOND = 1_000_000_000L; + private static final long MARGIN_BASE_NANOS = 500_000L; + static final long MARGIN_MIN_NANOS = 1_000_000L; + static final long MARGIN_CEILING_NANOS = 4_000_000L; + private static final long GATE_THRESHOLD_FLOOR_NANOS = 100_000L; + private static final long IDLE_MIN_BUDGET_NANOS = 500_000L; + private static final long PRESENT_SLOP_NANOS = 50_000L; + + private static final int PROBE_LEAD_PERIODS = 2; + static final int PROBE_LEAD_DIVISOR = 8; + static final int LOCK_PAIRS = 3; + static final int LOCK_FRAMES = 600; + static final int LOCK_WINDOW = 16; + private static final int CAP_EQ_DIVISOR = 32; + private static final int SNAP_TOL_DIVISOR = 8; + static final int CATCHUP_DIVISOR = 8; + private static final int GATE_THRESHOLD_DIVISOR = 32; + private static final int LOCK_TOL_DIVISOR = 8; + private static final int MARGIN_MAX_DIVISOR = 4; + private static final int STALL_MULTIPLE = 2; + private static final int TARGET_RESET_PERIODS = 2; + + private static final int CPU_SLOTS = 16; + private static final int OVERRUN_SLOTS = 128; + + private final LongSupplier clock; + private final PacerSleeper sleeper; + private LongConsumer idleWork; + + private long refreshPeriodNanos; + private boolean tearFree = true; + private boolean vsyncOn = true; + private int capHz; + + private long cadenceNanos; + private long targetNanos; + private boolean haveTarget; + private long startDeadlineNanos; + private long pEffPrevNanos; + private boolean havePEffPrev; + private long frameStartNanos; + private long lastPresentCallNanos; + private boolean havePresent; + private long lastPresentIntervalNanos; + + private final long[] cpuRing = new long[CPU_SLOTS]; + private final long[] residualRing = new long[OVERRUN_SLOTS]; + private final long[] idleRing = new long[OVERRUN_SLOTS]; + private int cpuSlot; + private int residualSlot; + private int idleSlot; + + private boolean locked; + private boolean probing; + private boolean probeExhausted; + private long probeLeadNanos; + private boolean haveCandidate; + private long candidateEndNanos; + private int framesSinceCandidate; + private int consistentPairs; + private int inconsistentPairs; + + private boolean pendingSample; + private long pendingDurationNanos; + private long pendingEndNanos; + private long pendingGpuWaitNanos; + private long lastGateEndNanos; + private boolean haveGateEnd; + + private long cpuHighNanos; + private long marginNanos; + private long slackNanos; + private long lastGpuWaitNanos; + private long lastIdleOvershootNanos; + + private boolean frameDriverPaced; + private boolean frameProbing; + private boolean frameAnchor; + private long frameWakeLateNanos; + + PacerCore(LongSupplier clock, PacerSleeper sleeper) { + this.clock = clock; + this.sleeper = sleeper; + } + + void setIdleWork(LongConsumer work) { + this.idleWork = work; + } + + void invalidate() { + dropLock(); + } + + void setRefreshPeriodNanos(long periodNanos) { + final long p = Math.max(periodNanos, 0L); + if (p == refreshPeriodNanos) return; + refreshPeriodNanos = p; + dropLock(); + } + + void setTearFree(boolean value) { + if (value == tearFree) return; + tearFree = value; + dropLock(); + } + + void setVsyncOn(boolean value) { + if (value == vsyncOn) return; + vsyncOn = value; + dropLock(); + } + + void setCap(int hz) { + final int cap = Math.max(hz, 0); + if (cap == capHz) return; + capHz = cap; + armProbe(); + } + + void onGpuWait(long nanos) { + if (nanos > 0L) pendingGpuWaitNanos += nanos; + } + + void onGateSample(long durationNanos, long endNanos) { + if (durationNanos <= 0L) return; + pendingSample = true; + pendingDurationNanos = durationNanos; + pendingEndNanos = endNanos; + } + + long beginFrame(long now) { + final boolean sample = pendingSample && (!haveGateEnd || pendingEndNanos > lastGateEndNanos); + final long sampleEnd = sample ? pendingEndNanos : 0L; + final long sampleDuration = sample ? pendingDurationNanos : 0L; + if (sample) { + lastGateEndNanos = pendingEndNanos; + haveGateEnd = true; + } + pendingSample = false; + + if (sample && (!havePresent || sampleEnd > lastPresentCallNanos)) { + pEffPrevNanos = sampleEnd; + havePEffPrev = true; + } else if (havePresent) { + pEffPrevNanos = lastPresentCallNanos; + havePEffPrev = true; + } + lastGpuWaitNanos = pendingGpuWaitNanos; + pendingGpuWaitNanos = 0L; + + final long c = cadencePeriodNanos(capHz, refreshPeriodNanos, tearFree); + final boolean candidate = sample && vsyncOn && tearFree && refreshPeriodNanos > 0L + && sampleDuration > gateThresholdNanos(c); + updateLock(candidate, sampleEnd); + + cadenceNanos = c; + frameWakeLateNanos = 0L; + if (c == 0L) { + probing = false; + frameProbing = false; + frameDriverPaced = false; + frameAnchor = false; + haveTarget = false; + armProbe(); + startDeadlineNanos = now; + slackNanos = 0L; + marginNanos = 0L; + lastIdleOvershootNanos = 0L; + return now; + } + + probing = vsyncOn && !locked && !probeExhausted && c == refreshPeriodNanos; + final long step = probing ? c - c / PROBE_LEAD_DIVISOR : c; + if (probing) { + probeLeadNanos += c - step; + probeExhausted = probeLeadNanos >= PROBE_LEAD_PERIODS * c; + } + + frameAnchor = !haveTarget; + if (frameAnchor) { + targetNanos = now; + haveTarget = true; + } else if (locked && candidate) { + long m = (now - sampleEnd) / c + 1L; + if (m < 1L) m = 1L; + targetNanos = sampleEnd + m * c; + } else { + long advanced = targetNanos + step; + if (havePEffPrev) { + final long floor = pEffPrevNanos + c - c / CATCHUP_DIVISOR; + if (floor > advanced) advanced = floor; + } + targetNanos = advanced; + } + if (targetNanos - now > TARGET_RESET_PERIODS * c) targetNanos = now + c; + + frameDriverPaced = driverPaced(); + frameProbing = probing; + cpuHighNanos = Math.max(0L, Longs.max(cpuRing)); + marginNanos = clampMargin(MARGIN_BASE_NANOS + Longs.max(residualRing), c); + final long justInTime = targetNanos - cpuHighNanos - marginNanos; + startDeadlineNanos = frameDriverPaced || frameAnchor ? now : justInTime; + + final long idleDeadline = frameDriverPaced || frameAnchor ? justInTime : startDeadlineNanos; + final LongConsumer work = idleWork; + lastIdleOvershootNanos = 0L; + if (work != null && idleDeadline - now - Longs.max(idleRing) > IDLE_MIN_BUDGET_NANOS) { + work.accept(idleDeadline); + final long idleEnd = clock.getAsLong(); + lastIdleOvershootNanos = Math.max(0L, idleEnd - idleDeadline); + idleRing[idleSlot] = lastIdleOvershootNanos; + idleSlot = (idleSlot + 1) % OVERRUN_SLOTS; + now = idleEnd; + } + + slackNanos = startDeadlineNanos - now; + if (now < startDeadlineNanos) { + now = sleeper.sleepUntil(startDeadlineNanos, now); + if (now > startDeadlineNanos) frameWakeLateNanos = now - startDeadlineNanos; + } + return now; + } + + void markFrameStart(long now) { + frameStartNanos = now; + } + + long beforePresent(long now, long firstClearBlockNanos) { + final long c = cadenceNanos; + if (c > 0L) { + final long cpu = now - frameStartNanos - firstClearBlockNanos; + if (cpu <= c * STALL_MULTIPLE) { + cpuRing[cpuSlot] = Math.min(cpu, c); + cpuSlot = (cpuSlot + 1) % CPU_SLOTS; + residualRing[residualSlot] = cpu - cpuHighNanos - (startDeadlineNanos - frameStartNanos); + residualSlot = (residualSlot + 1) % OVERRUN_SLOTS; + } else { + armProbe(); + } + + if (!frameDriverPaced && !frameProbing && !frameAnchor) { + if (targetNanos - now > PRESENT_SLOP_NANOS) { + now = sleeper.sleepUntil(targetNanos, now); + final long late = now - targetNanos; + if (late > frameWakeLateNanos) frameWakeLateNanos = late; + } + } + sleeper.noteFrame(slackNanos, frameWakeLateNanos); + } + + lastPresentIntervalNanos = havePresent ? now - lastPresentCallNanos : 0L; + lastPresentCallNanos = now; + havePresent = true; + return now; + } + + boolean paced() { + return cadenceNanos > 0L; + } + + boolean locked() { + return locked; + } + + boolean probing() { + return probing; + } + + private boolean driverPaced() { + return locked && cadenceNanos > 0L && cadenceNanos == refreshPeriodNanos; + } + + + int effectiveCapHz() { + return cadenceNanos == 0L ? 0 : RenderBackend.hzFromPeriod(cadenceNanos); + } + + long marginNanos() { + return marginNanos; + } + + + long slackNanos() { + return slackNanos; + } + + long lastGpuWaitNanos() { + return lastGpuWaitNanos; + } + + long lastIdleOvershootNanos() { + return lastIdleOvershootNanos; + } + + long lastPresentIntervalNanos() { + return lastPresentIntervalNanos; + } + + static long cadencePeriodNanos(int capHz, long refreshPeriodNanos, boolean tearFree) { + final long cap = Math.max(capHz, 0); + if (tearFree && refreshPeriodNanos > 0L) { + if (cap == 0L || cap * refreshPeriodNanos * CAP_EQ_DIVISOR >= NANOS_PER_SECOND * (CAP_EQ_DIVISOR - 1)) { + return refreshPeriodNanos; + } + final long capPeriod = NANOS_PER_SECOND / cap; + long k = (capPeriod + refreshPeriodNanos / 2) / refreshPeriodNanos; + if (k < 1L) k = 1L; + final long snapped = k * refreshPeriodNanos; + return Math.abs(snapped - capPeriod) <= capPeriod / SNAP_TOL_DIVISOR ? snapped : capPeriod; + } + return cap > 0L ? NANOS_PER_SECOND / cap : 0L; + } + + static long gateThresholdNanos(long cadenceNanos) { + return Math.max(GATE_THRESHOLD_FLOOR_NANOS, cadenceNanos / GATE_THRESHOLD_DIVISOR); + } + + static long clampMargin(long value, long cadenceNanos) { + final long ceiling = Math.min(cadenceNanos / MARGIN_MAX_DIVISOR, MARGIN_CEILING_NANOS); + return Math.min(Math.max(value, MARGIN_MIN_NANOS), ceiling); + } + + private void updateLock(boolean candidate, long sampleEnd) { + if (!candidate) { + if (framesSinceCandidate < Integer.MAX_VALUE) framesSinceCandidate++; + if (locked && framesSinceCandidate >= LOCK_FRAMES) dropLock(); + return; + } + if (haveCandidate && framesSinceCandidate <= LOCK_WINDOW) { + final long delta = sampleEnd - candidateEndNanos; + final long k = (delta + refreshPeriodNanos / 2) / refreshPeriodNanos; + if (k >= 1L && Math.abs(delta - k * refreshPeriodNanos) <= refreshPeriodNanos / LOCK_TOL_DIVISOR) { + inconsistentPairs = 0; + if (++consistentPairs >= LOCK_PAIRS) locked = true; + } else { + consistentPairs = 0; + if (++inconsistentPairs >= LOCK_WINDOW) { + dropLock(); + probeExhausted = true; + } + } + } + candidateEndNanos = sampleEnd; + haveCandidate = true; + framesSinceCandidate = 0; + } + + private void armProbe() { + probeLeadNanos = 0L; + probeExhausted = false; + } + + private void dropLock() { + armProbe(); + locked = false; + haveCandidate = false; + candidateEndNanos = 0L; + framesSinceCandidate = 0; + consistentPairs = 0; + inconsistentPairs = 0; + } + + +} diff --git a/src/main/java/com/gtnewhorizons/angelica/rendering/PacerSleeper.java b/src/main/java/com/gtnewhorizons/angelica/rendering/PacerSleeper.java index 7248a3959..f739eb9f3 100644 --- a/src/main/java/com/gtnewhorizons/angelica/rendering/PacerSleeper.java +++ b/src/main/java/com/gtnewhorizons/angelica/rendering/PacerSleeper.java @@ -8,6 +8,7 @@ final class PacerSleeper { private static final long MIN_PARK_NANOS = 1_000_000L; private static final long MAX_OVERSHOOT_NANOS = 4_000_000L; static final int SLOTS = 10; + static final long LATE_TOL_NANOS = 250_000L; static final long MIN_SPIN_FLOOR_NANOS = 20_000L; private static final long MAX_SPIN_FLOOR_NANOS = 500_000L; @@ -20,8 +21,9 @@ final class PacerSleeper { private long spinFloorNanos = MIN_SPIN_FLOOR_NANOS; private int offset; - long lastSpinNanos; - int lastParks; + long lastFrameSpinNanos; + private int frameParks; + private long frameSpinNanos; private long frames; private long slackSum; @@ -35,13 +37,8 @@ final class PacerSleeper { this.spinWait = spinWait; } - long spinFloorNanos() { - return spinFloorNanos; - } long sleepUntil(long deadlineNanos, long now) { - resetLastFrame(); - long spinFloor = spinFloorNanos; while (true) { final long remaining = deadlineNanos - now; @@ -59,7 +56,7 @@ final class PacerSleeper { now = clock.getAsLong(); record(Math.min(Math.max(0L, now - before - request), MAX_OVERSHOOT_NANOS)); spinFloor = spinFloorNanos; - lastParks++; + frameParks++; } if (spinWait) { @@ -69,27 +66,28 @@ final class PacerSleeper { else Thread.onSpinWait(); now = clock.getAsLong(); } - lastSpinNanos = now - spinStart; + frameSpinNanos += now - spinStart; } return now; } - void noteFrame(long slackNanos, long wakeLateNanos, boolean slept) { - if (!slept) resetLastFrame(); - + void noteFrame(long slackNanos, long wakeLateNanos) { frames++; slackSum += slackNanos; - parkTotal += lastParks; - if (slackNanos < 0L && -slackNanos > lateMax) lateMax = -slackNanos; - if (slackNanos < 0L || wakeLateNanos > 0L) lateFrames++; - } - - private void resetLastFrame() { - lastSpinNanos = 0L; - lastParks = 0; + parkTotal += frameParks; + frameParks = 0; + lastFrameSpinNanos = frameSpinNanos; + frameSpinNanos = 0L; + if (wakeLateNanos > LATE_TOL_NANOS) { + lateFrames++; + if (wakeLateNanos > lateMax) lateMax = wakeLateNanos; + } } void resetStats() { + frameParks = 0; + frameSpinNanos = 0L; + lastFrameSpinNanos = 0L; frames = 0L; slackSum = 0L; parkTotal = 0L; diff --git a/src/main/java/com/gtnewhorizons/angelica/rendering/ReducerStateMachine.java b/src/main/java/com/gtnewhorizons/angelica/rendering/ReducerStateMachine.java new file mode 100644 index 000000000..fc5460cbf --- /dev/null +++ b/src/main/java/com/gtnewhorizons/angelica/rendering/ReducerStateMachine.java @@ -0,0 +1,126 @@ +package com.gtnewhorizons.angelica.rendering; + +import me.jellysquid.mods.sodium.client.gui.FrameRateOptions; + +import java.util.function.LongSupplier; + +final class ReducerStateMachine { + + enum State { + DISABLED(null), + FORCED(" [reducer: forced]"), + MINIMIZED(" [reducer: minimized]"), + UNFOCUSED(" [reducer: unfocused]"), + IDLE(" [reducer: idle]"), + FOCUSED(null); + + private final String debugTag; + + State(String debugTag) { + this.debugTag = debugTag; + } + + String debugTag() { + return debugTag; + } + } + + static final int MAX_FPS_SETTING = FrameRateOptions.MAX_FRAMERATE; + static final int MINIMIZED_LOOP_HZ = 20; + static final int MENU_CAP_HZ = 30; + static final float MIN_VOLUME_MULT = 0.01f; + + private final LongSupplier clock; + private State state = State.FOCUSED; + private long lastInputNanos; + private boolean forced; + private boolean keybindDisabled; + + ReducerStateMachine(LongSupplier clock) { + this.clock = clock; + this.lastInputNanos = clock.getAsLong(); + } + + void onInput() { + lastInputNanos = clock.getAsLong(); + } + + void toggleForced() { + forced = !forced; + onInput(); + } + + void toggleKeybindDisabled() { + keybindDisabled = !keybindDisabled; + onInput(); + } + + boolean keybindDisabled() { + return keybindDisabled; + } + + State state() { + return state; + } + + boolean evaluate(boolean enabled, boolean visible, boolean active, long idleTimeoutNanos) { + final State next; + if (!enabled || keybindDisabled) { + next = State.DISABLED; + } else if (forced) { + next = State.FORCED; + } else if (!visible) { + next = State.MINIMIZED; + } else if (!active) { + next = State.UNFOCUSED; + } else if (idleTimeoutNanos > 0 && clock.getAsLong() - lastInputNanos >= idleTimeoutNanos) { + next = State.IDLE; + } else { + next = State.FOCUSED; + } + + final boolean changed = next != state; + state = next; + return changed; + } + + static int stateCap(State state, int unfocusedFps, int idleFps) { + return switch (state) { + case FORCED, UNFOCUSED -> uncapIfMax(unfocusedFps); + case MINIMIZED -> MINIMIZED_LOOP_HZ; + case IDLE -> uncapIfMax(idleFps); + default -> 0; + }; + } + + static int stateVolume(State state, int unfocusedVol, int minimizedVol, int idleVol) { + return switch (state) { + case FORCED, UNFOCUSED -> unfocusedVol; + case MINIMIZED -> minimizedVol; + case IDLE -> idleVol; + default -> 100; + }; + } + + static int mergeCap(int userLimit, int stateCap, boolean inMenu, boolean limitMenu) { + int cap = uncapIfMax(userLimit); + if (inMenu && limitMenu) { + cap = min0(cap, MENU_CAP_HZ); + } + return min0(cap, stateCap); + } + + static float masterVolume(float user, int percent) { + return percent >= 100 ? user : user * Math.max(MIN_VOLUME_MULT, percent / 100f); + } + + private static int uncapIfMax(int value) { + return value >= MAX_FPS_SETTING ? 0 : value; + } + + private static int min0(int a, int b) { + if (a == 0) return b; + if (b == 0) return a; + return Math.min(a, b); + } +} diff --git a/src/main/java/me/jellysquid/mods/sodium/client/gui/FrameRateOptions.java b/src/main/java/me/jellysquid/mods/sodium/client/gui/FrameRateOptions.java index 935c30f66..c27372af4 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/gui/FrameRateOptions.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/gui/FrameRateOptions.java @@ -19,6 +19,9 @@ import java.util.Locale; public record FrameRateOptions(Option vsync, Option maxFramerate) { + public static final int MIN_FRAMERATE = 5; + public static final int MAX_FRAMERATE = 260; + public static FrameRateOptions create(MinecraftOptionsStorage vanillaOpts, SodiumOptionsStorage sodiumOpts) { final VSyncMode[] allowed = selectableModes(); final String[] names = modeNames(VSyncMode.values()); @@ -40,7 +43,7 @@ public record FrameRateOptions(Option vsync, Option maxFrame final OptionImpl maxFramerate = OptionImpl.createBuilder(int.class, vanillaOpts) .setName(I18n.format("options.framerateLimit")) .setTooltip(I18n.format("sodium.options.fps_limit.tooltip")) - .setControl(option -> new SliderControl(option, 5, 260, 1, ControlValueFormatter.fpsLimit())) + .setControl(option -> new SliderControl(option, MIN_FRAMERATE, MAX_FRAMERATE, 1, ControlValueFormatter.fpsLimit())) .setBinding((opts, value) -> opts.limitFramerate = value, opts -> opts.limitFramerate) .build(); diff --git a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptionPages.java b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptionPages.java index 2a2e84799..30a8e1847 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptionPages.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptionPages.java @@ -3,6 +3,7 @@ package me.jellysquid.mods.sodium.client.gui; import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.function.BiConsumer; import java.util.function.IntSupplier; import java.util.function.Supplier; @@ -15,6 +16,7 @@ import com.cardinalstar.cubicchunks.api.compat.CubicChunksVideoSettings; import com.google.common.collect.ImmutableList; import com.gtnewhorizons.angelica.compat.ModStatus; import com.gtnewhorizons.angelica.config.AngelicaConfig; +import com.gtnewhorizons.angelica.rendering.FpsReducer; import com.gtnewhorizons.angelica.rendering.celeritas.MultiDrawModeResolver; import com.gtnewhorizons.angelica.rendering.culling.GpuCulling; import com.gtnewhorizons.angelica.config.GpuCullingMode; @@ -56,6 +58,13 @@ public class SodiumGameOptionPages { private static final int MIN_RENDER_AHEAD = 0; private static final int MAX_RENDER_AHEAD = 9; + private static BiConsumer reducerBinding(BiConsumer setter) { + return (opts, value) -> { + setter.accept(opts, value); + FpsReducer.markConfigChanged(); + }; + } + public static OptionPage general() { final List groups = new ArrayList<>(); final OptionGroup.Builder firstGroupBuilder = OptionGroup.createBuilder(); @@ -586,6 +595,91 @@ public class SodiumGameOptionPages { return new OptionPage(I18n.format("sodium.options.pages.performance"), ImmutableList.copyOf(groups)); } + public static OptionPage fpsReducer() { + final List groups = new ArrayList<>(); + + final FrameRateOptions frameRate = FrameRateOptions.create(vanillaOpts, sodiumOpts); + groups.add(OptionGroup.createBuilder() + .add(frameRate.vsync()) + .add(frameRate.maxFramerate()) + .build()); + + groups.add(OptionGroup.createBuilder() + .add(OptionImpl.createBuilder(boolean.class, sodiumOpts) + .setName(I18n.format("sodium.options.fps_reducer_enabled.name")) + .setTooltip(I18n.format("sodium.options.fps_reducer_enabled.tooltip")) + .setControl(TickBoxControl::new) + .setBinding(reducerBinding((opts, value) -> opts.reducer.enabled = value), + options -> options.reducer.enabled) + .setImpact(OptionImpact.LOW) + .build()) + .add(OptionImpl.createBuilder(int.class, sodiumOpts) + .setName(I18n.format("sodium.options.unfocused_fps_limit.name")) + .setTooltip(I18n.format("sodium.options.unfocused_fps_limit.tooltip")) + .setControl(option -> new SliderControl(option, FrameRateOptions.MIN_FRAMERATE, FrameRateOptions.MAX_FRAMERATE, 1, ControlValueFormatter.fpsLimit())) + .setBinding(reducerBinding((opts, value) -> opts.reducer.unfocusedFpsLimit = value), + options -> options.reducer.unfocusedFpsLimit) + .setImpact(OptionImpact.LOW) + .build()) + .add(OptionImpl.createBuilder(int.class, sodiumOpts) + .setName(I18n.format("sodium.options.unfocused_volume.name")) + .setTooltip(I18n.format("sodium.options.unfocused_volume.tooltip")) + .setControl(option -> new SliderControl(option, 0, 100, 1, ControlValueFormatter.percentage())) + .setBinding(reducerBinding((opts, value) -> opts.reducer.unfocusedVolume = value), + options -> options.reducer.unfocusedVolume) + .setImpact(OptionImpact.LOW) + .build()) + .add(OptionImpl.createBuilder(int.class, sodiumOpts) + .setName(I18n.format("sodium.options.minimized_volume.name")) + .setTooltip(I18n.format("sodium.options.minimized_volume.tooltip")) + .setControl(option -> new SliderControl(option, 0, 100, 1, ControlValueFormatter.percentage())) + .setBinding(reducerBinding((opts, value) -> opts.reducer.minimizedVolume = value), + options -> options.reducer.minimizedVolume) + .setImpact(OptionImpact.LOW) + .build()) + .build()); + + groups.add(OptionGroup.createBuilder() + .add(OptionImpl.createBuilder(int.class, sodiumOpts) + .setName(I18n.format("sodium.options.idle_timeout.name")) + .setTooltip(I18n.format("sodium.options.idle_timeout.tooltip")) + .setControl(option -> new SliderControl(option, 0, 30, 1, ControlValueFormatter.quantityOrDisabled("sodium.options.idle_timeout.value", "sodium.options.idle_timeout.off"))) + .setBinding(reducerBinding((opts, value) -> opts.reducer.idleTimeoutMinutes = value), + options -> options.reducer.idleTimeoutMinutes) + .setImpact(OptionImpact.LOW) + .build()) + .add(OptionImpl.createBuilder(int.class, sodiumOpts) + .setName(I18n.format("sodium.options.idle_fps_limit.name")) + .setTooltip(I18n.format("sodium.options.idle_fps_limit.tooltip")) + .setControl(option -> new SliderControl(option, FrameRateOptions.MIN_FRAMERATE, FrameRateOptions.MAX_FRAMERATE, 1, ControlValueFormatter.fpsLimit())) + .setBinding(reducerBinding((opts, value) -> opts.reducer.idleFpsLimit = value), + options -> options.reducer.idleFpsLimit) + .setImpact(OptionImpact.LOW) + .build()) + .add(OptionImpl.createBuilder(int.class, sodiumOpts) + .setName(I18n.format("sodium.options.idle_volume.name")) + .setTooltip(I18n.format("sodium.options.idle_volume.tooltip")) + .setControl(option -> new SliderControl(option, 0, 100, 1, ControlValueFormatter.percentage())) + .setBinding(reducerBinding((opts, value) -> opts.reducer.idleVolume = value), + options -> options.reducer.idleVolume) + .setImpact(OptionImpact.LOW) + .build()) + .build()); + + groups.add(OptionGroup.createBuilder() + .add(OptionImpl.createBuilder(boolean.class, sodiumOpts) + .setName(I18n.format("sodium.options.limit_menu_fps.name")) + .setTooltip(I18n.format("sodium.options.limit_menu_fps.tooltip")) + .setControl(TickBoxControl::new) + .setBinding(reducerBinding((opts, value) -> opts.reducer.limitMenuFrameRate = value), + options -> options.reducer.limitMenuFrameRate) + .setImpact(OptionImpact.LOW) + .build()) + .build()); + + return new OptionPage(I18n.format("sodium.options.pages.fps_reducer"), ImmutableList.copyOf(groups)); + } + public static OptionImpl anisotropicFilteringSlider(MinecraftOptionsStorage storage, Supplier mode, IntSupplier mipmapLevels) { final int min = SodiumGameOptions.minAnisotropyLevel(); diff --git a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptions.java b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptions.java index 0ee6b3563..493d901c4 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptions.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumGameOptions.java @@ -38,6 +38,7 @@ public class SodiumGameOptions { public final QualitySettings quality = new QualitySettings(); public final AdvancedSettings advanced = new AdvancedSettings(); public final PerformanceSettings performance = new PerformanceSettings(); + public final ReducerSettings reducer = new ReducerSettings(); private Path configPath; public static void recordAtlasMipmapClamp(int requestedMipmapLevels, int actualMipmapLevels) { @@ -242,4 +243,15 @@ public class SodiumGameOptions { public boolean useCeleritasSmoothLighting = true; public TextureFilterMode textureFilterMode = TextureFilterMode.RGSS; } + + public static class ReducerSettings { + public boolean enabled = false; + public int unfocusedFpsLimit = 10; + public int unfocusedVolume = 100; + public int minimizedVolume = 100; + public int idleTimeoutMinutes = 5; + public int idleFpsLimit = 30; + public int idleVolume = 100; + public boolean limitMenuFrameRate = true; + } } diff --git a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumOptionsGUI.java b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumOptionsGUI.java index aeb181557..6eb93fdf3 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumOptionsGUI.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/gui/SodiumOptionsGUI.java @@ -61,6 +61,7 @@ public class SodiumOptionsGUI extends ScrollableGuiScreen { this.pages.add(SodiumGameOptionPages.quality()); this.pages.add(SodiumGameOptionPages.advanced()); this.pages.add(SodiumGameOptionPages.performance()); + this.pages.add(SodiumGameOptionPages.fpsReducer()); this.pages.add(SodiumGameOptionPages.appearance()); this.pages.add(SodiumGameOptionPages.text()); diff --git a/src/main/java/me/jellysquid/mods/sodium/client/gui/options/control/ControlValueFormatter.java b/src/main/java/me/jellysquid/mods/sodium/client/gui/options/control/ControlValueFormatter.java index c670d3889..b6e94207b 100644 --- a/src/main/java/me/jellysquid/mods/sodium/client/gui/options/control/ControlValueFormatter.java +++ b/src/main/java/me/jellysquid/mods/sodium/client/gui/options/control/ControlValueFormatter.java @@ -1,5 +1,6 @@ package me.jellysquid.mods.sodium.client.gui.options.control; +import me.jellysquid.mods.sodium.client.gui.FrameRateOptions; import net.minecraft.client.resources.I18n; import java.util.Locale; @@ -10,7 +11,7 @@ public interface ControlValueFormatter { } static ControlValueFormatter fpsLimit() { - return (v) -> (v == 260) ? I18n.format("options.framerateLimit.max") : I18n.format("options.framerate", v); + return (v) -> (v == FrameRateOptions.MAX_FRAMERATE) ? I18n.format("options.framerateLimit.max") : I18n.format("options.framerate", v); } static ControlValueFormatter droppedItemLimitLimit() { return (v) -> (v == 2048) ? I18n.format("options.droppedItems.max") : I18n.format("options.droppedItems", v); diff --git a/src/main/resources/assets/angelica/lang/en_US.lang b/src/main/resources/assets/angelica/lang/en_US.lang index 397d7f12f..f68868919 100644 --- a/src/main/resources/assets/angelica/lang/en_US.lang +++ b/src/main/resources/assets/angelica/lang/en_US.lang @@ -15,6 +15,7 @@ sodium.options.pages.performance=Performance sodium.options.pages.advanced=Advanced sodium.options.pages.appearance=Appearance sodium.options.pages.text=Text Effects +sodium.options.pages.fps_reducer=FPS Reducer sodium.options.view_distance.tooltip=The render distance controls how far away terrain will be rendered. Shorter distances mean that less terrain will be rendered, improving frame rates. sodium.options.simulation_distance.tooltip=The simulation distance controls how far away terrain and entities will be loaded and ticked. Shorter distances can reduce the internal server's load and may improve frame rates. sodium.options.cc.vertical_view_distance.name=Vertical View Distance @@ -80,6 +81,24 @@ sodium.options.translucency_sorting.tooltip=If enabled, translucent blocks will sodium.options.cpu_render_ahead_limit.name=CPU Render-Ahead Limit sodium.options.cpu_render_ahead_limit.tooltip=How many frames the CPU may run ahead of the GPU. Lower values cut input latency, higher values smooth out uneven frame times. Very low or high values may create frame rate instability. 0 disables it; the SDL-GPU backend ignores it and applies its own backpressure. sodium.options.cpu_render_ahead_limit.value=%s frame(s) +sodium.options.fps_reducer_enabled.name=Reduce FPS in Background +sodium.options.fps_reducer_enabled.tooltip=Lowers the frame rate and volume while the game is in the background. +sodium.options.unfocused_fps_limit.name=Unfocused Framerate +sodium.options.unfocused_fps_limit.tooltip=Frame rate limit while another window has focus. +sodium.options.unfocused_volume.name=Unfocused Volume +sodium.options.unfocused_volume.tooltip=Master volume while another window has focus. +sodium.options.minimized_volume.name=Minimized Volume +sodium.options.minimized_volume.tooltip=Master volume while the game is minimized. +sodium.options.idle_timeout.name=Idle Timeout +sodium.options.idle_timeout.tooltip=Minutes without input before the idle limits apply. +sodium.options.idle_timeout.value=%s minute(s) +sodium.options.idle_timeout.off=Off +sodium.options.idle_fps_limit.name=Idle Framerate +sodium.options.idle_fps_limit.tooltip=Frame rate limit while idle. +sodium.options.idle_volume.name=Idle Volume +sodium.options.idle_volume.tooltip=Master volume while idle. +sodium.options.limit_menu_fps.name=Limit Menu Framerate +sodium.options.limit_menu_fps.tooltip=Caps the frame rate at 30 while no world is loaded. sodium.options.allow_direct_memory_access.name=Allow Direct Memory Access sodium.options.allow_direct_memory_access.tooltip=If enabled, some critical code paths will be allowed to use direct memory access for performance. This often greatly reduces CPU overhead for chunk and entity rendering, but can make it harder to diagnose some bugs and crashes. You should only disable this if you've been asked to or otherwise know what you're doing. sodium.options.ignore_driver_blacklist.name=Ignore Driver Blacklist @@ -129,6 +148,10 @@ iris.keybind.reload=Reload Shaders iris.keybind.toggleShaders=Toggle Shaders iris.keybind.shaderPackSelection=Shaderpack Selection Screen angelica.keybind.zoom=Zoom +angelica.keybind.fps_reducer_force=Force FPS Reducer +angelica.keybind.fps_reducer_disable=Toggle FPS Reducer +angelica.fps_reducer.hud.forced=FPS reducer: forced +angelica.fps_reducer.hud.disabled=FPS reducer: disabled options.iris.apply=Apply options.iris.refresh=Refresh diff --git a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft.java b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft.java index 2bb2ae326..843dcafb9 100644 --- a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft.java +++ b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft.java @@ -6,9 +6,12 @@ import com.gtnewhorizons.angelica.glsm.ffp.ShaderManager; import com.gtnewhorizons.angelica.glsm.streaming.TessellatorStreamingDrawer; import com.gtnewhorizons.angelica.mixins.interfaces.IGameSettingsExt; import com.gtnewhorizons.angelica.proxy.ClientProxy; +import com.gtnewhorizons.angelica.rendering.FpsReducer; import com.gtnewhorizons.angelica.rendering.FramePacer; import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.multiplayer.WorldClient; import net.minecraft.client.settings.GameSettings; import org.embeddedt.embeddium.impl.render.frame.RenderAheadManager; import org.lwjgl.input.Keyboard; @@ -29,10 +32,10 @@ public abstract class MixinMinecraft { public GameSettings gameSettings; @Shadow - public abstract boolean isFramerateLimitBelowMax(); + public WorldClient theWorld; @Shadow - public abstract int getLimitFramerate(); + public GuiScreen currentScreen; @Shadow(remap = false) private static int max_texture_size; @@ -40,6 +43,9 @@ public abstract class MixinMinecraft { @Unique private final RenderAheadManager celeritas$renderAheadManager = new RenderAheadManager(); + @Unique + private static boolean angelica$hadWorld; + /** * @author mitchej123 * @reason Avoid GL_PROXY_TEXTURE_2D which doesn't work with GLSM's texture binding. @@ -75,17 +81,25 @@ public abstract class MixinMinecraft { } @Inject(method = "runGameLoop", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;func_147120_f()V")) - private void celeritas$renderAheadFence(CallbackInfo ci) { + private void angelica$beforePresent(CallbackInfo ci) { if (ClientProxy.options().performance.cpuRenderAheadLimit > 0 && !GLStateManager.hasSwapchainBackpressure()) { celeritas$renderAheadManager.endFrame(); } + FramePacer.beforePresent(); } @WrapWithCondition(method = "runGameLoop", at = @At(value = "INVOKE", target = "Ljava/lang/Thread;yield()V", remap = false)) private boolean angelica$limitFPS() { if (AngelicaMod.proxy == null) return true; - final int capHz = isFramerateLimitBelowMax() ? getLimitFramerate() : 0; + final boolean hasWorld = theWorld != null; + if (hasWorld != angelica$hadWorld) { + angelica$hadWorld = hasWorld; + FramePacer.invalidate(); + } + + FpsReducer.evaluateFrame(); + final int capHz = FpsReducer.effectiveCap(gameSettings.limitFramerate, theWorld == null && currentScreen != null); AngelicaMod.proxy.putFrametime(FramePacer.endFrame(capHz, angelica$renderAheadWait)); return !FramePacer.pacedLastFrame(); } diff --git a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft_FrameHook.java b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft_FrameHook.java index e997379c1..538366238 100644 --- a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft_FrameHook.java +++ b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft_FrameHook.java @@ -56,8 +56,8 @@ public class MixinMinecraft_FrameHook { @Unique private static void angelica$pushFrameGroup() { CaptureGate.refresh(); - GLDebug.pushGroup("frame:" + angelica$frameCounter); - angelica$frameGroupActive = true; + angelica$frameGroupActive = GLDebug.isActive(); + if (angelica$frameGroupActive) GLDebug.pushGroup("frame:", (int) angelica$frameCounter); } @Unique @@ -65,8 +65,8 @@ public class MixinMinecraft_FrameHook { if (angelica$frameGroupActive) { GLDebug.popGroup(); angelica$frameGroupActive = false; - angelica$frameCounter++; } + angelica$frameCounter++; } @Inject(method = "shutdownMinecraftApplet", at = @At("HEAD")) diff --git a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft_IconifyGuard.java b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft_IconifyGuard.java index 395f2751d..2c68e0ac0 100644 --- a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft_IconifyGuard.java +++ b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/MixinMinecraft_IconifyGuard.java @@ -2,6 +2,7 @@ package com.gtnewhorizons.angelica.mixins.early.angelica; import com.gtnewhorizons.angelica.event.ClientEvent; import com.gtnewhorizons.angelica.event.ClientEventType; +import com.gtnewhorizons.angelica.rendering.FpsReducer; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.EntityRenderer; import net.minecraft.client.shader.Framebuffer; @@ -19,7 +20,8 @@ public class MixinMinecraft_IconifyGuard { private void angelica$skipRenderWhenIconified(EntityRenderer entityRenderer, float partialTicks) { ClientEvent.post(ClientEventType.ON_TICK); final Framebuffer fb = Minecraft.getMinecraft().getFramebuffer(); - if (fb == null || fb.framebufferWidth < 16 || fb.framebufferHeight < 16) { + if (fb == null || fb.framebufferWidth < 16 || fb.framebufferHeight < 16 || FpsReducer.skipRender()) { + FpsReducer.onRenderSkipped(); return; } entityRenderer.updateCameraAndRender(partialTicks); diff --git a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/debug/MixinMinecraft_FPSCap.java b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/debug/MixinMinecraft_FPSCap.java index 738fcc7c9..6e750ab96 100644 --- a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/debug/MixinMinecraft_FPSCap.java +++ b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/debug/MixinMinecraft_FPSCap.java @@ -1,5 +1,6 @@ package com.gtnewhorizons.angelica.mixins.early.angelica.debug; +import com.gtnewhorizons.angelica.rendering.FpsReducer; import com.gtnewhorizons.angelica.rendering.FramePacer; import net.minecraft.client.Minecraft; import net.minecraft.client.settings.GameSettings; @@ -9,9 +10,6 @@ import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -/** - * Adds FPS cap indicator to the F3 debug string. - */ @Mixin(Minecraft.class) public class MixinMinecraft_FPSCap { @@ -23,7 +21,9 @@ public class MixinMinecraft_FPSCap { private void angelica$appendFPSCapInfo(CallbackInfo ci) { if (!this.gameSettings.showDebugInfo) return; final String indicator = FramePacer.debugIndicator(); - if (indicator == null) return; - this.debug = this.debug.replace(" fps,", " fps" + indicator + ","); + final String tag = FpsReducer.debugTag(); + if (indicator == null && tag == null) return; + final String suffix = indicator == null ? tag : (tag == null ? indicator : indicator + tag); + this.debug = this.debug.replace(" fps,", " fps" + suffix + ","); } } diff --git a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinGameSettings_FpsReducerVolume.java b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinGameSettings_FpsReducerVolume.java new file mode 100644 index 000000000..913bde20f --- /dev/null +++ b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinGameSettings_FpsReducerVolume.java @@ -0,0 +1,18 @@ +package com.gtnewhorizons.angelica.mixins.early.angelica.fpsreducer; + +import com.gtnewhorizons.angelica.rendering.FpsReducer; +import net.minecraft.client.audio.SoundCategory; +import net.minecraft.client.audio.SoundHandler; +import net.minecraft.client.settings.GameSettings; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(GameSettings.class) +public class MixinGameSettings_FpsReducerVolume { + + @Redirect(method = "setSoundLevel", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/audio/SoundHandler;setSoundLevel(Lnet/minecraft/client/audio/SoundCategory;F)V")) + private void angelica$redirectSetSoundLevel(SoundHandler handler, SoundCategory category, float level) { + handler.setSoundLevel(category, category == SoundCategory.MASTER ? FpsReducer.scaleVolume(level) : level); + } +} diff --git a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinGuiScreen_FpsReducerInput.java b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinGuiScreen_FpsReducerInput.java new file mode 100644 index 000000000..5f2131471 --- /dev/null +++ b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinGuiScreen_FpsReducerInput.java @@ -0,0 +1,20 @@ +package com.gtnewhorizons.angelica.mixins.early.angelica.fpsreducer; + +import com.gtnewhorizons.angelica.rendering.FpsReducer; +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import net.minecraft.client.gui.GuiScreen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +@Mixin(GuiScreen.class) +public class MixinGuiScreen_FpsReducerInput { + + @ModifyExpressionValue(method = "handleInput", at = { + @At(value = "INVOKE", target = "Lorg/lwjgl/input/Mouse;next()Z", remap = false), + @At(value = "INVOKE", target = "Lorg/lwjgl/input/Keyboard;next()Z", remap = false) + }) + private boolean angelica$onInputNext(boolean original) { + if (original) FpsReducer.onInput(); + return original; + } +} diff --git a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinMinecraft_FpsReducerInput.java b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinMinecraft_FpsReducerInput.java new file mode 100644 index 000000000..a5c7195f6 --- /dev/null +++ b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinMinecraft_FpsReducerInput.java @@ -0,0 +1,20 @@ +package com.gtnewhorizons.angelica.mixins.early.angelica.fpsreducer; + +import com.gtnewhorizons.angelica.rendering.FpsReducer; +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +@Mixin(Minecraft.class) +public class MixinMinecraft_FpsReducerInput { + + @ModifyExpressionValue(method = "runTick", at = { + @At(value = "INVOKE", target = "Lorg/lwjgl/input/Mouse;next()Z", remap = false), + @At(value = "INVOKE", target = "Lorg/lwjgl/input/Keyboard;next()Z", remap = false) + }) + private boolean angelica$onInputNext(boolean original) { + if (original) FpsReducer.onInput(); + return original; + } +} diff --git a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinSoundManagerLoader_FpsReducerVolume.java b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinSoundManagerLoader_FpsReducerVolume.java new file mode 100644 index 000000000..27ae77fcc --- /dev/null +++ b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fpsreducer/MixinSoundManagerLoader_FpsReducerVolume.java @@ -0,0 +1,16 @@ +package com.gtnewhorizons.angelica.mixins.early.angelica.fpsreducer; + +import com.gtnewhorizons.angelica.rendering.FpsReducer; +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +@Mixin(targets = "net.minecraft.client.audio.SoundManager$1") +public class MixinSoundManagerLoader_FpsReducerVolume { + + @SuppressWarnings("UnresolvedMixinReference") + @ModifyExpressionValue(method = "run()V", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/settings/GameSettings;getSoundLevel(Lnet/minecraft/client/audio/SoundCategory;)F")) + private float angelica$scaleMasterOnReload(float original) { + return FpsReducer.scaleVolume(original); + } +} diff --git a/src/test/java/com/gtnewhorizons/angelica/rendering/FramePacerTest.java b/src/test/java/com/gtnewhorizons/angelica/rendering/FramePacerTest.java index 91e761ff9..13f94d66a 100644 --- a/src/test/java/com/gtnewhorizons/angelica/rendering/FramePacerTest.java +++ b/src/test/java/com/gtnewhorizons/angelica/rendering/FramePacerTest.java @@ -1,152 +1,252 @@ package com.gtnewhorizons.angelica.rendering; +import com.gtnewhorizons.angelica.glsm.backend.RenderBackend.GateSample; import com.gtnewhorizons.angelica.glsm.backend.VSyncMode; +import com.gtnewhorizons.angelica.glsm.testutil.Reflect; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.function.LongConsumer; +import java.util.function.LongSupplier; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class FramePacerTest { - @Test - void vsyncStillHonoursTheCap() { - assertEquals(60, FramePacer.pacingCeilingHz(VSyncMode.ON, 60, 144, true)); - assertEquals(30, FramePacer.pacingCeilingHz(VSyncMode.ON, 30, 144, false)); - } + private static final long CLOCK_TICK_NANOS = 1_000L; - @Test - void anActiveGateGetsTheHeadroomBackstop() { - assertEquals(151, FramePacer.pacingCeilingHz(VSyncMode.ON, 0, 144, true)); - assertEquals(151, FramePacer.pacingCeilingHz(VSyncMode.ON, 260, 144, true)); - } + private static final class FakeBackend implements FramePacer.Backend { + VSyncMode mode = VSyncMode.ON; + long refreshPeriodNanos; + boolean vsyncHonored = true; + int displayGeneration; + long gpuWaitNanos; + long gateDurationNanos; + long gateEndNanos; + long now; - @Test - void aQuietGateGetsThePlainRefreshBackstopToAvoidQueueSaturation() { - assertEquals(144, FramePacer.pacingCeilingHz(VSyncMode.ON, 0, 144, false)); - assertEquals(144, FramePacer.pacingCeilingHz(VSyncMode.ON, 260, 144, false)); - } + int awaitPresentCalls; + int readGateSampleCalls; + int parkCalls; + int pumpCalls; + boolean awaitedBeforeRead; + boolean readBeforePump; + boolean parkedBeforePump; - @Test - void mailboxGetsThePlainRefreshBackstopRegardlessOfTheGate() { - assertEquals(60, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 0, 60, false)); - assertEquals(60, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 0, 60, true)); - assertEquals(45, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 45, 60, false)); - } - - @Test - void vsyncOffIsBoundedOnlyByTheCap() { - assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.OFF, 0, 144, false)); - assertEquals(215, FramePacer.pacingCeilingHz(VSyncMode.OFF, 215, 144, false)); - assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.OFF, -1, 144, false)); - } - - @Test - void anUnknownRefreshRateLeavesTearFreeModesUnbounded() { - assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.ON, 0, 0, true)); - assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.ON, 0, 19, false)); - assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.ON, 0, 1001, true)); - assertEquals(215, FramePacer.pacingCeilingHz(VSyncMode.ON, 215, 1001, true)); - } - - @Test - void gateThresholdScalesWithThePeriodAboveAFloor() { - assertEquals(100_000L, FramePacer.gateBlockThresholdNanos(1_000_000L)); - assertEquals(173_611L, FramePacer.gateBlockThresholdNanos(5_555_555L)); - assertEquals(520_833L, FramePacer.gateBlockThresholdNanos(16_666_666L)); - } - - @Test - void implausibleRefreshIsIgnored() { - assertEquals(0, FramePacer.plausibleRefreshHz(0)); - assertEquals(0, FramePacer.plausibleRefreshHz(-1)); - assertEquals(0, FramePacer.plausibleRefreshHz(19)); - assertEquals(0, FramePacer.plausibleRefreshHz(1001)); - assertEquals(20, FramePacer.plausibleRefreshHz(20)); - assertEquals(1000, FramePacer.plausibleRefreshHz(1000)); - } - - @Test - void headroomRoundsToTheNearestHz() { - assertEquals(21, FramePacer.withHeadroom(20, FramePacer.PACING_HEADROOM_PERCENT)); - assertEquals(62, FramePacer.withHeadroom(59, FramePacer.PACING_HEADROOM_PERCENT)); - assertEquals(63, FramePacer.withHeadroom(60, FramePacer.PACING_HEADROOM_PERCENT)); - assertEquals(151, FramePacer.withHeadroom(144, FramePacer.PACING_HEADROOM_PERCENT)); - assertEquals(252, FramePacer.withHeadroom(240, FramePacer.PACING_HEADROOM_PERCENT)); - assertEquals(1050, FramePacer.withHeadroom(1000, FramePacer.PACING_HEADROOM_PERCENT)); - } - - @Test - void headroomAlwaysClearsTheRefreshRate() { - for (int hz = 20; hz <= 1000; hz++) { - assertTrue(FramePacer.withHeadroom(hz, FramePacer.PACING_HEADROOM_PERCENT) > hz, "headroom must exceed " + hz + "Hz"); - assertTrue(FramePacer.withHeadroom(hz, 0) > hz, "a zero-percent headroom must still exceed " + hz + "Hz"); + @Override + public VSyncMode effectiveVSyncMode() { + return mode; } - assertEquals(0, FramePacer.withHeadroom(0, FramePacer.PACING_HEADROOM_PERCENT)); - } - @Test - void theDeadlineAdvancesByExactlyOnePeriodDespiteJitter() { - final long target = 16_666_666L; - long deadline = FramePacer.nextDeadline(0L, 1_000L, target); - assertEquals(1_000L + target, deadline); + @Override + public long refreshPeriodNanos() { + return refreshPeriodNanos; + } - final long first = deadline; - final long[] wakeJitter = { -400_000L, 900_000L, -120_000L, 250_000L }; - for (int i = 0; i < wakeJitter.length; i++) { - deadline = FramePacer.nextDeadline(deadline, deadline + wakeJitter[i], target); - assertEquals(first + target * (i + 1), deadline, "jitter must not accumulate into the deadline"); + @Override + public boolean vsyncHonored() { + return vsyncHonored; + } + + @Override + public int displayGeneration() { + return displayGeneration; + } + + @Override + public void awaitPresent() { + awaitPresentCalls++; + } + + @Override + public void readGateSample(GateSample out) { + readGateSampleCalls++; + awaitedBeforeRead = awaitPresentCalls > 0; + out.durationNanos = gateDurationNanos; + out.endNanos = gateEndNanos; + out.gpuWaitNanos = gpuWaitNanos; + } + + @Override + public void pumpDisplayMessages() { + pumpCalls++; + readBeforePump = readGateSampleCalls > 0; + parkedBeforePump = parkCalls > 0; + } + + @Override + public void parkNanos(long nanos) { + parkCalls++; + now += nanos; + } + + @Override + public long nanoTime() { + final long t = now; + now += CLOCK_TICK_NANOS; + return t; } } - @Test - void aPeriodChangeKeepsThePhaseInsteadOfRestartingIt() { - final long previousDeadline = 1_000_000_000L; - final long newTarget = 1_000_000_000L / 76; - final long now = previousDeadline + 200_000L; - assertEquals(previousDeadline + newTarget, FramePacer.nextDeadline(previousDeadline, now, newTarget)); + @BeforeEach + void freshPacerState() { + final PacerSleeper previous = Reflect.getStatic(FramePacer.class, "sleeper"); + final LongSupplier clock = Reflect.get(previous, "clock"); + final LongConsumer parker = Reflect.get(previous, "parker"); + final PacerSleeper sleeper = new PacerSleeper(clock, parker, true); + Reflect.setStatic(FramePacer.class, "sleeper", sleeper); + Reflect.setStatic(FramePacer.class, "core", new PacerCore(clock, sleeper)); + Reflect.setStatic(FramePacer.class, "lastDisplayGeneration", 0); + Reflect.setStatic(FramePacer.class, "warnedUncapped", false); + Reflect.setStatic(FramePacer.class, "loggedMode", null); + Reflect.setStatic(FramePacer.class, "loggedRefreshHz", 0); + Reflect.setStatic(FramePacer.class, "loggedCapHz", 0); + Reflect.setStatic(FramePacer.class, "loggedEffectiveCapHz", 0); + Reflect.setStatic(FramePacer.class, "loggedLocked", false); + Reflect.setStatic(FramePacer.class, "loggedProbing", false); + } + + private static void withBackend(FramePacer.Backend fake, Runnable body) { + final FramePacer.Backend original = Reflect.getStatic(FramePacer.class, "BACKEND"); + Reflect.setStatic(FramePacer.class, "BACKEND", fake); + try { + body.run(); + } finally { + Reflect.setStatic(FramePacer.class, "BACKEND", original); + } + } + + @ParameterizedTest(name = "{0} refresh={1} cap={2}->{3} locked={4} probing={5}") + @CsvSource(nullValues = "null", value = { + "ON, 60, 0, 0, true, false, ' [vsync 60]'", + "ON, 60, 30, 30, true, false, ' [vsync 60, cap 30 -> 30]'", + "ON, 144, 50, 48, true, false, ' [vsync 144, cap 50 -> 48]'", + "MAILBOX, 144, 0, 0, false, false, ' [mailbox 144]'", + "OFF, 144, 100, 100, false, false, ' [cap 100]'", + "ON, 60, 0, 0, false, true, ' [vsync 60, probing]'", + "ON, 60, 0, 0, false, false, ' [vsync 60, wall clock]'", + "OFF, 144, 0, 0, false, false, null", + }) + void theIndicatorNamesEveryPacingState(VSyncMode mode, int refreshHz, int capHz, int effectiveCapHz, boolean locked, boolean probing, String expected) { + assertEquals(expected, FramePacer.debugIndicator(mode, refreshHz, capHz, effectiveCapHz, locked, probing)); + } + + @ParameterizedTest(name = "{0} refresh={1} cap={2}->{3} locked={4} probing={5}") + @CsvSource({ + "ON, 60, 0, 0, true, false, 'Frame pacing: vsync 60Hz, hardware paced'", + "ON, 60, 0, 0, false, true, 'Frame pacing: vsync 60Hz, probing'", + "ON, 60, 0, 0, false, false, 'Frame pacing: vsync 60Hz, driver not blocking, paced by wall clock'", + "MAILBOX, 144, 0, 0, true, false, 'Frame pacing: mailbox 144Hz'", + "OFF, 144, 100, 100, false, false, 'Frame pacing: vsync off, cap 100'", + "ON, 60, 30, 30, true, false, 'Frame pacing: vsync 60Hz, hardware paced, cap 30'", + "ON, 144, 50, 48, true, false, 'Frame pacing: vsync 144Hz, hardware paced, cap 50 -> 48'", + }) + void thePacingLineNamesEveryPacingState(VSyncMode mode, int refreshHz, int capHz, int effectiveCapHz, boolean locked, boolean probing, String expected) { + assertEquals(expected, FramePacer.pacingLine(mode, refreshHz, capHz, effectiveCapHz, locked, probing)); } @Test - void fallingMoreThanTwoPeriodsBehindResynchronises() { - final long target = 16_666_666L; - final long deadline = 1_000_000_000L; - final long lateNow = deadline + target * 3; - assertEquals(lateNow + target, FramePacer.nextDeadline(deadline, lateNow, target)); + void settersAreAppliedBeforeBeginFrame() { + final FakeBackend backend = new FakeBackend(); + backend.mode = VSyncMode.OFF; + backend.vsyncHonored = false; + withBackend(backend, () -> { + FramePacer.endFrame(60, null); + assertTrue(FramePacer.pacedLastFrame(), "a nonzero cap must pace even under vsync off"); + }); } @Test - void indicatorNamesWhatIsHoldingTheRateDown() { - assertEquals(" [vsync 144]", FramePacer.debugIndicator(VSyncMode.ON, 144, 0, false)); - assertEquals(" [vsync 144, cap 60]", FramePacer.debugIndicator(VSyncMode.ON, 144, 60, false)); - assertEquals(" [cap 215]", FramePacer.debugIndicator(VSyncMode.OFF, 144, 215, false)); - assertEquals(" [mailbox 144]", FramePacer.debugIndicator(VSyncMode.MAILBOX, 144, 0, false)); + void awaitPresentPrecedesReadGateSampleOnlyWhenVsyncOnAndHonored() { + final FakeBackend honored = new FakeBackend(); + honored.mode = VSyncMode.ON; + honored.vsyncHonored = true; + withBackend(honored, () -> { + FramePacer.endFrame(0, null); + assertEquals(1, honored.awaitPresentCalls); + assertTrue(honored.awaitedBeforeRead); + }); + + final FakeBackend notHonored = new FakeBackend(); + notHonored.mode = VSyncMode.ON; + notHonored.vsyncHonored = false; + withBackend(notHonored, () -> { + FramePacer.endFrame(60, null); + assertEquals(0, notHonored.awaitPresentCalls); + assertEquals(1, notHonored.readGateSampleCalls); + }); + + final FakeBackend off = new FakeBackend(); + off.mode = VSyncMode.OFF; + withBackend(off, () -> { + FramePacer.endFrame(60, null); + assertEquals(0, off.awaitPresentCalls); + assertEquals(1, off.readGateSampleCalls); + }); } @Test - void indicatorOmitsACapTheBackstopAlreadyBeats() { - assertEquals(" [vsync 144]", FramePacer.debugIndicator(VSyncMode.ON, 144, 260, false)); + void endFramePumpsExactlyOnceAfterTheGateReadAndThePacingSleep() { + final FakeBackend backend = new FakeBackend(); + backend.mode = VSyncMode.OFF; + withBackend(backend, () -> { + FramePacer.endFrame(60, null); + assertEquals(1, backend.pumpCalls); + assertTrue(backend.readBeforePump, "the gate sample must be read before the window is pumped"); + + FramePacer.endFrame(60, null); + assertEquals(2, backend.pumpCalls); + assertTrue(backend.parkedBeforePump, "the pump must follow the pacing sleep, not precede it"); + }); } @Test - void indicatorStillReportsVsyncWithoutARefreshRate() { - assertEquals(" [vsync]", FramePacer.debugIndicator(VSyncMode.ON, 0, 0, false)); - assertEquals(" [vsync, cap 60]", FramePacer.debugIndicator(VSyncMode.ON, 0, 60, false)); + void gpuWaitFedToTheCoreEqualsBackendGpuWaitPlusTheFenceWait() { + final FakeBackend backend = new FakeBackend(); + backend.mode = VSyncMode.OFF; + backend.gpuWaitNanos = 500_000L; + final long fenceNanos = 5_000_000L; + withBackend(backend, () -> { + FramePacer.endFrame(0, () -> backend.now += fenceNanos); + final long fed = FramePacer.core.lastGpuWaitNanos(); + assertEquals(backend.gpuWaitNanos + fenceNanos + CLOCK_TICK_NANOS, fed); + }); } @Test - void indicatorIsAbsentWhenNothingLimits() { - assertNull(FramePacer.debugIndicator(VSyncMode.OFF, 144, 0, false)); - assertNull(FramePacer.debugIndicator(VSyncMode.OFF, 0, 0, false)); + void endFrameReturnsThePresentInterval() { + final FakeBackend backend = new FakeBackend(); + backend.mode = VSyncMode.OFF; + withBackend(backend, () -> { + FramePacer.endFrame(0, null); + final long firstPresent = backend.nanoTime(); + FramePacer.core.beforePresent(firstPresent, 0L); + FramePacer.endFrame(0, null); + backend.now += 7_000_000L; + final long secondPresent = backend.nanoTime(); + FramePacer.core.beforePresent(secondPresent, 0L); + final long returned = FramePacer.endFrame(0, null); + assertEquals(secondPresent - firstPresent, returned); + }); } @Test - void endingTheStatsPutsThemBackToSleep() { - FramePacer.beginStats(); - assertTrue(FramePacer.statsActive()); + void aDisplayGenerationChangeReArmsTheProbe() { + final FakeBackend backend = new FakeBackend(); + backend.mode = VSyncMode.ON; + backend.vsyncHonored = true; + backend.refreshPeriodNanos = 16_666_666L; + withBackend(backend, () -> { + for (int i = 0; i < 60; i++) FramePacer.endFrame(0, null); + assertFalse(FramePacer.core.probing(), "the probe must exhaust its lead without ever locking"); - FramePacer.endStats(); - assertFalse(FramePacer.statsActive(), "a cancelled run must not leave the bookkeeping running forever"); + backend.displayGeneration++; + FramePacer.endFrame(0, null); + assertTrue(FramePacer.core.probing(), "a display change must re-arm the probe"); + }); } } diff --git a/src/test/java/com/gtnewhorizons/angelica/rendering/PacerCoreSimTest.java b/src/test/java/com/gtnewhorizons/angelica/rendering/PacerCoreSimTest.java new file mode 100644 index 000000000..a7661e316 --- /dev/null +++ b/src/test/java/com/gtnewhorizons/angelica/rendering/PacerCoreSimTest.java @@ -0,0 +1,614 @@ +package com.gtnewhorizons.angelica.rendering; + +import com.gtnewhorizons.angelica.glsm.testutil.Reflect; +import org.junit.jupiter.api.Test; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.function.LongConsumer; +import java.util.function.LongSupplier; +import java.util.function.ObjIntConsumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PacerCoreSimTest { + + private static final long US = 1_000L; + private static final long MS = 1_000_000L; + private static final int FRAMES = 3000; + private static final int WARMUP = 300; + + private static final long LAG_MEAN = 30 * US; + private static final long JITTER = 10 * US; + private static final long SEED = 987654321L; + private static final int MAILBOX_IMAGES = 2; + + private static final long P60 = 1_000_000_000L / 60; + private static final long P5994 = 1_000_000_000L * 1001 / 60_000; + private static final long P144 = 1_000_000_000L / 144; + + private enum Mode { + FIFO, MAILBOX, IMMEDIATE + } + + private enum Gate { + SWAP, FIRST_CLEAR, NONE + } + + private static final class FakeClock implements LongSupplier { + long now; + + @Override + public long getAsLong() { + return now; + } + } + + private static final class FakeSleeper implements LongConsumer { + private final FakeClock clock; + private final Random rng = new Random(SEED); + private final long granularity; + int parks; + + FakeSleeper(FakeClock clock, long granularity) { + this.clock = clock; + this.granularity = granularity; + } + + @Override + public void accept(long request) { + parks++; + long actual = request + LAG_MEAN + rng.nextInt(2 * (int) JITTER + 1) - JITTER; + if (actual < 0L) actual = 0L; + if (granularity > 0L) actual = Math.max(granularity, (actual + granularity - 1) / granularity * granularity); + clock.now += actual; + } + } + + private static final class Frame { + final int index; + final long frameStart; + final long ready; + long entry; + long showTime = -1L; + + Frame(int index, long frameStart, long ready) { + this.index = index; + this.frameStart = frameStart; + this.ready = ready; + } + } + + private static final class FakeDisplay { + long period; + private final long gpuTail; + private final int queue; + private final Gate gate; + private final Mode mode; + private final int outageFrom; + private final int outageTo; + private final ArrayDeque scheduled = new ArrayDeque<>(); + final List frames = new ArrayList<>(); + private long lastFlip = Long.MIN_VALUE; + private long deferredGate = Long.MIN_VALUE; + + FakeDisplay(Sim sim) { + this.period = sim.displayPeriod > 0L ? sim.displayPeriod + : sim.refreshPeriod + sim.refreshPeriod * sim.driftPpm / 1_000_000L; + this.gpuTail = sim.gpuTail; + this.queue = sim.queue; + this.gate = sim.gate; + this.mode = sim.mode; + this.outageFrom = sim.gateOutageFrom; + this.outageTo = sim.gateOutageTo; + } + + long present(int index, long frameStart, long entry) { + final boolean outage = index >= outageFrom && index < outageTo; + long accepted = entry; + final int depth = mode == Mode.FIFO ? queue : MAILBOX_IMAGES; + if (mode != Mode.IMMEDIATE && !outage) { + purge(accepted); + while (scheduled.size() >= depth) { + accepted = Math.max(accepted, scheduled.peek()); + purge(accepted); + } + } + final long ready = accepted + gpuTail; + final Frame frame = new Frame(index, frameStart, ready); + frame.entry = entry; + if (mode == Mode.FIFO) { + final long floor = lastFlip == Long.MIN_VALUE ? ready : Math.max(ready, lastFlip + period); + final long flip = ceilVblank(floor); + lastFlip = flip; + scheduled.add(flip); + frame.showTime = flip; + } else if (mode == Mode.IMMEDIATE) { + frame.showTime = ready; + } else { + scheduled.add(ceilVblank(ready)); + } + if (outage) { + scheduled.clear(); + lastFlip = Long.MIN_VALUE; + } + frames.add(frame); + + if (gate == Gate.FIRST_CLEAR) { + deferredGate = accepted; + return entry; + } + return accepted; + } + + long firstDraw(long now) { + if (gate != Gate.FIRST_CLEAR || deferredGate == Long.MIN_VALUE) return now; + final long blocked = Math.max(now, deferredGate); + deferredGate = Long.MIN_VALUE; + return blocked; + } + + private void purge(long now) { + while (!scheduled.isEmpty() && scheduled.peek() <= now) scheduled.poll(); + } + + private long ceilVblank(long time) { + return Math.floorDiv(time + period - 1, period) * period; + } + + void resolve(Result result) { + final long warmupTime = frames.get(WARMUP).frameStart; + if (mode == Mode.MAILBOX) resolveMailbox(result, warmupTime); + if (mode == Mode.FIFO) countVblanks(result, warmupTime); + } + + private void resolveMailbox(Result result, long warmupTime) { + int i = 0; + long vblank = ceilVblank(frames.get(0).ready); + while (i < frames.size()) { + Frame latest = null; + while (i < frames.size() && frames.get(i).ready <= vblank) { + if (latest != null && latest.index >= WARMUP) result.dropped++; + latest = frames.get(i); + i++; + } + if (latest != null) latest.showTime = vblank; + else if (vblank >= warmupTime) result.repeats++; + vblank += period; + } + } + + private void countVblanks(Result result, long warmupTime) { + final long last = frames.get(frames.size() - 1).showTime; + int next = 0; + for (long vblank = ceilVblank(warmupTime); vblank <= last; vblank += period) { + while (next < frames.size() && frames.get(next).showTime < vblank) next++; + if (next < frames.size() && frames.get(next).showTime == vblank) continue; + result.repeats++; + for (final Frame frame : frames) { + if (frame.ready <= vblank && frame.showTime > vblank) { + result.dups++; + break; + } + } + } + } + } + + private static final class Sim { + long refreshPeriod = P60; + long displayPeriod; + boolean tearFree = true; + boolean vsyncOn = true; + int cap; + Mode mode = Mode.FIFO; + Gate gate = Gate.SWAP; + int queue = 1; + int gateOutageFrom = Integer.MAX_VALUE; + int gateOutageTo = Integer.MAX_VALUE; + long gpuTail = 500 * US; + long work = 4 * MS; + long stallWork; + int stallEvery; + int refreshChangeFrame = Integer.MAX_VALUE; + long refreshChangeTo; + long granularity; + long driftPpm; + ObjIntConsumer schedule; + String name = ""; + + Sim named(String value) { + name = value; + return this; + } + } + + private static final class Result { + String name; + int dups; + int repeats; + int dropped; + int startParks; + int lockedStartParks; + int probeFrames; + int lockFrame = -1; + int unlockFrame = -1; + int relockFrame = -1; + long cadence; + long displayPeriod; + long minInterval; + long maxInterval; + long meanInterval; + long intervalSd; + long maxStartSleep; + long latencyP50; + long latencyP99; + long margin; + boolean driverPaced; + long[] entries; + long[] cadences; + long[] margins; + boolean[] probed; + boolean[] driven; + + @Override + public String toString() { + return name + " [C=" + cadence / 1000 + "us driverPaced=" + driverPaced + + " dups=" + dups + " repeats=" + repeats + " dropped=" + dropped + + " startParks=" + startParks + " lockedStartParks=" + lockedStartParks + + " lockFrame=" + lockFrame + " unlockFrame=" + unlockFrame + " relockFrame=" + relockFrame + + " probeFrames=" + probeFrames + + " intervalUs mean=" + meanInterval / 1000 + " sd=" + intervalSd / 1000 + "." + intervalSd % 1000 + + " min=" + minInterval / 1000 + " max=" + maxInterval / 1000 + + " latencyUs p50=" + latencyP50 / 1000 + " p99=" + latencyP99 / 1000 + + " marginUs=" + margin / 1000 + "]"; + } + } + + private static Result run(Sim sim) { + final FakeClock clock = new FakeClock(); + final FakeSleeper parker = new FakeSleeper(clock, sim.granularity); + final PacerSleeper sleeper = new PacerSleeper(clock, parker, false); + final PacerCore core = new PacerCore(clock, sleeper); + core.setRefreshPeriodNanos(sim.refreshPeriod); + core.setTearFree(sim.tearFree); + core.setVsyncOn(sim.vsyncOn); + core.setCap(sim.cap); + + final FakeDisplay display = new FakeDisplay(sim); + final Result result = new Result(); + result.name = sim.name; + result.displayPeriod = display.period; + result.entries = new long[FRAMES]; + result.cadences = new long[FRAMES]; + result.margins = new long[FRAMES]; + result.probed = new boolean[FRAMES]; + result.driven = new boolean[FRAMES]; + boolean wasLocked = false; + + for (int f = 0; f < FRAMES; f++) { + if (sim.schedule != null) sim.schedule.accept(core, f); + if (f == sim.refreshChangeFrame) { + core.setRefreshPeriodNanos(sim.refreshChangeTo); + display.period = sim.refreshChangeTo + sim.refreshChangeTo * sim.driftPpm / 1_000_000L; + } + + final int parksBefore = parker.parks; + clock.now = core.beginFrame(clock.now); + final int startParks = parker.parks - parksBefore; + if (f >= WARMUP) result.startParks += startParks; + result.probed[f] = core.probing(); + result.driven[f] = Reflect.invoke(core, "driverPaced", new Class[0]); + if (core.probing()) result.probeFrames++; + if (core.locked()) { + if (result.lockFrame < 0) result.lockFrame = f; + if (wasLocked == false && result.unlockFrame >= 0 && result.relockFrame < 0) result.relockFrame = f; + result.lockedStartParks += startParks; + wasLocked = true; + } else if (wasLocked) { + result.unlockFrame = f; + wasLocked = false; + } + if (core.slackNanos() > result.maxStartSleep) result.maxStartSleep = core.slackNanos(); + result.cadences[f] = Reflect.get(core, "cadenceNanos"); + result.margins[f] = core.marginNanos(); + + final long start = clock.now; + core.markFrameStart(start); + + long firstClearBlockNanos = 0L; + if (sim.gate == Gate.FIRST_CLEAR) { + final long blocked = display.firstDraw(clock.now); + if (blocked > clock.now) { + firstClearBlockNanos = blocked - clock.now; + core.onGateSample(firstClearBlockNanos, blocked); + clock.now = blocked; + } + } + + clock.now += sim.stallEvery > 0 && f > 0 && f % sim.stallEvery == 0 ? sim.stallWork : sim.work; + + final long entry = core.beforePresent(clock.now, firstClearBlockNanos); + clock.now = entry; + result.entries[f] = entry; + final long accepted = display.present(f, start, entry); + if (sim.gate == Gate.SWAP && accepted > entry) { + core.onGateSample(accepted - entry, accepted); + } + clock.now = accepted; + } + + result.driverPaced = Reflect.invoke(core, "driverPaced", new Class[0]); + result.cadence = Reflect.get(core, "cadenceNanos"); + result.margin = core.marginNanos(); + display.resolve(result); + measure(display.frames, result); + return result; + } + + private static void measure(List frames, Result result) { + final List intervals = new ArrayList<>(); + final List latencies = new ArrayList<>(); + for (int i = WARMUP; i < frames.size(); i++) { + final Frame frame = frames.get(i); + intervals.add(frame.entry - frames.get(i - 1).entry); + if (frame.showTime >= 0L) latencies.add(frame.showTime - frame.frameStart); + } + + long sum = 0L; + result.minInterval = Long.MAX_VALUE; + for (final long interval : intervals) { + sum += interval; + result.minInterval = Math.min(result.minInterval, interval); + result.maxInterval = Math.max(result.maxInterval, interval); + } + result.meanInterval = sum / intervals.size(); + double variance = 0.0; + for (final long interval : intervals) { + final double d = interval - (double) result.meanInterval; + variance += d * d; + } + result.intervalSd = (long) Math.sqrt(variance / intervals.size()); + + Collections.sort(latencies); + result.latencyP50 = latencies.get(latencies.size() / 2); + result.latencyP99 = latencies.get(Math.min(latencies.size() - 1, latencies.size() * 99 / 100)); + } + + private static Sim fifo60(long work, int queue, Gate gate) { + final Sim sim = new Sim(); + sim.work = work; + sim.queue = queue; + sim.gate = gate; + return sim; + } + + private static void assertNeverBunches(Result r, long slopNanos) { + for (int f = WARMUP; f < FRAMES; f++) { + if (r.driven[f] || r.driven[f - 1]) continue; + final long interval = r.entries[f] - r.entries[f - 1]; + final long c = r.cadences[f]; + final long unpinned = r.probed[f] ? r.margins[f] : 0L; + final long floor = c - c / PacerCore.CATCHUP_DIVISOR - slopNanos - unpinned; + assertTrue(interval >= floor, r + " interval " + interval + "ns at frame " + f + ", floor is " + floor); + assertTrue(r.maxStartSleep <= 2 * c, r + " start sleep " + r.maxStartSleep + "ns exceeds two periods"); + } + } + + @Test + void anHonoredVsyncGateIsLeftToPaceItself() { + for (final long drift : new long[] { 0L, 1000L }) { + for (final long work : new long[] { 4 * MS, 10 * MS }) { + for (final int queue : new int[] { 1, 2 }) { + for (final Gate gate : new Gate[] { Gate.SWAP, Gate.FIRST_CLEAR }) { + final Sim sim = fifo60(work, queue, gate) + .named("fifo60 R=" + work / MS + "ms Q=" + queue + " " + gate + " drift=" + drift); + sim.driftPpm = drift; + final Result r = run(sim); + assertTrue(r.driverPaced, r + " must hand pacing to the driver"); + assertTrue(r.lockFrame >= 0 && r.lockFrame <= 150, r + " must lock inside the probe window"); + assertEquals(0, r.dups, r.toString()); + assertEquals(0, r.repeats, r + " must fill every vblank"); + assertEquals(0, r.lockedStartParks, r + " must not sleep at frame start once locked"); + } + } + } + } + } + + @Test + void aPanelFasterThanItsReportedRateIsStillFoundByTheProbe() { + final Sim sim = fifo60(4 * MS, 2, Gate.SWAP).named("lateDrift60 Q=2"); + sim.refreshPeriod = P5994; + sim.displayPeriod = P60; + final Result r = run(sim); + + assertTrue(r.lockFrame >= 0 && r.lockFrame <= 150, r + " must lock inside the probe window"); + assertTrue(r.driverPaced, r.toString()); + assertEquals(0, r.repeats, r + " must fill every vblank once locked"); + assertEquals(0, r.lockedStartParks, r.toString()); + + final Sim single = fifo60(4 * MS, 1, Gate.SWAP).named("lateDrift60 Q=1"); + single.driftPpm = -1000L; + final Result rs = run(single); + + assertTrue(rs.displayPeriod < P60, rs + " the panel must run faster than its reported rate"); + assertTrue(rs.lockFrame >= 0 && rs.lockFrame <= 150, rs + " must lock inside the probe window"); + assertTrue(rs.driverPaced, rs.toString()); + assertEquals(0, rs.repeats, rs.toString()); + assertEquals(0, rs.lockedStartParks, rs.toString()); + } + + @Test + void aDriverThatNeverBlocksFallsBackToTheExactCadenceAfterTheProbe() { + final Sim sim = fifo60(4 * MS, Integer.MAX_VALUE, Gate.NONE).named("freeRunFifo60"); + final Result r = run(sim); + + assertEquals(-1, r.lockFrame, r + " has no gate to lock onto"); + + for (int f = WARMUP; f < FRAMES; f++) { + final long interval = r.entries[f] - r.entries[f - 1]; + assertTrue(Math.abs(interval - P60) <= 200 * US, r + " interval " + interval + "ns at frame " + f); + } + + final double vblanks = (r.entries[FRAMES - 1] - r.entries[WARMUP]) / (double) r.displayPeriod; + final double ratio = (FRAMES - 1 - WARMUP) / vblanks; + assertTrue(Math.abs(ratio - 1.0) <= 0.01, r + " presented " + ratio + " frames per vblank"); + assertTrue(r.repeats <= 2, r + " repeats"); + } + + @Test + void aGateOutageReleasesTheLockAndTheProbeFindsItAgain() { + final Sim sim = fifo60(4 * MS, 1, Gate.SWAP).named("gateOutage60"); + sim.gateOutageFrom = 100; + sim.gateOutageTo = 100 + PacerCore.LOCK_FRAMES; + final Result r = run(sim); + + assertTrue(r.lockFrame >= 0 && r.lockFrame < sim.gateOutageFrom, r + " must lock before the outage"); + assertTrue(r.unlockFrame >= sim.gateOutageTo - 1, r + " must hold the lock for the whole lapse window"); + assertTrue(r.relockFrame > r.unlockFrame && r.relockFrame - r.unlockFrame <= 150, + r + " the re-armed probe must find the gate again"); + assertTrue(r.driverPaced, r.toString()); + } + + @Test + void aFractionalRefreshRateSwallowsAnIntegerCapOfTheSameRate() { + final Sim sim = fifo60(4 * MS, 1, Gate.SWAP).named("fifo59.94 cap60"); + sim.refreshPeriod = P5994; + sim.cap = 60; + sim.driftPpm = 1000L; + final Result r = run(sim); + + assertTrue(r.lockFrame >= 0 && r.lockFrame <= 150, r + " must lock inside the probe window"); + assertEquals(P5994, r.cadence, r.toString()); + assertTrue(r.driverPaced, r.toString()); + assertEquals(0, r.dups, r.toString()); + assertEquals(0, r.repeats, r.toString()); + } + + @Test + void aCapBetweenVblanksSnapsDownToTheNearestMultiple() { + final Sim sim = fifo60(4 * MS, 1, Gate.SWAP).named("fifo144 cap50"); + sim.refreshPeriod = P144; + sim.cap = 50; + sim.gpuTail = 200 * US; + final Result r = run(sim); + + assertTrue(r.intervalSd < 300 * US, r.toString()); + assertTrue(Math.abs(r.meanInterval - 3 * P144) < 300 * US, r.toString()); + } + + @Test + void mailboxIsPacedToTheRefreshRateInsteadOfBeatingAgainstIt() { + final Sim sim = new Sim().named("mailbox144"); + sim.refreshPeriod = P144; + sim.mode = Mode.MAILBOX; + sim.gate = Gate.SWAP; + sim.vsyncOn = false; + sim.work = 3 * MS; + sim.gpuTail = MS; + final Result r = run(sim); + + assertTrue(r.dropped <= 1, r + " must not drop presents it paid to render"); + assertTrue(r.intervalSd < 200 * US, r.toString()); + } + + @Test + void aTearingCapHoldsItsPeriodAndKeepsLatencyToOneFrameOfWork() { + final Sim sim = new Sim().named("vsyncOff cap100"); + sim.tearFree = false; + sim.vsyncOn = false; + sim.mode = Mode.IMMEDIATE; + sim.gate = Gate.NONE; + sim.cap = 100; + sim.work = 4 * MS; + sim.gpuTail = 200 * US; + final Result r = run(sim); + + assertTrue(Math.abs(r.minInterval - 10 * MS) < 200 * US, r.toString()); + assertTrue(Math.abs(r.maxInterval - 10 * MS) < 200 * US, r.toString()); + assertTrue(r.latencyP50 <= sim.work + r.margin + MS, r.toString()); + assertTrue(r.latencyP99 <= sim.work + r.margin + MS, r.toString()); + } + + @Test + void aFrameSlowerThanTheCadenceIsNeverSleptOn() { + final Sim sim = new Sim().named("cpuBound R=20ms cap60"); + sim.tearFree = false; + sim.vsyncOn = false; + sim.mode = Mode.IMMEDIATE; + sim.gate = Gate.NONE; + sim.cap = 60; + sim.work = 20 * MS; + sim.gpuTail = 200 * US; + final Result r = run(sim); + + assertEquals(0, r.startParks, r.toString()); + assertEquals(20 * MS, r.minInterval, r.toString()); + assertEquals(20 * MS, r.maxInterval, r.toString()); + } + + @Test + void aCoarseSleepTimerWidensTheMarginWithoutSaturatingIt() { + final Sim sim = new Sim().named("coarseTimer cap60"); + sim.tearFree = false; + sim.vsyncOn = false; + sim.mode = Mode.IMMEDIATE; + sim.gate = Gate.NONE; + sim.cap = 60; + sim.granularity = 4 * MS; + sim.gpuTail = 200 * US; + final Result r = run(sim); + + assertTrue(r.margin > PacerCore.MARGIN_MIN_NANOS, r + " a coarse timer must buy extra lead"); + assertTrue(r.margin < PacerCore.MARGIN_CEILING_NANOS, r + " but not saturate the margin"); + } + + @Test + void aPeriodicStallIsWalkedOffWithoutBunchingPresents() { + final Sim sim = fifo60(8 * MS, 1, Gate.SWAP).named("fifo60 cap30 stall"); + sim.cap = 30; + sim.stallWork = 200 * MS; + sim.stallEvery = 300; + final Result r = run(sim); + + final int stalls = (FRAMES - WARMUP) / sim.stallEvery; + final long floor = r.cadence - r.cadence / PacerCore.CATCHUP_DIVISOR - PacerSleeper.MIN_SPIN_FLOOR_NANOS - JITTER; + assertTrue(r.dups <= stalls, r + " allowed " + stalls + " duplicated flips"); + assertTrue(r.minInterval >= floor, r + " floor is " + floor); + } + + @Test + void aMidRunCapChangeNeverBunchesPresents() { + final Sim sim = fifo60(4 * MS, 1, Gate.SWAP).named("capChange 0-30-0"); + sim.schedule = (core, frame) -> { + if (frame == 1000) core.setCap(30); + if (frame == 2000) core.setCap(0); + }; + final Result r = run(sim); + + assertEquals(P60, r.cadences[999], r.toString()); + assertEquals(2 * P60, r.cadences[1500], r.toString()); + assertEquals(P60, r.cadences[2500], r.toString()); + assertNeverBunches(r, PacerSleeper.MIN_SPIN_FLOOR_NANOS + JITTER); + } + + @Test + void aMidRunRefreshChangeNeverBunchesPresents() { + final Sim sim = fifo60(4 * MS, 1, Gate.SWAP).named("refreshChange 60-144"); + sim.refreshChangeFrame = 1500; + sim.refreshChangeTo = P144; + final Result r = run(sim); + + assertEquals(P60, r.cadences[1499], r.toString()); + assertEquals(P144, r.cadences[2500], r.toString()); + assertTrue(r.relockFrame > 1500 && r.relockFrame - 1500 <= 150, r + " must find the new panel's gate"); + assertTrue(r.driverPaced, r.toString()); + assertNeverBunches(r, PacerSleeper.MIN_SPIN_FLOOR_NANOS + JITTER); + } + +} diff --git a/src/test/java/com/gtnewhorizons/angelica/rendering/PacerCoreTest.java b/src/test/java/com/gtnewhorizons/angelica/rendering/PacerCoreTest.java new file mode 100644 index 000000000..a85648494 --- /dev/null +++ b/src/test/java/com/gtnewhorizons/angelica/rendering/PacerCoreTest.java @@ -0,0 +1,420 @@ +package com.gtnewhorizons.angelica.rendering; + +import com.gtnewhorizons.angelica.glsm.backend.RenderBackend; +import com.gtnewhorizons.angelica.glsm.testutil.Reflect; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PacerCoreTest { + + private static final long MS = 1_000_000L; + private static final long P60 = 1_000_000_000L / 60; + private static final long P5994 = 1_000_000_000L * 1001 / 60_000; + private static final long P144 = 1_000_000_000L / 144; + + private static final class Rig { + final long[] now = { 0L }; + final PacerCore core; + final List presents = new ArrayList<>(); + int parks; + + Rig(long refreshPeriodNanos, boolean tearFree, int capHz) { + this(refreshPeriodNanos, tearFree, tearFree, capHz); + } + + Rig(long refreshPeriodNanos, boolean tearFree, boolean vsyncOn, int capHz) { + final PacerSleeper sleeper = new PacerSleeper(() -> now[0], nanos -> { + parks++; + now[0] += nanos; + }, false); + core = new PacerCore(() -> now[0], sleeper); + core.setRefreshPeriodNanos(refreshPeriodNanos); + core.setTearFree(tearFree); + core.setVsyncOn(vsyncOn); + core.setCap(capHz); + } + + long frameWithFirstClear(long blockNanos, long workNanos) { + if (blockNanos > 0L) { + core.onGateSample(blockNanos, now[0]); + } + now[0] = core.beginFrame(now[0]); + core.markFrameStart(now[0]); + now[0] += blockNanos + workNanos; + final long present = core.beforePresent(now[0], blockNanos); + now[0] = present; + presents.add(present); + return present; + } + + long frame(long workNanos) { + now[0] = core.beginFrame(now[0]); + core.markFrameStart(now[0]); + now[0] += workNanos; + final long present = core.beforePresent(now[0], 0L); + now[0] = present; + presents.add(present); + return present; + } + + void feed(long gateEndNanos, long durationNanos, long atNanos) { + if (durationNanos > 0L) core.onGateSample(durationNanos, gateEndNanos); + now[0] = Math.max(now[0], atNanos); + now[0] = core.beginFrame(now[0]); + } + + void feedRun(int count, long firstEndNanos, long durationNanos, long strideNanos) { + long end = firstEndNanos; + for (int i = 0; i < count; i++) { + feed(end, durationNanos, end); + end += strideNanos; + } + } + + void idleFrames(int count, long atNanos) { + for (int i = 0; i < count; i++) { + now[0] = Math.max(now[0], atNanos + i); + core.beginFrame(now[0]); + } + } + } + + @Test + void aCapAtOrAboveTheRefreshRateCollapsesOntoTheRefreshPeriod() { + assertEquals(P5994, PacerCore.cadencePeriodNanos(60, P5994, true)); + assertEquals(P60, PacerCore.cadencePeriodNanos(58, P60, true)); + assertEquals(P60, PacerCore.cadencePeriodNanos(0, P60, true)); + assertEquals(P60, PacerCore.cadencePeriodNanos(240, P60, true)); + } + + @Test + void aCapNearAVblankMultipleSnapsToThatMultiple() { + assertEquals(2 * P60, PacerCore.cadencePeriodNanos(30, P60, true)); + assertEquals(3 * P144, PacerCore.cadencePeriodNanos(50, P144, true)); + assertEquals(48, RenderBackend.hzFromPeriod(3 * P144)); + } + + @Test + void aCapFarFromEveryMultipleKeepsItsLiteralPeriod() { + assertEquals(1_000_000_000L / 45, PacerCore.cadencePeriodNanos(45, P60, true)); + assertEquals(1_000_000_000L / 100, PacerCore.cadencePeriodNanos(100, P144, true)); + } + + @Test + void aTearingModeIgnoresTheRefreshPeriodEntirely() { + assertEquals(10 * MS, PacerCore.cadencePeriodNanos(100, P60, false)); + assertEquals(0L, PacerCore.cadencePeriodNanos(0, P60, false)); + assertEquals(0L, PacerCore.cadencePeriodNanos(0, 0L, true)); + assertEquals(10 * MS, PacerCore.cadencePeriodNanos(100, 0L, true)); + } + + @Test + void threeConsistentPairsOnTheRefreshGridLockThePhase() { + final Rig rig = new Rig(P60, true, 0); + rig.feedRun(PacerCore.LOCK_PAIRS, 20 * MS, 5 * MS, P60); + assertFalse(rig.core.locked(), "the last pair of the run has not arrived yet"); + assertTrue(rig.core.probing()); + + rig.feed(20 * MS + PacerCore.LOCK_PAIRS * P60, 5 * MS, 20 * MS + PacerCore.LOCK_PAIRS * P60); + assertTrue(rig.core.locked()); + assertTrue(Reflect.invoke(rig.core, "driverPaced", new Class[0])); + assertFalse(rig.core.probing()); + } + + @Test + void pairsSpanningSeveralRefreshPeriodsAlsoLock() { + final Rig rig = new Rig(P60, true, 0); + rig.feedRun(PacerCore.LOCK_PAIRS + 1, 20 * MS, 5 * MS, 2 * P60); + assertTrue(rig.core.locked()); + } + + @Test + void oneBadPairRestartsTheRunOfConsistentPairs() { + final Rig rig = new Rig(P60, true, 0); + rig.feedRun(PacerCore.LOCK_PAIRS, 20 * MS, 5 * MS, P60); + long end = 20 * MS + PacerCore.LOCK_PAIRS * P60; + + end += P60 / 2; + rig.feed(end, 5 * MS, end); + assertFalse(rig.core.locked()); + + for (int i = 0; i < PacerCore.LOCK_PAIRS; i++) { + end += P60; + rig.feed(end, 5 * MS, end); + } + assertTrue(rig.core.locked(), "a fresh run of consistent pairs must still lock"); + } + + @Test + void aRunOfInconsistentPairsGivesUpOnTheGateEntirely() { + final Rig rig = new Rig(P60, true, 0); + rig.feedRun(PacerCore.LOCK_PAIRS + 1, 20 * MS, 5 * MS, P60); + assertTrue(rig.core.locked()); + + long end = 20 * MS + (PacerCore.LOCK_PAIRS + 1) * P60; + for (int i = 0; i < PacerCore.LOCK_WINDOW + 1; i++) { + end += P60 / 2; + rig.feed(end, 5 * MS, end); + } + assertFalse(rig.core.locked(), "a gate that stops landing on the grid must be released"); + assertFalse(rig.core.probing(), "a gate proven off the grid must not be probed again"); + } + + @Test + void samplesFurtherApartThanTheLockWindowNeverPairUp() { + final Rig rig = new Rig(P60, true, 0); + long end = 20 * MS; + for (int i = 0; i < 5; i++) { + rig.feed(end, 5 * MS, end); + rig.idleFrames(PacerCore.LOCK_WINDOW + 1, end + 1); + end += (PacerCore.LOCK_WINDOW + 2) * P60; + } + assertFalse(rig.core.locked()); + } + + @Test + void aSampleShorterThanTheGateThresholdIsNotAPhaseCandidate() { + final Rig rig = new Rig(P60, true, 0); + rig.feedRun(PacerCore.LOCK_PAIRS + 2, 20 * MS, PacerCore.gateThresholdNanos(P60), P60); + assertFalse(rig.core.locked()); + } + + @Test + void theLockLapsesAfterSixHundredSampleFreeFrames() { + final Rig rig = new Rig(P60, true, 0); + rig.feedRun(PacerCore.LOCK_PAIRS + 1, 20 * MS, 5 * MS, P60); + assertTrue(rig.core.locked()); + + rig.idleFrames(PacerCore.LOCK_FRAMES - 1, 100 * MS); + assertTrue(rig.core.locked(), "the lock must survive one frame short of the timeout"); + + rig.idleFrames(1, 200 * MS); + assertFalse(rig.core.locked()); + } + + @Test + void aLockedGateStillLeavesADividedCadencePacedByTheWallClock() { + final Rig rig = new Rig(P60, true, 30); + rig.feedRun(PacerCore.LOCK_PAIRS + 1, 20 * MS, 5 * MS, P60); + assertTrue(rig.core.locked()); + assertEquals(2 * P60, Reflect.get(rig.core, "cadenceNanos")); + assertFalse(Reflect.invoke(rig.core, "driverPaced", new Class[0])); + } + + @Test + void aTearingModeNeverLocksHoweverRegularTheGateIs() { + final Rig rig = new Rig(P60, false, true, 60); + rig.feedRun(20, 20 * MS, 5 * MS, P60); + assertFalse(rig.core.locked()); + assertFalse(rig.core.probing()); + } + + @Test + void aFirstClearBlockIsChargedToTheDriverNotToTheFrame() { + final Rig blocked = new Rig(P60, false, 60); + blocked.frameWithFirstClear(5 * MS, 7 * MS); + blocked.frame(4 * MS); + + assertEquals(7 * MS, Reflect.get(blocked.core, "cpuHighNanos")); + + final Rig plain = new Rig(P60, false, 60); + plain.frame(12 * MS); + plain.frame(4 * MS); + + assertEquals(12 * MS, Reflect.get(plain.core, "cpuHighNanos")); + } + + @Test + void aStallIsWalkedOffAtOneEighthOfAPeriodPerFrameInsteadOfBunching() { + final Rig rig = new Rig(P60, false, 60); + final long cadence = 1_000_000_000L / 60; + for (int i = 0; i < 200; i++) rig.frame(4 * MS); + final int stallIndex = rig.presents.size(); + rig.frame(208 * MS); + for (int i = 0; i < 200; i++) rig.frame(4 * MS); + + long min = Long.MAX_VALUE; + for (int i = stallIndex + 1; i < rig.presents.size(); i++) { + min = Math.min(min, rig.presents.get(i) - rig.presents.get(i - 1)); + } + final long floor = cadence - cadence / 8 - PacerSleeper.MIN_SPIN_FLOOR_NANOS; + assertTrue(min >= floor, "shortest post-stall interval was " + min + "ns, floor is " + floor); + assertTrue(min < cadence, "the pacer must actually claw back the stall, min interval was " + min); + } + + @Test + void aStallFrameIsKeptOutOfTheMarginWindows() { + final Rig rig = new Rig(P60, false, 60); + for (int i = 0; i < 200; i++) rig.frame(4 * MS); + final long before = rig.core.marginNanos(); + final long cpuBefore = Reflect.get(rig.core, "cpuHighNanos"); + assertEquals(4 * MS, cpuBefore); + + rig.frame(208 * MS); + rig.frame(4 * MS); + assertEquals(before, rig.core.marginNanos()); + assertEquals(cpuBefore, Reflect.get(rig.core, "cpuHighNanos")); + + rig.frame(4 * MS); + assertEquals(before, rig.core.marginNanos()); + assertEquals(cpuBefore, Reflect.get(rig.core, "cpuHighNanos")); + } + + @Test + void aFrameThatStartsLateWidensTheMargin() { + final Rig rig = new Rig(P60, false, 60); + for (int i = 0; i < 300; i++) rig.frame(4 * MS); + final long before = rig.core.marginNanos(); + assertEquals(PacerCore.MARGIN_MIN_NANOS, before); + + for (int i = 0; i < PacerCore.LOCK_PAIRS + 1; i++) { + rig.frame(6 * MS); + for (int j = 0; j < 20; j++) rig.frame(4 * MS); + } + + final long after = rig.core.marginNanos(); + assertTrue(after > before, "an overrunning frame must buy more lead, margin is " + after); + assertTrue(after < PacerCore.MARGIN_CEILING_NANOS, "a 2ms overrun must not saturate the margin, margin is " + after); + } + + @Test + void idleWorkStillRunsWhenTheDriverPaces() { + final Rig rig = new Rig(P60, true, 0); + rig.feedRun(PacerCore.LOCK_PAIRS + 1, 20 * MS, 5 * MS, P60); + assertTrue(Reflect.invoke(rig.core, "driverPaced", new Class[0])); + + final int[] runs = { 0 }; + rig.core.setIdleWork(deadline -> { + runs[0]++; + rig.now[0] += MS; + }); + final int parksBefore = rig.parks; + for (int i = 0; i < 10; i++) rig.frame(2 * MS); + + assertEquals(10, runs[0], "a driver-paced frame still has idle time before the present target"); + assertEquals(parksBefore, rig.parks, "but it must never sleep"); + } + + @Test + void theFirstPacedFrameAfterAnUnpacedStretchIsAnchoredWithoutSleeping() { + final Rig rig = new Rig(P60, false, 0); + for (int i = 0; i < 5; i++) rig.frame(4 * MS); + assertEquals(0, rig.parks); + + rig.core.setCap(60); + rig.frame(4 * MS); + assertEquals(0, rig.parks, "the anchor frame must not stall the loop"); + assertEquals(0L, rig.core.slackNanos()); + + rig.frame(4 * MS); + assertTrue(rig.parks > 0, "the cadence resumes on the frame after the anchor"); + } + + @Test + void aTargetMoreThanTwoPeriodsAheadIsPulledBack() { + final Rig rig = new Rig(P60, false, 60); + for (int i = 0; i < 20; i++) rig.frame(4 * MS); + + rig.now[0] -= 500 * MS; + rig.core.beginFrame(rig.now[0]); + + assertTrue(rig.core.slackNanos() <= P60, "a backward clock step must not park for half a second: " + rig.core.slackNanos()); + } + + @Test + void theEffectiveCapReportsTheCadenceThePacerActuallyRuns() { + final Rig unpaced = new Rig(0L, false, 0); + unpaced.frame(4 * MS); + assertEquals(0, unpaced.core.effectiveCapHz()); + assertFalse(unpaced.core.paced()); + + final Rig snapped = new Rig(P144, true, 50); + snapped.frame(4 * MS); + assertEquals(48, snapped.core.effectiveCapHz()); + assertTrue(snapped.core.paced()); + } + + @Test + void anUnpacedFrameNeverSleeps() { + final Rig rig = new Rig(P60, false, 0); + for (int i = 0; i < 50; i++) rig.frame(4 * MS); + assertEquals(0, rig.parks); + assertEquals(0L, rig.core.slackNanos()); + } + + @Test + void theMarginIsClampedIntoItsBandByTheCadence() { + assertEquals(PacerCore.MARGIN_MIN_NANOS, PacerCore.clampMargin(0L, 16_666_666L)); + assertEquals(PacerCore.MARGIN_CEILING_NANOS, PacerCore.clampMargin(50 * MS, 100 * MS)); + assertEquals(2 * MS, PacerCore.clampMargin(2 * MS, 16_666_666L)); + assertEquals(P144 / 4, PacerCore.clampMargin(3 * MS, P144), "a short cadence caps the margin below its own minimum"); + } + + @Test + void theProbeRunsAheadOfThePanelOnlyUntilItHasGainedTwoPeriods() { + final Rig rig = new Rig(P60, true, 0); + for (int i = 0; i < 400; i++) rig.frame(4 * MS); + final long lead = P60 / PacerCore.PROBE_LEAD_DIVISOR; + + assertEquals(P60 - lead, rig.presents.get(10) - rig.presents.get(9), "the probe must present ahead of the panel"); + assertEquals(P60, rig.presents.get(300) - rig.presents.get(299), "an expired probe must fall back to the exact cadence"); + } + + @Test + void mailboxIsNeverProbedAndNeverLocks() { + final Rig rig = new Rig(P60, true, false, 0); + for (int i = 0; i < 40; i++) { + final long end = 20 * MS + i * P60; + rig.feed(end, 5 * MS, end); + assertFalse(rig.core.probing()); + } + assertFalse(rig.core.locked()); + assertEquals(P60, Reflect.get(rig.core, "cadenceNanos")); + } + + @Test + void aCapChangeRearmsTheProbe() { + final Rig rig = new Rig(P60, true, 0); + for (int i = 0; i < 200; i++) rig.frame(4 * MS); + assertFalse(rig.core.probing()); + + rig.core.setCap(45); + rig.frame(4 * MS); + assertFalse(rig.core.probing(), "a cadence off the refresh period is never probed"); + + rig.core.setCap(0); + rig.frame(4 * MS); + assertTrue(rig.core.probing()); + } + + @Test + void aRefreshChangeRearmsTheProbe() { + final Rig rig = new Rig(P60, true, 0); + for (int i = 0; i < 200; i++) rig.frame(4 * MS); + assertFalse(rig.core.probing()); + + rig.core.setRefreshPeriodNanos(P144); + rig.frame(4 * MS); + assertTrue(rig.core.probing()); + } + + @Test + void aFirstClearBlockIsKeptOutOfTheStartResidualToo() { + final Rig plain = new Rig(P60, false, 60); + final Rig blocked = new Rig(P60, false, 60); + for (int i = 0; i < 300; i++) { + plain.frame(4 * MS); + blocked.frameWithFirstClear(5 * MS, 4 * MS); + } + + assertEquals(Reflect.get(plain.core, "cpuHighNanos"), Reflect.get(blocked.core, "cpuHighNanos")); + assertEquals(plain.core.marginNanos(), blocked.core.marginNanos()); + assertTrue(blocked.core.marginNanos() < PacerCore.MARGIN_CEILING_NANOS, "the driver block must not saturate the margin"); + } +} diff --git a/src/test/java/com/gtnewhorizons/angelica/rendering/PacerSleeperTest.java b/src/test/java/com/gtnewhorizons/angelica/rendering/PacerSleeperTest.java index 2f77b7c81..7d35b233a 100644 --- a/src/test/java/com/gtnewhorizons/angelica/rendering/PacerSleeperTest.java +++ b/src/test/java/com/gtnewhorizons/angelica/rendering/PacerSleeperTest.java @@ -1,5 +1,6 @@ package com.gtnewhorizons.angelica.rendering; +import com.gtnewhorizons.angelica.glsm.testutil.Reflect; import org.junit.jupiter.api.Test; import java.util.function.LongConsumer; @@ -64,7 +65,7 @@ class PacerSleeperTest { for (int i = 0; i < 20; i++) { final long deadline = time.now + 13 * MS; - final long floor = s.spinFloorNanos(); + final long floor = Reflect.get(s, "spinFloorNanos"); final long woke = s.sleepUntil(deadline, time.now); assertTrue(deadline - woke <= floor, "left " + (deadline - woke) + "ns to spin, floor is " + floor); } @@ -95,7 +96,7 @@ class PacerSleeperTest { final long woke = s.sleepUntil(10 * MS, 0L); assertEquals(2, time.parks, "a short park must be followed by another, not by a spin"); - assertTrue(10 * MS - woke <= s.spinFloorNanos(), "the second park should still land inside the floor"); + assertTrue(10 * MS - woke <= Reflect.get(s, "spinFloorNanos"), "the second park should still land inside the floor"); } @Test @@ -111,13 +112,6 @@ class PacerSleeperTest { assertEquals(i + 1, time.parks, "every frame must still park exactly once"); assertTrue(woke >= deadline, "frame " + i + " left " + (deadline - woke) + "ns to busy-wait; a coarse timer must never hand the frame to the spin loop"); } - } - - @Test - void aPeriodShorterThanTheWakeUpLagStillParksRatherThanSpinning() { - final FakeTime time = new FakeTime(); - time.granularity = 15_600_000L; - final PacerSleeper s = sleeper(time); for (int i = 0; i < 30; i++) { final int before = time.parks; @@ -175,13 +169,18 @@ class PacerSleeperTest { void theSpinLoopFinishesTheFrameAndIsCounted() { final long step = 10_000L; final long[] clock = { 0L }; - final PacerSleeper s = new PacerSleeper(() -> clock[0] += step, nanos -> clock[0] += nanos, true); + final int[] parks = { 0 }; + final PacerSleeper s = new PacerSleeper(() -> clock[0] += step, nanos -> { + parks[0]++; + clock[0] += nanos; + }, true); final long woke = s.sleepUntil(5 * MS, 0L); + s.noteFrame(0L, 0L); assertEquals(5 * MS, woke, "the spin must carry the frame to the deadline"); - assertEquals(1, s.lastParks); - assertEquals(10_000L, s.lastSpinNanos, "the spin covers what the park left short"); + assertEquals(1, parks[0]); + assertEquals(10_000L, s.lastFrameSpinNanos, "the spin covers what the park left short"); } @Test @@ -189,18 +188,25 @@ class PacerSleeperTest { final long step = 10_000L; final long overshoot = 2 * MS; final long[] clock = { 0L }; - final PacerSleeper s = new PacerSleeper(() -> clock[0] += step, nanos -> clock[0] += nanos + overshoot, true); + final int[] parks = { 0 }; + final PacerSleeper s = new PacerSleeper(() -> clock[0] += step, nanos -> { + parks[0]++; + clock[0] += nanos + overshoot; + }, true); for (int i = 0; i < PacerSleeper.SLOTS; i++) { s.sleepUntil(clock[0] + 13 * MS, clock[0]); } + s.noteFrame(0L, 0L); assertTrue(s.avgOvershootNanos() > MS, "the park estimate must exceed the remaining gap for this branch to be reached"); final long deadline = clock[0] + MS; + final int before = parks[0]; final long woke = s.sleepUntil(deadline, clock[0]); + s.noteFrame(0L, 0L); - assertEquals(0, s.lastParks, "a gap under one park quantum must not park again"); - assertTrue(s.lastSpinNanos > 0L, "the spin loop has to close a gap far wider than the floor"); + assertEquals(before, parks[0], "a gap under one park quantum must not park again"); + assertTrue(s.lastFrameSpinNanos > 0L, "the spin loop has to close a gap far wider than the floor"); assertTrue(woke >= deadline, "woke " + woke + "ns short of deadline " + deadline); } @@ -214,7 +220,7 @@ class PacerSleeperTest { s.sleepUntil(time.now + 13 * MS, time.now); } - assertEquals(PacerSleeper.MIN_SPIN_FLOOR_NANOS, s.spinFloorNanos(), "identical overshoots are pure bias, not jitter, so the floor stays at its minimum"); + assertEquals(PacerSleeper.MIN_SPIN_FLOOR_NANOS, Reflect.get(s, "spinFloorNanos"), "identical overshoots are pure bias, not jitter, so the floor stays at its minimum"); } @Test @@ -230,7 +236,7 @@ class PacerSleeperTest { s.sleepUntil(time.now + 13 * MS, time.now); assertEquals(140_000L, s.avgOvershootNanos()); - assertEquals(500_000L - 140_000L, s.spinFloorNanos(), "the floor must cover the spread between the worst park and the mean, not the mean itself"); + assertEquals(500_000L - 140_000L, Reflect.get(s, "spinFloorNanos"), "the floor must cover the spread between the worst park and the mean, not the mean itself"); } @Test @@ -243,4 +249,46 @@ class PacerSleeperTest { assertEquals(0, time.parks); assertEquals(0L, woke); } + + @Test + void everyWaitInAFrameLandsOnTheOneFrameOfBookkeeping() { + final FakeTime time = new FakeTime(); + final PacerSleeper s = sleeper(time); + + s.sleepUntil(10 * MS, 0L); + s.sleepUntil(time.now + 10 * MS, time.now); + s.noteFrame(0L, 0L); + s.sleepUntil(time.now + 10 * MS, time.now); + s.noteFrame(0L, 0L); + + assertEquals(3, time.parks); + assertEquals(2L, Reflect.get(s, "frames")); + assertEquals(3L, Reflect.get(s, "parkTotal")); + + s.sleepUntil(time.now + 10 * MS, time.now); + s.resetStats(); + s.noteFrame(0L, 0L); + + assertEquals(1L, Reflect.get(s, "frames")); + assertEquals(0L, Reflect.get(s, "parkTotal")); + } + + @Test + void aWakeInsideTheToleranceIsNotLate() { + final FakeTime time = new FakeTime(); + final PacerSleeper s = sleeper(time); + + s.noteFrame(0L, PacerSleeper.LATE_TOL_NANOS); + s.noteFrame(-5 * MS, 0L); + + assertEquals(0L, Reflect.get(s, "lateFrames")); + assertEquals(0L, Reflect.get(s, "lateMax")); + + s.noteFrame(0L, PacerSleeper.LATE_TOL_NANOS + 1); + s.noteFrame(0L, 3 * MS); + s.noteFrame(0L, MS); + + assertEquals(3L, Reflect.get(s, "lateFrames")); + assertEquals(3 * MS, Reflect.get(s, "lateMax")); + } } diff --git a/src/test/java/com/gtnewhorizons/angelica/rendering/ReducerStateMachineTest.java b/src/test/java/com/gtnewhorizons/angelica/rendering/ReducerStateMachineTest.java new file mode 100644 index 000000000..3e28b8780 --- /dev/null +++ b/src/test/java/com/gtnewhorizons/angelica/rendering/ReducerStateMachineTest.java @@ -0,0 +1,179 @@ +package com.gtnewhorizons.angelica.rendering; + +import com.gtnewhorizons.angelica.rendering.ReducerStateMachine.State; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReducerStateMachineTest { + + private static final float DELTA = 1e-6f; + + private long time = 1_000_000_000L; + private final ReducerStateMachine machine = new ReducerStateMachine(() -> time); + + @Test + void configOffWinsOverEverything() { + machine.toggleForced(); + assertTrue(machine.evaluate(false, false, false, 1_000_000_000L)); + assertEquals(State.DISABLED, machine.state()); + } + + @Test + void keybindDisabledWinsOverForced() { + machine.toggleForced(); + machine.toggleKeybindDisabled(); + machine.evaluate(true, true, true, 0); + assertEquals(State.DISABLED, machine.state()); + } + + @Test + void forcedWinsOverMinimized() { + machine.toggleForced(); + machine.evaluate(true, false, true, 0); + assertEquals(State.FORCED, machine.state()); + } + + @Test + void minimizedWinsOverUnfocused() { + machine.evaluate(true, false, false, 0); + assertEquals(State.MINIMIZED, machine.state()); + } + + @Test + void unfocusedWhenNotActiveButVisible() { + machine.evaluate(true, true, false, 0); + assertEquals(State.UNFOCUSED, machine.state()); + + time += 10_000_000_000L; + machine.evaluate(true, true, false, 1_000_000_000L); + assertEquals(State.UNFOCUSED, machine.state()); + } + + @Test + void idleAfterTimeoutWhileFocused() { + time += 999_999_999L; + assertFalse(machine.evaluate(true, true, true, 1_000_000_000L)); + assertEquals(State.FOCUSED, machine.state()); + + time += 1L; + assertTrue(machine.evaluate(true, true, true, 1_000_000_000L)); + assertEquals(State.IDLE, machine.state()); + } + + @Test + void idleTimeoutZeroNeverIdles() { + time += 10_000_000_000L; + machine.evaluate(true, true, true, 0); + assertEquals(State.FOCUSED, machine.state()); + } + + @Test + void inputReturnsFromIdle() { + time += 1_000_000_000L; + machine.evaluate(true, true, true, 1_000_000_000L); + assertEquals(State.IDLE, machine.state()); + + machine.onInput(); + machine.evaluate(true, true, true, 1_000_000_000L); + assertEquals(State.FOCUSED, machine.state()); + } + + @Test + void toggleForcedCountsAsInput() { + time += 10_000_000_000L; + machine.evaluate(true, true, true, 1_000_000_000L); + assertEquals(State.IDLE, machine.state()); + + machine.toggleForced(); + machine.evaluate(true, true, true, 1_000_000_000L); + assertEquals(State.FORCED, machine.state()); + + machine.toggleForced(); + machine.evaluate(true, true, true, 1_000_000_000L); + assertEquals(State.FOCUSED, machine.state()); + } + + @Test + void toggleKeybindDisabledCountsAsInput() { + time += 10_000_000_000L; + machine.evaluate(true, true, true, 1_000_000_000L); + assertEquals(State.IDLE, machine.state()); + + machine.toggleKeybindDisabled(); + machine.evaluate(true, true, true, 1_000_000_000L); + assertEquals(State.DISABLED, machine.state()); + + machine.toggleKeybindDisabled(); + machine.evaluate(true, true, true, 1_000_000_000L); + assertEquals(State.FOCUSED, machine.state()); + } + + @Test + void evaluateReportsTransitionOnce() { + assertFalse(machine.evaluate(true, true, true, 0)); + machine.toggleForced(); + assertTrue(machine.evaluate(true, true, true, 0)); + assertFalse(machine.evaluate(true, true, true, 0)); + } + + + @Test + void unlimitedUserAndNoStateCapIsUncapped() { + assertEquals(0, ReducerStateMachine.mergeCap(0, 0, false, true)); + assertEquals(0, ReducerStateMachine.mergeCap(260, 0, false, true)); + } + + @Test + void theLowerOfTheUserAndStateCapWins() { + assertEquals(10, ReducerStateMachine.mergeCap(120, 10, false, true)); + assertEquals(30, ReducerStateMachine.mergeCap(30, 60, false, true)); + } + + @Test + void menuCapAppliesOnlyWhenOptionOn() { + assertEquals(30, ReducerStateMachine.mergeCap(0, 0, true, true)); + assertEquals(0, ReducerStateMachine.mergeCap(0, 0, true, false)); + } + + @Test + void menuCapNeverRaisesUserCap() { + assertEquals(20, ReducerStateMachine.mergeCap(20, 0, true, true)); + assertEquals(30, ReducerStateMachine.mergeCap(260, 0, true, true)); + assertEquals(10, ReducerStateMachine.mergeCap(260, 10, true, true)); + } + + @Test + void stateCapSelectsProfile() { + assertEquals(10, ReducerStateMachine.stateCap(State.FORCED, 10, 30)); + assertEquals(10, ReducerStateMachine.stateCap(State.UNFOCUSED, 10, 30)); + assertEquals(20, ReducerStateMachine.stateCap(State.MINIMIZED, 5, 5)); + assertEquals(20, ReducerStateMachine.stateCap(State.MINIMIZED, 260, 260)); + assertEquals(30, ReducerStateMachine.stateCap(State.IDLE, 10, 30)); + assertEquals(0, ReducerStateMachine.stateCap(State.FOCUSED, 10, 10)); + assertEquals(0, ReducerStateMachine.stateCap(State.DISABLED, 10, 10)); + assertEquals(0, ReducerStateMachine.stateCap(State.UNFOCUSED, 260, 30)); + assertEquals(0, ReducerStateMachine.stateCap(State.IDLE, 10, 260)); + assertEquals(120, ReducerStateMachine.mergeCap(120, ReducerStateMachine.stateCap(State.UNFOCUSED, 260, 30), false, true)); + } + + @Test + void masterVolumeScalesByPercent() { + assertEquals(0.73f, ReducerStateMachine.masterVolume(0.73f, 100), DELTA); + assertEquals(0.5f, ReducerStateMachine.masterVolume(1.0f, 50), DELTA); + assertEquals(0.01f, ReducerStateMachine.masterVolume(1.0f, 0), DELTA); + } + + @Test + void stateVolumeSelectsProfile() { + assertEquals(10, ReducerStateMachine.stateVolume(State.FORCED, 10, 20, 30)); + assertEquals(10, ReducerStateMachine.stateVolume(State.UNFOCUSED, 10, 20, 30)); + assertEquals(20, ReducerStateMachine.stateVolume(State.MINIMIZED, 10, 20, 30)); + assertEquals(30, ReducerStateMachine.stateVolume(State.IDLE, 10, 20, 30)); + assertEquals(100, ReducerStateMachine.stateVolume(State.FOCUSED, 10, 20, 30)); + assertEquals(100, ReducerStateMachine.stateVolume(State.DISABLED, 10, 20, 30)); + } +}