Adjust vsync and fps limiters, and fix WSR with player models (#2002)

This commit is contained in:
Jason Mitchell authored and GitHub committed 2026-08-09 20:06:53 -07:00
1 parent 8c31eec525
commit 3659b59db9
36 files changed
+1494 -292

No files matched your search

@@ -10,6 +10,8 @@ import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.glsm.DisplayListManager.RecordMode;
import com.gtnewhorizons.angelica.glsm.backend.BackendManager;
import com.gtnewhorizons.angelica.glsm.backend.GLDebugMessageListener;
import com.gtnewhorizons.angelica.glsm.backend.VSyncMode;
import com.gtnewhorizons.angelica.glsm.backend.RenderBackend;
import com.gtnewhorizons.angelica.glsm.profiling.Tracy;
import com.gtnewhorizons.angelica.glsm.ffp.ShaderManager;
import com.gtnewhorizons.angelica.glsm.ffp.VAOManager;
@@ -4517,7 +4519,10 @@ public class GLStateManager {
RENDER_BACKEND.readPixels(x, y, width, height, format, type, pixels);
restorePixelPackBuffer();
}
public static void glTexStorage2D(int target, int levels, int internalFormat, int width, int height) { RENDER_BACKEND.texStorage2D(target, levels, internalFormat, width, height); }
public static void glTexStorage2D(int target, int levels, int internalFormat, int width, int height) {
RENDER_BACKEND.texStorage2D(target, levels, internalFormat, width, height);
TextureInfoCache.INSTANCE.onTexStorage2D(target, internalFormat, width, height);
}
public static void glClearTexImage(int texture, int level, int format, int type, ByteBuffer data) { RENDER_BACKEND.clearTexImage(texture, level, format, type); }
public static int glGenSamplers() { return RENDER_BACKEND.genSamplers(); }
@@ -5043,7 +5048,39 @@ public class GLStateManager {
}
public static void setVSyncEnabled(boolean enabled) {
RENDER_BACKEND.setVSyncEnabled(enabled);
setVSyncMode(RENDER_BACKEND.getPreferredVSyncMode(), enabled);
}
public static void setVSyncMode(VSyncMode preferred, boolean vsyncEnabled) {
RENDER_BACKEND.setVSyncMode(preferred, vsyncEnabled);
}
public static VSyncMode getEffectiveVSyncMode() {
return RENDER_BACKEND.getEffectiveVSyncMode();
}
public static List<VSyncMode> getSelectableVSyncModes() {
return VSyncMode.selectable(RENDER_BACKEND::supportsVSyncMode);
}
public static int getMinRenderAhead() {
return RENDER_BACKEND.getMinRenderAhead();
}
public static int getMaxRenderAhead() {
return RENDER_BACKEND.getMaxRenderAhead();
}
public static int renderAheadLimit(int configured) {
return RenderBackend.clampRenderAhead(configured, RENDER_BACKEND.getMinRenderAhead(), RENDER_BACKEND.getMaxRenderAhead());
}
public static void applyRenderAheadLimit(int configured) {
RENDER_BACKEND.setRenderAheadLimit(renderAheadLimit(configured));
}
public static int getDisplayRefreshRateHz() {
return RENDER_BACKEND.getDisplayRefreshRateHz();
}
public static int glGetError() {
@@ -5126,6 +5163,7 @@ public class GLStateManager {
suspendPixelUnpackBuffer();
RENDER_BACKEND.texSubImage2D(target, level, xoffset, yoffset, width, height, format, type, pixels);
restorePixelUnpackBuffer();
TextureInfoCache.INSTANCE.onTexSubImage2D(target, level);
maybeGenerateMipmap(target, level);
}
@@ -5141,6 +5179,7 @@ public class GLStateManager {
suspendPixelUnpackBuffer();
RENDER_BACKEND.texSubImage2D(target, level, xoffset, yoffset, width, height, format, type, pixels);
restorePixelUnpackBuffer();
TextureInfoCache.INSTANCE.onTexSubImage2D(target, level);
maybeGenerateMipmap(target, level);
}
@@ -5150,6 +5189,7 @@ public class GLStateManager {
throw DisplayListManager.unsupportedInList("glTexSubImage2D with buffer offset");
}
RENDER_BACKEND.texSubImage2D(target, level, xoffset, yoffset, width, height, format, type, pixels_buffer_offset);
TextureInfoCache.INSTANCE.onTexSubImage2D(target, level);
maybeGenerateMipmap(target, level);
}
@@ -5190,6 +5230,7 @@ public class GLStateManager {
throw DisplayListManager.unsupportedInList("glCopyTexSubImage2D");
}
RENDER_BACKEND.copyTexSubImage2D(target, level, xoffset, yoffset, x, y, width, height);
TextureInfoCache.INSTANCE.onTexSubImage2D(target, level);
maybeGenerateMipmap(target, level);
}
@@ -10,6 +10,7 @@ import org.lwjgl.opengl.ARBDebugOutputCallback;
import org.lwjgl.opengl.ARBDirectStateAccess;
import org.lwjgl.opengl.ARBTimerQuery;
import org.lwjgl.opengl.ContextCapabilities;
import org.lwjgl.opengl.DisplayMode;
import org.lwjgl.opengl.EXTDirectStateAccess;
import org.lwjgl.opengl.EXTShaderImageLoadStore;
import org.lwjgl.opengl.GLContext;
@@ -114,7 +115,15 @@ public final class Lwjgl2GLRenderBackend extends RenderBackend {
public int getMinGLSLVersion() {return 330;}
@Override
public void setVSyncEnabled(boolean enabled) {Display.setVSyncEnabled(enabled);}
protected void setSwapInterval(boolean vsync) {Display.setVSyncEnabled(vsync);}
@Override
public int getDisplayRefreshRateHz() {
final DisplayMode mode = Display.getDisplayMode();
if (mode != null && mode.getFrequency() > 0) return mode.getFrequency();
final DisplayMode desktop = Display.getDesktopDisplayMode();
return desktop == null ? 0 : desktop.getFrequency();
}
@Override
public boolean isAnisotropicSupported() {
@@ -62,7 +62,47 @@ public abstract class RenderBackend {
public abstract void flush();
public abstract void finish();
public abstract void setVSyncEnabled(boolean enabled);
protected abstract void setSwapInterval(boolean vsync);
private VSyncMode preferredVSyncMode = VSyncMode.AUTO;
private VSyncMode effectiveVSyncMode = VSyncMode.ON;
public final void setVSyncMode(VSyncMode preferred, boolean vsyncEnabled) {
preferredVSyncMode = preferred == null ? VSyncMode.AUTO : preferred;
effectiveVSyncMode = applyVSyncMode(preferredVSyncMode, vsyncEnabled);
}
protected VSyncMode applyVSyncMode(VSyncMode preferred, boolean vsyncEnabled) {
final VSyncMode mode = preferred.resolve(vsyncEnabled).swapIntervalEquivalent();
setSwapInterval(mode.usesVSyncSwapInterval());
return mode;
}
public boolean supportsVSyncMode(VSyncMode mode) { return mode != VSyncMode.MAILBOX; }
public int getMinRenderAhead() { return 0; }
public int getMaxRenderAhead() { return 9; }
public void setRenderAheadLimit(int frames) {}
public static int clampRenderAhead(int value, int min, int max) {
if (value <= 0) return min <= 0 ? 0 : max;
return Math.min(Math.max(value, Math.max(min, 1)), max);
}
public final VSyncMode getPreferredVSyncMode() { return preferredVSyncMode; }
public final VSyncMode getEffectiveVSyncMode() { return effectiveVSyncMode; }
public int getDisplayRefreshRateHz() { return 0; }
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;
}
public String getPresentDebugInfo() { return null; }
public void onFrameBegin() {
if (GLStateManager.takeStateSeedPending()) {
@@ -0,0 +1,54 @@
package com.gtnewhorizons.angelica.glsm.backend;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
public enum VSyncMode {
AUTO,
ON,
MAILBOX,
OFF;
private static final List<VSyncMode> OFF_ORDER = List.of(OFF, MAILBOX, ON);
private static final List<VSyncMode> MAILBOX_ORDER = List.of(MAILBOX, ON);
private static final List<VSyncMode> ON_ORDER = List.of(ON);
private static final List<VSyncMode> DISPLAY_ORDER = List.of(OFF, ON, MAILBOX);
public boolean blocksOnVBlank() {
return this == ON;
}
public boolean usesVSyncSwapInterval() {
return this == ON || this == MAILBOX;
}
public VSyncMode swapIntervalEquivalent() {
return this == MAILBOX ? ON : this;
}
public VSyncMode resolve(boolean vsyncEnabled) {
return this != AUTO ? this : (vsyncEnabled ? ON : OFF);
}
public List<VSyncMode> fallbackOrder() {
return switch (this) {
case OFF -> OFF_ORDER;
case MAILBOX -> MAILBOX_ORDER;
default -> ON_ORDER;
};
}
public boolean tearFree() {
return this != OFF;
}
public static List<VSyncMode> selectable(Predicate<VSyncMode> supported) {
final List<VSyncMode> modes = new ArrayList<>(DISPLAY_ORDER.size());
for (VSyncMode mode : DISPLAY_ORDER) {
if (supported.test(mode)) modes.add(mode);
}
return modes;
}
}
@@ -36,6 +36,12 @@ public class TextureInfo {
@Getter @Setter protected boolean generateMipmap = false;
@Getter protected int uploadGeneration;
void bumpUploadGeneration() {
uploadGeneration++;
}
protected TextureInfo(int id) {
this.id = id;
}
@@ -53,6 +53,7 @@ public class TextureInfoCache {
info.resolvedInternalFormat = isGenericCompressedInternalFormat(internalformat) ? -1 : internalformat;
info.width = width;
info.height = height;
info.bumpUploadGeneration();
}
}
@@ -65,9 +66,29 @@ public class TextureInfoCache {
info.resolvedInternalFormat = isGenericCompressedInternalFormat(internalformat) ? -1 : internalformat;
info.width = width;
info.height = height;
info.bumpUploadGeneration();
}
}
public void onTexStorage2D(int target, int internalformat, int width, int height) {
if (!isTrackedTarget(target)) return;
final TextureInfo info = getInfo(GLStateManager.getBoundTextureForServerState());
if (info == null) return;
info.setTarget(target);
info.internalFormat = internalformat;
info.resolvedInternalFormat = isGenericCompressedInternalFormat(internalformat) ? -1 : internalformat;
info.width = width;
info.height = height;
info.bumpUploadGeneration();
}
public void onTexSubImage2D(int target, int level) {
if (!isTrackedTarget(target) || level != 0) return;
final TextureInfo info = getInfo(GLStateManager.getBoundTextureForServerState());
if (info == null) return;
info.bumpUploadGeneration();
}
public static boolean isGenericCompressedInternalFormat(int internalFormat) {
return switch (internalFormat) {
case GL30.GL_COMPRESSED_RED,
@@ -0,0 +1,71 @@
package com.gtnewhorizons.angelica.glsm;
import com.gtnewhorizons.angelica.glsm.texture.TextureInfo;
import com.gtnewhorizons.angelica.glsm.texture.TextureInfoCache;
import org.junit.jupiter.api.Test;
import org.lwjgl.BufferUtils;
import org.lwjgl.opengl.GL11;
import java.nio.ByteBuffer;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
@GLCoreTest
public class GLSM_TextureUploadGeneration_GLTest {
private static int allocate(int size) {
final int texId = GLStateManager.glGenTextures();
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, texId);
GLStateManager.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA8, size, size, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, BufferUtils.createByteBuffer(size * size * 4));
return texId;
}
@Test
void anUploadLeavesANonZeroGeneration() {
final int texId = allocate(4);
try {
assertNotEquals(0, TextureInfoCache.INSTANCE.getInfo(texId).getUploadGeneration());
} finally {
GLStateManager.glDeleteTextures(texId);
}
}
@Test
void recreatingATextureAtAReusedIdYieldsAFreshInfo() {
final int texId = allocate(4);
try {
final TextureInfo first = TextureInfoCache.INSTANCE.getInfo(texId);
final int firstGeneration = first.getUploadGeneration();
TextureInfoCache.INSTANCE.onDeleteTexture(texId);
final ByteBuffer pixels = BufferUtils.createByteBuffer(4 * 4 * 4);
GLStateManager.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA8, 4, 4, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, pixels);
final TextureInfo second = TextureInfoCache.INSTANCE.getInfo(texId);
assertNotSame(first, second, "a deleted id drops its entry, so the replacement must be a distinct instance");
assertEquals(firstGeneration, second.getUploadGeneration(), "generations alias across a delete/recreate cycle, which is exactly why identity has to carry the distinction");
} finally {
GLStateManager.glDeleteTextures(texId);
}
}
@Test
void anInPlaceRewriteBumpsTheGeneration() {
final int texId = allocate(4);
try {
final TextureInfo info = TextureInfoCache.INSTANCE.getInfo(texId);
final int afterAlloc = info.getUploadGeneration();
GLStateManager.glTexSubImage2D(GL11.GL_TEXTURE_2D, 0, 0, 0, 2, 2, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, BufferUtils.createByteBuffer(2 * 2 * 4));
assertNotEquals(afterAlloc, info.getUploadGeneration(), "content changed, so anything caching the pixels is stale");
final int afterSub = info.getUploadGeneration();
GLStateManager.glTexSubImage2D(GL11.GL_TEXTURE_2D, 1, 0, 0, 1, 1, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, BufferUtils.createByteBuffer(4));
assertEquals(afterSub, info.getUploadGeneration(), "only level 0 is tracked");
} finally {
GLStateManager.glDeleteTextures(texId);
}
}
}
@@ -0,0 +1,120 @@
package com.gtnewhorizons.angelica.glsm.backend;
import org.junit.jupiter.api.Test;
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 BackendPolicyTest {
@Test
void autoFollowsTheVsyncToggle() {
assertEquals(VSyncMode.ON, VSyncMode.AUTO.resolve(true));
assertEquals(VSyncMode.OFF, VSyncMode.AUTO.resolve(false));
}
@Test
void anExplicitModeIgnoresTheVsyncToggle() {
for (VSyncMode mode : new VSyncMode[]{ VSyncMode.ON, VSyncMode.MAILBOX, VSyncMode.OFF }) {
assertEquals(mode, mode.resolve(true));
assertEquals(mode, mode.resolve(false));
}
}
@Test
void onlyOnBlocks() {
assertTrue(VSyncMode.ON.blocksOnVBlank());
assertFalse(VSyncMode.MAILBOX.blocksOnVBlank());
assertFalse(VSyncMode.OFF.blocksOnVBlank());
}
@Test
void mailboxRidesOnSwapIntervalOne() {
assertTrue(VSyncMode.ON.usesVSyncSwapInterval());
assertTrue(VSyncMode.MAILBOX.usesVSyncSwapInterval());
assertFalse(VSyncMode.OFF.usesVSyncSwapInterval());
}
@Test
void swapIntervalBackendsReportWhatTheyActuallyGet() {
assertEquals(VSyncMode.ON, VSyncMode.MAILBOX.swapIntervalEquivalent());
assertEquals(VSyncMode.ON, VSyncMode.ON.swapIntervalEquivalent());
assertEquals(VSyncMode.OFF, VSyncMode.OFF.swapIntervalEquivalent());
}
@Test
void fallbackNeverWidensTearing() {
assertEquals(List.of(VSyncMode.ON), VSyncMode.ON.fallbackOrder());
assertEquals(List.of(VSyncMode.MAILBOX, VSyncMode.ON), VSyncMode.MAILBOX.fallbackOrder());
assertEquals(List.of(VSyncMode.OFF, VSyncMode.MAILBOX, VSyncMode.ON), VSyncMode.OFF.fallbackOrder());
}
@Test
void onlyOffCanTear() {
assertTrue(VSyncMode.ON.tearFree());
assertTrue(VSyncMode.MAILBOX.tearFree());
assertFalse(VSyncMode.OFF.tearFree());
}
@Test
void onlyBackendSupportedModesAreSelectable() {
assertEquals(List.of(VSyncMode.OFF, VSyncMode.ON), VSyncMode.selectable(m -> m != VSyncMode.MAILBOX));
assertEquals(List.of(VSyncMode.OFF, VSyncMode.ON, VSyncMode.MAILBOX), VSyncMode.selectable(m -> true));
}
@Test
void autoIsNeverSelectable() {
assertFalse(VSyncMode.selectable(m -> true).contains(VSyncMode.AUTO));
}
@Test
void refreshPrefersTheExactRational() {
assertEquals(144, RenderBackend.refreshHzFrom(144, 1, 0.0f));
assertEquals(60, RenderBackend.refreshHzFrom(60000, 1001, 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));
}
@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));
}
@Test
void glRenderAheadKeepsZeroAsOff() {
assertEquals(0, RenderBackend.clampRenderAhead(0, 0, 9));
assertEquals(0, RenderBackend.clampRenderAhead(-1, 0, 9));
}
@Test
void glRenderAheadClampsToItsRange() {
assertEquals(1, RenderBackend.clampRenderAhead(1, 0, 9));
assertEquals(3, RenderBackend.clampRenderAhead(3, 0, 9));
assertEquals(9, RenderBackend.clampRenderAhead(9, 0, 9));
assertEquals(9, RenderBackend.clampRenderAhead(20, 0, 9));
}
@Test
void sdlRenderAheadHasNoOffSwitch() {
assertEquals(3, RenderBackend.clampRenderAhead(0, 1, 3));
assertEquals(3, RenderBackend.clampRenderAhead(-1, 1, 3));
}
@Test
void sdlRenderAheadClampsToItsRange() {
assertEquals(1, RenderBackend.clampRenderAhead(1, 1, 3));
assertEquals(2, RenderBackend.clampRenderAhead(2, 1, 3));
assertEquals(3, RenderBackend.clampRenderAhead(3, 1, 3));
assertEquals(3, RenderBackend.clampRenderAhead(6, 1, 3));
}
}
@@ -32,6 +32,7 @@ import org.lwjgl.opengl.EXTPolygonOffsetClamp;
import org.lwjgl.opengl.EXTShaderImageLoadStore;
import org.lwjgl.opengl.GLCapabilities;
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.SDLEvents;
@@ -39,6 +40,7 @@ import org.lwjgl.sdl.SDLVideo;
import org.lwjgl.system.JNI;
import org.lwjgl.system.MemoryStack;
import org.lwjgl.system.MemoryUtil;
import org.lwjglx.opengl.Display;
import java.nio.Buffer;
import java.nio.ByteBuffer;
@@ -169,7 +171,17 @@ public final class Lwjgl3GLRenderBackend extends RenderBackend {
public int getMinGLSLVersion() {return 330;}
@Override
public void setVSyncEnabled(boolean enabled) {SDLVideo.SDL_GL_SetSwapInterval(enabled ? 1 : 0);}
protected void setSwapInterval(boolean vsync) {SDLVideo.SDL_GL_SetSwapInterval(vsync ? 1 : 0);}
@Override
public int getDisplayRefreshRateHz() {
final long window = Display.getWindow();
final int displayId = window == 0 ? SDLVideo.SDL_GetPrimaryDisplay() : SDLVideo.SDL_GetDisplayForWindow(window);
if (displayId == 0) return 0;
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());
}
@Override
public boolean isAnisotropicSupported() {
@@ -7,6 +7,7 @@ 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.VSyncMode;
import com.gtnewhorizons.angelica.glsm.backend.RenderBackend;
import com.gtnewhorizons.angelica.glsm.hooks.GLSMConfig;
import com.gtnewhorizons.angelica.glsm.texture.TextureInfoCache;
@@ -375,8 +376,26 @@ public class SDLGPURenderBackend extends RenderBackend {
LOG.info("Populated GL capabilities for SDL GPU backend");
}
@Override public void setVSyncEnabled(boolean enabled) {
device.setVSyncEnabled(enabled);
@Override protected void setSwapInterval(boolean vsync) {
device.setVSyncMode(VSyncMode.AUTO, vsync);
}
@Override protected VSyncMode applyVSyncMode(VSyncMode preferred, boolean vsyncEnabled) {
return device.setVSyncMode(preferred, vsyncEnabled);
}
@Override public boolean supportsVSyncMode(VSyncMode mode) {
return mode == VSyncMode.AUTO || device.supportsVSyncMode(mode);
}
@Override public int getMinRenderAhead() { return Device.MIN_FRAMES_IN_FLIGHT; }
@Override public int getMaxRenderAhead() { return Device.MAX_FRAMES_IN_FLIGHT; }
@Override public void setRenderAheadLimit(int frames) { device.setFramesInFlight(frames); }
@Override public int getDisplayRefreshRateHz() {
return device.getDisplayRefreshRateHz();
}
@Override public void shutdown() {
@@ -681,6 +700,54 @@ public class SDLGPURenderBackend extends RenderBackend {
return transferThread != null ? transferThread.getDebugInfo() : null;
}
private static final long PRESENT_STATS_WINDOW_NANOS = 1_000_000_000L;
private static final long PRESENT_STATS_STALE_NANOS = 2_000_000_000L;
private long presentStatsNanos;
private long presentStatsLoopFrames;
private long presentStatsPresentedFrames;
private long presentStatsSkips;
private long presentStatsEmpty;
private long presentStatsAcquireNanos;
private String presentDebugLine;
@Override public String getPresentDebugInfo() {
if (shutdown) return null;
final long now = System.nanoTime();
final long elapsed = now - presentStatsNanos;
if (presentStatsNanos == 0L || elapsed > PRESENT_STATS_STALE_NANOS) {
snapshotPresentStats(now);
presentDebugLine = null;
return null;
}
if (elapsed < PRESENT_STATS_WINDOW_NANOS) return presentDebugLine;
final long loopDelta = frameManager.statLoopFrames() - presentStatsLoopFrames;
final long presentedDelta = frameManager.statPresentedFrames() - presentStatsPresentedFrames;
final long skipDelta = frameManager.statPresentSkips() - presentStatsSkips;
final long emptyDelta = frameManager.statEmptyFrames() - presentStatsEmpty;
final long acquireDelta = frameManager.statAcquireWaitNanos() - presentStatsAcquireNanos;
final double seconds = elapsed / 1.0e9;
presentDebugLine = String.format("Present: %s %dHz | loop %.0f/s shown %.0f/s | skip %.0f/s empty %.0f/s | acq %.2fms | fif %d",
getEffectiveVSyncMode(), device.getDisplayRefreshRateHz(),
loopDelta / seconds, presentedDelta / seconds,
skipDelta / seconds, emptyDelta / seconds,
loopDelta == 0 ? 0.0 : acquireDelta / (double) loopDelta / 1.0e6,
Device.framesInFlight());
snapshotPresentStats(now);
return presentDebugLine;
}
private void snapshotPresentStats(long now) {
presentStatsNanos = now;
presentStatsLoopFrames = frameManager.statLoopFrames();
presentStatsPresentedFrames = frameManager.statPresentedFrames();
presentStatsSkips = frameManager.statPresentSkips();
presentStatsEmpty = frameManager.statEmptyFrames();
presentStatsAcquireNanos = frameManager.statAcquireWaitNanos();
}
@Override public void enable(int cap) { setBoolCap(cap, true); }
@Override public void enablei(int cap, int index) { setBoolCapIndexed(cap, index, true); }
@Override public void disable(int cap) { setBoolCap(cap, false); }
@@ -1,6 +1,8 @@
package com.gtnewhorizons.angelica.sdlgpu.device;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.glsm.backend.VSyncMode;
import com.gtnewhorizons.angelica.glsm.backend.RenderBackend;
import com.gtnewhorizons.angelica.sdlgpu.util.DebugMessageRelay;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -29,7 +31,13 @@ import static org.lwjgl.system.MemoryUtil.memUTF8;
public final class Device {
private static final Logger LOG = LogManager.getLogger("Angelica-SDLGPU");
public static final int FRAMES_IN_FLIGHT = 3;
public static final int MIN_FRAMES_IN_FLIGHT = 1;
public static final int DEFAULT_FRAMES_IN_FLIGHT = 2;
public static final int MAX_FRAMES_IN_FLIGHT = 3;
private static volatile int framesInFlight = DEFAULT_FRAMES_IN_FLIGHT;
public static int framesInFlight() { return framesInFlight; }
private static final int REQUESTED_SHADER_FORMATS = SDL_GPU_SHADERFORMAT_SPIRV | SDL_GPU_SHADERFORMAT_MSL | SDL_GPU_SHADERFORMAT_DXBC | SDL_GPU_SHADERFORMAT_DXIL;
@@ -40,7 +48,8 @@ public final class Device {
private String deviceName;
private String driverVersion;
private String driverInfo;
private int presentMode = SDL_GPU_PRESENTMODE_VSYNC;
private VSyncMode vsyncMode = VSyncMode.ON;
private volatile String windowThreadName;
private SDL_LogOutputFunction logCallback; // prevent GC
private volatile boolean lost;
@@ -146,13 +155,7 @@ public final class Device {
throw new RuntimeException("Failed to claim window for SDL GPU device: " + SDLError.SDL_GetError());
}
claimed = true;
final int n = FRAMES_IN_FLIGHT;
if (!SDL_SetGPUAllowedFramesInFlight(device, n)) {
LOG.warn("SDL_SetGPUAllowedFramesInFlight({}) failed: {}", n, SDLError.SDL_GetError());
} else {
LOG.info("SDL frames-in-flight set to {}", n);
}
windowThreadName = Thread.currentThread().getName();
logWindowDiagnostics(window);
LOG.info("SDL GPU device claimed window successfully");
@@ -260,6 +263,10 @@ public final class Device {
return driverName;
}
public String getWindowThreadName() {
return windowThreadName;
}
public String getDeviceName() {
return deviceName;
}
@@ -288,22 +295,56 @@ public final class Device {
return (supportedShaderFormats & SDL_GPU_SHADERFORMAT_DXBC) != 0;
}
public void setVSyncEnabled(boolean enabled) {
final long window = Display.getWindow();
final int mode;
if (enabled) {
mode = SDL_GPU_PRESENTMODE_VSYNC;
} else if (SDL_WindowSupportsGPUPresentMode(device, window, SDL_GPU_PRESENTMODE_MAILBOX)) {
mode = SDL_GPU_PRESENTMODE_MAILBOX;
} else {
mode = SDL_GPU_PRESENTMODE_IMMEDIATE;
}
if (!SDL_SetGPUSwapchainParameters(device, window, SDL_GPU_SWAPCHAINCOMPOSITION_SDR, mode)) {
LOG.warn("Failed to set present mode {}: {} (keeping prior mode {})", mode, SDLError.SDL_GetError(), presentMode);
public void setFramesInFlight(int frames) {
final int n = Math.min(Math.max(frames, MIN_FRAMES_IN_FLIGHT), MAX_FRAMES_IN_FLIGHT);
if (n == framesInFlight || device == 0 || !claimed) return;
if (!SDL_SetGPUAllowedFramesInFlight(device, n)) {
LOG.warn("SDL_SetGPUAllowedFramesInFlight({}) failed: {}; keeping {}", n, SDLError.SDL_GetError(), framesInFlight);
return;
}
presentMode = mode;
LOG.info("SDL present mode set to {}", mode);
framesInFlight = n;
LOG.info("SDL frames-in-flight set to {}", n);
}
public VSyncMode setVSyncMode(VSyncMode preferred, boolean vsyncEnabled) {
final long window = Display.getWindow();
final VSyncMode requested = preferred.resolve(vsyncEnabled);
for (VSyncMode candidate : requested.fallbackOrder()) {
final int sdlMode = toSdl(candidate);
if (!SDL_WindowSupportsGPUPresentMode(device, window, sdlMode)) continue;
if (!SDL_SetGPUSwapchainParameters(device, window, SDL_GPU_SWAPCHAINCOMPOSITION_SDR, sdlMode)) {
LOG.warn("Failed to set present mode {}: {}", candidate, SDLError.SDL_GetError());
continue;
}
vsyncMode = candidate;
LOG.info("SDL present mode {} (requested {}, preference {}, vsync={}), refresh {}Hz, supported: vsync={} mailbox={} immediate={}",
candidate, requested, preferred, vsyncEnabled, getDisplayRefreshRateHz(),
supportsVSyncMode(VSyncMode.ON), supportsVSyncMode(VSyncMode.MAILBOX), supportsVSyncMode(VSyncMode.OFF));
return candidate;
}
LOG.warn("No usable present mode for request {}; keeping {}", requested, vsyncMode);
return vsyncMode;
}
public boolean supportsVSyncMode(VSyncMode mode) {
return device != 0 && claimed && SDL_WindowSupportsGPUPresentMode(device, Display.getWindow(), toSdl(mode));
}
private static int toSdl(VSyncMode mode) {
return switch (mode) {
case OFF -> SDL_GPU_PRESENTMODE_IMMEDIATE;
case MAILBOX -> SDL_GPU_PRESENTMODE_MAILBOX;
default -> SDL_GPU_PRESENTMODE_VSYNC;
};
}
public int getDisplayRefreshRateHz() {
final long window = Display.getWindow();
final int displayId = window == 0 ? SDL_GetPrimaryDisplay() : SDL_GetDisplayForWindow(window);
if (displayId == 0) return 0;
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());
}
public int getSwapchainTextureFormat() {
@@ -37,7 +37,6 @@ public final class FrameManager {
private static final Tracy.ZoneId Z_SDL_FENCE_WAIT = Tracy.zoneId("sdlFenceWait", Tracy.COLOR_SWAP);
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_SWAPCHAIN_WAIT = Tracy.zoneId("sdlSwapchainWait", Tracy.COLOR_SWAP);
private final Device device;
private ResourceManager resourceManager;
@@ -89,7 +88,6 @@ public final class FrameManager {
public int emptyFramesThisFrame;
public int droppedDrawsThisFrame;
public int computeBatchJoinsThisFrame;
public boolean drawableStarved;
public int clearPassesThisFrame;
public int swapchainPassesThisFrame;
public int fboPassesThisFrame;
@@ -181,6 +179,18 @@ public final class FrameManager {
private Runnable beforeEndCopyPass;
private long statLoopFrames;
private long statPresentedFrames;
private long statPresentSkips;
private long statEmptyFrames;
private long statAcquireWaitNanos;
public long statLoopFrames() { return statLoopFrames; }
public long statPresentedFrames() { return statPresentedFrames; }
public long statPresentSkips() { return statPresentSkips; }
public long statEmptyFrames() { return statEmptyFrames; }
public long statAcquireWaitNanos() { return statAcquireWaitNanos; }
public FrameManager(Device device) {
this.device = device;
}
@@ -199,7 +209,13 @@ public final class FrameManager {
}
if (!threadLogged) {
threadLogged = true;
LOG.info("First beginFrame() on thread={}; must match the SDL window-creating thread", Thread.currentThread().getName());
final String renderThread = Thread.currentThread().getName();
final String windowThread = device.getWindowThreadName();
if (windowThread != null && !windowThread.equals(renderThread)) {
LOG.warn("Frames are recorded on '{}' but the SDL window was created on '{}'; SDL requires swapchain acquire/wait on the window-creating thread", renderThread, windowThread);
} else {
LOG.info("First beginFrame() on thread={}", renderThread);
}
}
flushPendingUploadCommandBuffer(f);
@@ -210,13 +226,6 @@ public final class FrameManager {
finally { Tracy.endZone(); }
}
if (f.drawableStarved) {
f.drawableStarved = false;
Tracy.beginZone(Z_SDL_SWAPCHAIN_WAIT);
try { SDL_WaitForGPUSwapchain(device.getDevice(), Display.getWindow()); }
finally { Tracy.endZone(); }
}
f.commandBuffer = SDL_AcquireGPUCommandBuffer(device.getDevice());
if (f.commandBuffer == 0) {
throw new RuntimeException("Failed to acquire GPU command buffer: " + SDLError.SDL_GetError());
@@ -529,9 +538,14 @@ public final class FrameManager {
f.commandBuffer = 0;
}
f.drawableStarved = f.presentSkipsThisFrame > 0;
if (Tracy.ENABLED && resourceManager != null) SdlFramePlots.emit(f, resourceManager);
statLoopFrames++;
if (f.swapchainAcquiredThisFrame) statPresentedFrames++;
statPresentSkips += f.presentSkipsThisFrame;
statEmptyFrames += f.emptyFramesThisFrame;
statAcquireWaitNanos += f.acquireWaitNanosThisFrame;
f.lastPlottedRenderPassGen = f.renderPassGeneration;
f.computePassesThisFrame = 0;
f.submitsThisFrame = 0;
@@ -847,7 +861,7 @@ public final class FrameManager {
endActiveEncoders(f);
final long t0 = Tracy.ENABLED ? System.nanoTime() : 0L;
final long t0 = System.nanoTime();
final long tex;
final int w;
final int h;
@@ -858,7 +872,7 @@ public final class FrameManager {
final IntBuffer pHeight = stack.ints(0);
Tracy.beginZone(Z_SDL_ACQUIRE_WAIT);
try {
callOk = SDL_AcquireGPUSwapchainTexture(f.commandBuffer, Display.getWindow(), pTexture, pWidth, pHeight);
callOk = SDL_WaitAndAcquireGPUSwapchainTexture(f.commandBuffer, Display.getWindow(), pTexture, pWidth, pHeight);
} finally {
Tracy.endZone();
}
@@ -866,11 +880,11 @@ public final class FrameManager {
w = pWidth.get(0);
h = pHeight.get(0);
} catch (RuntimeException re) {
if (Tracy.ENABLED) f.acquireWaitNanosThisFrame += System.nanoTime() - t0;
f.acquireWaitNanosThisFrame += System.nanoTime() - t0;
f.swapchainUnavailable = true;
throw re;
}
if (Tracy.ENABLED) f.acquireWaitNanosThisFrame += System.nanoTime() - t0;
f.acquireWaitNanosThisFrame += System.nanoTime() - t0;
if (!callOk) {
f.swapchainUnavailable = true;
@@ -97,7 +97,7 @@ final class SdlFramePlots {
final long nowNanos = System.nanoTime();
if (f.lastEndFrameNanos != 0) Tracy.plotInt(PLOT_PRESENT_TO_PRESENT_US, (nowNanos - f.lastEndFrameNanos) / 1000);
f.lastEndFrameNanos = nowNanos;
Tracy.plotInt(PLOT_FRAMES_IN_FLIGHT, Device.FRAMES_IN_FLIGHT);
Tracy.plotInt(PLOT_FRAMES_IN_FLIGHT, Device.framesInFlight());
Tracy.plotInt(PLOT_MIP_GENS, f.mipGensThisFrame);
Tracy.plotInt(PLOT_BLIT_FALLBACKS, f.blitsThisFrame);
Tracy.plotInt(PLOT_PRESENT_SKIPS, f.presentSkipsThisFrame);
@@ -587,7 +587,7 @@ public final class ResourceManager {
final GpuBucket b = gpuBufferPool.get(Hashing.packHiLo(usage, bucket));
if (b == null || b.handles.isEmpty()) return 0;
for (int i = 0, n = b.handles.size(); i < n; i++) {
if (gpuPoolCurrentFrame - b.frames.getLong(i) >= Device.FRAMES_IN_FLIGHT) {
if (gpuPoolCurrentFrame - b.frames.getLong(i) >= Device.framesInFlight()) {
final long handle = b.handles.getLong(i);
final int last = n - 1;
if (i != last) {
@@ -1376,7 +1376,7 @@ public final class ResourceManager {
for (int i = 0; i < n; i += 2) {
final long handle = batchSegmentPending.getLong(i);
final long stamp = batchSegmentPending.getLong(i + 1);
if (currentFrame - stamp >= Device.FRAMES_IN_FLIGHT) {
if (currentFrame - stamp >= Device.framesInFlight()) {
batchSegmentFree.enqueue(handle);
} else {
batchSegmentPending.set(w, handle);
@@ -2115,7 +2115,7 @@ public final class ResourceManager {
}
private static final int DUMMY_VBO_SIZE = 16 * 16;
public static final int ATTRIB_RING_BLOCKS = 2048 * Device.FRAMES_IN_FLIGHT;
public static final int ATTRIB_RING_BLOCKS = 2048 * Device.MAX_FRAMES_IN_FLIGHT;
public static final int ATTRIB_RING_CHUNK_BLOCKS = 64;
private static final int ATTRIB_RING_CHUNKS = ATTRIB_RING_BLOCKS / ATTRIB_RING_CHUNK_BLOCKS;
private final AtomicLong attribRingCounter = new AtomicLong();
@@ -11,9 +11,18 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class TransferRetireSeqTest {
private static TransferThread thread() {
private static TransferThread idleThread() {
final SdlTestRig rig = SdlTestRig.create();
return new TransferThread(rig.device, rig.resourceManager);
final TransferThread tt = new TransferThread(rig.device, rig.resourceManager);
tt.shutdown();
try {
tt.getThread().join(5_000L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError("interrupted while stopping the transfer thread", e);
}
assertFalse(tt.getThread().isAlive(), "the worker must be stopped first: its loop zeroes openHighestSeq after publishing, so a live worker races this test");
return tt;
}
@Test
@@ -34,21 +43,19 @@ class TransferRetireSeqTest {
@Test
void aDroppedTextureUploadStillRetiresItsSeq() {
final TransferThread tt = thread();
final TransferThread tt = idleThread();
Reflect.invoke(tt, "recordTextureUpload",
new Class<?>[]{ long.class, long.class, int.class, int.class, int.class, int.class, int.class, long.class, long.class },
0L, 0xBEEFL, 0, 0, 16, 16, 0, 1024L, 42L);
assertEquals(42L, (long) Reflect.get(tt, "openHighestSeq"), "a dropped upload must still advance the watermark or awaitSubmittedUpTo blocks forever");
tt.shutdown();
}
@Test
void aDroppedBufferUploadStillRetiresItsSeq() {
final TransferThread tt = thread();
final TransferThread tt = idleThread();
Reflect.invoke(tt, "recordBufferUpload", new Class<?>[]{ long.class, long.class, long.class, long.class, long.class, boolean.class }, 0L, 256L, 0xF00L, 0L, 7L, false);
assertEquals(7L, (long) Reflect.get(tt, "openHighestSeq"));
tt.shutdown();
}
}
@@ -0,0 +1,8 @@
package com.gtnewhorizons.angelica.mixins.interfaces;
import net.minecraft.client.model.TexturedQuad;
public interface ModelBoxQuads {
TexturedQuad[] angelica$getQuadList();
}
@@ -106,6 +106,10 @@ public final class ClientProxy extends CommonProxy {
return CONFIG;
}
public static void applyVSyncMode(boolean vsyncEnabled) {
GLStateManager.setVSyncMode(options().advanced.vsyncMode, vsyncEnabled);
}
@Override
public void preInit(FMLPreInitializationEvent event) {
ModStatus.preInit();
@@ -0,0 +1,116 @@
package com.gtnewhorizons.angelica.rendering;
import com.gtnewhorizons.angelica.AngelicaMod;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
import com.gtnewhorizons.angelica.glsm.backend.VSyncMode;
public final class FramePacer {
private static final int MIN_PLAUSIBLE_REFRESH_HZ = 20;
private static final int MAX_PLAUSIBLE_REFRESH_HZ = 1000;
private static final long REFRESH_POLL_NANOS = 1_000_000_000L;
private static long lastFrameTime;
private static long fpsLimitOverhead;
private static long lastRefreshPollNanos;
private static int cachedRefreshHz;
private static boolean warnedUnknownRefresh;
private static VSyncMode lastVSyncMode = VSyncMode.ON;
private static int lastCapHz;
private static int lastRefreshHz;
private FramePacer() {}
public static int plausibleRefreshHz(int hz) {
return hz >= MIN_PLAUSIBLE_REFRESH_HZ && hz <= MAX_PLAUSIBLE_REFRESH_HZ ? hz : 0;
}
public static int pacingCeilingHz(VSyncMode mode, int capHz, int refreshHz) {
if (mode.blocksOnVBlank()) return 0;
final int cap = Math.max(capHz, 0);
if (mode != VSyncMode.MAILBOX) return cap;
final int refresh = plausibleRefreshHz(refreshHz);
if (cap == 0) return refresh;
return refresh == 0 ? cap : Math.min(cap, refresh);
}
public static long sleepNanosFor(long targetNanos, long lastWorkNanos, long overheadNanos) {
return targetNanos - lastWorkNanos - overheadNanos;
}
public static int updateCeiling(int capHz) {
lastVSyncMode = GLStateManager.getEffectiveVSyncMode();
lastCapHz = Math.max(capHz, 0);
lastRefreshHz = refreshHz();
return pacingCeilingHz(lastVSyncMode, lastCapHz, lastRefreshHz);
}
public static long pace(int ceilingHz) {
if (ceilingHz > 0) {
final long time = System.nanoTime();
final long targetNanos = 1_000_000_000L / ceilingHz;
final long sleepNanos = sleepNanosFor(targetNanos, time - lastFrameTime, fpsLimitOverhead);
if (sleepNanos > 0) {
try {
Thread.sleep(sleepNanos / 1_000_000, (int) sleepNanos % 1_000_000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
long overhead = System.nanoTime() - time - sleepNanos;
if (overhead < 0 || overhead > targetNanos / 2) overhead = 0;
fpsLimitOverhead = overhead;
}
}
final long time = System.nanoTime();
final long period = time - lastFrameTime;
lastFrameTime = time;
return period;
}
private static int refreshHz() {
final long now = System.nanoTime();
if (lastRefreshPollNanos == 0L || now - lastRefreshPollNanos >= REFRESH_POLL_NANOS) {
lastRefreshPollNanos = now;
cachedRefreshHz = GLStateManager.getDisplayRefreshRateHz();
}
final int hz = plausibleRefreshHz(cachedRefreshHz);
if (hz == 0 && lastVSyncMode == VSyncMode.MAILBOX && !warnedUnknownRefresh) {
warnedUnknownRefresh = true;
AngelicaMod.LOGGER.warn("Display refresh rate reported as {}Hz; Mailbox will not be paced to the refresh rate. The Max Framerate setting still applies.", cachedRefreshHz);
}
return hz;
}
public static String debugIndicator(VSyncMode mode, int refreshHz, int capHz) {
final boolean vsync = mode.blocksOnVBlank();
final boolean mailbox = mode == VSyncMode.MAILBOX;
final boolean showCap = capHz > 0 && !vsync;
if (!vsync && !mailbox && !showCap) return null;
final StringBuilder sb = new StringBuilder(" [");
if (vsync || mailbox) {
sb.append(vsync ? "vsync" : "mailbox");
if (refreshHz > 0) sb.append(' ').append(refreshHz);
if (showCap) sb.append(", ");
}
if (showCap) sb.append("cap ").append(capHz);
return sb.append(']').toString();
}
public static String debugIndicator() {
return debugIndicator(lastVSyncMode, lastRefreshHz, lastCapHz);
}
public static VSyncMode lastVSyncMode() { return lastVSyncMode; }
public static int lastCapHz() { return lastCapHz; }
public static int lastRefreshHz() { return lastRefreshHz; }
}
@@ -5,66 +5,103 @@ import com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFlags;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFormat;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
import com.gtnewhorizons.angelica.glsm.QuadConverter;
import com.gtnewhorizons.angelica.glsm.profiling.Tracy;
import com.gtnewhorizons.angelica.glsm.states.ColorMask;
import com.gtnewhorizons.angelica.glsm.texture.TextureInfo;
import com.gtnewhorizons.angelica.glsm.texture.TextureInfoCache;
import com.gtnewhorizons.angelica.mixins.interfaces.ModelBoxQuads;
import com.gtnewhorizons.angelica.rendering.tesr.PassRebindGate;
import lombok.Getter;
import net.coderbot.iris.Iris;
import net.coderbot.iris.gl.blending.DepthColorStorage;
import net.coderbot.iris.gl.image.GlImage;
import net.coderbot.iris.pipeline.DeferredWorldRenderingPipeline;
import net.coderbot.iris.uniforms.CapturedRenderingState;
import net.minecraft.client.model.ModelBiped;
import net.minecraft.client.model.ModelBox;
import net.minecraft.client.model.ModelRenderer;
import net.minecraft.client.model.PositionTextureVertex;
import net.minecraft.client.model.TexturedQuad;
import net.minecraft.client.renderer.entity.Render;
import net.minecraft.client.renderer.entity.RenderManager;
import net.minecraft.client.renderer.entity.RendererLivingEntity;
import net.minecraft.entity.player.EntityPlayer;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.lwjgl.opengl.GL11;
import org.lwjgl.opengl.GL13;
import org.lwjgl.opengl.GL15;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memAddress0;
import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memAlloc;
import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memFree;
/**
* Skins in 1.7.10 are 64x32 while modern has 64x64 textures. We have to address it here.
* Feeds the shaderpack's world-space player reflection, which expects the modern 12-box player model:
* 288 vertices over a 64x64 skin layout. 1.7.10 has 7 boxes and a 64x32 layout, so both are synthesized here.
*/
public final class PlayerReflectionCapture {
public static final int VERTEX_COUNT = 288;
public static final int FLOATS_PER_VERTEX = 5; // x, y, z, u, v
public static final int PART_COUNT = 12;
public static final int VERTICES_PER_PART = VERTEX_COUNT / PART_COUNT;
private static final int RAW_INTS_PER_VERTEX = 8;
private static final int ATLAS_LAYOUT = 64;
private static final int VERTEX_FLAGS = VertexFlags.convertToFlags(true, false, true, false);
private static final Tracy.ZoneId Z_PLAYER_REFLECTION_FLUSH = Tracy.zoneId("playerReflectionFlush", Tracy.COLOR_IRIS);
private static boolean active;
private static boolean drawnThisCapture;
private static boolean captured;
@Getter
private static Object target;
@Getter
private static EntityPlayer targetEntity;
private static boolean pending;
private static final Matrix4f pendingModelView = new Matrix4f();
private static int pendingSkin;
private static int pendingEntityId;
private static int vbo;
private static int vao;
private static int[] rawData;
private static float[] vertexScratch;
private static int skin64Tex;
private static int skin64Size;
private static boolean skin64Valid;
private static ByteBuffer skinPixels;
private static int lastCapturedSkin;
private static int lastUploadedAtlas;
private static int skinCopyWidth = 64;
private static int skinCopyHeight = 32;
private static TextureInfo lastCapturedInfo;
private static int lastCapturedGeneration = -1;
private static TextureInfo lastUploadedAtlasInfo;
private static int lastUploadedAtlasGeneration = -1;
private static int skinCopyWidth;
private static int skinCopyHeight;
private static ByteBuffer repackBuffer;
private static long repackAddress;
private static int repackCapacity;
private static final ModelRenderer[] partOrder = new ModelRenderer[PART_COUNT];
private static final Matrix4f partMatrix = new Matrix4f();
private static final Vector3f partVertex = new Vector3f();
private static final Matrix4f savedModelView = new Matrix4f();
private PlayerReflectionCapture() {}
public static void begin(EntityPlayer player) {
active = false;
drawnThisCapture = false;
captured = false;
pending = false;
target = null;
targetEntity = null;
if (player == null) return;
@@ -79,24 +116,122 @@ public final class PlayerReflectionCapture {
public static void end() {
active = false;
pending = false;
target = null;
targetEntity = null;
}
public static boolean shouldCapture() {
return active && !drawnThisCapture;
return active && !captured;
}
/**
* Emit the 288-vertex player batch VBO.
*/
public static void drawPlayerCapture(float[] xyzuv) {
if (drawnThisCapture) return;
drawnThisCapture = true;
public static void emitAndSubmit(ModelBiped model, float scale) {
captured = true;
final float[] data = vertexScratch();
emitModel(model, scale, data);
pendingModelView.set(GLStateManager.getModelViewMatrix());
pendingSkin = GLStateManager.getBoundTextureForServerState(0);
pendingEntityId = CapturedRenderingState.INSTANCE.getCurrentRenderedEntity();
pending = true;
}
public static ModelRenderer[] partOrder(ModelBiped model, ModelRenderer[] out) {
out[0] = model.bipedHead;
out[1] = model.bipedHeadwear;
out[2] = model.bipedRightArm;
out[3] = model.bipedRightArm;
out[4] = model.bipedLeftLeg;
out[5] = model.bipedLeftLeg;
out[6] = model.bipedLeftArm;
out[7] = model.bipedLeftArm;
out[8] = model.bipedRightLeg;
out[9] = model.bipedRightLeg;
out[10] = model.bipedBody;
out[11] = model.bipedBody;
return out;
}
private static void emitModel(ModelBiped model, float scale, float[] out) {
final ModelRenderer[] parts = partOrder(model, partOrder);
int o = 0;
for (int i = 0; i < PART_COUNT; i++) {
o = emitBox(parts[i], quadsOf(parts[i]), out, o, scale);
}
}
private static TexturedQuad[] quadsOf(ModelRenderer part) {
if (part == null || part.cubeList == null || part.cubeList.isEmpty()) return null;
final ModelBox box = part.cubeList.getFirst();
return ((ModelBoxQuads) box).angelica$getQuadList();
}
public static int emitBox(ModelRenderer part, TexturedQuad[] quads, float[] out, int o, float scale) {
if (part == null || quads == null || part.isHidden || !part.showModel || part.textureWidth > ATLAS_LAYOUT || part.textureHeight > ATLAS_LAYOUT) {
final int end = o + VERTICES_PER_PART * FLOATS_PER_VERTEX;
Arrays.fill(out, o, end, 0.0f);
return end;
}
final Matrix4f m = partMatrix
.rotationZYX(part.rotateAngleZ, part.rotateAngleY, part.rotateAngleX)
.setTranslation(
part.offsetX + part.rotationPointX * scale,
part.offsetY + part.rotationPointY * scale,
part.offsetZ + part.rotationPointZ * scale);
final float uScale = part.textureWidth / ATLAS_LAYOUT;
final float vScale = part.textureHeight / ATLAS_LAYOUT;
final Vector3f v = partVertex;
for (int q = 0; q < 6; q++) {
final PositionTextureVertex[] corners = quads[q].vertexPositions;
for (int c = 0; c < 4; c++) {
final PositionTextureVertex corner = corners[c];
v.set((float) corner.vector3D.xCoord * scale,
(float) corner.vector3D.yCoord * scale,
(float) corner.vector3D.zCoord * scale);
m.transformPosition(v);
out[o++] = v.x;
out[o++] = v.y;
out[o++] = v.z;
out[o++] = corner.texturePositionX * uScale;
out[o++] = corner.texturePositionY * vScale;
}
}
return o;
}
public static void flush() {
if (!pending) return;
pending = false;
if (!Iris.enabled) return;
final float[] xyzuv = vertexScratch;
if (xyzuv == null || xyzuv.length < VERTEX_COUNT * FLOATS_PER_VERTEX) return;
// Build the Tessellator layout
if (rawData == null) rawData = new int[VERTEX_COUNT * RAW_INTS_PER_VERTEX];
PassRebindGate.rebindIfDirty();
if (!(Iris.getPipelineManager().getPipelineNullable() instanceof DeferredWorldRenderingPipeline pipeline)) return;
if (GLStateManager.getActiveProgram() != pipeline.getActivePassProgramId()) return;
if (Tracy.ENABLED) Tracy.beginZone(Z_PLAYER_REFLECTION_FLUSH);
try {
flushInner(xyzuv);
} finally {
if (Tracy.ENABLED) Tracy.endZone();
}
}
private static void flushInner(float[] xyzuv) {
if (rawData == null) {
rawData = new int[VERTEX_COUNT * RAW_INTS_PER_VERTEX];
for (int i = 0; i < VERTEX_COUNT; i++) {
final int d = i * RAW_INTS_PER_VERTEX;
rawData[d + 5] = 0xFFFFFFFF; // color
rawData[d + 6] = 0x00008000; // normal
rawData[d + 7] = 0x00F000F0; // light
}
}
final int[] raw = rawData;
for (int i = 0; i < VERTEX_COUNT; i++) {
final int s = i * FLOATS_PER_VERTEX;
@@ -106,12 +241,8 @@ public final class PlayerReflectionCapture {
raw[d + 2] = Float.floatToRawIntBits(xyzuv[s + 2]);
raw[d + 3] = Float.floatToRawIntBits(xyzuv[s + 3]);
raw[d + 4] = Float.floatToRawIntBits(xyzuv[s + 4]);
raw[d + 5] = 0xFFFFFFFF; // color
raw[d + 6] = 0x00008000; // normal
raw[d + 7] = 0x00F000F0; // light
}
// Pack into our own native buffer using the player model's vertex format
final VertexFormat format = DefaultVertexFormat.ALL_FORMATS[VERTEX_FLAGS];
ensureRepackCapacity(VERTEX_COUNT * format.getVertexSize());
@@ -119,25 +250,33 @@ public final class PlayerReflectionCapture {
repackBuffer.position(0);
repackBuffer.limit((int) (writePtr - repackAddress));
// Upload to our dedicated VBO at offset 0
ensureVAO(format);
GLStateManager.glBindVertexArray(vao);
GLStateManager.glBindBuffer(GL15.GL_ARRAY_BUFFER, vbo);
GLStateManager.glBufferData(GL15.GL_ARRAY_BUFFER, repackBuffer, GL15.GL_STREAM_DRAW);
GLStateManager.glBindBuffer(GL15.GL_ARRAY_BUFFER, 0);
// Bind a 64x64 copy of the skin as `tex`
final int activeSkin = GLStateManager.getBoundTextureForServerState();
final boolean haveSkin = activeSkin != 0 && activeSkin != skin64Tex;
if (haveSkin) {
ensureSkin64Texture();
final int prevUnit = GL13.GL_TEXTURE0 + GLStateManager.getActiveTextureUnitForServerState();
GLStateManager.glActiveTexture(GL13.GL_TEXTURE0);
final int prevTexture = GLStateManager.getBoundTextureForServerState();
if (pendingSkin > 0 && pendingSkin != skin64Tex) {
final TextureInfo info = TextureInfoCache.INSTANCE.getInfo(pendingSkin);
final int skinGeneration = info.getUploadGeneration();
final boolean skinChanged = info != lastCapturedInfo || skinGeneration != lastCapturedGeneration;
final GlImage atlas = GlImage.BY_NAME.get("playerAtlas_img");
final int atlasId = atlas != null ? atlas.getId() : 0;
final boolean skinChanged = activeSkin != lastCapturedSkin;
final TextureInfo atlasInfo = atlasId > 0 ? TextureInfoCache.INSTANCE.getInfo(atlasId) : null;
final int atlasGeneration = atlasInfo != null ? atlasInfo.getUploadGeneration() : 0;
final boolean atlasChanged = atlasInfo != lastUploadedAtlasInfo || atlasGeneration != lastUploadedAtlasGeneration;
if (skinChanged) {
final TextureInfo info = TextureInfoCache.INSTANCE.getInfo(activeSkin);
lastCapturedInfo = info;
lastCapturedGeneration = skinGeneration;
skinCopyWidth = 0;
skinCopyHeight = 0;
final int srcW = info.getWidth();
final int srcH = info.getHeight();
if (srcW > 0 && srcH > 0) {
@@ -145,48 +284,72 @@ public final class PlayerReflectionCapture {
if (skinPixels == null || skinPixels.capacity() < required) {
skinPixels = ByteBuffer.allocateDirect(required).order(ByteOrder.nativeOrder());
}
// The 1.7.10 model UVs only address the top 64x32 region.
skinCopyWidth = Math.min(64, srcW);
skinCopyHeight = Math.min(32, srcH);
skinPixels.clear();
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, activeSkin);
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, pendingSkin);
GLStateManager.glGetTexImage(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, skinPixels);
lastCapturedSkin = activeSkin;
skinCopyWidth = srcW;
skinCopyHeight = srcH;
}
}
if (skinPixels != null && (skinChanged || atlasId != lastUploadedAtlas)) {
skinPixels.rewind();
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, skin64Tex);
GLStateManager.glTexSubImage2D(GL11.GL_TEXTURE_2D, 0, 0, 0, skinCopyWidth, skinCopyHeight, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, skinPixels);
if (skinCopyWidth > 0 && (skinChanged || atlasChanged)) {
ensureSkin64Texture(Math.max(skinCopyWidth, skinCopyHeight));
if (atlas != null) {
final int prevRowLength = GLStateManager.glGetInteger(GL11.GL_UNPACK_ROW_LENGTH);
GLStateManager.glPixelStorei(GL11.GL_UNPACK_ROW_LENGTH, skinCopyWidth);
try {
skinPixels.rewind();
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, atlasId);
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, skin64Tex);
GLStateManager.glTexSubImage2D(GL11.GL_TEXTURE_2D, 0, 0, 0, skinCopyWidth, skinCopyHeight, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, skinPixels);
skin64Valid = true;
if (atlasInfo != null) {
final int dstW = Math.min(atlasInfo.getWidth(), skinCopyWidth);
final int dstH = Math.min(atlasInfo.getHeight(), skinCopyHeight);
if (dstW > 0 && dstH > 0) {
skinPixels.rewind();
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, atlasId);
GLStateManager.glTexSubImage2D(GL11.GL_TEXTURE_2D, 0, 0, 0, dstW, dstH, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, skinPixels);
}
}
} finally {
GLStateManager.glPixelStorei(GL11.GL_UNPACK_ROW_LENGTH, prevRowLength);
}
lastUploadedAtlas = atlasId;
lastUploadedAtlasInfo = atlasInfo;
lastUploadedAtlasGeneration = atlasInfo != null ? atlasInfo.getUploadGeneration() : 0;
}
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, skin64Tex);
if (skin64Valid) GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, skin64Tex);
}
final boolean maskable = !DepthColorStorage.isDepthColorLocked();
final ColorMask prevColor = GLStateManager.getColorMask();
final boolean pr = prevColor.red, pg = prevColor.green, pb = prevColor.blue, pa = prevColor.alpha;
final boolean prevDepthWrite = GLStateManager.getDepthState().isEnabled();
GLStateManager.glColorMask(false, false, false, false);
GLStateManager.glDepthMask(false);
QuadConverter.drawQuadsAsTriangles(0, VERTEX_COUNT);
GLStateManager.glDepthMask(prevDepthWrite);
GLStateManager.glColorMask(pr, pg, pb, pa);
final int prevEntityId = CapturedRenderingState.INSTANCE.getCurrentRenderedEntity();
savedModelView.set(GLStateManager.getModelViewMatrix());
if (haveSkin) {
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, activeSkin);
CapturedRenderingState.INSTANCE.setCurrentEntity(pendingEntityId);
GLStateManager.setModelViewMatrix(pendingModelView);
if (maskable) {
GLStateManager.glColorMask(false, false, false, false);
GLStateManager.glDepthMask(false);
}
try {
QuadConverter.drawQuadsAsTriangles(0, VERTEX_COUNT);
} finally {
if (maskable) {
GLStateManager.glDepthMask(prevDepthWrite);
GLStateManager.glColorMask(pr, pg, pb, pa);
}
GLStateManager.setModelViewMatrix(savedModelView);
CapturedRenderingState.INSTANCE.setCurrentEntity(prevEntityId);
GLStateManager.glBindVertexArray(0);
GLStateManager.glBindVertexArray(0);
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, prevTexture);
GLStateManager.glActiveTexture(prevUnit);
}
}
public static float[] vertexScratch() {
@@ -205,16 +368,16 @@ public final class PlayerReflectionCapture {
GLStateManager.glBindVertexArray(0);
}
private static void ensureSkin64Texture() {
if (skin64Tex != 0) return;
final int prev = GLStateManager.getBoundTextureForServerState();
skin64Tex = GLStateManager.glGenTextures();
private static void ensureSkin64Texture(int size) {
if (skin64Tex != 0 && skin64Size == size) return;
if (skin64Tex == 0) skin64Tex = GLStateManager.glGenTextures();
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, skin64Tex);
final ByteBuffer zero = ByteBuffer.allocateDirect(64 * 64 * 4);
GLStateManager.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA8, 64, 64, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, zero);
final ByteBuffer zero = ByteBuffer.allocateDirect(size * size * 4);
GLStateManager.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA8, size, size, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, zero);
GLStateManager.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST);
GLStateManager.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST);
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, prev);
skin64Size = size;
skin64Valid = false;
}
private static void ensureRepackCapacity(int requiredBytes) {
@@ -78,6 +78,10 @@ public class CeleritasDebugScreenHandler {
if (transferInfo != null) {
lines.add(transferInfo);
}
final String presentInfo = RENDER_BACKEND.getPresentDebugInfo();
if (presentInfo != null) {
lines.add(presentInfo);
}
if (SystemProperties.DEBUG_F3_DETAIL) {
lines.addAll(TesrBatchRenderer.INSTANCE.getDebugDetailStrings());
lines.add(ModelPartBatcher.INSTANCE.getDebugString());
@@ -3,10 +3,10 @@ package jss.notfine.gui;
import com.google.common.collect.ImmutableList;
import com.gtnewhorizons.angelica.config.AngelicaConfig;
import com.gtnewhorizons.angelica.config.SystemProperties;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
import jss.notfine.config.NotFineConfig;
import jss.notfine.core.Settings;
import jss.notfine.core.SettingsManager;
import me.jellysquid.mods.sodium.client.gui.FrameRateOptions;
import me.jellysquid.mods.sodium.client.gui.SodiumGameOptionPages;
import me.jellysquid.mods.sodium.client.gui.SodiumGameOptions;
import me.jellysquid.mods.sodium.client.gui.options.OptionFlag;
@@ -43,6 +43,7 @@ public class NotFineGameOptionPages {
public static OptionPage general() {
List<OptionGroup> groups = new ArrayList<>();
final FrameRateOptions frameRate = FrameRateOptions.create(vanillaOpts, sodiumOpts);
groups.add(OptionGroup.createBuilder()
.add(OptionImpl.createBuilder(GraphicsMode.class, vanillaOpts)
.setName(I18n.format("options.graphics"))
@@ -63,16 +64,7 @@ public class NotFineGameOptionPages {
.setImpact(OptionImpact.HIGH)
.setFlags(OptionFlag.REQUIRES_RENDERER_RELOAD)
.build())
.add(OptionImpl.createBuilder(boolean.class, vanillaOpts)
.setName(I18n.format("options.vsync"))
.setTooltip(I18n.format("sodium.options.v_sync.tooltip"))
.setControl(TickBoxControl::new)
.setBinding((opts, value) -> {
opts.enableVsync = value;
GLStateManager.setVSyncEnabled(opts.enableVsync);
}, opts -> opts.enableVsync)
.setImpact(OptionImpact.VARIES)
.build())
.add(frameRate.vsync())
.add(OptionImpl.createBuilder(boolean.class, vanillaOpts)
.setName(I18n.format("options.fullscreen"))
.setTooltip(I18n.format("sodium.options.fullscreen.tooltip"))
@@ -90,12 +82,7 @@ public class NotFineGameOptionPages {
.build());
groups.add(OptionGroup.createBuilder()
.add(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()))
.setBinding((opts, value) -> opts.limitFramerate = value, opts -> opts.limitFramerate)
.build())
.add(frameRate.maxFramerate())
.build());
final OptionImpl<GameSettings, Integer> mipmapLevels = OptionImpl.createBuilder(int.class, vanillaOpts)
@@ -7,19 +7,20 @@ import net.minecraft.client.Minecraft;
public class CyclingControlElement<T extends Enum<T>> extends NotFineControlElement<T> {
private final T[] allowedValues;
private final String[] names;
private int currentIndex = 0;
public CyclingControlElement(Option<T> option, Dim2i dim, T[] allowedValues, String[] names) {
super(option, dim);
this.allowedValues = allowedValues;
this.names = names;
}
private int currentIndex() {
for(int i = 0; i < allowedValues.length; ++i) {
if(allowedValues[i] == option.getValue()) {
currentIndex = i;
break;
return i;
}
}
return 0;
}
@Override
@@ -31,8 +32,7 @@ public class CyclingControlElement<T extends Enum<T>> extends NotFineControlElem
@Override
public boolean mousePressed(Minecraft mc, int mouseX, int mouseY) {
if(super.mousePressed(mc, mouseX, mouseY)) {
currentIndex = (currentIndex + 1) % allowedValues.length;
option.setValue(allowedValues[currentIndex]);
option.setValue(allowedValues[(currentIndex() + 1) % allowedValues.length]);
onOptionValueChanged();
return true;
} else {
@@ -0,0 +1,52 @@
package me.jellysquid.mods.sodium.client.gui;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
import com.gtnewhorizons.angelica.glsm.backend.VSyncMode;
import me.jellysquid.mods.sodium.client.gui.options.Option;
import me.jellysquid.mods.sodium.client.gui.options.OptionImpact;
import me.jellysquid.mods.sodium.client.gui.options.OptionImpl;
import me.jellysquid.mods.sodium.client.gui.options.control.ControlValueFormatter;
import me.jellysquid.mods.sodium.client.gui.options.control.CyclingControl;
import me.jellysquid.mods.sodium.client.gui.options.control.SliderControl;
import me.jellysquid.mods.sodium.client.gui.options.storage.MinecraftOptionsStorage;
import me.jellysquid.mods.sodium.client.gui.options.storage.SodiumOptionsStorage;
import net.minecraft.client.Minecraft;
import net.minecraft.client.resources.I18n;
import net.minecraft.client.settings.GameSettings;
public record FrameRateOptions(Option<VSyncMode> vsync, Option<Integer> maxFramerate) {
public static FrameRateOptions create(MinecraftOptionsStorage vanillaOpts, SodiumOptionsStorage sodiumOpts) {
final OptionImpl<SodiumGameOptions, VSyncMode> vsync = OptionImpl.createBuilder(VSyncMode.class, sodiumOpts)
.setName(I18n.format("options.vsync"))
.setTooltip(I18n.format("sodium.options.v_sync.tooltip"))
.setControl(option -> new CyclingControl<>(option, VSyncMode.class,
GLStateManager.getSelectableVSyncModes().toArray(new VSyncMode[0]),
new String[]{
I18n.format("sodium.options.vsync_mode.auto"),
I18n.format("sodium.options.vsync_mode.on"),
I18n.format("sodium.options.vsync_mode.mailbox"),
I18n.format("sodium.options.vsync_mode.off")}))
.setBinding((opts, value) -> {
opts.advanced.vsyncMode = value;
final GameSettings settings = Minecraft.getMinecraft().gameSettings;
settings.enableVsync = value.tearFree();
settings.saveOptions();
GLStateManager.setVSyncMode(value, settings.enableVsync);
}, opts -> opts.advanced.vsyncMode.resolve(Minecraft.getMinecraft().gameSettings.enableVsync))
.setImpact(OptionImpact.VARIES)
.build();
final OptionImpl<GameSettings, Integer> 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, 5, ControlValueFormatter.fpsLimit()))
.setBinding((opts, value) -> opts.limitFramerate = value, opts -> opts.limitFramerate)
.build();
maxFramerate.iris$dynamicallyEnable(() -> !vsync.getValue().blocksOnVBlank());
return new FrameRateOptions(vsync, maxFramerate);
}
}
@@ -105,6 +105,7 @@ public class SodiumGameOptionPages {
groups.add(firstGroupBuilder.build());
int maxGuiScale = Math.max(3, Math.min(Minecraft.getMinecraft().displayWidth / 320, Minecraft.getMinecraft().displayHeight / 240));
final FrameRateOptions frameRate = FrameRateOptions.create(vanillaOpts, sodiumOpts);
groups.add(OptionGroup.createBuilder()
.add(OptionImpl.createBuilder(int.class, vanillaOpts)
.setName(I18n.format("options.guiScale"))
@@ -138,22 +139,8 @@ public class SodiumGameOptionPages {
}
}, (opts) -> opts.fullScreen)
.build())
.add(OptionImpl.createBuilder(boolean.class, vanillaOpts)
.setName(I18n.format("options.vsync"))
.setTooltip(I18n.format("sodium.options.v_sync.tooltip"))
.setControl(TickBoxControl::new)
.setBinding((opts, value) -> {
opts.enableVsync = value;
GLStateManager.setVSyncEnabled(opts.enableVsync);
}, opts -> opts.enableVsync)
.setImpact(OptionImpact.VARIES)
.build())
.add(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, 5, ControlValueFormatter.fpsLimit()))
.setBinding((opts, value) -> opts.limitFramerate = value, opts -> opts.limitFramerate)
.build())
.add(frameRate.vsync())
.add(frameRate.maxFramerate())
.build());
groups.add(OptionGroup.createBuilder()
@@ -521,9 +508,12 @@ public class SodiumGameOptionPages {
.add(OptionImpl.createBuilder(int.class, sodiumOpts)
.setName(I18n.format("sodium.options.cpu_render_ahead_limit.name"))
.setTooltip(I18n.format("sodium.options.cpu_render_ahead_limit.tooltip"))
.setControl(o -> new SliderControl(o, 0, 9, 1, ControlValueFormatter.quantity("sodium.options.cpu_render_ahead_limit.value")))
.setControl(o -> new SliderControl(o, GLStateManager.getMinRenderAhead(), GLStateManager.getMaxRenderAhead(), 1, ControlValueFormatter.quantity("sodium.options.cpu_render_ahead_limit.value")))
.setImpact(OptionImpact.MEDIUM)
.setBinding((opts, value) -> opts.performance.cpuRenderAheadLimit = value, opts -> opts.performance.cpuRenderAheadLimit)
.setBinding((opts, value) -> {
opts.performance.cpuRenderAheadLimit = value;
GLStateManager.applyRenderAheadLimit(value);
}, opts -> GLStateManager.renderAheadLimit(opts.performance.cpuRenderAheadLimit))
.setEnabled(GLStateManager.capabilities != null && GLStateManager.capabilities.OpenGL32)
.build())
@@ -6,6 +6,7 @@ import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import com.gtnewhorizons.angelica.AngelicaMod;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
import com.gtnewhorizons.angelica.glsm.backend.VSyncMode;
import com.gtnewhorizons.angelica.glsm.streaming.StreamingUploader;
import com.gtnewhorizons.angelica.proxy.ClientProxy;
import me.jellysquid.mods.sodium.client.gui.options.named.GraphicsQuality;
@@ -203,6 +204,7 @@ public class SodiumGameOptions {
public boolean ignoreDriverBlacklist = false;
public StreamingUploader.UploadStrategy streamingUploadStrategy = StreamingUploader.UploadStrategy.MAP_BUFFER_RANGE;
public boolean enableDeferredBatching = true;
public VSyncMode vsyncMode = VSyncMode.AUTO;
}
public static class PerformanceSettings {
@@ -27,6 +27,17 @@ public class CyclingControl<T extends Enum<T>> implements Control<T> {
this.names = names;
}
public CyclingControl(Option<T> option, Class<T> enumType, T[] allowedValues, String[] names) {
final T[] universe = enumType.getEnumConstants();
Validate.isTrue(universe.length == names.length, "Mismatch between universe length and names array length");
Validate.notEmpty(allowedValues, "There must be at least one allowed value");
this.option = option;
this.allowedValues = allowedValues;
this.names = names;
}
public CyclingControl(Option<T> option, Class<T> enumType, T[] allowedValues) {
final T[] universe = enumType.getEnumConstants();
@@ -27,21 +27,21 @@ public class SodiumControlElementFactory implements ControlElementFactory {
private static class CyclingControlElement<T extends Enum<T>> extends SodiumControlElement<T> {
private final T[] allowedValues;
private final String[] names;
private int currentIndex;
public CyclingControlElement(Option<T> option, Dim2i dim, T[] allowedValues, String[] names) {
super(option, dim);
this.allowedValues = allowedValues;
this.names = names;
this.currentIndex = 0;
}
for (int i = 0; i < allowedValues.length; i++) {
if (allowedValues[i] == option.getValue()) {
this.currentIndex = i;
break;
private int currentIndex() {
for (int i = 0; i < this.allowedValues.length; i++) {
if (this.allowedValues[i] == this.option.getValue()) {
return i;
}
}
return 0;
}
@Override
@@ -59,16 +59,14 @@ public class SodiumControlElementFactory implements ControlElementFactory {
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if(AngelicaConfig.enableReesesSodiumOptions) {
if (this.option.isAvailable() && this.dim.containsCursor(mouseX, mouseY) && (button == 0 || button == 1)) {
this.currentIndex = Math.floorMod(this.currentIndex + (button == 0 ? 1 : -1), this.allowedValues.length);
this.option.setValue(this.allowedValues[this.currentIndex]);
this.option.setValue(this.allowedValues[Math.floorMod(this.currentIndex() + (button == 0 ? 1 : -1), this.allowedValues.length)]);
this.playClickSound();
return true;
}
} else {
if (this.option.isAvailable() && button == 0 && this.dim.containsCursor(mouseX, mouseY)) {
this.currentIndex = (this.currentIndex + 1) % this.allowedValues.length;
this.option.setValue(this.allowedValues[this.currentIndex]);
this.option.setValue(this.allowedValues[(this.currentIndex() + 1) % this.allowedValues.length]);
this.playClickSound();
return true;
@@ -547,11 +547,11 @@ public class ShadowRenderer {
profiler.endStartSection("iris_shadow_build_geometry");
setupEntityShadowState(modelView, cameraX, cameraY, cameraZ);
if (playerReflectionCaptureEnabled) {
PlayerReflectionCapture.begin(player);
}
ModelPartBatcher.INSTANCE.begin(ModelPartBatcher.Mode.ENTITIES, true);
try {
if (playerReflectionCaptureEnabled) {
PlayerReflectionCapture.begin(player);
}
for (Entity entity : renderedEntitiesList) {
final long start = Tracy.ENABLED ? System.nanoTime() : 0L;
try {
@@ -629,7 +629,7 @@ public class ShadowRenderer {
// Saved RenderManager position for shadow pass
private double savedRenderPosX, savedRenderPosY, savedRenderPosZ;
/** Flushes shadow-pass batched model parts while GL_POLYGON_OFFSET_FILL is still enabled. */
/** Flushes deferred shadow-pass geometry while GL_POLYGON_OFFSET_FILL is still enabled. */
private static void flushShadowModelParts() {
if (Tracy.ENABLED) Tracy.beginZone(Z_SHADOW_MODEL_PARTS);
final boolean alphaEnabled = GLStateManager.getAlphaTest().isEnabled();
@@ -637,6 +637,7 @@ public class ShadowRenderer {
final float alphaRef = GLStateManager.getAlphaState().getReference();
try {
ModelPartBatcher.INSTANCE.flush();
PlayerReflectionCapture.flush();
} finally {
if (alphaEnabled) GLStateManager.enableAlphaTest();
else GLStateManager.disableAlphaTest();
@@ -712,14 +713,11 @@ public class ShadowRenderer {
if (playerReflectionCaptureEnabled) {
PlayerReflectionCapture.begin(player);
}
try {
RenderManager.instance.renderEntitySimple(player, tickDelta);
} finally {
PlayerReflectionCapture.end();
}
RenderManager.instance.renderEntitySimple(player, tickDelta);
shadowEntities++;
} finally {
flushShadowModelParts();
PlayerReflectionCapture.end();
teardownEntityShadowState();
}
@@ -24,8 +24,8 @@ sodium.options.clouds.name=Clouds
sodium.options.clouds.tooltip=Controls whether or not clouds will be visible.
sodium.options.gui_scale.tooltip=Sets the maximum scale factor to be used for the user interface. If 'auto' is used, then the largest scale factor will always be used.
sodium.options.fullscreen.tooltip=If enabled, the game will display in full-screen (if supported).
sodium.options.v_sync.tooltip=If enabled, the game's frame rate will be synchronized to the monitor's refresh rate, making for a generally smoother experience at the expense of overall input latency. This setting might reduce performance if your system is too slow.
sodium.options.fps_limit.tooltip=Limits the maximum FPS (frames per second). This can help reduce battery usage and general system load when multi-tasking. If V-Sync is enabled, this option will be ignored unless it is lower than your display's refresh rate.
sodium.options.v_sync.tooltip=Synchronizes frames to your monitor to stop tearing. §lOff§r shows each frame the moment it is ready: lowest latency, but the image can tear, and Max Framerate applies. §lOn§r waits for the monitor, which removes tearing and hands it the frame rate, so §lMax Framerate has no effect§r. §lMailbox§r removes tearing without waiting, keeping most of the latency of Off, and Max Framerate still applies. Mailbox needs the SDL-GPU backend on Vulkan or D3D12.
sodium.options.fps_limit.tooltip=Limits the maximum FPS (frames per second). This can help reduce battery usage and general system load when multi-tasking. §lHas no effect while VSync is On§r, because the monitor sets the frame rate instead; use Mailbox to combine a limit with tear-free output.
sodium.options.view_bobbing.tooltip=If enabled, the player's view will sway and bob when moving around. Players who experience motion sickness while playing can benefit from disabling this.
sodium.options.attack_indicator.tooltip=Controls where the Attack Indicator is displayed on screen.
sodium.options.autosave_indicator.tooltip=If enabled, an indicator will be shown when the game is saving the world to disk.
@@ -75,7 +75,7 @@ sodium.options.animate_only_visible_textures.tooltip=If enabled, only animated t
sodium.options.translucency_sorting.name=Translucency Sorting
sodium.options.translucency_sorting.tooltip=If enabled, translucent blocks will be sorted correctly from back to front to provide better rendering, especially when multiple translucent blocks are in view and layered. This is a bit of an expensive operation.
sodium.options.cpu_render_ahead_limit.name=CPU Render-Ahead Limit
sodium.options.cpu_render_ahead_limit.tooltip=Specifies the maximum number of frames the CPU can be waiting on the GPU to finish rendering. Very low or high values may create frame rate instability.
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. The SDL-GPU backend limits this to 3.
sodium.options.cpu_render_ahead_limit.value=%s frame(s)
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.
@@ -261,6 +261,10 @@ options.angelica.texteffects.drop_shadow.tooltip=Per-segment drop shadow via %s,
options.angelica.texteffects.ampersand_conversion=Ampersand Conversion
options.angelica.texteffects.ampersand_conversion.tooltip=Convert %s prefix codes to formatting codes at render time
sodium.options.vsync_mode.auto=Auto
sodium.options.vsync_mode.on=On
sodium.options.vsync_mode.mailbox=Mailbox
sodium.options.vsync_mode.off=Off
sodium.options.multidraw_mode.name=Multi-Draw Mode
sodium.options.multidraw_mode.tooltip=Controls how draw commands are dispatched for terrain rendering. §lDirect§r uses glMultiDrawElementsBaseVertex (default, GL 3.2+). §lIndirect§r uses GPU indirect rendering (GL 4.3+), may reduce CPU overhead on some hardware, and is required for GPU / Hi-Z culling. §lIndividual§r issues individual draw calls.
sodium.options.multidraw_mode.direct=Direct
@@ -6,6 +6,7 @@ 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.FramePacer;
import com.llamalad7.mixinextras.injector.v2.WrapWithCondition;
import net.minecraft.client.Minecraft;
import net.minecraft.client.settings.GameSettings;
@@ -36,12 +37,6 @@ public abstract class MixinMinecraft {
@Shadow(remap = false)
private static int max_texture_size;
@Unique
private static long angelica$lastFrameTime = 0;
@Unique
private static long angelica$fpsLimitOverhead = 0;
@Unique
private final RenderAheadManager celeritas$renderAheadManager = new RenderAheadManager();
@@ -66,48 +61,23 @@ public abstract class MixinMinecraft {
GLStateManager.glEnable(GL11.GL_LIGHTING);
}
@Inject(
method = "func_147120_f",
at = @At(value = "INVOKE", target = "Lorg/lwjgl/opengl/Display;update()V", remap = false)
)
@Inject(method = "func_147120_f", at = @At("RETURN"))
private void angelica$limitFPS(CallbackInfo ci) {
if (AngelicaMod.proxy == null) return;
if (isFramerateLimitBelowMax() && !gameSettings.enableVsync) {
final long time = System.nanoTime();
final long lastWorkTime = time - angelica$lastFrameTime;
final long targetNanos = (long) (1.0 / getLimitFramerate() * 1_000_000_000L);
// Account for overhead, so the average FPS remains stable.
final long sleepNanos = targetNanos - lastWorkTime - angelica$fpsLimitOverhead;
if (sleepNanos > 0) {
try {
Thread.sleep(sleepNanos / 1_000_000, (int) sleepNanos % 1_000_000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// Record overhead, capping it to prevent outsized spikes from affecting framerates for too long.
// In testing, spikes were followed by 1-frame dips, which I think is acceptable.
long overhead = System.nanoTime() - time - sleepNanos;
if (overhead < 0 || overhead > targetNanos / 2) overhead = 0;
angelica$fpsLimitOverhead = overhead;
}
final long time = System.nanoTime();
AngelicaMod.proxy.putFrametime(time - angelica$lastFrameTime);
angelica$lastFrameTime = time;
final int capHz = isFramerateLimitBelowMax() ? getLimitFramerate() : 0;
AngelicaMod.proxy.putFrametime(FramePacer.pace(FramePacer.updateCeiling(capHz)));
}
@Redirect(method = {"startGame", "toggleFullscreen"}, at = @At(value = "INVOKE", target = "Lorg/lwjgl/opengl/Display;setVSyncEnabled(Z)V", remap = false))
private void angelica$redirectVSync(boolean sync) {
GLStateManager.setVSyncEnabled(sync);
ClientProxy.applyVSyncMode(sync);
}
@Inject(method = "startGame", at = @At("RETURN"))
private void angelica$markSplashCompleteOnStartGame(CallbackInfo ci) {
GLStateManager.markSplashComplete("startGame");
GLStateManager.applyRenderAheadLimit(ClientProxy.options().performance.cpuRenderAheadLimit);
}
@WrapWithCondition(method = "runGameLoop", at = @At(value = "INVOKE", target = "Lorg/lwjgl/opengl/Display;sync(I)V", remap = false))
@@ -122,7 +92,7 @@ public abstract class MixinMinecraft {
@Inject(method = "runTick", at = @At("HEAD"))
private void celeritas$renderAheadStartFrame(CallbackInfo ci) {
final int limit = ClientProxy.options().performance.cpuRenderAheadLimit;
final int limit = GLStateManager.renderAheadLimit(ClientProxy.options().performance.cpuRenderAheadLimit);
if (limit > 0) {
celeritas$renderAheadManager.startFrame(limit);
}
@@ -130,7 +100,7 @@ public abstract class MixinMinecraft {
@Inject(method = "runTick", at = @At("RETURN"))
private void celeritas$renderAheadEndFrame(CallbackInfo ci) {
if (ClientProxy.options().performance.cpuRenderAheadLimit > 0) {
if (GLStateManager.renderAheadLimit(ClientProxy.options().performance.cpuRenderAheadLimit) > 0) {
celeritas$renderAheadManager.endFrame();
}
}
@@ -1,7 +1,7 @@
package com.gtnewhorizons.angelica.mixins.early.angelica.debug;
import com.gtnewhorizons.angelica.rendering.FramePacer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.settings.GameSettings;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
@@ -15,19 +15,11 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
public class MixinMinecraft_FPSCap {
@Shadow public String debug;
@Shadow public GameSettings gameSettings;
@Inject(method = "runGameLoop", at = @At(value = "FIELD", target = "Lnet/minecraft/client/Minecraft;debug:Ljava/lang/String;", shift = At.Shift.AFTER, ordinal = 0))
private void angelica$appendFPSCapInfo(CallbackInfo ci) {
final int limit = this.gameSettings.limitFramerate;
String indicator = null;
if (this.gameSettings.enableVsync) {
indicator = " [vsync]";
} else if (limit < 260) {
indicator = " [cap " + limit + "]";
}
if (indicator != null) {
this.debug = this.debug.replace(" fps,", " fps" + indicator + ",");
}
final String indicator = FramePacer.debugIndicator();
if (indicator == null) return;
this.debug = this.debug.replace(" fps,", " fps" + indicator + ",");
}
}
@@ -1,13 +1,15 @@
package com.gtnewhorizons.angelica.mixins.early.shaders;
import com.gtnewhorizons.angelica.mixins.interfaces.ModelBoxQuads;
import net.minecraft.client.model.ModelBox;
import net.minecraft.client.model.TexturedQuad;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(ModelBox.class)
public interface AccessorModelBox {
public interface AccessorModelBox extends ModelBoxQuads {
@Override
@Accessor("quadList")
TexturedQuad[] angelica$getQuadList();
}
@@ -3,29 +3,18 @@ package com.gtnewhorizons.angelica.mixins.early.shaders;
import com.gtnewhorizons.angelica.glsm.profiling.Tracy;
import com.gtnewhorizons.angelica.rendering.PlayerReflectionCapture;
import net.minecraft.client.model.ModelBiped;
import net.minecraft.client.model.ModelBox;
import net.minecraft.client.model.ModelRenderer;
import net.minecraft.client.model.PositionTextureVertex;
import net.minecraft.client.model.TexturedQuad;
import net.minecraft.entity.Entity;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.Unique;
import java.util.Arrays;
@Mixin(ModelBiped.class)
public abstract class MixinModelBiped {
@Unique private static final Tracy.ZoneId Z_PLAYER_REFLECTION_CAPTURE = Tracy.zoneId("playerReflectionCapture", Tracy.COLOR_IRIS);
@Unique private static final Matrix4f angelica$mat = new Matrix4f();
@Unique private static final Vector3f angelica$vec = new Vector3f();
@Inject(method = "render(Lnet/minecraft/entity/Entity;FFFFFF)V", at = @At("RETURN"))
private void angelica$capturePlayerReflection(Entity entity, float limbSwing, float limbSwingAmount,
float ageInTicks, float netHeadYaw, float headPitch, float scale, CallbackInfo ci) {
@@ -34,65 +23,11 @@ public abstract class MixinModelBiped {
if ((Object) this != PlayerReflectionCapture.getTarget()) return;
if (entity != PlayerReflectionCapture.getTargetEntity()) return;
final ModelBiped model = (ModelBiped) (Object) this;
final float[] data = PlayerReflectionCapture.vertexScratch();
if (Tracy.ENABLED) Tracy.beginZone(Z_PLAYER_REFLECTION_CAPTURE);
try {
// Six 48-vertex blocks = body parts, each base box + overlay box
int o = 0;
o = angelica$emitBox(model.bipedHead, data, o, scale);
o = angelica$emitBox(model.bipedHeadwear, data, o, scale);
o = angelica$emitBox(model.bipedRightArm, data, o, scale);
o = angelica$emitBox(model.bipedRightArm, data, o, scale);
o = angelica$emitBox(model.bipedLeftLeg, data, o, scale);
o = angelica$emitBox(model.bipedLeftLeg, data, o, scale);
o = angelica$emitBox(model.bipedLeftArm, data, o, scale);
o = angelica$emitBox(model.bipedLeftArm, data, o, scale);
o = angelica$emitBox(model.bipedRightLeg, data, o, scale);
o = angelica$emitBox(model.bipedRightLeg, data, o, scale);
o = angelica$emitBox(model.bipedBody, data, o, scale);
angelica$emitBox(model.bipedBody, data, o, scale);
PlayerReflectionCapture.drawPlayerCapture(data);
PlayerReflectionCapture.emitAndSubmit((ModelBiped) (Object) this, scale);
} finally {
if (Tracy.ENABLED) Tracy.endZone();
}
}
private static int angelica$emitBox(ModelRenderer part, float[] out, int o, float scale) {
if (part == null || part.cubeList == null || part.cubeList.isEmpty()) {
final int end = o + 24 * PlayerReflectionCapture.FLOATS_PER_VERTEX;
Arrays.fill(out, o, end, 0.0f);
return end;
}
final Matrix4f m = angelica$mat.identity();
m.translate(part.offsetX, part.offsetY, part.offsetZ);
m.translate(part.rotationPointX * scale, part.rotationPointY * scale, part.rotationPointZ * scale);
if (part.rotateAngleZ != 0.0f) m.rotateZ(part.rotateAngleZ);
if (part.rotateAngleY != 0.0f) m.rotateY(part.rotateAngleY);
if (part.rotateAngleX != 0.0f) m.rotateX(part.rotateAngleX);
final ModelBox box = part.cubeList.getFirst();
final TexturedQuad[] quads = ((AccessorModelBox) (Object) box).angelica$getQuadList();
final Vector3f v = angelica$vec;
for (int q = 0; q < 6; q++) {
final PositionTextureVertex[] corners = quads[q].vertexPositions;
for (int c = 0; c < 4; c++) {
final PositionTextureVertex corner = corners[c];
v.set((float) corner.vector3D.xCoord * scale,
(float) corner.vector3D.yCoord * scale,
(float) corner.vector3D.zCoord * scale);
m.transformPosition(v);
out[o++] = v.x;
out[o++] = v.y;
out[o++] = v.z;
out[o++] = corner.texturePositionX;
out[o++] = corner.texturePositionY * 0.5f;
}
}
return o;
}
}
@@ -0,0 +1,97 @@
package com.gtnewhorizons.angelica.rendering;
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.assertNull;
class FramePacerTest {
@Test
void vsyncNeverPaces() {
assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.ON, 0, 0));
assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.ON, 60, 144));
assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.ON, 260, 60));
}
@Test
void vsyncOffHonorsOnlyTheCap() {
assertEquals(215, FramePacer.pacingCeilingHz(VSyncMode.OFF, 215, 0));
assertEquals(215, FramePacer.pacingCeilingHz(VSyncMode.OFF, 215, 144));
assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.OFF, 0, 144));
}
@Test
void mailboxTakesTheLowerOfCapAndRefresh() {
assertEquals(60, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 60, 143));
assertEquals(143, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 215, 143));
assertEquals(143, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 143, 143));
}
@Test
void mailboxTreatsZeroAsNoLimitFromThatSource() {
assertEquals(143, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 0, 143));
assertEquals(215, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 215, 0));
assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 0, 0));
}
@Test
void nonPositiveCapIsUncapped() {
assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.OFF, -1, 0));
assertEquals(143, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, -1, 143));
}
@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 implausibleRefreshLeavesMailboxOnTheCapAlone() {
assertEquals(215, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 215, 19));
assertEquals(215, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 215, 1001));
assertEquals(0, FramePacer.pacingCeilingHz(VSyncMode.MAILBOX, 0, 1001));
}
@Test
void sleepDeductsWorkAndOverhead() {
assertEquals(10_000L, FramePacer.sleepNanosFor(16_000L, 5_000L, 1_000L));
}
@Test
void anOverrunAsksForNoSleep() {
assertEquals(-5_000L, FramePacer.sleepNanosFor(16_000L, 21_000L, 0L));
}
@Test
void indicatorNamesWhatIsHoldingTheRateDown() {
assertEquals(" [vsync 144]", FramePacer.debugIndicator(VSyncMode.ON, 144, 0));
assertEquals(" [mailbox 144]", FramePacer.debugIndicator(VSyncMode.MAILBOX, 144, 0));
assertEquals(" [mailbox 144, cap 60]", FramePacer.debugIndicator(VSyncMode.MAILBOX, 144, 60));
assertEquals(" [cap 60]", FramePacer.debugIndicator(VSyncMode.OFF, 144, 60));
}
@Test
void indicatorOmitsACapThatDoesNothing() {
assertEquals(" [vsync 144]", FramePacer.debugIndicator(VSyncMode.ON, 144, 60));
assertEquals(" [vsync]", FramePacer.debugIndicator(VSyncMode.ON, 0, 60));
}
@Test
void indicatorStillReportsVsyncWithoutARefreshRate() {
assertEquals(" [vsync]", FramePacer.debugIndicator(VSyncMode.ON, 0, 0));
assertEquals(" [mailbox, cap 60]", FramePacer.debugIndicator(VSyncMode.MAILBOX, 0, 60));
}
@Test
void indicatorIsAbsentWhenNothingLimits() {
assertNull(FramePacer.debugIndicator(VSyncMode.OFF, 144, 0));
assertNull(FramePacer.debugIndicator(VSyncMode.OFF, 0, 0));
}
}
@@ -0,0 +1,180 @@
package com.gtnewhorizons.angelica.rendering;
import net.minecraft.client.model.ModelBiped;
import net.minecraft.client.model.ModelBox;
import net.minecraft.client.model.ModelRenderer;
import net.minecraft.client.model.TexturedQuad;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PlayerReflectionLayoutTest {
static final int FLOATS = PlayerReflectionCapture.FLOATS_PER_VERTEX;
static final int PART_VERTS = PlayerReflectionCapture.VERTICES_PER_PART;
static final float MODEL_SCALE = 0.0625f;
private static Field quadListField;
static TexturedQuad[] quadsOf(ModelRenderer part) {
if (part == null || part.cubeList == null || part.cubeList.isEmpty()) return null;
try {
if (quadListField == null) {
quadListField = ModelBox.class.getDeclaredField("quadList");
quadListField.setAccessible(true);
}
return (TexturedQuad[]) quadListField.get(part.cubeList.getFirst());
} catch (ReflectiveOperationException e) {
throw new AssertionError("ModelBox.quadList is no longer reachable", e);
}
}
static float[] emit(ModelBiped model, float scale) {
final float[] out = new float[PlayerReflectionCapture.VERTEX_COUNT * FLOATS];
final ModelRenderer[] parts = PlayerReflectionCapture.partOrder(model, new ModelRenderer[PlayerReflectionCapture.PART_COUNT]);
int o = 0;
for (ModelRenderer part : parts) {
o = PlayerReflectionCapture.emitBox(part, quadsOf(part), out, o, scale);
}
assertEquals(out.length, o, "emit did not fill exactly 288 vertices");
return out;
}
private static float min(float[] v, int block, int component) {
float m = Float.POSITIVE_INFINITY;
for (int i = 0; i < PART_VERTS; i++) m = Math.min(m, v[(block * PART_VERTS + i) * FLOATS + component]);
return m;
}
private static float max(float[] v, int block, int component) {
float m = Float.NEGATIVE_INFINITY;
for (int i = 0; i < PART_VERTS; i++) m = Math.max(m, v[(block * PART_VERTS + i) * FLOATS + component]);
return m;
}
private static void assertTexelRange(float[] v, int block, int u0, int u1, int v0, int v1, String part) {
assertEquals(u0, Math.round(min(v, block, 3) * 64f), part + ": u start");
assertEquals(u1, Math.round(max(v, block, 3) * 64f), part + ": u end");
assertEquals(v0, Math.round(min(v, block, 4) * 64f), part + ": v start");
assertEquals(v1, Math.round(max(v, block, 4) * 64f), part + ": v end");
}
@Test
void partOrderMatchesTheShaderpackVertexIdPartitioning() {
final ModelBiped model = new ModelBiped(0.0f);
final ModelRenderer[] parts = PlayerReflectionCapture.partOrder(model, new ModelRenderer[PlayerReflectionCapture.PART_COUNT]);
assertSame(model.bipedHead, parts[0], "block 0 must be the head");
assertSame(model.bipedHeadwear, parts[1], "block 1 must stand in for the hat overlay");
assertSame(model.bipedRightArm, parts[2], "block 2 must be the right arm");
assertSame(model.bipedRightArm, parts[3], "block 3 must stand in for the right sleeve");
assertSame(model.bipedLeftLeg, parts[4], "block 4 must be the left leg");
assertSame(model.bipedLeftLeg, parts[5], "block 5 must stand in for the left trouser leg");
assertSame(model.bipedLeftArm, parts[6], "block 6 must be the left arm");
assertSame(model.bipedLeftArm, parts[7], "block 7 must stand in for the left sleeve");
assertSame(model.bipedRightLeg, parts[8], "block 8 must be the right leg");
assertSame(model.bipedRightLeg, parts[9], "block 9 must stand in for the right trouser leg");
assertSame(model.bipedBody, parts[10], "block 10 must be the body");
assertSame(model.bipedBody, parts[11], "block 11 must stand in for the jacket");
}
@Test
void skinTexelRangesLandWhereTheRayTracerSamples() {
final float[] v = emit(new ModelBiped(0.0f), MODEL_SCALE);
assertTexelRange(v, 0, 0, 32, 0, 16, "head");
assertTexelRange(v, 1, 32, 64, 0, 16, "hat");
assertTexelRange(v, 2, 40, 56, 16, 32, "right arm");
assertTexelRange(v, 4, 0, 16, 16, 32, "left leg");
assertTexelRange(v, 6, 40, 56, 16, 32, "left arm");
assertTexelRange(v, 8, 0, 16, 16, 32, "right leg");
assertTexelRange(v, 10, 16, 40, 16, 32, "body");
}
@Test
void everyQuadIsAParallelogramInV0V1V2Order() {
final float[] v = emit(new ModelBiped(0.0f), MODEL_SCALE);
for (int q = 0; q < PlayerReflectionCapture.VERTEX_COUNT / 4; q++) {
final int b = q * 4 * FLOATS;
final float[] v0 = { v[b], v[b + 1], v[b + 2] };
final float[] v1 = { v[b + FLOATS], v[b + FLOATS + 1], v[b + FLOATS + 2] };
final float[] v2 = { v[b + 2 * FLOATS], v[b + 2 * FLOATS + 1], v[b + 2 * FLOATS + 2] };
final float[] v3 = { v[b + 3 * FLOATS], v[b + 3 * FLOATS + 1], v[b + 3 * FLOATS + 2] };
for (int c = 0; c < 3; c++) {
assertEquals(v0[c] + v2[c] - v1[c], v3[c], 1e-4f, "quad " + q + " component " + c + ": the ray tracer reconstructs the 4th corner as v0 + v2 - v1");
}
float dot = 0.0f;
for (int c = 0; c < 3; c++) dot += (v1[c] - v0[c]) * (v2[c] - v1[c]);
assertEquals(0.0f, dot, 1e-6f, "quad " + q + ": edges v0->v1 and v1->v2 must be perpendicular");
}
}
@Test
void boxExtentsMatchTheVanillaModel() {
final float[] v = emit(new ModelBiped(0.0f), MODEL_SCALE);
assertEquals(-0.25f, min(v, 0, 0), 1e-5f, "head min x");
assertEquals(0.25f, max(v, 0, 0), 1e-5f, "head max x");
assertEquals(-0.5f, min(v, 0, 1), 1e-5f, "head min y");
assertEquals(0.0f, max(v, 0, 1), 1e-5f, "head max y");
assertEquals(-0.25f, min(v, 10, 0), 1e-5f, "body min x");
assertEquals(0.25f, max(v, 10, 0), 1e-5f, "body max x");
assertEquals(0.0f, min(v, 10, 1), 1e-5f, "body min y");
assertEquals(0.75f, max(v, 10, 1), 1e-5f, "body max y");
assertEquals(-0.125f, min(v, 10, 2), 1e-5f, "body min z");
assertEquals(0.125f, max(v, 10, 2), 1e-5f, "body max z");
}
@Test
void hatIsLargerThanTheHeadSoAnOpaqueOverlayWins() {
final float[] v = emit(new ModelBiped(0.0f), MODEL_SCALE);
assertTrue(max(v, 1, 0) > max(v, 0, 0), "the hat box must enclose the head box");
assertEquals(0.5f + 2 * 0.5f * MODEL_SCALE, max(v, 1, 0) - min(v, 1, 0), 1e-5f, "hat width");
}
@Test
void hiddenPartsEmitDegenerateQuads() {
final ModelBiped model = new ModelBiped(0.0f);
model.bipedHeadwear.showModel = false;
model.bipedRightArm.isHidden = true;
final float[] v = emit(model, MODEL_SCALE);
final float[] zeros = new float[PART_VERTS * FLOATS];
assertArrayEquals(zeros, java.util.Arrays.copyOfRange(v, PART_VERTS * FLOATS, 2 * PART_VERTS * FLOATS), "a hidden hat must not be ray-traced");
assertArrayEquals(zeros, java.util.Arrays.copyOfRange(v, 2 * PART_VERTS * FLOATS, 3 * PART_VERTS * FLOATS), "a hidden right arm must not be ray-traced");
}
@Test
void aPartWhoseLayoutOverflowsTheAtlasIsDropped() {
final ModelBiped model = new ModelBiped(0.0f);
model.bipedHead.setTextureSize(128, 128);
final float[] v = emit(model, MODEL_SCALE);
assertArrayEquals(new float[PART_VERTS * FLOATS], java.util.Arrays.copyOfRange(v, 0, PART_VERTS * FLOATS), "a >64 layout cannot be expressed in the 64x64 atlas, so the head block must be degenerate rather than emit UVs > 1");
assertTexelRange(v, 10, 16, 40, 16, 32, "body is unaffected by the head's layout");
}
@Test
void headUvsAreUnaffectedByHeadRotation() {
final ModelBiped model = new ModelBiped(0.0f);
model.bipedHead.rotateAngleY = 0.9f;
model.bipedHead.rotateAngleX = -0.4f;
final float[] v = emit(model, MODEL_SCALE);
assertTexelRange(v, 0, 0, 32, 0, 16, "rotated head");
assertTrue(max(v, 0, 0) - min(v, 0, 0) > 0.5f, "a yawed head must present a wider silhouette than 8 units");
}
}
@@ -0,0 +1,186 @@
package com.gtnewhorizons.angelica.rendering;
import com.gtnewhorizons.angelica.glsm.GLCoreTest;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
import com.gtnewhorizons.angelica.glsm.QuadConverter;
import com.gtnewhorizons.angelica.glsm.RenderSystem;
import net.minecraft.client.model.ModelBiped;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.lwjgl.BufferUtils;
import org.lwjgl.opengl.GL11;
import org.lwjgl.opengl.GL15;
import org.lwjgl.opengl.GL20;
import org.lwjgl.opengl.GL30;
import org.lwjgl.opengl.GL42;
import org.lwjgl.opengl.GL43;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import static com.gtnewhorizons.angelica.rendering.PlayerReflectionLayoutTest.MODEL_SCALE;
import static com.gtnewhorizons.angelica.rendering.PlayerReflectionLayoutTest.emit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
@GLCoreTest
class PlayerReflectionSsboMappingTest {
private static final int SSBO_ENTRIES = 216;
private static final int POSITION_STRIDE = 16;
private static final int DATA_OFFSET = SSBO_ENTRIES * POSITION_STRIDE;
private static final int DATA_STRIDE = 8;
private static final int SSBO_BYTES = DATA_OFFSET + SSBO_ENTRIES * DATA_STRIDE;
private static final int BINDING = 3;
private static final int VISITED_BINDING = 4;
private static final int VISITED_BYTES = PlayerReflectionCapture.VERTEX_COUNT * 4;
private static final String VS = "#version 430 core\n"
+ "layout(location = 0) in vec3 a_Pos;\n"
+ "layout(location = 1) in vec2 a_Uv;\n"
+ "layout(std430, binding = " + BINDING + ") buffer playerVerticesBuffer {\n"
+ " vec3 vertexPositions[" + SSBO_ENTRIES + "];\n"
+ " vec2 vertexData[" + SSBO_ENTRIES + "];\n"
+ "} ssbo;\n"
+ "layout(std430, binding = " + VISITED_BINDING + ") buffer visitedBuffer {\n"
+ " uint visited[" + PlayerReflectionCapture.VERTEX_COUNT + "];\n"
+ "};\n"
+ "void main() {\n"
+ " visited[gl_VertexID] = 1u;\n"
+ " if (gl_VertexID % 4 != 3) {\n"
+ " int i = gl_VertexID - gl_VertexID / 4;\n"
+ " ssbo.vertexPositions[i] = a_Pos;\n"
+ " ssbo.vertexData[i] = a_Uv;\n"
+ " }\n"
+ " gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n"
+ "}\n";
private static final String FS = "#version 430 core\n"
+ "out vec4 fragColor;\n"
+ "void main() { fragColor = vec4(1.0); }\n";
private int program;
private int vao;
private int vbo;
private int ssbo;
private int visitedSsbo;
@AfterEach
void cleanup() {
GLStateManager.glUseProgram(0);
GLStateManager.glBindVertexArray(0);
if (program != 0) GLStateManager.glDeleteProgram(program);
if (vbo != 0) GLStateManager.glDeleteBuffers(vbo);
if (ssbo != 0) GLStateManager.glDeleteBuffers(ssbo);
if (visitedSsbo != 0) GLStateManager.glDeleteBuffers(visitedSsbo);
if (vao != 0) GLStateManager.glDeleteVertexArrays(vao);
program = vao = vbo = ssbo = visitedSsbo = 0;
}
private static int compile(int type, String source) {
final int shader = GL20.glCreateShader(type);
GL20.glShaderSource(shader, source);
GL20.glCompileShader(shader);
assertEquals(GL11.GL_TRUE, GL20.glGetShaderi(shader, GL20.GL_COMPILE_STATUS), "shader failed to compile: " + GL20.glGetShaderInfoLog(shader, 4096));
return shader;
}
private void buildProgram() {
final int vs = compile(GL20.GL_VERTEX_SHADER, VS);
final int fs = compile(GL20.GL_FRAGMENT_SHADER, FS);
program = GL20.glCreateProgram();
GL20.glAttachShader(program, vs);
GL20.glAttachShader(program, fs);
GL20.glLinkProgram(program);
assertEquals(GL11.GL_TRUE, GL20.glGetProgrami(program, GL20.GL_LINK_STATUS), "program failed to link: " + GL20.glGetProgramInfoLog(program, 4096));
GL20.glDeleteShader(vs);
GL20.glDeleteShader(fs);
}
private void uploadVertices(float[] xyzuv) {
final FloatBuffer verts = BufferUtils.createFloatBuffer(xyzuv.length);
verts.put(xyzuv).flip();
vao = GLStateManager.glGenVertexArrays();
vbo = GLStateManager.glGenBuffers();
GLStateManager.glBindVertexArray(vao);
GLStateManager.glBindBuffer(GL15.GL_ARRAY_BUFFER, vbo);
GLStateManager.glBufferData(GL15.GL_ARRAY_BUFFER, verts, GL15.GL_STATIC_DRAW);
final int stride = PlayerReflectionLayoutTest.FLOATS * 4;
GL20.glVertexAttribPointer(0, 3, GL11.GL_FLOAT, false, stride, 0L);
GL20.glEnableVertexAttribArray(0);
GL20.glVertexAttribPointer(1, 2, GL11.GL_FLOAT, false, stride, 12L);
GL20.glEnableVertexAttribArray(1);
GLStateManager.glBindBuffer(GL15.GL_ARRAY_BUFFER, 0);
}
private void allocateSsbo() {
ssbo = GLStateManager.glGenBuffers();
GLStateManager.glBindBuffer(GL43.GL_SHADER_STORAGE_BUFFER, ssbo);
GLStateManager.glBufferData(GL43.GL_SHADER_STORAGE_BUFFER, ByteBuffer.allocateDirect(SSBO_BYTES).order(ByteOrder.nativeOrder()), GL15.GL_DYNAMIC_DRAW);
GL30.glBindBufferBase(GL43.GL_SHADER_STORAGE_BUFFER, BINDING, ssbo);
visitedSsbo = GLStateManager.glGenBuffers();
GLStateManager.glBindBuffer(GL43.GL_SHADER_STORAGE_BUFFER, visitedSsbo);
GLStateManager.glBufferData(GL43.GL_SHADER_STORAGE_BUFFER, ByteBuffer.allocateDirect(VISITED_BYTES).order(ByteOrder.nativeOrder()), GL15.GL_DYNAMIC_DRAW);
GL30.glBindBufferBase(GL43.GL_SHADER_STORAGE_BUFFER, VISITED_BINDING, visitedSsbo);
GLStateManager.glBindBuffer(GL43.GL_SHADER_STORAGE_BUFFER, 0);
}
private ByteBuffer readback(int buffer, int bytes) {
GL42.glMemoryBarrier(GL43.GL_SHADER_STORAGE_BARRIER_BIT | GL42.GL_BUFFER_UPDATE_BARRIER_BIT);
final ByteBuffer out = BufferUtils.createByteBuffer(bytes);
GLStateManager.glBindBuffer(GL43.GL_SHADER_STORAGE_BUFFER, buffer);
GLStateManager.glGetBufferSubData(GL43.GL_SHADER_STORAGE_BUFFER, 0L, out);
GLStateManager.glBindBuffer(GL43.GL_SHADER_STORAGE_BUFFER, 0);
return out;
}
@Test
void quadIndexPatternProducesTheVertexIdsThePackIndexesBy() {
assumeTrue(RenderSystem.supportsSSBO(), "SSBOs unsupported");
final float[] xyzuv = emit(new ModelBiped(0.0f), MODEL_SCALE);
buildProgram();
uploadVertices(xyzuv);
allocateSsbo();
GLStateManager.glUseProgram(program);
GLStateManager.glBindVertexArray(vao);
QuadConverter.drawQuadsAsTriangles(0, PlayerReflectionCapture.VERTEX_COUNT);
final ByteBuffer ssboData = readback(ssbo, SSBO_BYTES);
final int floats = PlayerReflectionLayoutTest.FLOATS;
for (int i = 0; i < SSBO_ENTRIES; i++) {
final int vertexId = i + i / 3;
final int src = vertexId * floats;
assertEquals(xyzuv[src], ssboData.getFloat(i * POSITION_STRIDE), 1e-6f, "entry " + i + " x came from a vertex other than " + vertexId);
assertEquals(xyzuv[src + 1], ssboData.getFloat(i * POSITION_STRIDE + 4), 1e-6f, "entry " + i + " y came from a vertex other than " + vertexId);
assertEquals(xyzuv[src + 2], ssboData.getFloat(i * POSITION_STRIDE + 8), 1e-6f, "entry " + i + " z came from a vertex other than " + vertexId);
assertEquals(xyzuv[src + 3], ssboData.getFloat(DATA_OFFSET + i * DATA_STRIDE), 1e-6f, "entry " + i + " u came from a vertex other than " + vertexId);
assertEquals(xyzuv[src + 4], ssboData.getFloat(DATA_OFFSET + i * DATA_STRIDE + 4), 1e-6f, "entry " + i + " v came from a vertex other than " + vertexId);
}
}
@Test
void everyQuadCornerRunsTheVertexShader() {
assumeTrue(RenderSystem.supportsSSBO(), "SSBOs unsupported");
final float[] xyzuv = emit(new ModelBiped(0.0f), MODEL_SCALE);
buildProgram();
uploadVertices(xyzuv);
allocateSsbo();
GLStateManager.glUseProgram(program);
GLStateManager.glBindVertexArray(vao);
QuadConverter.drawQuadsAsTriangles(0, PlayerReflectionCapture.VERTEX_COUNT);
final ByteBuffer visitedData = readback(visitedSsbo, VISITED_BYTES);
for (int vertexId = 0; vertexId < PlayerReflectionCapture.VERTEX_COUNT; vertexId++) {
assertEquals(1, visitedData.getInt(vertexId * 4), "vertex " + vertexId + " (corner " + (vertexId % 4) + " of quad " + (vertexId / 4) + ") never ran; the shared quad EBO must visit all four corners of every quad");
}
}
}