From 4b09131c7b883c21e98324dff760d9978cf74f54 Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 6 Feb 2026 20:32:21 -0500 Subject: [PATCH 1/4] Add /kamkeel testfont GUI with scalable OpenSans renderer --- .../kamkeel/npcs/command/CommandKamkeel.java | 2 +- .../kamkeel/npcs/command/TestCommand.java | 27 ++ .../kamkeel/npcs/network/PacketHandler.java | 2 + .../npcs/network/enums/EnumDataPacket.java | 1 + .../packets/data/gui/GuiFontTestPacket.java | 41 ++ .../noppes/npcs/client/gui/GuiFontTest.java | 66 ++++ .../client/gui/font/ScalableFontRenderer.java | 351 ++++++++++++++++++ 7 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 src/main/java/kamkeel/npcs/command/TestCommand.java create mode 100644 src/main/java/kamkeel/npcs/network/packets/data/gui/GuiFontTestPacket.java create mode 100644 src/main/java/noppes/npcs/client/gui/GuiFontTest.java create mode 100644 src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java diff --git a/src/main/java/kamkeel/npcs/command/CommandKamkeel.java b/src/main/java/kamkeel/npcs/command/CommandKamkeel.java index 91356c825..57ccf9ac5 100644 --- a/src/main/java/kamkeel/npcs/command/CommandKamkeel.java +++ b/src/main/java/kamkeel/npcs/command/CommandKamkeel.java @@ -47,6 +47,7 @@ public CommandKamkeel() { registerCommand(new EffectCommand()); registerCommand(new AbilityCommand()); registerCommand(new MoneyCommand()); + registerCommand(new TestCommand()); if (ConfigMarket.AuctionEnabled) registerCommand(new AuctionCommand()); if (ConfigMain.AttributesEnabled) @@ -181,4 +182,3 @@ public static boolean canSendCommand(ICommandSender sender, CommandKamkeelBase c } } - diff --git a/src/main/java/kamkeel/npcs/command/TestCommand.java b/src/main/java/kamkeel/npcs/command/TestCommand.java new file mode 100644 index 000000000..6811c02db --- /dev/null +++ b/src/main/java/kamkeel/npcs/command/TestCommand.java @@ -0,0 +1,27 @@ +package kamkeel.npcs.command; + +import kamkeel.npcs.network.packets.data.gui.GuiFontTestPacket; +import net.minecraft.command.CommandException; +import net.minecraft.command.ICommandSender; +import net.minecraft.entity.player.EntityPlayerMP; + +public class TestCommand extends CommandKamkeelBase { + + @Override + public String getCommandName() { + return "testfont"; + } + + @Override + public String getDescription() { + return "Open the client-side font test GUI"; + } + + @Override + public void processCommand(ICommandSender sender, String[] args) throws CommandException { + if (!(sender instanceof EntityPlayerMP)) { + throw new CommandException("This command can only be used by a player"); + } + GuiFontTestPacket.open((EntityPlayerMP) sender); + } +} diff --git a/src/main/java/kamkeel/npcs/network/PacketHandler.java b/src/main/java/kamkeel/npcs/network/PacketHandler.java index bcfd18abd..0084b0408 100644 --- a/src/main/java/kamkeel/npcs/network/PacketHandler.java +++ b/src/main/java/kamkeel/npcs/network/PacketHandler.java @@ -31,6 +31,7 @@ import kamkeel.npcs.network.packets.data.telegraph.TelegraphSpawnPacket; import kamkeel.npcs.network.packets.data.gui.GuiClosePacket; import kamkeel.npcs.network.packets.data.gui.GuiErrorPacket; +import kamkeel.npcs.network.packets.data.gui.GuiFontTestPacket; import kamkeel.npcs.network.packets.data.gui.GuiOpenBookPacket; import kamkeel.npcs.network.packets.data.gui.GuiOpenPacket; import kamkeel.npcs.network.packets.data.gui.GuiRedstonePacket; @@ -559,6 +560,7 @@ public void registerDataPackets() { DATA_PACKET.registerPacket(new GuiWaypointPacket()); DATA_PACKET.registerPacket(new IsGuiOpenPacket()); DATA_PACKET.registerPacket(new GuiOpenBookPacket()); + DATA_PACKET.registerPacket(new GuiFontTestPacket()); // Data | NPC Packets DATA_PACKET.registerPacket(new DeleteNpcPacket()); diff --git a/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java b/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java index 18321d0a4..51fbb03be 100644 --- a/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java +++ b/src/main/java/kamkeel/npcs/network/enums/EnumDataPacket.java @@ -42,6 +42,7 @@ public enum EnumDataPacket { GUI_ERROR, GUI_CLOSE, ISGUIOPEN, + GUI_FONT_TEST, // Visual SCRIPTED_PARTICLE, diff --git a/src/main/java/kamkeel/npcs/network/packets/data/gui/GuiFontTestPacket.java b/src/main/java/kamkeel/npcs/network/packets/data/gui/GuiFontTestPacket.java new file mode 100644 index 000000000..ac0a7dd57 --- /dev/null +++ b/src/main/java/kamkeel/npcs/network/packets/data/gui/GuiFontTestPacket.java @@ -0,0 +1,41 @@ +package kamkeel.npcs.network.packets.data.gui; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import io.netty.buffer.ByteBuf; +import kamkeel.npcs.network.AbstractPacket; +import kamkeel.npcs.network.PacketChannel; +import kamkeel.npcs.network.PacketHandler; +import kamkeel.npcs.network.enums.EnumDataPacket; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; +import noppes.npcs.client.gui.GuiFontTest; + +import java.io.IOException; + +public final class GuiFontTestPacket extends AbstractPacket { + + public static void open(EntityPlayerMP player) { + PacketHandler.Instance.sendToPlayer(new GuiFontTestPacket(), player); + } + + @Override + public Enum getType() { + return EnumDataPacket.GUI_FONT_TEST; + } + + @Override + public PacketChannel getChannel() { + return PacketHandler.DATA_PACKET; + } + + @Override + public void sendData(ByteBuf out) throws IOException { + } + + @SideOnly(Side.CLIENT) + @Override + public void receiveData(ByteBuf in, EntityPlayer player) throws IOException { + GuiFontTest.open(); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/GuiFontTest.java b/src/main/java/noppes/npcs/client/gui/GuiFontTest.java new file mode 100644 index 000000000..b1e305b66 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/GuiFontTest.java @@ -0,0 +1,66 @@ +package noppes.npcs.client.gui; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.gui.ScaledResolution; +import noppes.npcs.client.gui.font.ScalableFontRenderer; + +@SideOnly(Side.CLIENT) +public class GuiFontTest extends GuiScreen { + private static final int[] SIZES = new int[]{8, 12, 16, 24, 32, 48, 64}; + private static final String FONT_PATH = "assets/customnpcs/OpenSans.ttf"; + + private ScalableFontRenderer font; + + @Override + public void initGui() { + if (font == null) { + font = ScalableFontRenderer.create(FONT_PATH); + } + } + + @Override + public void drawScreen(int mouseX, int mouseY, float partialTicks) { + drawDefaultBackground(); + + ScaledResolution sr = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); + int left = 30; + int right = width - 30; + int baseline = 34; + + font.drawString("CustomNPC+ Font Test", left, baseline, 24, 0xFFFFFFFF); + baseline += 16; + + for (int size : SIZES) { + drawHorizontalLine(left - 8, right, baseline, 0x55FFFFFF); + String text = "OpenSans " + size + "px Sphinx of black quartz, judge my vow 0123456789"; + font.drawString(text, left, baseline, size, 0xFFE6F2FF); + baseline += Math.max(size + 8, 18); + } + + baseline += 8; + drawHorizontalLine(left - 8, right, baseline, 0x66AAFFAA); + baseline += 16; + + font.drawString("Renderer: " + font.getRendererPath(), left, baseline, 12, 0xFFB5FFC9); + baseline += 16; + font.drawString("Font resource: " + font.getSourcePath(), left, baseline, 12, 0xFFB5FFC9); + baseline += 16; + font.drawString("Atlas: " + font.getAtlasWidth() + "x" + font.getAtlasHeight(), left, baseline, 12, 0xFFB5FFC9); + baseline += 16; + font.drawString("GUI scale (settings): " + mc.gameSettings.guiScale + " | active scale factor: " + sr.getScaleFactor(), left, baseline, 12, 0xFFB5FFC9); + + super.drawScreen(mouseX, mouseY, partialTicks); + } + + @Override + public boolean doesGuiPauseGame() { + return false; + } + + public static void open() { + Minecraft.getMinecraft().displayGuiScreen(new GuiFontTest()); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java b/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java new file mode 100644 index 000000000..57f9ada33 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java @@ -0,0 +1,351 @@ +package noppes.npcs.client.gui.font; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.Tessellator; +import net.minecraft.util.ResourceLocation; +import org.lwjgl.BufferUtils; +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL20; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; + +@SideOnly(Side.CLIENT) +public class ScalableFontRenderer { + private static final String VERT = "#version 120\n" + + "varying vec2 vUv;\n" + + "void main(){\n" + + "gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;\n" + + "vUv = gl_MultiTexCoord0.xy;\n" + + "}"; + + private static final String FRAG = "#version 120\n" + + "uniform sampler2D tex;\n" + + "uniform float edge;\n" + + "uniform vec4 color;\n" + + "varying vec2 vUv;\n" + + "void main(){\n" + + "float dist = texture2D(tex, vUv).r;\n" + + "float alpha = smoothstep(0.5-edge, 0.5+edge, dist);\n" + + "gl_FragColor = vec4(color.rgb, color.a * alpha);\n" + + "}"; + + private static final int FIRST_CHAR = 32; + private static final int LAST_CHAR = 126; + private static final int SPREAD = 8; + + private final ResourceLocation fontResource; + private final String sourcePath; + private final float basePixelSize; + private final GlyphData[] sdfGlyphs; + private final int sdfTex; + private final int atlasW; + private final int atlasH; + private final int shaderProgram; + private final int edgeLoc; + private final int colorLoc; + private final int fallbackAtlasW; + private final int fallbackAtlasH; + private final Map fallbackAtlases = new HashMap(); + + public static ScalableFontRenderer create(String sourcePath) { + return new ScalableFontRenderer(sourcePath, new ResourceLocation("customnpcs", "OpenSans.ttf")); + } + + private ScalableFontRenderer(String sourcePath, ResourceLocation fontResource) { + this.sourcePath = sourcePath; + this.fontResource = fontResource; + this.basePixelSize = 64f; + + Font font = loadFont(basePixelSize); + Atlas sdf = bakeAtlas(font, true); + this.sdfGlyphs = sdf.glyphs; + this.sdfTex = uploadAtlas(sdf.image); + this.atlasW = sdf.width; + this.atlasH = sdf.height; + + int shader = compileProgram(VERT, FRAG); + this.shaderProgram = shader; + this.edgeLoc = shader == 0 ? -1 : GL20.glGetUniformLocation(shader, "edge"); + this.colorLoc = shader == 0 ? -1 : GL20.glGetUniformLocation(shader, "color"); + + Atlas fallbackBase = bakeAtlas(font, false); + this.fallbackAtlasW = fallbackBase.width; + this.fallbackAtlasH = fallbackBase.height; + this.fallbackAtlases.put((int) basePixelSize, fallbackBase.withTexture(uploadAtlas(fallbackBase.image))); + } + + public String getSourcePath() { return sourcePath; } + public String getRendererPath() { return shaderProgram != 0 ? "SDF atlas + GL20 shader" : "Fallback per-size raster atlas"; } + public int getAtlasWidth() { return shaderProgram != 0 ? atlasW : fallbackAtlasW; } + public int getAtlasHeight() { return shaderProgram != 0 ? atlasH : fallbackAtlasH; } + + public int getStringWidth(String text, int size) { + GlyphData[] glyphs = glyphsForSize(size); + float scale = shaderProgram != 0 ? (float) size / basePixelSize : 1f; + float width = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c < FIRST_CHAR || c > LAST_CHAR) { width += size * 0.5f; continue; } + width += glyphs[c - FIRST_CHAR].advance * scale; + } + return Math.round(width); + } + + public int drawString(String text, float x, float baselineY, int size, int color) { + if (text == null || text.isEmpty()) return 0; + + Atlas activeAtlas = null; + GlyphData[] glyphs; + float scale; + if (shaderProgram != 0) { + glyphs = sdfGlyphs; + scale = (float) size / basePixelSize; + } else { + activeAtlas = getFallback(size); + glyphs = activeAtlas.glyphs; + scale = 1f; + } + + GL11.glPushAttrib(GL11.GL_ENABLE_BIT | GL11.GL_COLOR_BUFFER_BIT | GL11.GL_TEXTURE_BIT | GL11.GL_DEPTH_BUFFER_BIT | GL11.GL_CURRENT_BIT); + GL11.glPushMatrix(); + GL11.glDisable(GL11.GL_LIGHTING); + GL11.glDisable(GL11.GL_DEPTH_TEST); + GL11.glEnable(GL11.GL_TEXTURE_2D); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + + int texture = shaderProgram != 0 ? sdfTex : activeAtlas.texture; + GL11.glBindTexture(GL11.GL_TEXTURE_2D, texture); + + float r = ((color >> 16) & 255) / 255f; + float g = ((color >> 8) & 255) / 255f; + float b = (color & 255) / 255f; + float a = ((color >> 24) & 255) / 255f; + + if (shaderProgram != 0) { + GL20.glUseProgram(shaderProgram); + GL20.glUniform1f(edgeLoc, Math.max(0.03f, 0.2f / scale)); + GL20.glUniform4f(colorLoc, r, g, b, a <= 0 ? 1f : a); + } else { + GL11.glColor4f(r, g, b, a <= 0 ? 1f : a); + } + + float cursorX = x; + Tessellator t = Tessellator.instance; + t.startDrawingQuads(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c < FIRST_CHAR || c > LAST_CHAR) { cursorX += size * 0.5f; continue; } + GlyphData glyph = glyphs[c - FIRST_CHAR]; + float x0 = cursorX + glyph.bearingX * scale; + float y0 = baselineY - glyph.bearingY * scale; + float x1 = x0 + glyph.width * scale; + float y1 = y0 + glyph.height * scale; + t.addVertexWithUV(x0, y1, 0, glyph.u0, glyph.v1); + t.addVertexWithUV(x1, y1, 0, glyph.u1, glyph.v1); + t.addVertexWithUV(x1, y0, 0, glyph.u1, glyph.v0); + t.addVertexWithUV(x0, y0, 0, glyph.u0, glyph.v0); + cursorX += glyph.advance * scale; + } + t.draw(); + if (shaderProgram != 0) GL20.glUseProgram(0); + GL11.glPopMatrix(); + GL11.glPopAttrib(); + return Math.round(cursorX - x); + } + + private GlyphData[] glyphsForSize(int size) { + return shaderProgram != 0 ? sdfGlyphs : getFallback(size).glyphs; + } + + private Atlas getFallback(int size) { + Atlas cached = fallbackAtlases.get(size); + if (cached != null) return cached; + Atlas atlas = bakeAtlas(loadFont(size), false); + Atlas withTex = atlas.withTexture(uploadAtlas(atlas.image)); + fallbackAtlases.put(size, withTex); + return withTex; + } + + private Font loadFont(float size) { + try { + InputStream stream = Minecraft.getMinecraft().getResourceManager().getResource(fontResource).getInputStream(); + try { + return Font.createFont(Font.TRUETYPE_FONT, stream).deriveFont(size); + } finally { + stream.close(); + } + } catch (Exception e) { + throw new IllegalStateException("Unable to load font " + fontResource, e); + } + } + + private Atlas bakeAtlas(Font font, boolean sdf) { + BufferedImage probe = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = probe.createGraphics(); + g.setFont(font); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + FontMetrics metrics = g.getFontMetrics(); + + int pad = sdf ? SPREAD + 2 : 2; + int rowH = metrics.getHeight() + pad * 2; + int x = 0, y = 0, width = 1024; + GlyphData[] glyphs = new GlyphData[LAST_CHAR - FIRST_CHAR + 1]; + for (int c = FIRST_CHAR; c <= LAST_CHAR; c++) { + int gw = Math.max(1, metrics.charWidth((char) c)); + int cellW = gw + pad * 2; + if (x + cellW >= width) { x = 0; y += rowH; } + glyphs[c - FIRST_CHAR] = new GlyphData(x + pad, y + pad, gw, metrics.getHeight(), metrics.getAscent(), metrics.charWidth((char) c)); + x += cellW; + } + g.dispose(); + + int height = y + rowH; + BufferedImage atlas = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = atlas.createGraphics(); + graphics.setFont(font); + graphics.setColor(Color.WHITE); + graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + FontMetrics fm = graphics.getFontMetrics(); + for (int c = FIRST_CHAR; c <= LAST_CHAR; c++) { + GlyphData gd = glyphs[c - FIRST_CHAR]; + graphics.drawString(String.valueOf((char) c), gd.x, gd.y + fm.getAscent()); + gd.width = Math.max(1, fm.charWidth((char) c)); + gd.height = fm.getHeight(); + gd.bearingY = fm.getAscent(); + gd.advance = fm.charWidth((char) c); + } + graphics.dispose(); + + BufferedImage finalAtlas = sdf ? makeSdf(atlas) : alphaOnly(atlas); + for (GlyphData gd : glyphs) { + int w = finalAtlas.getWidth(), h = finalAtlas.getHeight(); + gd.u0 = (float) gd.x / w; + gd.v0 = (float) gd.y / h; + gd.u1 = (float) (gd.x + gd.width) / w; + gd.v1 = (float) (gd.y + gd.height) / h; + } + return new Atlas(finalAtlas, finalAtlas.getWidth(), finalAtlas.getHeight(), 0, glyphs); + } + + private BufferedImage alphaOnly(BufferedImage source) { + BufferedImage out = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < source.getHeight(); y++) { + for (int x = 0; x < source.getWidth(); x++) { + int a = (source.getRGB(x, y) >>> 24) & 255; + out.setRGB(x, y, (a << 24) | 0x00FFFFFF); + } + } + return out; + } + + private BufferedImage makeSdf(BufferedImage source) { + int w = source.getWidth(), h = source.getHeight(); + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + boolean[][] inside = new boolean[w][h]; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + inside[x][y] = ((source.getRGB(x, y) >>> 24) & 255) > 32; + } + } + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + float d = nearestDistance(inside, w, h, x, y, inside[x][y]); + float signed = inside[x][y] ? d : -d; + float normalized = Math.max(0f, Math.min(1f, 0.5f + (signed / (SPREAD * 2f)))); + int v = (int) (normalized * 255f); + out.setRGB(x, y, (v << 24) | (v << 16) | (v << 8) | v); + } + } + return out; + } + + private float nearestDistance(boolean[][] inside, int w, int h, int px, int py, boolean currentInside) { + float best = SPREAD; + int minX = Math.max(0, px - SPREAD), maxX = Math.min(w - 1, px + SPREAD); + int minY = Math.max(0, py - SPREAD), maxY = Math.min(h - 1, py + SPREAD); + for (int y = minY; y <= maxY; y++) { + for (int x = minX; x <= maxX; x++) { + if (inside[x][y] != currentInside) { + float dx = x - px, dy = y - py; + float dist = (float) Math.sqrt(dx * dx + dy * dy); + if (dist < best) best = dist; + } + } + } + return best; + } + + private int uploadAtlas(BufferedImage image) { + int w = image.getWidth(), h = image.getHeight(); + int[] pixels = new int[w * h]; + image.getRGB(0, 0, w, h, pixels, 0, w); + ByteBuffer data = BufferUtils.createByteBuffer(w * h * 4); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int argb = pixels[y * w + x]; + data.put((byte) ((argb >> 16) & 255)); + data.put((byte) ((argb >> 8) & 255)); + data.put((byte) (argb & 255)); + data.put((byte) ((argb >> 24) & 255)); + } + } + data.flip(); + + int tex = GL11.glGenTextures(); + GL11.glBindTexture(GL11.GL_TEXTURE_2D, tex); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, 0x812F); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, 0x812F); + GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA8, w, h, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, data); + return tex; + } + + private int compileProgram(String vsSrc, String fsSrc) { + try { + int vs = GL20.glCreateShader(GL20.GL_VERTEX_SHADER); + GL20.glShaderSource(vs, vsSrc); + GL20.glCompileShader(vs); + if (GL20.glGetShaderi(vs, GL20.GL_COMPILE_STATUS) == 0) return 0; + int fs = GL20.glCreateShader(GL20.GL_FRAGMENT_SHADER); + GL20.glShaderSource(fs, fsSrc); + GL20.glCompileShader(fs); + if (GL20.glGetShaderi(fs, GL20.GL_COMPILE_STATUS) == 0) return 0; + int program = GL20.glCreateProgram(); + GL20.glAttachShader(program, vs); + GL20.glAttachShader(program, fs); + GL20.glLinkProgram(program); + if (GL20.glGetProgrami(program, GL20.GL_LINK_STATUS) == 0) return 0; + return program; + } catch (Throwable ignored) { + return 0; + } + } + + private static class GlyphData { + int x, y, width, height, bearingX, bearingY, advance; + float u0, v0, u1, v1; + GlyphData(int x, int y, int width, int height, int bearingY, int advance) { + this.x = x; this.y = y; this.width = width; this.height = height; + this.bearingX = 0; this.bearingY = bearingY; this.advance = advance; + } + } + + private static class Atlas { + final BufferedImage image; + final int width, height, texture; + final GlyphData[] glyphs; + Atlas(BufferedImage image, int width, int height, int texture, GlyphData[] glyphs) { + this.image = image; this.width = width; this.height = height; this.texture = texture; this.glyphs = glyphs; + } + Atlas withTexture(int texture) { return new Atlas(image, width, height, texture, glyphs); } + } +} From 2db136a7d224b6f1cadbb81c24672e241f3f3419 Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 6 Feb 2026 20:45:13 -0500 Subject: [PATCH 2/4] Switch font test renderer to per-size baked atlas cache --- .../noppes/npcs/client/gui/GuiFontTest.java | 14 +- .../client/gui/font/ScalableFontRenderer.java | 399 ++++++++---------- 2 files changed, 183 insertions(+), 230 deletions(-) diff --git a/src/main/java/noppes/npcs/client/gui/GuiFontTest.java b/src/main/java/noppes/npcs/client/gui/GuiFontTest.java index b1e305b66..6a7eaf7e1 100644 --- a/src/main/java/noppes/npcs/client/gui/GuiFontTest.java +++ b/src/main/java/noppes/npcs/client/gui/GuiFontTest.java @@ -26,18 +26,18 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { drawDefaultBackground(); ScaledResolution sr = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); - int left = 30; - int right = width - 30; - int baseline = 34; + int left = Math.round(30f); + int right = Math.round(width - 30f); + int baseline = Math.round(34f); font.drawString("CustomNPC+ Font Test", left, baseline, 24, 0xFFFFFFFF); - baseline += 16; + baseline += font.getLineHeight(24) + 6; for (int size : SIZES) { drawHorizontalLine(left - 8, right, baseline, 0x55FFFFFF); String text = "OpenSans " + size + "px Sphinx of black quartz, judge my vow 0123456789"; font.drawString(text, left, baseline, size, 0xFFE6F2FF); - baseline += Math.max(size + 8, 18); + baseline += font.getLineHeight(size) + 8; } baseline += 8; @@ -48,7 +48,9 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { baseline += 16; font.drawString("Font resource: " + font.getSourcePath(), left, baseline, 12, 0xFFB5FFC9); baseline += 16; - font.drawString("Atlas: " + font.getAtlasWidth() + "x" + font.getAtlasHeight(), left, baseline, 12, 0xFFB5FFC9); + font.drawString("Baked atlases: " + font.getCachedAtlasSummary(), left, baseline, 12, 0xFFB5FFC9); + baseline += 16; + font.drawString("Current line atlas: " + font.getAtlasSummaryForSize(12), left, baseline, 12, 0xFFB5FFC9); baseline += 16; font.drawString("GUI scale (settings): " + mc.gameSettings.guiScale + " | active scale factor: " + sr.getScaleFactor(), left, baseline, 12, 0xFFB5FFC9); diff --git a/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java b/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java index 57f9ada33..baa09afe5 100644 --- a/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java +++ b/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java @@ -7,52 +7,23 @@ import net.minecraft.util.ResourceLocation; import org.lwjgl.BufferUtils; import org.lwjgl.opengl.GL11; -import org.lwjgl.opengl.GL20; +import org.lwjgl.opengl.GL12; import java.awt.*; import java.awt.image.BufferedImage; import java.io.InputStream; import java.nio.ByteBuffer; -import java.util.HashMap; import java.util.Map; +import java.util.TreeMap; @SideOnly(Side.CLIENT) public class ScalableFontRenderer { - private static final String VERT = "#version 120\n" + - "varying vec2 vUv;\n" + - "void main(){\n" + - "gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;\n" + - "vUv = gl_MultiTexCoord0.xy;\n" + - "}"; - - private static final String FRAG = "#version 120\n" + - "uniform sampler2D tex;\n" + - "uniform float edge;\n" + - "uniform vec4 color;\n" + - "varying vec2 vUv;\n" + - "void main(){\n" + - "float dist = texture2D(tex, vUv).r;\n" + - "float alpha = smoothstep(0.5-edge, 0.5+edge, dist);\n" + - "gl_FragColor = vec4(color.rgb, color.a * alpha);\n" + - "}"; - private static final int FIRST_CHAR = 32; private static final int LAST_CHAR = 126; - private static final int SPREAD = 8; private final ResourceLocation fontResource; private final String sourcePath; - private final float basePixelSize; - private final GlyphData[] sdfGlyphs; - private final int sdfTex; - private final int atlasW; - private final int atlasH; - private final int shaderProgram; - private final int edgeLoc; - private final int colorLoc; - private final int fallbackAtlasW; - private final int fallbackAtlasH; - private final Map fallbackAtlases = new HashMap(); + private final Map sizeToAtlas = new TreeMap(); public static ScalableFontRenderer create(String sourcePath) { return new ScalableFontRenderer(sourcePath, new ResourceLocation("customnpcs", "OpenSans.ttf")); @@ -61,58 +32,66 @@ public static ScalableFontRenderer create(String sourcePath) { private ScalableFontRenderer(String sourcePath, ResourceLocation fontResource) { this.sourcePath = sourcePath; this.fontResource = fontResource; - this.basePixelSize = 64f; - - Font font = loadFont(basePixelSize); - Atlas sdf = bakeAtlas(font, true); - this.sdfGlyphs = sdf.glyphs; - this.sdfTex = uploadAtlas(sdf.image); - this.atlasW = sdf.width; - this.atlasH = sdf.height; - - int shader = compileProgram(VERT, FRAG); - this.shaderProgram = shader; - this.edgeLoc = shader == 0 ? -1 : GL20.glGetUniformLocation(shader, "edge"); - this.colorLoc = shader == 0 ? -1 : GL20.glGetUniformLocation(shader, "color"); - - Atlas fallbackBase = bakeAtlas(font, false); - this.fallbackAtlasW = fallbackBase.width; - this.fallbackAtlasH = fallbackBase.height; - this.fallbackAtlases.put((int) basePixelSize, fallbackBase.withTexture(uploadAtlas(fallbackBase.image))); } - public String getSourcePath() { return sourcePath; } - public String getRendererPath() { return shaderProgram != 0 ? "SDF atlas + GL20 shader" : "Fallback per-size raster atlas"; } - public int getAtlasWidth() { return shaderProgram != 0 ? atlasW : fallbackAtlasW; } - public int getAtlasHeight() { return shaderProgram != 0 ? atlasH : fallbackAtlasH; } + public String getSourcePath() { + return sourcePath; + } + + public String getRendererPath() { + return "Per-size baked atlas (no scaling)"; + } + + public String getCachedAtlasSummary() { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : sizeToAtlas.entrySet()) { + if (sb.length() > 0) { + sb.append(" "); + } + BakedFontAtlas atlas = entry.getValue(); + sb.append(entry.getKey()).append("px=").append(atlas.width).append("x").append(atlas.height); + } + return sb.length() == 0 ? "none" : sb.toString(); + } + + public String getAtlasSummaryForSize(int size) { + BakedFontAtlas atlas = getAtlas(size); + return size + "px=" + atlas.width + "x" + atlas.height; + } + + public int getLineHeight(int size) { + return getAtlas(size).lineHeight; + } public int getStringWidth(String text, int size) { - GlyphData[] glyphs = glyphsForSize(size); - float scale = shaderProgram != 0 ? (float) size / basePixelSize : 1f; - float width = 0; + if (text == null || text.isEmpty()) { + return 0; + } + + BakedFontAtlas atlas = getAtlas(size); + int width = 0; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); - if (c < FIRST_CHAR || c > LAST_CHAR) { width += size * 0.5f; continue; } - width += glyphs[c - FIRST_CHAR].advance * scale; + if (c < FIRST_CHAR || c > LAST_CHAR) { + width += size / 2; + continue; + } + width += atlas.glyphs[c - FIRST_CHAR].advance; } - return Math.round(width); + return width; } public int drawString(String text, float x, float baselineY, int size, int color) { - if (text == null || text.isEmpty()) return 0; - - Atlas activeAtlas = null; - GlyphData[] glyphs; - float scale; - if (shaderProgram != 0) { - glyphs = sdfGlyphs; - scale = (float) size / basePixelSize; - } else { - activeAtlas = getFallback(size); - glyphs = activeAtlas.glyphs; - scale = 1f; + if (text == null || text.isEmpty()) { + return 0; } + BakedFontAtlas atlas = getAtlas(size); + float drawX = Math.round(x); + float drawBaseline = Math.round(baselineY); + float cursorX = drawX; + float topY = drawBaseline - atlas.ascent; + GL11.glPushAttrib(GL11.GL_ENABLE_BIT | GL11.GL_COLOR_BUFFER_BIT | GL11.GL_TEXTURE_BIT | GL11.GL_DEPTH_BUFFER_BIT | GL11.GL_CURRENT_BIT); GL11.glPushMatrix(); GL11.glDisable(GL11.GL_LIGHTING); @@ -121,118 +100,125 @@ public int drawString(String text, float x, float baselineY, int size, int color GL11.glEnable(GL11.GL_BLEND); GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); - int texture = shaderProgram != 0 ? sdfTex : activeAtlas.texture; - GL11.glBindTexture(GL11.GL_TEXTURE_2D, texture); + GL11.glBindTexture(GL11.GL_TEXTURE_2D, atlas.textureId); + float a = ((color >> 24) & 255) / 255f; float r = ((color >> 16) & 255) / 255f; float g = ((color >> 8) & 255) / 255f; float b = (color & 255) / 255f; - float a = ((color >> 24) & 255) / 255f; + GL11.glColor4f(r, g, b, a <= 0f ? 1f : a); - if (shaderProgram != 0) { - GL20.glUseProgram(shaderProgram); - GL20.glUniform1f(edgeLoc, Math.max(0.03f, 0.2f / scale)); - GL20.glUniform4f(colorLoc, r, g, b, a <= 0 ? 1f : a); - } else { - GL11.glColor4f(r, g, b, a <= 0 ? 1f : a); - } - - float cursorX = x; Tessellator t = Tessellator.instance; t.startDrawingQuads(); + for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); - if (c < FIRST_CHAR || c > LAST_CHAR) { cursorX += size * 0.5f; continue; } - GlyphData glyph = glyphs[c - FIRST_CHAR]; - float x0 = cursorX + glyph.bearingX * scale; - float y0 = baselineY - glyph.bearingY * scale; - float x1 = x0 + glyph.width * scale; - float y1 = y0 + glyph.height * scale; + if (c < FIRST_CHAR || c > LAST_CHAR) { + cursorX += size / 2f; + continue; + } + + Glyph glyph = atlas.glyphs[c - FIRST_CHAR]; + float x0 = Math.round(cursorX); + float y0 = topY; + float x1 = x0 + glyph.width; + float y1 = y0 + atlas.lineHeight; + t.addVertexWithUV(x0, y1, 0, glyph.u0, glyph.v1); t.addVertexWithUV(x1, y1, 0, glyph.u1, glyph.v1); t.addVertexWithUV(x1, y0, 0, glyph.u1, glyph.v0); t.addVertexWithUV(x0, y0, 0, glyph.u0, glyph.v0); - cursorX += glyph.advance * scale; + + cursorX += glyph.advance; } + t.draw(); - if (shaderProgram != 0) GL20.glUseProgram(0); + GL11.glPopMatrix(); GL11.glPopAttrib(); - return Math.round(cursorX - x); - } - private GlyphData[] glyphsForSize(int size) { - return shaderProgram != 0 ? sdfGlyphs : getFallback(size).glyphs; + return Math.round(cursorX - drawX); } - private Atlas getFallback(int size) { - Atlas cached = fallbackAtlases.get(size); - if (cached != null) return cached; - Atlas atlas = bakeAtlas(loadFont(size), false); - Atlas withTex = atlas.withTexture(uploadAtlas(atlas.image)); - fallbackAtlases.put(size, withTex); - return withTex; + private BakedFontAtlas getAtlas(int size) { + BakedFontAtlas cached = sizeToAtlas.get(size); + if (cached != null) { + return cached; + } + + BakedFontAtlas baked = bakeAtlas(size); + sizeToAtlas.put(size, baked); + return baked; } - private Font loadFont(float size) { - try { - InputStream stream = Minecraft.getMinecraft().getResourceManager().getResource(fontResource).getInputStream(); - try { - return Font.createFont(Font.TRUETYPE_FONT, stream).deriveFont(size); - } finally { - stream.close(); + private BakedFontAtlas bakeAtlas(int size) { + Font font = loadFont(size); + + BufferedImage probeImage = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); + Graphics2D probe = probeImage.createGraphics(); + probe.setFont(font); + probe.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + FontMetrics fm = probe.getFontMetrics(); + + int ascent = fm.getAscent(); + int descent = fm.getDescent(); + int lineHeight = fm.getHeight(); + int pad = 2; + int atlasWidth = 1024; + int rowHeight = lineHeight + pad * 2; + int x = 0; + int y = 0; + + Glyph[] glyphs = new Glyph[LAST_CHAR - FIRST_CHAR + 1]; + for (int c = FIRST_CHAR; c <= LAST_CHAR; c++) { + int glyphWidth = Math.max(1, fm.charWidth((char) c)); + int cellWidth = glyphWidth + pad * 2; + if (x + cellWidth >= atlasWidth) { + x = 0; + y += rowHeight; } - } catch (Exception e) { - throw new IllegalStateException("Unable to load font " + fontResource, e); + + glyphs[c - FIRST_CHAR] = new Glyph(x + pad, y + pad, glyphWidth, Math.max(1, glyphWidth)); + x += cellWidth; } - } + probe.dispose(); - private Atlas bakeAtlas(Font font, boolean sdf) { - BufferedImage probe = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); - Graphics2D g = probe.createGraphics(); + int atlasHeight = y + rowHeight; + BufferedImage atlas = new BufferedImage(atlasWidth, atlasHeight, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = atlas.createGraphics(); g.setFont(font); + g.setColor(Color.WHITE); g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); - FontMetrics metrics = g.getFontMetrics(); - int pad = sdf ? SPREAD + 2 : 2; - int rowH = metrics.getHeight() + pad * 2; - int x = 0, y = 0, width = 1024; - GlyphData[] glyphs = new GlyphData[LAST_CHAR - FIRST_CHAR + 1]; for (int c = FIRST_CHAR; c <= LAST_CHAR; c++) { - int gw = Math.max(1, metrics.charWidth((char) c)); - int cellW = gw + pad * 2; - if (x + cellW >= width) { x = 0; y += rowH; } - glyphs[c - FIRST_CHAR] = new GlyphData(x + pad, y + pad, gw, metrics.getHeight(), metrics.getAscent(), metrics.charWidth((char) c)); - x += cellW; + Glyph glyph = glyphs[c - FIRST_CHAR]; + g.drawString(String.valueOf((char) c), glyph.x, glyph.y + ascent); } g.dispose(); - int height = y + rowH; - BufferedImage atlas = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); - Graphics2D graphics = atlas.createGraphics(); - graphics.setFont(font); - graphics.setColor(Color.WHITE); - graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); - FontMetrics fm = graphics.getFontMetrics(); - for (int c = FIRST_CHAR; c <= LAST_CHAR; c++) { - GlyphData gd = glyphs[c - FIRST_CHAR]; - graphics.drawString(String.valueOf((char) c), gd.x, gd.y + fm.getAscent()); - gd.width = Math.max(1, fm.charWidth((char) c)); - gd.height = fm.getHeight(); - gd.bearingY = fm.getAscent(); - gd.advance = fm.charWidth((char) c); + BufferedImage alphaAtlas = alphaOnly(atlas); + for (Glyph glyph : glyphs) { + glyph.u0 = (float) glyph.x / alphaAtlas.getWidth(); + glyph.v0 = (float) glyph.y / alphaAtlas.getHeight(); + glyph.u1 = (float) (glyph.x + glyph.width) / alphaAtlas.getWidth(); + glyph.v1 = (float) (glyph.y + lineHeight) / alphaAtlas.getHeight(); } - graphics.dispose(); - - BufferedImage finalAtlas = sdf ? makeSdf(atlas) : alphaOnly(atlas); - for (GlyphData gd : glyphs) { - int w = finalAtlas.getWidth(), h = finalAtlas.getHeight(); - gd.u0 = (float) gd.x / w; - gd.v0 = (float) gd.y / h; - gd.u1 = (float) (gd.x + gd.width) / w; - gd.v1 = (float) (gd.y + gd.height) / h; + + int textureId = uploadAtlas(alphaAtlas); + return new BakedFontAtlas(textureId, alphaAtlas.getWidth(), alphaAtlas.getHeight(), lineHeight, ascent, descent, glyphs); + } + + private Font loadFont(float size) { + try { + InputStream stream = Minecraft.getMinecraft().getResourceManager().getResource(fontResource).getInputStream(); + try { + return Font.createFont(Font.TRUETYPE_FONT, stream).deriveFont(size); + } finally { + stream.close(); + } + } catch (Exception e) { + throw new IllegalStateException("Unable to load font " + fontResource, e); } - return new Atlas(finalAtlas, finalAtlas.getWidth(), finalAtlas.getHeight(), 0, glyphs); } private BufferedImage alphaOnly(BufferedImage source) { @@ -246,47 +232,12 @@ private BufferedImage alphaOnly(BufferedImage source) { return out; } - private BufferedImage makeSdf(BufferedImage source) { - int w = source.getWidth(), h = source.getHeight(); - BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); - boolean[][] inside = new boolean[w][h]; - for (int y = 0; y < h; y++) { - for (int x = 0; x < w; x++) { - inside[x][y] = ((source.getRGB(x, y) >>> 24) & 255) > 32; - } - } - for (int y = 0; y < h; y++) { - for (int x = 0; x < w; x++) { - float d = nearestDistance(inside, w, h, x, y, inside[x][y]); - float signed = inside[x][y] ? d : -d; - float normalized = Math.max(0f, Math.min(1f, 0.5f + (signed / (SPREAD * 2f)))); - int v = (int) (normalized * 255f); - out.setRGB(x, y, (v << 24) | (v << 16) | (v << 8) | v); - } - } - return out; - } - - private float nearestDistance(boolean[][] inside, int w, int h, int px, int py, boolean currentInside) { - float best = SPREAD; - int minX = Math.max(0, px - SPREAD), maxX = Math.min(w - 1, px + SPREAD); - int minY = Math.max(0, py - SPREAD), maxY = Math.min(h - 1, py + SPREAD); - for (int y = minY; y <= maxY; y++) { - for (int x = minX; x <= maxX; x++) { - if (inside[x][y] != currentInside) { - float dx = x - px, dy = y - py; - float dist = (float) Math.sqrt(dx * dx + dy * dy); - if (dist < best) best = dist; - } - } - } - return best; - } - private int uploadAtlas(BufferedImage image) { - int w = image.getWidth(), h = image.getHeight(); + int w = image.getWidth(); + int h = image.getHeight(); int[] pixels = new int[w * h]; image.getRGB(0, 0, w, h, pixels, 0, w); + ByteBuffer data = BufferUtils.createByteBuffer(w * h * 4); for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { @@ -303,49 +254,49 @@ private int uploadAtlas(BufferedImage image) { GL11.glBindTexture(GL11.GL_TEXTURE_2D, tex); GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR); GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR); - GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, 0x812F); - GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, 0x812F); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL12.GL_CLAMP_TO_EDGE); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL12.GL_CLAMP_TO_EDGE); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL12.GL_TEXTURE_BASE_LEVEL, 0); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL12.GL_TEXTURE_MAX_LEVEL, 0); GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA8, w, h, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, data); return tex; } - private int compileProgram(String vsSrc, String fsSrc) { - try { - int vs = GL20.glCreateShader(GL20.GL_VERTEX_SHADER); - GL20.glShaderSource(vs, vsSrc); - GL20.glCompileShader(vs); - if (GL20.glGetShaderi(vs, GL20.GL_COMPILE_STATUS) == 0) return 0; - int fs = GL20.glCreateShader(GL20.GL_FRAGMENT_SHADER); - GL20.glShaderSource(fs, fsSrc); - GL20.glCompileShader(fs); - if (GL20.glGetShaderi(fs, GL20.GL_COMPILE_STATUS) == 0) return 0; - int program = GL20.glCreateProgram(); - GL20.glAttachShader(program, vs); - GL20.glAttachShader(program, fs); - GL20.glLinkProgram(program); - if (GL20.glGetProgrami(program, GL20.GL_LINK_STATUS) == 0) return 0; - return program; - } catch (Throwable ignored) { - return 0; - } - } - - private static class GlyphData { - int x, y, width, height, bearingX, bearingY, advance; - float u0, v0, u1, v1; - GlyphData(int x, int y, int width, int height, int bearingY, int advance) { - this.x = x; this.y = y; this.width = width; this.height = height; - this.bearingX = 0; this.bearingY = bearingY; this.advance = advance; + private static class Glyph { + final int x; + final int y; + final int width; + final int advance; + float u0; + float v0; + float u1; + float v1; + + private Glyph(int x, int y, int width, int advance) { + this.x = x; + this.y = y; + this.width = width; + this.advance = advance; } } - private static class Atlas { - final BufferedImage image; - final int width, height, texture; - final GlyphData[] glyphs; - Atlas(BufferedImage image, int width, int height, int texture, GlyphData[] glyphs) { - this.image = image; this.width = width; this.height = height; this.texture = texture; this.glyphs = glyphs; + public static class BakedFontAtlas { + public final int textureId; + public final int width; + public final int height; + public final int lineHeight; + public final int ascent; + public final int descent; + private final Glyph[] glyphs; + + private BakedFontAtlas(int textureId, int width, int height, int lineHeight, int ascent, int descent, Glyph[] glyphs) { + this.textureId = textureId; + this.width = width; + this.height = height; + this.lineHeight = lineHeight; + this.ascent = ascent; + this.descent = descent; + this.glyphs = glyphs; } - Atlas withTexture(int texture) { return new Atlas(image, width, height, texture, glyphs); } } } From e579b025d2cbd2ab24c186091cd2212072f3f38c Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 6 Feb 2026 20:56:31 -0500 Subject: [PATCH 3/4] Fix glyph bounds and UV bleed in scalable font atlas --- .../client/gui/font/ScalableFontRenderer.java | 73 +++++++++++++------ 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java b/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java index baa09afe5..e39be173f 100644 --- a/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java +++ b/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java @@ -10,6 +10,8 @@ import org.lwjgl.opengl.GL12; import java.awt.*; +import java.awt.font.FontRenderContext; +import java.awt.font.GlyphVector; import java.awt.image.BufferedImage; import java.io.InputStream; import java.nio.ByteBuffer; @@ -90,7 +92,7 @@ public int drawString(String text, float x, float baselineY, int size, int color float drawX = Math.round(x); float drawBaseline = Math.round(baselineY); float cursorX = drawX; - float topY = drawBaseline - atlas.ascent; + int topY = Math.round(drawBaseline - atlas.ascent); GL11.glPushAttrib(GL11.GL_ENABLE_BIT | GL11.GL_COLOR_BUFFER_BIT | GL11.GL_TEXTURE_BIT | GL11.GL_DEPTH_BUFFER_BIT | GL11.GL_CURRENT_BIT); GL11.glPushMatrix(); @@ -119,10 +121,10 @@ public int drawString(String text, float x, float baselineY, int size, int color } Glyph glyph = atlas.glyphs[c - FIRST_CHAR]; - float x0 = Math.round(cursorX); - float y0 = topY; + float x0 = Math.round(cursorX + glyph.xOffset); + float y0 = Math.round(topY + atlas.ascent + glyph.yOffset); float x1 = x0 + glyph.width; - float y1 = y0 + atlas.lineHeight; + float y1 = y0 + glyph.height; t.addVertexWithUV(x0, y1, 0, glyph.u0, glyph.v1); t.addVertexWithUV(x1, y1, 0, glyph.u1, glyph.v1); @@ -158,50 +160,69 @@ private BakedFontAtlas bakeAtlas(int size) { Graphics2D probe = probeImage.createGraphics(); probe.setFont(font); probe.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + probe.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); FontMetrics fm = probe.getFontMetrics(); + FontRenderContext frc = probe.getFontRenderContext(); int ascent = fm.getAscent(); int descent = fm.getDescent(); int lineHeight = fm.getHeight(); - int pad = 2; + int pad = 6; int atlasWidth = 1024; - int rowHeight = lineHeight + pad * 2; int x = 0; int y = 0; + int currentRowMaxHeight = 0; Glyph[] glyphs = new Glyph[LAST_CHAR - FIRST_CHAR + 1]; for (int c = FIRST_CHAR; c <= LAST_CHAR; c++) { - int glyphWidth = Math.max(1, fm.charWidth((char) c)); + char ch = (char) c; + GlyphVector gv = font.createGlyphVector(frc, new char[]{ch}); + Rectangle bounds = gv.getPixelBounds(frc, 0, 0); + + int glyphWidth = Math.max(1, bounds.width); + int glyphHeight = Math.max(1, bounds.height); + int xOffset = bounds.x; + int yOffset = bounds.y; + int advance = Math.max(1, Math.round(gv.getGlyphMetrics(0).getAdvance())); + int cellWidth = glyphWidth + pad * 2; if (x + cellWidth >= atlasWidth) { x = 0; - y += rowHeight; + y += currentRowMaxHeight + pad * 2; + currentRowMaxHeight = 0; } - glyphs[c - FIRST_CHAR] = new Glyph(x + pad, y + pad, glyphWidth, Math.max(1, glyphWidth)); + glyphs[c - FIRST_CHAR] = new Glyph(ch, x + pad, y + pad, glyphWidth, glyphHeight, xOffset, yOffset, advance); x += cellWidth; + if (glyphHeight > currentRowMaxHeight) { + currentRowMaxHeight = glyphHeight; + } } probe.dispose(); - int atlasHeight = y + rowHeight; + int atlasHeight = y + currentRowMaxHeight + pad * 2; BufferedImage atlas = new BufferedImage(atlasWidth, atlasHeight, BufferedImage.TYPE_INT_ARGB); Graphics2D g = atlas.createGraphics(); g.setFont(font); g.setColor(Color.WHITE); g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); - for (int c = FIRST_CHAR; c <= LAST_CHAR; c++) { - Glyph glyph = glyphs[c - FIRST_CHAR]; - g.drawString(String.valueOf((char) c), glyph.x, glyph.y + ascent); + for (Glyph glyph : glyphs) { + GlyphVector gv = font.createGlyphVector(g.getFontRenderContext(), new char[]{glyph.character}); + g.drawGlyphVector(gv, glyph.x - glyph.xOffset, glyph.y - glyph.yOffset); } g.dispose(); BufferedImage alphaAtlas = alphaOnly(atlas); + float invW = 1f / alphaAtlas.getWidth(); + float invH = 1f / alphaAtlas.getHeight(); + for (Glyph glyph : glyphs) { - glyph.u0 = (float) glyph.x / alphaAtlas.getWidth(); - glyph.v0 = (float) glyph.y / alphaAtlas.getHeight(); - glyph.u1 = (float) (glyph.x + glyph.width) / alphaAtlas.getWidth(); - glyph.v1 = (float) (glyph.y + lineHeight) / alphaAtlas.getHeight(); + glyph.u0 = (glyph.x + 0.5f) * invW; + glyph.v0 = (glyph.y + 0.5f) * invH; + glyph.u1 = (glyph.x + glyph.width - 0.5f) * invW; + glyph.v1 = (glyph.y + glyph.height - 0.5f) * invH; } int textureId = uploadAtlas(alphaAtlas); @@ -239,9 +260,9 @@ private int uploadAtlas(BufferedImage image) { image.getRGB(0, 0, w, h, pixels, 0, w); ByteBuffer data = BufferUtils.createByteBuffer(w * h * 4); - for (int y = 0; y < h; y++) { - for (int x = 0; x < w; x++) { - int argb = pixels[y * w + x]; + for (int py = 0; py < h; py++) { + for (int px = 0; px < w; px++) { + int argb = pixels[py * w + px]; data.put((byte) ((argb >> 16) & 255)); data.put((byte) ((argb >> 8) & 255)); data.put((byte) (argb & 255)); @@ -253,7 +274,7 @@ private int uploadAtlas(BufferedImage image) { int tex = GL11.glGenTextures(); GL11.glBindTexture(GL11.GL_TEXTURE_2D, tex); GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR); - GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST); GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL12.GL_CLAMP_TO_EDGE); GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL12.GL_CLAMP_TO_EDGE); GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL12.GL_TEXTURE_BASE_LEVEL, 0); @@ -263,19 +284,27 @@ private int uploadAtlas(BufferedImage image) { } private static class Glyph { + final char character; final int x; final int y; final int width; + final int height; + final int xOffset; + final int yOffset; final int advance; float u0; float v0; float u1; float v1; - private Glyph(int x, int y, int width, int advance) { + private Glyph(char character, int x, int y, int width, int height, int xOffset, int yOffset, int advance) { + this.character = character; this.x = x; this.y = y; this.width = width; + this.height = height; + this.xOffset = xOffset; + this.yOffset = yOffset; this.advance = advance; } } From ccc5f1b5bf7f3d578f9d5877710fed014133035d Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 6 Feb 2026 21:52:44 -0500 Subject: [PATCH 4/4] Implement tiered downscale font atlases for GUI text readability --- .../noppes/npcs/client/gui/GuiFontTest.java | 9 +- .../client/gui/font/ScalableFontRenderer.java | 143 +++++++++++++----- 2 files changed, 109 insertions(+), 43 deletions(-) diff --git a/src/main/java/noppes/npcs/client/gui/GuiFontTest.java b/src/main/java/noppes/npcs/client/gui/GuiFontTest.java index 6a7eaf7e1..8f80f40ef 100644 --- a/src/main/java/noppes/npcs/client/gui/GuiFontTest.java +++ b/src/main/java/noppes/npcs/client/gui/GuiFontTest.java @@ -48,12 +48,17 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { baseline += 16; font.drawString("Font resource: " + font.getSourcePath(), left, baseline, 12, 0xFFB5FFC9); baseline += 16; - font.drawString("Baked atlases: " + font.getCachedAtlasSummary(), left, baseline, 12, 0xFFB5FFC9); + font.drawString("Tier atlases: " + font.getCachedAtlasSummary(), left, baseline, 12, 0xFFB5FFC9); baseline += 16; - font.drawString("Current line atlas: " + font.getAtlasSummaryForSize(12), left, baseline, 12, 0xFFB5FFC9); + font.drawString("Mipmaps: " + (font.isMipmapsEnabled() ? "enabled" : "disabled"), left, baseline, 12, 0xFFB5FFC9); baseline += 16; font.drawString("GUI scale (settings): " + mc.gameSettings.guiScale + " | active scale factor: " + sr.getScaleFactor(), left, baseline, 12, 0xFFB5FFC9); + for (int size : SIZES) { + baseline += 16; + font.drawString(font.getDebugLineForSize(size), left, baseline, 12, 0xFFB5FFC9); + } + super.drawScreen(mouseX, mouseY, partialTicks); } diff --git a/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java b/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java index e39be173f..be36d1141 100644 --- a/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java +++ b/src/main/java/noppes/npcs/client/gui/font/ScalableFontRenderer.java @@ -22,10 +22,13 @@ public class ScalableFontRenderer { private static final int FIRST_CHAR = 32; private static final int LAST_CHAR = 126; + private static final int[] DEFAULT_TIERS = new int[]{16, 24, 32, 48, 64, 96}; + private static final int GL_LINEAR_MIPMAP_LINEAR = 0x2703; private final ResourceLocation fontResource; private final String sourcePath; - private final Map sizeToAtlas = new TreeMap(); + private final Map tierToAtlas = new TreeMap(); + private final boolean mipmapsEnabled; public static ScalableFontRenderer create(String sourcePath) { return new ScalableFontRenderer(sourcePath, new ResourceLocation("customnpcs", "OpenSans.ttf")); @@ -34,6 +37,7 @@ public static ScalableFontRenderer create(String sourcePath) { private ScalableFontRenderer(String sourcePath, ResourceLocation fontResource) { this.sourcePath = sourcePath; this.fontResource = fontResource; + this.mipmapsEnabled = false; } public String getSourcePath() { @@ -41,12 +45,31 @@ public String getSourcePath() { } public String getRendererPath() { - return "Per-size baked atlas (no scaling)"; + return "Tiered high-res atlas (downscale only)"; + } + + public boolean isMipmapsEnabled() { + return mipmapsEnabled; + } + + public int getTierForSize(int requestedSize) { + int safe = Math.max(1, requestedSize); + for (int tier : DEFAULT_TIERS) { + if (tier >= safe) { + return tier; + } + } + return roundUpToMultiple(safe, 16); + } + + public float getScaleForSize(int requestedSize) { + int tier = getTierForSize(requestedSize); + return requestedSize / (float) tier; } public String getCachedAtlasSummary() { StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : sizeToAtlas.entrySet()) { + for (Map.Entry entry : tierToAtlas.entrySet()) { if (sb.length() > 0) { sb.append(" "); } @@ -57,12 +80,23 @@ public String getCachedAtlasSummary() { } public String getAtlasSummaryForSize(int size) { - BakedFontAtlas atlas = getAtlas(size); - return size + "px=" + atlas.width + "x" + atlas.height; + int tier = getTierForSize(size); + BakedFontAtlas atlas = getAtlasForTier(tier); + return size + "px->" + tier + "px @" + atlas.width + "x" + atlas.height; + } + + public String getDebugLineForSize(int requestedSize) { + int tier = getTierForSize(requestedSize); + float scale = requestedSize / (float) tier; + BakedFontAtlas atlas = getAtlasForTier(tier); + return "Req " + requestedSize + "px -> tier " + tier + "px, scale " + String.format("%.3f", scale) + + ", atlas " + atlas.width + "x" + atlas.height + ", mipmaps " + (mipmapsEnabled ? "on" : "off"); } public int getLineHeight(int size) { - return getAtlas(size).lineHeight; + int tier = getTierForSize(size); + float scale = size / (float) tier; + return Math.max(1, Math.round(getAtlasForTier(tier).lineHeight * scale)); } public int getStringWidth(String text, int size) { @@ -70,17 +104,19 @@ public int getStringWidth(String text, int size) { return 0; } - BakedFontAtlas atlas = getAtlas(size); - int width = 0; + int tier = getTierForSize(size); + float scale = size / (float) tier; + BakedFontAtlas atlas = getAtlasForTier(tier); + int tierWidth = 0; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); if (c < FIRST_CHAR || c > LAST_CHAR) { - width += size / 2; + tierWidth += tier / 2; continue; } - width += atlas.glyphs[c - FIRST_CHAR].advance; + tierWidth += atlas.glyphs[c - FIRST_CHAR].advance; } - return width; + return Math.max(1, Math.round(tierWidth * scale)); } public int drawString(String text, float x, float baselineY, int size, int color) { @@ -88,16 +124,19 @@ public int drawString(String text, float x, float baselineY, int size, int color return 0; } - BakedFontAtlas atlas = getAtlas(size); + int tier = getTierForSize(size); + float scale = size / (float) tier; + BakedFontAtlas atlas = getAtlasForTier(tier); + float drawX = Math.round(x); float drawBaseline = Math.round(baselineY); - float cursorX = drawX; - int topY = Math.round(drawBaseline - atlas.ascent); + float cursorTier = 0f; GL11.glPushAttrib(GL11.GL_ENABLE_BIT | GL11.GL_COLOR_BUFFER_BIT | GL11.GL_TEXTURE_BIT | GL11.GL_DEPTH_BUFFER_BIT | GL11.GL_CURRENT_BIT); GL11.glPushMatrix(); GL11.glDisable(GL11.GL_LIGHTING); GL11.glDisable(GL11.GL_DEPTH_TEST); + GL11.glDisable(GL11.GL_ALPHA_TEST); GL11.glEnable(GL11.GL_TEXTURE_2D); GL11.glEnable(GL11.GL_BLEND); GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); @@ -116,22 +155,22 @@ public int drawString(String text, float x, float baselineY, int size, int color for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); if (c < FIRST_CHAR || c > LAST_CHAR) { - cursorX += size / 2f; + cursorTier += tier / 2f; continue; } Glyph glyph = atlas.glyphs[c - FIRST_CHAR]; - float x0 = Math.round(cursorX + glyph.xOffset); - float y0 = Math.round(topY + atlas.ascent + glyph.yOffset); - float x1 = x0 + glyph.width; - float y1 = y0 + glyph.height; + float x0 = Math.round((cursorTier + glyph.xOffset) * scale + drawX); + float y0 = Math.round((glyph.yOffset) * scale + drawBaseline); + float x1 = Math.round((cursorTier + glyph.xOffset + glyph.width) * scale + drawX); + float y1 = Math.round((glyph.yOffset + glyph.height) * scale + drawBaseline); t.addVertexWithUV(x0, y1, 0, glyph.u0, glyph.v1); t.addVertexWithUV(x1, y1, 0, glyph.u1, glyph.v1); t.addVertexWithUV(x1, y0, 0, glyph.u1, glyph.v0); t.addVertexWithUV(x0, y0, 0, glyph.u0, glyph.v0); - cursorX += glyph.advance; + cursorTier += glyph.advance; } t.draw(); @@ -139,35 +178,35 @@ public int drawString(String text, float x, float baselineY, int size, int color GL11.glPopMatrix(); GL11.glPopAttrib(); - return Math.round(cursorX - drawX); + return Math.max(1, Math.round(cursorTier * scale)); } - private BakedFontAtlas getAtlas(int size) { - BakedFontAtlas cached = sizeToAtlas.get(size); + private BakedFontAtlas getAtlasForTier(int tier) { + BakedFontAtlas cached = tierToAtlas.get(tier); if (cached != null) { return cached; } - BakedFontAtlas baked = bakeAtlas(size); - sizeToAtlas.put(size, baked); + BakedFontAtlas baked = bakeAtlas(tier); + tierToAtlas.put(tier, baked); return baked; } - private BakedFontAtlas bakeAtlas(int size) { - Font font = loadFont(size); + private BakedFontAtlas bakeAtlas(int tierSize) { + Font font = loadFont(tierSize); BufferedImage probeImage = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); Graphics2D probe = probeImage.createGraphics(); probe.setFont(font); probe.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); - probe.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); + probe.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_OFF); FontMetrics fm = probe.getFontMetrics(); FontRenderContext frc = probe.getFontRenderContext(); + int lineHeight = fm.getHeight(); int ascent = fm.getAscent(); int descent = fm.getDescent(); - int lineHeight = fm.getHeight(); - int pad = 6; + int pad = 10; int atlasWidth = 1024; int x = 0; int y = 0; @@ -206,7 +245,7 @@ private BakedFontAtlas bakeAtlas(int size) { g.setFont(font); g.setColor(Color.WHITE); g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); - g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); + g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_OFF); for (Glyph glyph : glyphs) { GlyphVector gv = font.createGlyphVector(g.getFontRenderContext(), new char[]{glyph.character}); @@ -214,10 +253,9 @@ private BakedFontAtlas bakeAtlas(int size) { } g.dispose(); - BufferedImage alphaAtlas = alphaOnly(atlas); + BufferedImage alphaAtlas = alphaOnlyFromCoverage(atlas); float invW = 1f / alphaAtlas.getWidth(); float invH = 1f / alphaAtlas.getHeight(); - for (Glyph glyph : glyphs) { glyph.u0 = (glyph.x + 0.5f) * invW; glyph.v0 = (glyph.y + 0.5f) * invH; @@ -242,12 +280,17 @@ private Font loadFont(float size) { } } - private BufferedImage alphaOnly(BufferedImage source) { + private BufferedImage alphaOnlyFromCoverage(BufferedImage source) { BufferedImage out = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_ARGB); - for (int y = 0; y < source.getHeight(); y++) { - for (int x = 0; x < source.getWidth(); x++) { - int a = (source.getRGB(x, y) >>> 24) & 255; - out.setRGB(x, y, (a << 24) | 0x00FFFFFF); + for (int py = 0; py < source.getHeight(); py++) { + for (int px = 0; px < source.getWidth(); px++) { + int argb = source.getRGB(px, py); + int a = (argb >>> 24) & 255; + int r = (argb >>> 16) & 255; + int g = (argb >>> 8) & 255; + int b = argb & 255; + int coverage = Math.max(a, Math.max(r, Math.max(g, b))); + out.setRGB(px, py, (coverage << 24) | 0x00FFFFFF); } } return out; @@ -273,16 +316,23 @@ private int uploadAtlas(BufferedImage image) { int tex = GL11.glGenTextures(); GL11.glBindTexture(GL11.GL_TEXTURE_2D, tex); - GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR); - GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, mipmapsEnabled ? GL_LINEAR_MIPMAP_LINEAR : GL11.GL_LINEAR); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR); GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL12.GL_CLAMP_TO_EDGE); GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL12.GL_CLAMP_TO_EDGE); GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL12.GL_TEXTURE_BASE_LEVEL, 0); - GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL12.GL_TEXTURE_MAX_LEVEL, 0); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL12.GL_TEXTURE_MAX_LEVEL, mipmapsEnabled ? 1000 : 0); GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA8, w, h, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, data); + if (mipmapsEnabled) { + GL30Compat.generateMipmap(GL11.GL_TEXTURE_2D); + } return tex; } + private int roundUpToMultiple(int value, int multiple) { + return ((value + multiple - 1) / multiple) * multiple; + } + private static class Glyph { final char character; final int x; @@ -328,4 +378,15 @@ private BakedFontAtlas(int textureId, int width, int height, int lineHeight, int this.glyphs = glyphs; } } + + private static class GL30Compat { + private static void generateMipmap(int target) { + try { + Class gl30 = Class.forName("org.lwjgl.opengl.GL30"); + gl30.getMethod("glGenerateMipmap", int.class).invoke(null, target); + } catch (Throwable ignored) { + // Optional path only. + } + } + } }