SDL presenter fixes and frame pacer adjustments (#2025)

This commit is contained in:
Jason Mitchell authored and GitHub committed 2026-08-16 17:59:33 -07:00
1 parent 553dec5b6d
commit b2f597a73a
17 files changed
+336 -93

No files matched your search

@@ -58,7 +58,7 @@ public final class SystemProperties {
public static final boolean DEBUG_F3_DETAIL = Boolean.getBoolean("angelica.debug.f3Detail");
public static final boolean ENABLE_TEST_BLOCKS = Boolean.getBoolean("angelica.debug.testBlocks");
public static final boolean DUMP_CLASS = Boolean.getBoolean("angelica.debug.dumpClass");
public static final boolean DUMP_SHADERS = Boolean.getBoolean("angelica.debug.dumpShaders") || isDeobf();
private static final boolean DUMP_SHADERS_PROP = Boolean.getBoolean("angelica.debug.dumpShaders");
public static final boolean REDIRECTOR_LOGSPAM = Boolean.getBoolean("angelica.debug.redirectorLogspam");
public static final boolean DEBUG_DISPLAY_LISTS = Boolean.getBoolean("angelica.debug.displayLists");
public static final boolean LOG_DISPLAY_LIST_COMPILATION = Boolean.getBoolean("angelica.debug.displayLists.compilation");
@@ -68,8 +68,12 @@ public final class SystemProperties {
// Set by us, read by celeritas
public static final String KEY_CELERITAS_ENABLE_GL_DEBUG = "celeritas.enableGLDebug";
public static boolean dumpShaders() {
return DUMP_SHADERS_PROP || isDeobf();
}
public static Path shaderDumpDir(String phase) {
return DUMP_SHADERS ? Paths.get(SHADER_DUMP_ROOT, phase) : null;
return dumpShaders() ? Paths.get(SHADER_DUMP_ROOT, phase) : null;
}
public enum UnmappedGLMode {
@@ -118,7 +122,7 @@ public final class SystemProperties {
}
}
private static boolean isDeobf() {
public static boolean isDeobf() {
return Launch.blackboard != null && Boolean.TRUE.equals(Launch.blackboard.get("fml.deobfuscatedEnvironment"));
}
@@ -111,6 +111,7 @@ public final class Tracy {
LOGGER.warn("Tracy requested (-Dangelica.tracy=true) but no TracyBackend service present");
return null;
}
if (!implementsCurrentAbi(found)) return null;
final TracyBackend backend = found;
final CountDownLatch done = new CountDownLatch(1);
final AtomicReference<Boolean> result = new AtomicReference<>();
@@ -136,10 +137,28 @@ public final class Tracy {
return backend;
}
private static final String ABI_SENTINEL = "sectionEnter";
private static boolean implementsCurrentAbi(TracyBackend backend) {
try {
backend.getClass().getMethod(ABI_SENTINEL, int.class, String.class);
return true;
} catch (NoSuchMethodException e) {
LOGGER.warn("Tracy backend {} was built against an older TracyBackend interface; profiling disabled", backend.getClass().getName());
return false;
}
}
private record InitRunner(TracyBackend backend, CountDownLatch done, AtomicReference<Boolean> result) implements Runnable {
@Override
public void run() {
final boolean ok = backend.init();
boolean ok;
try {
ok = backend.init();
} catch (LinkageError e) {
LOGGER.warn("Tracy backend {} failed to link; profiling disabled: {}", backend.getClass().getName(), e);
ok = false;
}
if (!result.compareAndSet(null, ok) && ok) {
backend.shutdown();
}
@@ -88,7 +88,6 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import org.lwjgl.sdl.SDLGPU;
import org.lwjgl.sdl.SDLSurface;
import org.lwjgl.sdl.SDL_GPUBlitInfo;
import org.lwjgl.sdl.SDL_GPUBufferLocation;
import org.lwjgl.sdl.SDL_GPUTextureLocation;
@@ -111,7 +110,7 @@ public class SDLGPURenderBackend extends RenderBackend {
private final Device device = SDLGPUGate.device();
private final FrameManager frameManager = new FrameManager(device);
private Presenter presenter;
private volatile Presenter presenter;
private final ResourceManager resourceManager = new ResourceManager(device, frameManager);
private final Image3DClear image3DClear = new Image3DClear();
private final ShaderManager shaderManager = new ShaderManager(device);
@@ -155,7 +154,8 @@ public class SDLGPURenderBackend extends RenderBackend {
private volatile boolean shutdown;
private OffscreenTarget splashTarget;
private volatile OffscreenTarget splashTarget;
private volatile boolean splashTeardownPending;
private static final ThreadRegistry<ContextState> registeredStates = new ThreadRegistry<>(new ContextState[0]);
private static final ThreadLocal<ContextState> tlState = new ThreadLocal<>();
@@ -241,16 +241,12 @@ public class SDLGPURenderBackend extends RenderBackend {
});
GLSMHooks.LOADING_CHECKPOINT
.addListener(event -> {
if (Thread.currentThread() == GLStateManager.getMainThread()) {
if (!GLStateManager.isSplashComplete()) {
SplashDispatcher.tryDispatch(this, splashTarget);
} else if (splashTarget != null) {
SplashDispatcher.signalFrameReady();
SplashDispatcher.tryDispatch(this, splashTarget);
splashTarget.destroy(resourceManager);
splashTarget = null;
SplashDispatcher.reset();
if (!GLStateManager.isSplashComplete()) {
if (Thread.currentThread() == GLStateManager.getMainThread()) {
SplashDispatcher.tryDispatch(this, splashTarget, false);
}
} else if (splashTarget != null) {
splashTeardownPending = true;
}
if (event.requiresSync) {
frameManager.requestSyncFlushOnAllRegisteredFrames();
@@ -411,12 +407,12 @@ public class SDLGPURenderBackend extends RenderBackend {
@Override public boolean wantsDisplayUpdateGateTiming() { return false; }
@Override public long lastFrameGateNanos() {
if (presenter != null && presenter.isEngaged()) return frameManager.windowFrame().lastFrameGateNanos;
if (presenter != null) return frameManager.windowFrame().lastFrameGateNanos;
return frameManager.lastFrameGateNanos();
}
@Override public long lastFrameGateEndNanos() {
if (presenter != null && presenter.isEngaged()) return frameManager.windowFrame().lastFrameGateEndNanos;
if (presenter != null) return frameManager.windowFrame().lastFrameGateEndNanos;
return frameManager.lastFrameGateEndNanos();
}
@@ -520,8 +516,9 @@ public class SDLGPURenderBackend extends RenderBackend {
@Override public boolean handleMakeCurrent(Object drawable) {
if (!isSDLManagedDrawable(drawable)) return false;
Lwjgl3GLCapabilitiesShim.installOnCurrentThread(advertisedCapabilities());
if (splashTarget != null && Thread.currentThread() != GLStateManager.getMainThread()) {
s().boundFboId = splashTarget.fboId();
final OffscreenTarget splash = splashTarget;
if (splash != null && Thread.currentThread() != GLStateManager.getMainThread()) {
s().boundFboId = splash.fboId();
frameManager.frame().swapchainUnavailable = true;
}
return true;
@@ -542,7 +539,8 @@ public class SDLGPURenderBackend extends RenderBackend {
}
@Override public boolean handleSwapBuffers() {
if (splashTarget != null && splashTarget.isFor(s()) && Thread.currentThread() != GLStateManager.getMainThread()) {
final OffscreenTarget splash = splashTarget;
if (splash != null && splash.isFor(s()) && Thread.currentThread() != GLStateManager.getMainThread()) {
endFrameUploadFlush();
frameManager.endFrame();
SplashDispatcher.signalFrameReady((int) s().viewportW, (int) s().viewportH);
@@ -560,13 +558,24 @@ public class SDLGPURenderBackend extends RenderBackend {
final long drawnSize = SplashDispatcher.getDrawnSize();
final int srcW = Math.max(1, Math.min((int) (drawnSize >> 32), target.width()));
final int srcH = Math.max(1, Math.min((int) drawnSize, target.height()));
final FrameState f = frameManager.frame();
frameManager.presentBlit(f, target.colorTexture(), srcW, srcH, SDLSurface.SDL_FLIP_VERTICAL);
f.presentedThisFrame = false;
frameManager.presentSplash(target.colorTexture(), srcW, srcH);
}
private void finishSplash() {
splashTeardownPending = false;
final OffscreenTarget target = splashTarget;
if (target == null) return;
SplashDispatcher.signalFrameReady();
SplashDispatcher.tryDispatch(this, target, true);
if (presenter != null) presenter.drain();
splashTarget = null;
target.destroy(resourceManager);
SplashDispatcher.reset();
}
@Override public void onFrameBegin() {
if (shutdown) return;
if (splashTeardownPending) finishSplash();
if (GLStateManager.isSplashComplete()) applyPendingVSync();
frameManager.beginFrame();
beginFrameInit();
@@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicLong;
import static org.lwjgl.sdl.SDLGPU.*;
import static org.lwjgl.sdl.SDLSurface.SDL_FLIP_NONE;
import static org.lwjgl.sdl.SDLSurface.SDL_FLIP_VERTICAL;
import static org.lwjgl.system.MemoryStack.*;
/**
@@ -61,7 +62,6 @@ public final class FrameManager {
public boolean fbo0UsedThisFrame;
public boolean presentedThisFrame;
public boolean swapchainUnavailable;
public boolean windowThreadChecked;
public long renderPassGeneration;
public boolean wantFenceOnNextSubmit;
@@ -464,6 +464,20 @@ public final class FrameManager {
}
}
private volatile boolean acquireThreadReported;
private void checkAcquireOnWindowThread() {
if (acquireThreadReported) return;
final Thread self = Thread.currentThread();
final Thread window = device.getWindowThread();
if (window == null || window == self) return;
acquireThreadReported = true;
final String message = "Swapchain acquired on '" + self.getName() + "', window created on '" + window.getName() + "'";
if (SystemProperties.isDeobf() && !SystemProperties.DISABLE_SDL_PRESENTER_THREAD) throw new IllegalStateException(message);
LOG.warn(message);
}
public long ensureCopyPass() {
final FrameState f = frame();
if (f.copyPass != 0) {
@@ -863,7 +877,7 @@ public final class FrameManager {
return frame().lastFrameGateEndNanos;
}
private Presenter presenter;
private volatile Presenter presenter;
public void setPresenter(Presenter presenter) {
this.presenter = presenter;
@@ -877,18 +891,24 @@ public final class FrameManager {
public void presentFinalTarget() {
if (!finalTarget.hasContent()) return;
final long texture = finalTarget.colorTexture();
final int width = finalTarget.width();
final int height = finalTarget.height();
present(finalTarget.colorTexture(), finalTarget.width(), finalTarget.height(), SDL_FLIP_NONE, false);
}
public void presentSplash(long srcTexture, int srcW, int srcH) {
present(srcTexture, srcW, srcH, SDL_FLIP_VERTICAL, true);
}
private void present(long srcTexture, int srcW, int srcH, int flipMode, boolean splash) {
final FrameState f = frame();
if (f.presentedThisFrame) return;
final Presenter p = presenter;
if (p != null && p.isEngaged()) {
final FrameState f = frame();
if (f.presentedThisFrame) return;
if (p != null) {
f.presentedThisFrame = true;
p.requestPresent(texture, width, height);
p.requestPresent(srcTexture, srcW, srcH, flipMode);
} else {
presentBlit(frame(), texture, width, height, SDL_FLIP_NONE);
presentBlit(f, srcTexture, srcW, srcH, flipMode);
}
if (splash) f.presentedThisFrame = false;
}
private final AtomicLong windowAcquireWaitNanos = new AtomicLong();
@@ -915,8 +935,8 @@ public final class FrameManager {
else f.presentSkipsThisFrame++;
}
public void presentOnWindowThread(long srcTexture, int srcW, int srcH) {
presentBlit(windowFrame, srcTexture, srcW, srcH, SDL_FLIP_NONE);
public void presentOnWindowThread(long srcTexture, int srcW, int srcH, int flipMode) {
presentBlit(windowFrame, srcTexture, srcW, srcH, flipMode);
windowFrame.presentedThisFrame = false;
windowFrame.swapchainUnavailable = false;
}
@@ -925,16 +945,7 @@ public final class FrameManager {
if (srcTexture == 0 || srcW <= 0 || srcH <= 0) return false;
if (f.presentedThisFrame || f.swapchainUnavailable) return false;
if (!f.windowThreadChecked) {
f.windowThreadChecked = true;
final Thread self = Thread.currentThread();
final Thread windowThread = device.getWindowThread();
if (windowThread != null && windowThread != self) {
LOG.warn("Swapchain acquired on '{}' but the SDL window was created on '{}'; SDL requires acquire on the window-creating thread", self.getName(), windowThread.getName());
} else {
LOG.info("First swapchain acquire on thread={}", self.getName());
}
}
checkAcquireOnWindowThread();
final long window = device.getClaimedWindow();
if (window == 0) return false;
@@ -1,7 +1,5 @@
package com.gtnewhorizons.angelica.sdlgpu.frame;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
import java.util.concurrent.Executor;
import java.util.concurrent.Semaphore;
@@ -16,17 +14,13 @@ public final class Presenter {
this.windowThreadExecutor = windowThreadExecutor;
}
public boolean isEngaged() {
return windowThreadExecutor != null && GLStateManager.isSplashComplete();
}
public void requestPresent(long srcTexture, int srcW, int srcH) {
public void requestPresent(long srcTexture, int srcW, int srcH, int flipMode) {
pending.acquireUninterruptibly();
boolean submitted = false;
try {
windowThreadExecutor.execute(() -> {
try {
frameManager.presentOnWindowThread(srcTexture, srcW, srcH);
frameManager.presentOnWindowThread(srcTexture, srcW, srcH, flipMode);
} finally {
pending.release();
}
@@ -57,7 +57,6 @@ final class SdlFramePlots {
private static final long PLOT_PIPELINE_KEY_RECOMPUTES = Tracy.plotHandle("sdl.pipelineKeyRecomputes");
private static final long PLOT_PIPELINE_FAST_PATH_HITS = Tracy.plotHandle("sdl.pipelineFastPathHits");
private static final long PLOT_MID_FRAME_SUBMITS = Tracy.plotHandle("sdl.midFrameSubmits");
private static final long PLOT_GATE_US = Tracy.plotHandle("sdl.gateUs");
private static final int[] contextCounters = new int[SDLGPURenderBackend.CTR_COUNT];
@@ -110,6 +109,5 @@ final class SdlFramePlots {
Tracy.plotInt(PLOT_EMPTY_FRAMES, f.emptyFramesThisFrame);
Tracy.plotInt(PLOT_DROPPED_DRAWS, f.droppedDrawsThisFrame);
Tracy.plotInt(PLOT_MID_FRAME_SUBMITS, f.midFrameSubmitsThisFrame);
Tracy.plotInt(PLOT_GATE_US, f.lastFrameGateNanos / 1000);
}
}
@@ -681,7 +681,7 @@ public final class ShaderManager {
return null;
}
LOG.info("Vertex attribute conversion: program {} converted {}", program, attribs);
if (SystemProperties.DUMP_SHADERS) dumpVariantSource(program, src);
if (SystemProperties.dumpShaders()) dumpVariantSource(program, src);
return v;
} finally {
memFree(r.spirv());
@@ -80,7 +80,7 @@ public final class DxbcCrossCompile {
return new Output(copy, cached.entrypoint);
}
final int dumpId = SystemProperties.DUMP_SHADERS ? CrossCompileUtil.SHADER_DUMP_COUNTER.getAndIncrement() : -1;
final int dumpId = SystemProperties.dumpShaders() ? CrossCompileUtil.SHADER_DUMP_COUNTER.getAndIncrement() : -1;
if (dumpId >= 0) CrossCompileUtil.dumpSpirv(spirv, dumpId, glShaderType);
final String hlsl = compileToHlsl(spirv, glShaderType);
@@ -87,7 +87,7 @@ public final class MslCrossCompile {
return new Output(copy, cached.entrypoint);
}
final int dumpId = SystemProperties.DUMP_SHADERS ? CrossCompileUtil.SHADER_DUMP_COUNTER.getAndIncrement() : -1;
final int dumpId = SystemProperties.dumpShaders() ? CrossCompileUtil.SHADER_DUMP_COUNTER.getAndIncrement() : -1;
if (dumpId >= 0) CrossCompileUtil.dumpSpirv(spirv, dumpId, glShaderType);
try (MemoryStack stack = stackPush()) {
@@ -33,10 +33,10 @@ public final class SplashDispatcher {
return (long) w << 32 | (h & 0xffffffffL);
}
public static void tryDispatch(SDLGPURenderBackend backend, OffscreenTarget target) {
public static void tryDispatch(SDLGPURenderBackend backend, OffscreenTarget target, boolean force) {
if (!frameReady || target == null) return;
final long now = System.nanoTime();
if (now - lastBlitNanos < MIN_INTERVAL_NS) return;
if (!force && now - lastBlitNanos < MIN_INTERVAL_NS) return;
lastBlitNanos = now;
frameReady = false;
backend.dispatchSplashBlit(target);
@@ -10,6 +10,7 @@ import java.util.concurrent.Executor;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.lwjgl.sdl.SDLSurface.SDL_FLIP_NONE;
@Timeout(10)
class PresenterTest {
@@ -43,10 +44,10 @@ class PresenterTest {
final DeferredExecutor executor = new DeferredExecutor();
final Presenter p = presenter(executor);
p.requestPresent(1L, 10, 10);
p.requestPresent(1L, 10, 10, SDL_FLIP_NONE);
assertEquals(1, executor.tasks.size());
final Thread second = new Thread(() -> p.requestPresent(2L, 10, 10));
final Thread second = new Thread(() -> p.requestPresent(2L, 10, 10, SDL_FLIP_NONE));
second.start();
awaitWaiting(second);
assertEquals(1, executor.tasks.size(), "second present must not enqueue while the first is in flight");
@@ -61,7 +62,7 @@ class PresenterTest {
final DeferredExecutor executor = new DeferredExecutor();
final Presenter p = presenter(executor);
p.requestPresent(1L, 10, 10);
p.requestPresent(1L, 10, 10, SDL_FLIP_NONE);
final Thread drainer = new Thread(p::drain);
drainer.start();
@@ -74,8 +75,8 @@ class PresenterTest {
@Test
void anInlineExecutorReleasesTheSlotImmediately() {
final Presenter p = new Presenter(new FrameManager(new Device()), Runnable::run);
p.requestPresent(1L, 10, 10);
p.requestPresent(2L, 10, 10);
p.requestPresent(1L, 10, 10, SDL_FLIP_NONE);
p.requestPresent(2L, 10, 10, SDL_FLIP_NONE);
p.drain();
}
@@ -87,7 +88,7 @@ class PresenterTest {
fm.frame();
final int before = fm.registeredFrameCount();
p.requestPresent(1L, 10, 10);
p.requestPresent(1L, 10, 10, SDL_FLIP_NONE);
final Thread windowThread = new Thread(executor::runNext, "window-thread");
windowThread.start();
@@ -95,4 +96,18 @@ class PresenterTest {
assertEquals(before, fm.registeredFrameCount(), "the window thread must stay out of the frame registry");
}
@Test
void theSplashPresentGoesThroughTheWindowThreadToo() {
final DeferredExecutor executor = new DeferredExecutor();
final FrameManager fm = new FrameManager(new Device());
fm.setPresenter(new Presenter(fm, executor));
fm.frame();
final int before = fm.registeredFrameCount();
fm.presentSplash(1L, 10, 10);
assertEquals(1, executor.tasks.size(), "the splash blit must be enqueued on the window thread");
assertEquals(before, fm.registeredFrameCount(), "the splash present must not touch the calling thread's frame");
}
}
@@ -2,6 +2,7 @@ package com.gtnewhorizons.angelica.debug.flyby;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.glsm.profiling.Tracy;
import com.gtnewhorizons.angelica.rendering.FramePacer;
import cpw.mods.fml.common.eventhandler.SubscribeEvent;
import cpw.mods.fml.common.gameevent.TickEvent;
import net.minecraft.client.Minecraft;
@@ -279,6 +280,7 @@ public final class FlybyRunner {
this.frameCount = 0;
this.lastFrameNs = 0L;
this.runStartNs = System.nanoTime();
FramePacer.beginStats();
if (Tracy.ENABLED) {
Tracy.message("flyby start route=" + this.route.id() + " length=" + this.runLength + this.route.lengthUnit() + " speed=" + this.route.speedOr(this.speed) + "b/t ticks=" + this.runTicks + " sdlgpu=" + SystemProperties.USE_SDL_GPU);
}
@@ -384,6 +386,7 @@ public final class FlybyRunner {
final String summary = this.summarise(elapsedNs, player);
LOGGER.info(summary);
LOGGER.info(FramePacer.endStats());
if (mc.thePlayer != null) {
mc.thePlayer.addChatMessage(new ChatComponentText(EnumChatFormatting.AQUA + "[Angelica] " + EnumChatFormatting.WHITE + summary));
}
@@ -438,6 +441,13 @@ public final class FlybyRunner {
if (this.isActive()) {
LOGGER.info("Flyby cancelled");
this.state = State.DONE;
Tracy.sectionLeave(this.warmupSection);
this.warmupSection = 0L;
Tracy.sectionLeave(this.legSection);
this.legSection = 0L;
Tracy.sectionLeave(this.runSection);
this.runSection = 0L;
FramePacer.endStats();
}
}
@@ -4,6 +4,7 @@ import com.gtnewhorizons.angelica.AngelicaMod;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
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;
@@ -25,16 +26,21 @@ public final class FramePacer {
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 long deadline;
private static long lastFrameTime;
private static boolean warnedUncapped;
private static boolean pacedLastFrame;
private static boolean statsActive;
private static VSyncMode lastVSyncMode = VSyncMode.ON;
private static int lastCapHz;
private static int lastRefreshHz;
private static int lastCeilingHz;
private static IdleWork idleWork;
private static PacerSleeper sleeper = newSleeper();
@@ -47,7 +53,6 @@ public final class FramePacer {
private static int loggedRefreshHz;
private static int loggedCapHz;
private static boolean gateBlocking;
private static long lastSeenGateEnd;
private FramePacer() {}
@@ -60,6 +65,35 @@ public final class FramePacer {
idleWork = work;
}
public static boolean pacedLastFrame() {
return pacedLastFrame;
}
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) {
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()));
}
public static int plausibleRefreshHz(int hz) {
return hz >= MIN_PLAUSIBLE_REFRESH_HZ && hz <= MAX_PLAUSIBLE_REFRESH_HZ ? hz : 0;
}
@@ -155,11 +189,6 @@ public final class FramePacer {
}
public static long pace(int ceilingHz, Runnable renderAheadWait) {
if (ceilingHz != lastCeilingHz) {
lastCeilingHz = ceilingHz;
deadline = 0L;
}
long now = System.nanoTime();
long phaseEnd;
@@ -167,6 +196,9 @@ public final class FramePacer {
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();
@@ -192,8 +224,11 @@ public final class FramePacer {
now = phaseEnd;
}
if (now < deadline) {
final long preSleep = now;
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);
@@ -201,6 +236,10 @@ public final class FramePacer {
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;
}
@@ -215,15 +254,18 @@ public final class FramePacer {
final long waitNanos = phaseEnd - now;
now = phaseEnd;
if (ceilingHz > 0 && waitNanos > thresholdNanos) deadline = now;
gateBlocking = observeGate(ceilingHz > 0 ? Math.max(frameGateNanos, waitNanos) : 0L, thresholdNanos);
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);
if (vsyncNotHonoured(lastVSyncMode, blocking)) AngelicaMod.LOGGER.warn(line);
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;
@@ -5,31 +5,50 @@ import java.util.function.LongSupplier;
final class PacerSleeper {
private static final long SPIN_FLOOR_NANOS = 100_000L;
private static final long MIN_PARK_NANOS = 1_000_000L;
private static final long MAX_OVERSHOOT_NANOS = 4_000_000L;
private static final int SLOTS = 10;
static final int SLOTS = 10;
static final long MIN_SPIN_FLOOR_NANOS = 20_000L;
private static final long MAX_SPIN_FLOOR_NANOS = 500_000L;
private final LongSupplier clock;
private final LongConsumer parker;
private final boolean spinWait;
private final long[] overshoots = new long[SLOTS];
private long overshootSum;
private long spinFloorNanos = MIN_SPIN_FLOOR_NANOS;
private int offset;
long lastSpinNanos;
int lastParks;
private long frames;
private long slackSum;
private long parkTotal;
private long lateFrames;
private long lateMax;
PacerSleeper(LongSupplier clock, LongConsumer parker, boolean spinWait) {
this.clock = clock;
this.parker = parker;
this.spinWait = spinWait;
}
long spinFloorNanos() {
return spinFloorNanos;
}
long sleepUntil(long deadlineNanos, long now) {
resetLastFrame();
long spinFloor = spinFloorNanos;
while (true) {
final long remaining = deadlineNanos - now;
if (remaining <= SPIN_FLOOR_NANOS) break;
if (remaining <= spinFloor) break;
if (Thread.currentThread().isInterrupted()) return clock.getAsLong();
long request = remaining - avgOvershootNanos() - SPIN_FLOOR_NANOS;
long request = remaining - avgOvershootNanos() - spinFloor;
if (request <= 0L) {
if (remaining <= MIN_PARK_NANOS) break;
request = MIN_PARK_NANOS;
@@ -39,17 +58,51 @@ final class PacerSleeper {
parker.accept(request);
now = clock.getAsLong();
record(Math.min(Math.max(0L, now - before - request), MAX_OVERSHOOT_NANOS));
spinFloor = spinFloorNanos;
lastParks++;
}
if (spinWait) {
final long spinStart = now;
while (now < deadlineNanos) {
Thread.yield();
if (deadlineNanos - now > spinFloor) Thread.yield();
else Thread.onSpinWait();
now = clock.getAsLong();
}
lastSpinNanos = now - spinStart;
}
return now;
}
void noteFrame(long slackNanos, long wakeLateNanos, boolean slept) {
if (!slept) resetLastFrame();
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;
}
void resetStats() {
frames = 0L;
slackSum = 0L;
parkTotal = 0L;
lateFrames = 0L;
lateMax = 0L;
}
String summary(String config) {
if (frames == 0L) return "pacer: " + config + " no paced frames";
return "pacer: " + config + " frames=" + frames + " parks=" + parkTotal + " slackUs avg=" + slackSum / frames / 1000L + " floorUs=" + spinFloorNanos / 1000L
+ " overshootUs est=" + avgOvershootNanos() / 1000L + " lateFrames=" + lateFrames + " lateMaxUs=" + lateMax / 1000L;
}
long avgOvershootNanos() {
return overshootSum / SLOTS;
}
@@ -58,5 +111,12 @@ final class PacerSleeper {
overshootSum += nanos - overshoots[offset];
overshoots[offset] = nanos;
offset = (offset + 1) % SLOTS;
long max = 0L;
for (final long o : overshoots) {
if (o > max) max = o;
}
final long jitter = max - avgOvershootNanos();
spinFloorNanos = Math.max(MIN_SPIN_FLOOR_NANOS, Math.min(MAX_SPIN_FLOOR_NANOS, jitter));
}
}
@@ -81,12 +81,13 @@ public abstract class MixinMinecraft {
}
}
@Inject(method = "runGameLoop", at = @At(value = "INVOKE", target = "Ljava/lang/Thread;yield()V", remap = false))
private void angelica$limitFPS(CallbackInfo ci) {
if (AngelicaMod.proxy == null) return;
@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;
AngelicaMod.proxy.putFrametime(FramePacer.endFrame(capHz, angelica$renderAheadWait));
return !FramePacer.pacedLastFrame();
}
@Redirect(method = {"startGame", "toggleFullscreen"}, at = @At(value = "INVOKE", target = "Lorg/lwjgl/opengl/Display;setVSyncEnabled(Z)V", remap = false))
@@ -4,6 +4,7 @@ import com.gtnewhorizons.angelica.glsm.backend.VSyncMode;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -99,6 +100,14 @@ class FramePacerTest {
}
}
@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));
}
@Test
void fallingMoreThanTwoPeriodsBehindResynchronises() {
final long target = 16_666_666L;
@@ -131,4 +140,13 @@ class FramePacerTest {
assertNull(FramePacer.debugIndicator(VSyncMode.OFF, 144, 0, false));
assertNull(FramePacer.debugIndicator(VSyncMode.OFF, 0, 0, false));
}
@Test
void endingTheStatsPutsThemBackToSleep() {
FramePacer.beginStats();
assertTrue(FramePacer.statsActive());
FramePacer.endStats();
assertFalse(FramePacer.statsActive(), "a cancelled run must not leave the bookkeeping running forever");
}
}
@@ -11,7 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class PacerSleeperTest {
private static final long MS = 1_000_000L;
private static final long SPIN_FLOOR = 100_000L;
private static final long COLD_FLOOR = PacerSleeper.MIN_SPIN_FLOOR_NANOS;
private static final class FakeTime {
long now;
@@ -53,20 +53,20 @@ class PacerSleeperTest {
s.sleepUntil(10 * MS, 0L);
assertEquals(1, time.parks, "the request must be sized to the deadline, not to a fixed chunk");
assertEquals(10 * MS - SPIN_FLOOR, time.lastRequest, "the park should land one spin floor short of the deadline");
assertEquals(10 * MS - COLD_FLOOR, time.lastRequest, "the park should land one spin floor short of the deadline");
}
@Test
void whatIsLeftForTheSpinNeverExceedsTheFloor() {
void aWellBehavedTimerLeavesNoMoreThanTheFloorToSpin() {
final FakeTime time = new FakeTime();
time.lag = 20_000L;
final PacerSleeper s = sleeper(time);
for (int i = 0; i < 20; i++) {
final long deadline = time.now + 13 * MS;
final long floor = s.spinFloorNanos();
final long woke = s.sleepUntil(deadline, time.now);
assertTrue(deadline - woke <= SPIN_FLOOR,
"left " + (deadline - woke) + "ns to spin, floor is " + SPIN_FLOOR);
assertTrue(deadline - woke <= floor, "left " + (deadline - woke) + "ns to spin, floor is " + floor);
}
}
@@ -95,7 +95,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 <= SPIN_FLOOR, "the second park should still land inside the floor");
assertTrue(10 * MS - woke <= s.spinFloorNanos(), "the second park should still land inside the floor");
}
@Test
@@ -171,12 +171,74 @@ class PacerSleeperTest {
assertEquals(0, time.parks);
}
@Test
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 long woke = s.sleepUntil(5 * MS, 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");
}
@Test
void aGapWiderThanTheFloorIsStillClosedWithoutParking() {
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);
for (int i = 0; i < PacerSleeper.SLOTS; i++) {
s.sleepUntil(clock[0] + 13 * MS, clock[0]);
}
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 long woke = s.sleepUntil(deadline, clock[0]);
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");
assertTrue(woke >= deadline, "woke " + woke + "ns short of deadline " + deadline);
}
@Test
void aSteadyPlatformNeedsOnlyTheMinimumSpinFloor() {
final FakeTime time = new FakeTime();
time.lag = 300_000L;
final PacerSleeper s = sleeper(time);
for (int i = 0; i < PacerSleeper.SLOTS; i++) {
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");
}
@Test
void aJitteryPlatformWidensTheSpinFloorToCoverTheWorstPark() {
final FakeTime time = new FakeTime();
time.lag = 100_000L;
final PacerSleeper s = sleeper(time);
for (int i = 0; i < PacerSleeper.SLOTS - 1; i++) {
s.sleepUntil(time.now + 13 * MS, time.now);
}
time.lag = 500_000L;
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");
}
@Test
void aDeadlineInsideTheFloorParksNotAtAll() {
final FakeTime time = new FakeTime();
final PacerSleeper s = sleeper(time);
final long woke = s.sleepUntil(SPIN_FLOOR, 0L);
final long woke = s.sleepUntil(COLD_FLOOR, 0L);
assertEquals(0, time.parks);
assertEquals(0L, woke);