Fix crash when mods measure text off-thread with a custom font (#2204)

This commit is contained in:
Jason Mitchell authored and GitHub committed 2026-10-03 15:28:15 -07:00
1 parent 7cd97d1216
commit 61bb779dea
9 files changed
+540 -198

No files matched your search

@@ -308,6 +308,11 @@ public class GLStateManager {
return Thread.currentThread() == MainThread; return Thread.currentThread() == MainThread;
} }
public static void requireContext(String call) {
if (Thread.currentThread() == MainThread || RENDER_BACKEND.hasContextOnThread()) return;
throw new IllegalStateException(call + " on thread " + Thread.currentThread().getName() + ", which has no GL context");
}
@Getter private static GLSMInitConfig initConfig; @Getter private static GLSMInitConfig initConfig;
public static ContextCapabilities capabilities; public static ContextCapabilities capabilities;
@@ -2525,11 +2530,13 @@ public class GLStateManager {
} }
public static int glGenTextures() { public static int glGenTextures() {
requireContext("glGenTextures");
flushDeferredTextureDeletes(); flushDeferredTextureDeletes();
return RENDER_BACKEND.genTextures(); return RENDER_BACKEND.genTextures();
} }
public static void glGenTextures(IntBuffer textures) { public static void glGenTextures(IntBuffer textures) {
requireContext("glGenTextures");
flushDeferredTextureDeletes(); flushDeferredTextureDeletes();
RENDER_BACKEND.genTextures(textures); RENDER_BACKEND.genTextures(textures);
} }
@@ -5325,13 +5332,18 @@ public class GLStateManager {
} }
public static void glClearTexImage(int texture, int level, int format, int type, ByteBuffer data) { RENDER_BACKEND.clearTexImage(texture, level, format, type); } 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(); } public static int glGenSamplers() {
requireContext("glGenSamplers");
return RENDER_BACKEND.genSamplers();
}
public static void glGenSamplers(IntBuffer samplers) { public static void glGenSamplers(IntBuffer samplers) {
requireContext("glGenSamplers");
for (int i = samplers.position(); i < samplers.limit(); i++) samplers.put(i, RENDER_BACKEND.genSamplers()); for (int i = samplers.position(); i < samplers.limit(); i++) samplers.put(i, RENDER_BACKEND.genSamplers());
} }
public static void glGenSamplers(int[] samplers) { public static void glGenSamplers(int[] samplers) {
requireContext("glGenSamplers");
for (int i = 0; i < samplers.length; i++) samplers[i] = RENDER_BACKEND.genSamplers(); for (int i = 0; i < samplers.length; i++) samplers[i] = RENDER_BACKEND.genSamplers();
} }
@@ -6748,26 +6760,32 @@ public class GLStateManager {
} }
public static int glGenBuffers() { public static int glGenBuffers() {
requireContext("glGenBuffers");
return RENDER_BACKEND.genBuffers(); return RENDER_BACKEND.genBuffers();
} }
public static void glGenBuffers(IntBuffer buffers) { public static void glGenBuffers(IntBuffer buffers) {
requireContext("glGenBuffers");
for (int i = buffers.position(); i < buffers.limit(); i++) buffers.put(i, RENDER_BACKEND.genBuffers()); for (int i = buffers.position(); i < buffers.limit(); i++) buffers.put(i, RENDER_BACKEND.genBuffers());
} }
public static void glGenBuffers(int[] buffers) { public static void glGenBuffers(int[] buffers) {
requireContext("glGenBuffers");
for (int i = 0; i < buffers.length; i++) buffers[i] = RENDER_BACKEND.genBuffers(); for (int i = 0; i < buffers.length; i++) buffers[i] = RENDER_BACKEND.genBuffers();
} }
public static int glCreateBuffers() { public static int glCreateBuffers() {
requireContext("glCreateBuffers");
return RENDER_BACKEND.createBuffers(); return RENDER_BACKEND.createBuffers();
} }
public static void glCreateBuffers(IntBuffer buffers) { public static void glCreateBuffers(IntBuffer buffers) {
requireContext("glCreateBuffers");
for (int i = buffers.position(); i < buffers.limit(); i++) buffers.put(i, RENDER_BACKEND.createBuffers()); for (int i = buffers.position(); i < buffers.limit(); i++) buffers.put(i, RENDER_BACKEND.createBuffers());
} }
public static void glCreateBuffers(int[] buffers) { public static void glCreateBuffers(int[] buffers) {
requireContext("glCreateBuffers");
for (int i = 0; i < buffers.length; i++) buffers[i] = RENDER_BACKEND.createBuffers(); for (int i = 0; i < buffers.length; i++) buffers[i] = RENDER_BACKEND.createBuffers();
} }
@@ -7158,6 +7176,7 @@ public class GLStateManager {
} }
public static int glGenVertexArrays() { public static int glGenVertexArrays() {
requireContext("glGenVertexArrays");
return RENDER_BACKEND.genVertexArrays(); return RENDER_BACKEND.genVertexArrays();
} }
@@ -7211,6 +7230,7 @@ public class GLStateManager {
} }
public static int glGenFramebuffers() { public static int glGenFramebuffers() {
requireContext("glGenFramebuffers");
return RENDER_BACKEND.genFramebuffers(); return RENDER_BACKEND.genFramebuffers();
} }
@@ -7228,15 +7248,24 @@ public class GLStateManager {
public static void glFramebufferTexture(int target, int attachment, int texture, int level) { RENDER_BACKEND.framebufferTexture(target, attachment, texture, level); } public static void glFramebufferTexture(int target, int attachment, int texture, int level) { RENDER_BACKEND.framebufferTexture(target, attachment, texture, level); }
public static int glGenRenderbuffers() { return RENDER_BACKEND.genRenderbuffers(); } public static int glGenRenderbuffers() {
requireContext("glGenRenderbuffers");
return RENDER_BACKEND.genRenderbuffers();
}
public static void glDeleteRenderbuffers(int renderbuffer) { RENDER_BACKEND.deleteRenderbuffers(renderbuffer); } public static void glDeleteRenderbuffers(int renderbuffer) { RENDER_BACKEND.deleteRenderbuffers(renderbuffer); }
public static void glBindRenderbuffer(int target, int renderbuffer) { RENDER_BACKEND.bindRenderbuffer(target, renderbuffer); } public static void glBindRenderbuffer(int target, int renderbuffer) { RENDER_BACKEND.bindRenderbuffer(target, renderbuffer); }
public static void glRenderbufferStorage(int target, int internalformat, int width, int height) { RENDER_BACKEND.renderbufferStorage(target, internalformat, width, height); } public static void glRenderbufferStorage(int target, int internalformat, int width, int height) { RENDER_BACKEND.renderbufferStorage(target, internalformat, width, height); }
public static void glRenderbufferStorageMultisample(int target, int samples, int internalformat, int width, int height) { RENDER_BACKEND.renderbufferStorageMultisample(target, samples, internalformat, width, height); } public static void glRenderbufferStorageMultisample(int target, int samples, int internalformat, int width, int height) { RENDER_BACKEND.renderbufferStorageMultisample(target, samples, internalformat, width, height); }
public static void glFramebufferRenderbuffer(int target, int attachment, int renderbuffertarget, int renderbuffer) { RENDER_BACKEND.framebufferRenderbuffer(target, attachment, renderbuffertarget, renderbuffer); } public static void glFramebufferRenderbuffer(int target, int attachment, int renderbuffertarget, int renderbuffer) { RENDER_BACKEND.framebufferRenderbuffer(target, attachment, renderbuffertarget, renderbuffer); }
public static void glGenQueries(IntBuffer ids) { RENDER_BACKEND.genQueries(ids); } public static void glGenQueries(IntBuffer ids) {
public static int glGenQueries() { return RENDER_BACKEND.genQueries(); } requireContext("glGenQueries");
RENDER_BACKEND.genQueries(ids);
}
public static int glGenQueries() {
requireContext("glGenQueries");
return RENDER_BACKEND.genQueries();
}
public static void glDeleteQueries(int id) { RENDER_BACKEND.deleteQueries(id); } public static void glDeleteQueries(int id) { RENDER_BACKEND.deleteQueries(id); }
public static void glBeginQuery(int target, int id) { RENDER_BACKEND.beginQuery(target, id); } public static void glBeginQuery(int target, int id) { RENDER_BACKEND.beginQuery(target, id); }
public static void glEndQuery(int target) { RENDER_BACKEND.endQuery(target); } public static void glEndQuery(int target) { RENDER_BACKEND.endQuery(target); }
@@ -7916,6 +7945,7 @@ public class GLStateManager {
} }
public static int glCreateShader(int type) { public static int glCreateShader(int type) {
requireContext("glCreateShader");
return RENDER_BACKEND.createShader(type); return RENDER_BACKEND.createShader(type);
} }
@@ -7924,6 +7954,7 @@ public class GLStateManager {
} }
public static int glCreateProgram() { public static int glCreateProgram() {
requireContext("glCreateProgram");
final int program = RENDER_BACKEND.createProgram(); final int program = RENDER_BACKEND.createProgram();
programsPendingDeletion.remove(program); programsPendingDeletion.remove(program);
programLifetimeGeneration++; programLifetimeGeneration++;
@@ -36,6 +36,9 @@ public abstract class RenderBackend {
/** Returns true if the current thread has a valid render context. */ /** Returns true if the current thread has a valid render context. */
public abstract boolean hasContext(); public abstract boolean hasContext();
/** Returns true if the calling thread may issue render commands. */
public boolean hasContextOnThread() { return hasContext(); }
/** Returns true if the calling thread owns the "GL Context" */ /** Returns true if the calling thread owns the "GL Context" */
public abstract boolean isCurrent(); public abstract boolean isCurrent();
@@ -133,11 +133,13 @@ public class DSAARB extends DSAUnsupported {
@Override @Override
public int createFramebuffer() { public int createFramebuffer() {
GLStateManager.requireContext("glCreateFramebuffers");
return RENDER_BACKEND.createFramebuffers(); return RENDER_BACKEND.createFramebuffers();
} }
@Override @Override
public int createTexture(int target) { public int createTexture(int target) {
GLStateManager.requireContext("glCreateTextures");
return RENDER_BACKEND.createTextures(target); return RENDER_BACKEND.createTextures(target);
} }
} }
@@ -165,7 +165,7 @@ public class DSAUnsupported implements DSAAccess {
@Override @Override
public int createTexture(int target) { public int createTexture(int target) {
final int texture = RENDER_BACKEND.genTextures(); final int texture = GLStateManager.glGenTextures();
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, texture); GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, texture);
return texture; return texture;
} }
@@ -0,0 +1,75 @@
package com.gtnewhorizons.angelica.glsm;
import com.gtnewhorizons.angelica.glsm.testutil.TestThreads;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import org.lwjgl.BufferUtils;
import org.lwjgl.opengl.Display;
import org.lwjgl.opengl.GL11;
import org.lwjgl.opengl.GL20;
import org.lwjgl.opengl.SharedDrawable;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@GLCoreTest
public class GLSM_OffContextCreation_GLTest {
private SharedDrawable sharedDrawable;
@AfterEach
void cleanup() {
if (sharedDrawable != null) {
DrawableContexts.destroy(sharedDrawable);
sharedDrawable = null;
}
}
private static Map<String, Executable> creationCalls() {
final Map<String, Executable> calls = new LinkedHashMap<>();
calls.put("glGenTextures()", GLStateManager::glGenTextures);
calls.put("glGenTextures(IntBuffer)", () -> GLStateManager.glGenTextures(BufferUtils.createIntBuffer(1)));
calls.put("glGenSamplers()", GLStateManager::glGenSamplers);
calls.put("glGenSamplers(IntBuffer)", () -> GLStateManager.glGenSamplers(BufferUtils.createIntBuffer(1)));
calls.put("glGenSamplers(int[])", () -> GLStateManager.glGenSamplers(new int[1]));
calls.put("glGenBuffers()", GLStateManager::glGenBuffers);
calls.put("glGenBuffers(IntBuffer)", () -> GLStateManager.glGenBuffers(BufferUtils.createIntBuffer(1)));
calls.put("glGenBuffers(int[])", () -> GLStateManager.glGenBuffers(new int[1]));
calls.put("glCreateBuffers()", GLStateManager::glCreateBuffers);
calls.put("glCreateBuffers(IntBuffer)", () -> GLStateManager.glCreateBuffers(BufferUtils.createIntBuffer(1)));
calls.put("glCreateBuffers(int[])", () -> GLStateManager.glCreateBuffers(new int[1]));
calls.put("glGenVertexArrays()", GLStateManager::glGenVertexArrays);
calls.put("glGenFramebuffers()", GLStateManager::glGenFramebuffers);
calls.put("glGenRenderbuffers()", GLStateManager::glGenRenderbuffers);
calls.put("glGenQueries()", GLStateManager::glGenQueries);
calls.put("glGenQueries(IntBuffer)", () -> GLStateManager.glGenQueries(BufferUtils.createIntBuffer(1)));
calls.put("glCreateShader", () -> GLStateManager.glCreateShader(GL20.GL_VERTEX_SHADER));
calls.put("glCreateProgram", GLStateManager::glCreateProgram);
calls.put("RenderSystem.createTexture", () -> RenderSystem.createTexture(GL11.GL_TEXTURE_2D));
calls.put("RenderSystem.createFramebuffer", RenderSystem::createFramebuffer);
return calls;
}
@Test
void creationWithoutContextThrows() throws InterruptedException {
TestThreads.run("glsm-no-context", () -> {
for (Map.Entry<String, Executable> call : creationCalls().entrySet()) {
assertThrows(IllegalStateException.class, call.getValue(), call.getKey() + " did not throw off-context");
}
});
}
@Test
void creationOnSharedContextThreadPasses() throws Exception {
sharedDrawable = new SharedDrawable(Display.getDrawable());
DrawableContexts.run(sharedDrawable, "glsm-shared-context", () -> {
final int texture = GLStateManager.glGenTextures();
assertNotEquals(0, texture, "no texture name on a thread that owns a shared context");
GLStateManager.glDeleteTextures(texture);
});
}
}
@@ -543,6 +543,10 @@ public class SDLGPURenderBackend extends RenderBackend {
return device.getDevice() != 0; return device.getDevice() != 0;
} }
@Override public boolean hasContextOnThread() {
return hasContext() && (Thread.currentThread() == GLStateManager.getMainThread() || tlState.get() != null);
}
@Override public boolean isCurrent() { @Override public boolean isCurrent() {
return Thread.currentThread() == GLStateManager.getMainThread(); return Thread.currentThread() == GLStateManager.getMainThread();
} }
@@ -0,0 +1,33 @@
package com.gtnewhorizons.angelica.sdlgpu.glsm;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
import com.gtnewhorizons.angelica.glsm.testutil.TestThreads;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@Tag("glsm-sdl")
@ExtendWith(GlsmSdlFrameExtension.class)
class GlsmSdlOffContextCreationTest {
@BeforeAll
static void boot() {
GlsmSdlHeadlessRig.boot();
}
@Test
void creationWithoutContextThrows() throws InterruptedException {
TestThreads.run("sdl-no-context", () -> assertThrows(IllegalStateException.class, GLStateManager::glGenTextures));
}
@Test
void creationOnRenderThreadPasses() {
final int texture = GLStateManager.glGenTextures();
assertNotEquals(0, texture);
GLStateManager.glDeleteTextures(texture);
}
}
@@ -1,15 +1,11 @@
package com.gtnewhorizons.angelica.client.font; package com.gtnewhorizons.angelica.client.font;
import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memAddress0;
import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memAllocInt; import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memAllocInt;
import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memFree; import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memFree;
import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memPutInt;
import com.gtnewhorizons.angelica.config.FontConfig; import com.gtnewhorizons.angelica.config.FontConfig;
import com.gtnewhorizons.angelica.glsm.GLStateManager; import com.gtnewhorizons.angelica.glsm.GLStateManager;
import jss.util.RandomXoshiro256StarStar; import jss.util.RandomXoshiro256StarStar;
import lombok.Setter;
import lombok.Value;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.lwjgl.opengl.GL11; import org.lwjgl.opengl.GL11;
@@ -20,10 +16,9 @@ import java.awt.FontMetrics;
import java.awt.Graphics2D; import java.awt.Graphics2D;
import java.awt.RenderingHints; import java.awt.RenderingHints;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.awt.image.DataBufferInt; import java.awt.image.DataBufferInt;
import java.awt.image.Raster;
import java.nio.IntBuffer; import java.nio.IntBuffer;
import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import static com.gtnewhorizons.angelica.client.font.FontStrategist.getFontName; import static com.gtnewhorizons.angelica.client.font.FontStrategist.getFontName;
@@ -33,222 +28,241 @@ public final class FontProviderCustom implements FontProvider {
public static final Logger LOGGER = LogManager.getLogger("Angelica"); public static final Logger LOGGER = LogManager.getLogger("Angelica");
static final int ATLAS_SIZE = 128; static final int ATLAS_SIZE = 128;
static final int ATLAS_COUNT = 512; static final int ATLAS_COUNT = 512;
private final byte id; // 0 - primary font, 1 - fallback font
private final RandomXoshiro256StarStar fontRandom = new RandomXoshiro256StarStar(); private final RandomXoshiro256StarStar fontRandom = new RandomXoshiro256StarStar();
private FontAtlas[] fontAtlases = new FontAtlas[ATLAS_COUNT]; private final FontAtlas[] fontAtlases = new FontAtlas[ATLAS_COUNT];
private float currentFontQuality = FontConfig.customFontQuality; private final int[] atlasTextures = new int[ATLAS_COUNT];
@Setter
private Font font; private Font font;
private FontProviderCustom(byte id) { FontProviderCustom(Font font) {
this.id = id; this.font = font;
Font[] availableFonts = FontStrategist.getAvailableFonts(); }
String myFontName = switch (this.id) {
case 0 -> FontConfig.customFontNamePrimary; private static Font configured(String fontName) {
case 1 -> FontConfig.customFontNameFallback; if (Objects.equals(fontName, "(none)")) {
default -> null; return null;
};
if (Objects.equals(myFontName, "(none)")) {
this.font = null;
return;
} }
int fontPos = -1; for (Font available : FontStrategist.getAvailableFonts()) {
for (int i = 0; i < availableFonts.length; i++) { if (Objects.equals(fontName, getFontName(available))) {
if (Objects.equals(myFontName, getFontName(availableFonts[i]))) { return available.deriveFont((float) FontConfig.customFontQuality);
fontPos = i;
break;
} }
} }
if (fontPos == -1) { LOGGER.info("Could not find previously set font \"{}\". ", fontName);
LOGGER.info("Could not find previously set font \"{}\". ", myFontName); return null;
this.font = null;
return;
}
this.font = availableFonts[fontPos].deriveFont(this.currentFontQuality);
} }
private static class InstLoader { private static class InstLoader {
static final FontProviderCustom instance0 = new FontProviderCustom((byte)0); static final FontProviderCustom instance0 = new FontProviderCustom(configured(FontConfig.customFontNamePrimary));
static final FontProviderCustom instance1 = new FontProviderCustom((byte)1); static final FontProviderCustom instance1 = new FontProviderCustom(configured(FontConfig.customFontNameFallback));
} }
public static FontProviderCustom getPrimary() { return InstLoader.instance0; } public static FontProviderCustom getPrimary() { return InstLoader.instance0; }
public static FontProviderCustom getFallback() { return InstLoader.instance1; } public static FontProviderCustom getFallback() { return InstLoader.instance1; }
public void setFont(Font font) {
synchronized (this) {
this.font = font;
Arrays.fill(this.fontAtlases, null);
}
for (int i = 0; i < ATLAS_COUNT; i++) {
if (this.atlasTextures[i] != 0) {
GLStateManager.glDeleteTextures(this.atlasTextures[i]);
this.atlasTextures[i] = 0;
}
}
}
public void reloadFont(int fontID) { public void reloadFont(int fontID) {
this.currentFontQuality = FontConfig.customFontQuality; setFont(FontStrategist.getAvailableFonts()[fontID].deriveFont((float) FontConfig.customFontQuality));
this.font = FontStrategist.getAvailableFonts()[fontID].deriveFont(this.currentFontQuality);
for (FontAtlas atlas : fontAtlases) {
if (atlas != null && atlas.texture != 0) {
GLStateManager.glDeleteTextures(atlas.texture);
}
}
this.fontAtlases = new FontAtlas[ATLAS_COUNT];
} }
@Value private static final class FontAtlas {
private class GlyphData { static final int STRIDE = 6;
float uStart; static final int U_START = 0;
float vStart; static final int V_START = 1;
float xAdvance; static final int X_ADVANCE = 2;
float glyphW; static final int GLYPH_W = 3;
float uSz; static final int U_SIZE = 4;
float vSz; static final int V_SIZE = 5;
}
private class FontAtlas { final float[] metrics;
GlyphData[] glyphData = new GlyphData[ATLAS_SIZE]; final long availLo;
private int texture; final long availHi;
private final int id;
FontAtlas(int id) { FontAtlas(float[] metrics, long availLo, long availHi) {
this.id = id; this.metrics = metrics;
this.availLo = availLo;
this.availHi = availHi;
} }
void construct(Font font) { boolean has(int slot) {
int atlasChars = 0; return bit(this.availLo, this.availHi, slot);
for (int i = 0; i < ATLAS_SIZE; i++) { }
final char ch = (char) (i + ATLAS_SIZE * this.id);
if (font.canDisplay(ch)) { atlasChars++; }
}
if (atlasChars == 0) { return; }
BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); static boolean bit(long lo, long hi, int slot) {
Graphics2D g2d = image.createGraphics(); return ((slot < 64 ? lo >>> slot : hi >>> (slot - 64)) & 1L) != 0L;
g2d.setFont(font); }
FontMetrics fm = g2d.getFontMetrics(); }
private static final FontAtlas EMPTY = new FontAtlas(new float[ATLAS_SIZE * FontAtlas.STRIDE], 0L, 0L);
private static void setHints(Graphics2D g2d) {
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_DITHERING, RenderingHints.VALUE_DITHER_DISABLE);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
}
private static BufferedImage layout(Font font, int id, long availLo, long availHi, float[] metricsOut) {
final float quality = font.getSize2D();
int atlasChars = Long.bitCount(availLo) + Long.bitCount(availHi);
BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = image.createGraphics();
g2d.setFont(font);
FontMetrics fm = g2d.getFontMetrics();
final int atlasTilesX = (int) Math.ceil(Math.sqrt(atlasChars) * 1.5f);
final int atlasTilesY = (int) Math.ceil((double) atlasChars / atlasTilesX);
final float charSeparator = quality / 3f;
int rowWidth = 0;
int maxRowWidth = 0;
atlasChars = 0;
for (int i = 0; i < ATLAS_SIZE; i++) {
if (atlasChars % atlasTilesX == 0) {
maxRowWidth = Math.max(maxRowWidth, rowWidth);
rowWidth = 0;
}
if (FontAtlas.bit(availLo, availHi, i)) {
rowWidth += (int) (charSeparator + fm.charWidth((char) (i + ATLAS_SIZE * id)));
atlasChars++;
}
}
maxRowWidth = Math.max(maxRowWidth, rowWidth);
final int lineHeight = fm.getHeight();
final float desc = fm.getDescent();
final int imageWidth = (int) (maxRowWidth + charSeparator);
final int imageHeight = (int) ((charSeparator + lineHeight) * atlasTilesY + charSeparator);
final boolean rasterize = metricsOut == null;
if (rasterize) {
g2d.dispose(); g2d.dispose();
final int atlasTilesX = (int) Math.ceil(Math.sqrt(atlasChars) * 1.5f);
final int atlasTilesY = (int) Math.ceil((double) atlasChars / atlasTilesX);
final float charSeparator = currentFontQuality / 3f;
int rowWidth = 0;
int maxRowWidth = 0;
atlasChars = 0;
for (int i = 0; i < ATLAS_SIZE; i++) {
if (atlasChars % atlasTilesX == 0) {
maxRowWidth = Math.max(maxRowWidth, rowWidth);
rowWidth = 0;
}
final char ch = (char)(i + ATLAS_SIZE * this.id);
if (font.canDisplay(ch)) {
rowWidth += (int) (charSeparator + fm.charWidth(ch));
atlasChars++;
}
}
maxRowWidth = Math.max(maxRowWidth, rowWidth);
final int lineHeight = fm.getHeight();
final float desc = fm.getDescent();
final int imageWidth = (int) (maxRowWidth + charSeparator);
final int imageHeight = (int) ((charSeparator + lineHeight) * atlasTilesY + charSeparator);
image = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_ARGB); image = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_ARGB);
g2d = image.createGraphics(); g2d = image.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY); setHints(g2d);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_DITHERING, RenderingHints.VALUE_DITHER_DISABLE);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.setFont(font); g2d.setFont(font);
fm = g2d.getFontMetrics(); } else {
setHints(g2d);
int tileX = 0, tileY = 0; // position in atlas tiles
int imgX = (int) charSeparator; // position in pixels
for (int i = 0; i < ATLAS_SIZE; i++) {
final char ch = (char) (i + ATLAS_SIZE * this.id);
if (!font.canDisplay(ch)) { continue; }
if (tileX >= atlasTilesX) {
tileX = 0;
imgX = (int) charSeparator;
tileY++;
}
final int charWidth = fm.charWidth(ch);
final float charAspectRatio = (float) charWidth / lineHeight;
final float inset = currentFontQuality / 16;
g2d.drawString(Character.toString(ch), imgX, (lineHeight + charSeparator) * (tileY + 1) - desc);
final float uStart = (float) (imgX - inset * charAspectRatio) / imageWidth;
final float vStart = ((lineHeight + charSeparator) * (tileY + 1) - lineHeight - inset) / imageHeight;
final float xAdvance = charAspectRatio * 8 * charWidth / (charWidth + 2 * inset * charAspectRatio);
final float glyphW = charAspectRatio * 8 + 1;
final float uSz = (float) (charWidth + 2 * inset * charAspectRatio) / imageWidth;
final float vSz = (float) (lineHeight + 2 * inset) / imageHeight;
this.glyphData[i] = new GlyphData(uStart, vStart, xAdvance, glyphW, uSz, vSz);
imgX += (int) (charWidth + charSeparator);
tileX++;
}
g2d.dispose();
int id = GLStateManager.glGenTextures();
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, id);
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.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL11.GL_REPEAT);
GLStateManager.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL11.GL_REPEAT);
final int width = image.getWidth();
final int height = image.getHeight();
final IntBuffer pixelBuffer = memAllocInt(width * height);
long ptr = memAddress0(pixelBuffer);
if (image.getRaster().getDataBuffer() instanceof DataBufferInt dataBufferInt) {
final int[] pixelValues = dataBufferInt.getData();
for (int i : pixelValues) {
memPutInt(ptr, i);
ptr += 4;
}
pixelBuffer.limit(pixelValues.length);
} else {
Raster raster = image.getRaster();
ColorModel colorModel = image.getColorModel();
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
Object pixel = raster.getDataElements(x, y, null);
memPutInt(ptr, colorModel.getRGB(pixel));
ptr += 4;
}
}
pixelBuffer.limit(image.getHeight() * image.getWidth());
}
GLStateManager.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA, image.getWidth(), image.getHeight(), 0, GL12.GL_BGRA, GL12.GL_UNSIGNED_INT_8_8_8_8_REV, pixelBuffer);
memFree(pixelBuffer);
this.texture = id;
} }
fm = g2d.getFontMetrics();
int tileX = 0, tileY = 0; // position in atlas tiles
int imgX = (int) charSeparator; // position in pixels
for (int i = 0; i < ATLAS_SIZE; i++) {
if (!FontAtlas.bit(availLo, availHi, i)) { continue; }
final char ch = (char) (i + ATLAS_SIZE * id);
if (tileX >= atlasTilesX) {
tileX = 0;
imgX = (int) charSeparator;
tileY++;
}
final int charWidth = fm.charWidth(ch);
if (rasterize) {
g2d.drawString(Character.toString(ch), imgX, (lineHeight + charSeparator) * (tileY + 1) - desc);
} else {
final float charAspectRatio = (float) charWidth / lineHeight;
final float inset = quality / 16;
final int o = i * FontAtlas.STRIDE;
metricsOut[o + FontAtlas.U_START] = (float) (imgX - inset * charAspectRatio) / imageWidth;
metricsOut[o + FontAtlas.V_START] = ((lineHeight + charSeparator) * (tileY + 1) - lineHeight - inset) / imageHeight;
metricsOut[o + FontAtlas.X_ADVANCE] = charAspectRatio * 8 * charWidth / (charWidth + 2 * inset * charAspectRatio);
metricsOut[o + FontAtlas.GLYPH_W] = charAspectRatio * 8 + 1;
metricsOut[o + FontAtlas.U_SIZE] = (float) (charWidth + 2 * inset * charAspectRatio) / imageWidth;
metricsOut[o + FontAtlas.V_SIZE] = (float) (lineHeight + 2 * inset) / imageHeight;
}
imgX += (int) (charWidth + charSeparator);
tileX++;
}
g2d.dispose();
return rasterize ? image : null;
} }
private FontAtlas getAtlas(char chr) { private FontAtlas getAtlas(char chr) {
int id = chr / ATLAS_SIZE; final FontAtlas fa = this.fontAtlases[chr / ATLAS_SIZE];
return fa != null ? fa : buildAtlas(chr / ATLAS_SIZE);
}
private synchronized FontAtlas buildAtlas(int id) {
FontAtlas fa = this.fontAtlases[id]; FontAtlas fa = this.fontAtlases[id];
if (fa == null) { if (fa != null) { return fa; }
fa = new FontAtlas(id); final Font f = this.font;
fa.construct(this.font); if (f == null) { return EMPTY; }
this.fontAtlases[id] = fa; long availLo = 0L;
long availHi = 0L;
for (int i = 0; i < ATLAS_SIZE; i++) {
if (f.canDisplay((char) (i + ATLAS_SIZE * id))) {
if (i < 64) { availLo |= 1L << i; } else { availHi |= 1L << (i - 64); }
}
} }
if ((availLo | availHi) == 0L) {
fa = EMPTY;
} else {
final float[] metrics = new float[ATLAS_SIZE * FontAtlas.STRIDE];
layout(f, id, availLo, availHi, metrics);
fa = new FontAtlas(metrics, availLo, availHi);
}
this.fontAtlases[id] = fa;
return fa; return fa;
} }
private synchronized BufferedImage rasterize(int id) {
final Font f = this.font;
final FontAtlas fa = this.fontAtlases[id];
if (f == null || fa == null || fa == EMPTY) { return null; }
return layout(f, id, fa.availLo, fa.availHi, null);
}
private int uploadAtlas(int id) {
final BufferedImage image = rasterize(id);
if (image == null) { return 0; }
final int texture = GLStateManager.glGenTextures();
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, texture);
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.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL11.GL_REPEAT);
GLStateManager.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL11.GL_REPEAT);
final int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
final IntBuffer pixelBuffer = memAllocInt(pixels.length);
pixelBuffer.put(pixels);
pixelBuffer.flip();
GLStateManager.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA, image.getWidth(), image.getHeight(), 0, GL12.GL_BGRA, GL12.GL_UNSIGNED_INT_8_8_8_8_REV, pixelBuffer);
memFree(pixelBuffer);
this.atlasTextures[id] = texture;
return texture;
}
@Override @Override
public boolean isGlyphAvailable(char chr) { public boolean isGlyphAvailable(char chr) {
if (this.font == null) { return false; } if (this.font == null) { return false; }
return (getAtlas(chr).glyphData[chr % ATLAS_SIZE] != null); return getAtlas(chr).has(chr % ATLAS_SIZE);
} }
/** /**
* A random glyph of the same width, for {@code §k}. Kept inside the character's own * A random glyph of the same width, for {@code §k}. Kept inside the character's own
* atlas: atlases are rasterized on first use, so reaching outside one would build a * atlas: reaching outside one would measure and rasterize another atlas mid-frame.
* new atlas mid-frame. Slots the font has no glyph for are skipped. * Slots the font has no glyph for are skipped.
*/ */
@Override @Override
public char getRandomReplacement(char chr) { public char getRandomReplacement(char chr) {
@@ -256,49 +270,52 @@ public final class FontProviderCustom implements FontProvider {
return chr; return chr;
} }
final FontAtlas atlas = getAtlas(chr); final FontAtlas atlas = getAtlas(chr);
final GlyphData target = atlas.glyphData[chr % ATLAS_SIZE]; if (!atlas.has(chr % ATLAS_SIZE)) {
if (target == null) {
return chr; return chr;
} }
final float targetAdvance = atlas.metrics[(chr % ATLAS_SIZE) * FontAtlas.STRIDE + FontAtlas.X_ADVANCE];
final int atlasStart = (chr / ATLAS_SIZE) * ATLAS_SIZE; final int atlasStart = (chr / ATLAS_SIZE) * ATLAS_SIZE;
for (int attempt = 0; attempt < RANDOM_GLYPH_TRIES; attempt++) { for (int attempt = 0; attempt < RANDOM_GLYPH_TRIES; attempt++) {
final int slot = fontRandom.nextInt(ATLAS_SIZE); final int slot = fontRandom.nextInt(ATLAS_SIZE);
final GlyphData candidate = atlas.glyphData[slot]; if (atlas.has(slot) && atlas.metrics[slot * FontAtlas.STRIDE + FontAtlas.X_ADVANCE] == targetAdvance) {
if (candidate != null && candidate.xAdvance == target.xAdvance) {
return (char) (atlasStart + slot); return (char) (atlasStart + slot);
} }
} }
return chr; return chr;
} }
private float metric(char chr, int field) {
return getAtlas(chr).metrics[(chr % ATLAS_SIZE) * FontAtlas.STRIDE + field];
}
@Override @Override
public float getUStart(char chr) { public float getUStart(char chr) {
return getAtlas(chr).glyphData[chr % ATLAS_SIZE].uStart; return metric(chr, FontAtlas.U_START);
} }
@Override @Override
public float getVStart(char chr) { public float getVStart(char chr) {
return getAtlas(chr).glyphData[chr % ATLAS_SIZE].vStart; return metric(chr, FontAtlas.V_START);
} }
@Override @Override
public float getXAdvance(char chr) { public float getXAdvance(char chr) {
return getAtlas(chr).glyphData[chr % ATLAS_SIZE].xAdvance * FontConfig.customFontScale; return metric(chr, FontAtlas.X_ADVANCE) * FontConfig.customFontScale;
} }
@Override @Override
public float getGlyphW(char chr) { public float getGlyphW(char chr) {
return getAtlas(chr).glyphData[chr % ATLAS_SIZE].glyphW * FontConfig.customFontScale; return metric(chr, FontAtlas.GLYPH_W) * FontConfig.customFontScale;
} }
@Override @Override
public float getUSize(char chr) { public float getUSize(char chr) {
return getAtlas(chr).glyphData[chr % ATLAS_SIZE].uSz; return metric(chr, FontAtlas.U_SIZE);
} }
@Override @Override
public float getVSize(char chr) { public float getVSize(char chr) {
return getAtlas(chr).glyphData[chr % ATLAS_SIZE].vSz; return metric(chr, FontAtlas.V_SIZE);
} }
@Override @Override
@@ -308,7 +325,9 @@ public final class FontProviderCustom implements FontProvider {
@Override @Override
public int getTexture(char chr) { public int getTexture(char chr) {
return getAtlas(chr).texture; final int id = chr / ATLAS_SIZE;
final int texture = this.atlasTextures[id];
return texture != 0 ? texture : uploadAtlas(id);
} }
@Override @Override
@@ -0,0 +1,175 @@
package com.gtnewhorizons.angelica.client.font;
import com.gtnewhorizons.angelica.config.FontConfig;
import com.gtnewhorizons.angelica.glsm.GLCoreTest;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
import com.gtnewhorizons.angelica.glsm.testutil.Reflect;
import com.gtnewhorizons.angelica.glsm.testutil.TestThreads;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.lwjgl.BufferUtils;
import org.lwjgl.opengl.GL11;
import org.lwjgl.opengl.GL12;
import java.awt.Font;
import java.nio.IntBuffer;
import java.util.concurrent.CyclicBarrier;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@GLCoreTest
class FontProviderCustomGLTest {
private static final int QUALITY = 30;
private static final int THREADS = 8;
private static final char SURROGATE = '\uD800';
private final Font font = new Font(Font.DIALOG, Font.PLAIN, QUALITY);
private FontProviderCustom provider;
private float scaleBefore;
@BeforeEach
void setUp() {
scaleBefore = FontConfig.customFontScale;
FontConfig.customFontScale = 1.0f;
provider = new FontProviderCustom(font);
}
@AfterEach
void tearDown() {
FontConfig.customFontScale = scaleBefore;
provider.setFont(null);
}
private int[] textures() {
return Reflect.get(provider, "atlasTextures");
}
private Object atlas(char chr) {
return Reflect.<Object[]>get(provider, "fontAtlases")[chr / FontProviderCustom.ATLAS_SIZE];
}
@Test
void metricsFromThreadWithoutContext() throws InterruptedException {
TestThreads.run("font-no-context", () -> {
assertTrue(provider.isGlyphAvailable('A'), "glyph not reported available off-thread");
assertTrue(provider.getXAdvance('A') > 0.0f, "no advance measured off-thread");
});
assertArrayEquals(new int[FontProviderCustom.ATLAS_COUNT], textures(), "measuring created a texture");
}
@Test
void getTextureWithoutContextThrows() throws InterruptedException {
assertTrue(provider.isGlyphAvailable('A'));
TestThreads.run("font-no-context", () -> assertThrows(IllegalStateException.class, () -> provider.getTexture('A')));
}
@Test
void textureCreatedOnceOnFirstDraw() {
assertTrue(provider.isGlyphAvailable('A'));
final int texture = provider.getTexture('A');
assertNotEquals(0, texture, "no texture created on first draw-path lookup");
assertTrue(GL11.glIsTexture(texture), "atlas id is not a texture");
assertEquals(texture, provider.getTexture('A'), "atlas texture recreated on second lookup");
assertEquals(texture, provider.getTexture('B'), "same block resolved to another texture");
}
@Test
void glyphInkLandsInsideMeasuredRects() {
assertTrue(provider.isGlyphAvailable('A'));
GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, provider.getTexture('A'));
final int width = GL11.glGetTexLevelParameteri(GL11.GL_TEXTURE_2D, 0, GL11.GL_TEXTURE_WIDTH);
final int height = GL11.glGetTexLevelParameteri(GL11.GL_TEXTURE_2D, 0, GL11.GL_TEXTURE_HEIGHT);
final IntBuffer pixels = BufferUtils.createIntBuffer(width * height);
GLStateManager.glGetTexImage(GL11.GL_TEXTURE_2D, 0, GL12.GL_BGRA, GL12.GL_UNSIGNED_INT_8_8_8_8_REV, pixels);
final int margin = QUALITY / 6;
final boolean[] covered = new boolean[width * height];
for (int i = 0; i < FontProviderCustom.ATLAS_SIZE; i++) {
final char chr = (char) i;
if (!provider.isGlyphAvailable(chr)) continue;
final int x0 = (int) Math.floor(provider.getUStart(chr) * width);
final int x1 = (int) Math.ceil((provider.getUStart(chr) + provider.getUSize(chr)) * width);
final int y0 = (int) Math.floor(provider.getVStart(chr) * height);
final int y1 = (int) Math.ceil((provider.getVStart(chr) + provider.getVSize(chr)) * height);
int ink = 0;
for (int y = Math.max(0, y0 - margin); y < Math.min(height, y1 + margin); y++) {
for (int x = Math.max(0, x0 - margin); x < Math.min(width, x1 + margin); x++) {
covered[y * width + x] = true;
if (x >= x0 && x < x1 && y >= y0 && y < y1 && (pixels.get(y * width + x) >>> 24) != 0) ink++;
}
}
if (Character.isLetterOrDigit(chr)) assertTrue(ink > 0, "no ink inside the measured rect of '" + chr + "'");
}
int stray = 0;
for (int i = 0; i < covered.length; i++) {
if (!covered[i] && (pixels.get(i) >>> 24) != 0) stray++;
}
assertEquals(0, stray, "ink outside every measured glyph rect");
}
@Test
void setFontDropsAtlasesAndTextures() {
assertTrue(provider.isGlyphAvailable('A'));
final Object atlasBefore = atlas('A');
assertNotEquals(0, provider.getTexture('A'));
provider.setFont(font.deriveFont(20.0f));
assertArrayEquals(new int[FontProviderCustom.ATLAS_COUNT], textures(), "font change kept a texture");
assertNull(atlas('A'), "font change kept an atlas");
assertTrue(provider.isGlyphAvailable('A'));
assertNotSame(atlasBefore, atlas('A'), "atlas not rebuilt for the new font");
assertTrue(GL11.glIsTexture(provider.getTexture('A')), "no texture for the new font");
provider.setFont(null);
assertFalse(provider.isGlyphAvailable('A'), "glyph available with no font set");
assertArrayEquals(new int[FontProviderCustom.ATLAS_COUNT], textures(), "clearing the font kept a texture");
}
@Test
void concurrentFirstTouchBuildsOneAtlas() throws Exception {
final CyclicBarrier start = new CyclicBarrier(THREADS);
final Object[] seen = new Object[THREADS];
final TestThreads.Worker[] workers = new TestThreads.Worker[THREADS];
for (int i = 0; i < THREADS; i++) {
final int slot = i;
workers[i] = TestThreads.start("font-metrics-race-" + i, () -> {
start.await();
assertTrue(provider.isGlyphAvailable('A'));
seen[slot] = atlas('A');
});
}
for (TestThreads.Worker worker : workers) worker.join();
assertNotNull(seen[0], "atlas was not published");
for (int i = 1; i < THREADS; i++) assertSame(seen[0], seen[i], "threads observed different atlases");
}
@Test
void availabilityMatchesFont() {
for (int i = 0; i < FontProviderCustom.ATLAS_SIZE; i++) {
assertEquals(font.canDisplay((char) i), provider.isGlyphAvailable((char) i), "availability differs from the font for slot " + i);
assertEquals(font.canDisplay((char) (SURROGATE + i)), provider.isGlyphAvailable((char) (SURROGATE + i)), "availability differs from the font for surrogate slot " + i);
}
}
@Test
void randomReplacementKeepsWidth() {
final float advance = provider.getXAdvance('A');
for (int i = 0; i < 200; i++) {
final char replacement = provider.getRandomReplacement('A');
assertTrue(replacement < FontProviderCustom.ATLAS_SIZE, "replacement left the atlas");
assertTrue(provider.isGlyphAvailable(replacement), "replacement has no glyph");
assertEquals(advance, provider.getXAdvance(replacement), 0.0f, "replacement has another width");
}
}
}