diff --git a/src/main/java/meteordevelopment/meteorclient/renderer/Fonts.java b/src/main/java/meteordevelopment/meteorclient/renderer/Fonts.java index e67d60cd72..62e4728e47 100644 --- a/src/main/java/meteordevelopment/meteorclient/renderer/Fonts.java +++ b/src/main/java/meteordevelopment/meteorclient/renderer/Fonts.java @@ -17,6 +17,8 @@ import meteordevelopment.meteorclient.utils.render.FontUtils; import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -25,6 +27,23 @@ public class Fonts { public static final String[] BUILTIN_FONTS = {"JetBrains Mono", "Comfortaa", "Tw Cen MT", "Pixelation"}; + private static final String[] FALLBACK_FONT_FAMILIES = { + "Noto Sans SC", + "Microsoft YaHei", + "Microsoft YaHei UI", + "DengXian", + "SimHei", + "SimSun", + "KaiTi", + "Microsoft JhengHei", + "Malgun Gothic", + "Yu Gothic", + "MS Gothic", + "Source Han Sans SC", + "WenQuanYi Zen Hei", + "PingFang SC", + "Hiragino Sans GB" + }; public static String DEFAULT_FONT_FAMILY; public static FontFace DEFAULT_FONT; @@ -90,4 +109,57 @@ public static FontFamily getFamily(String name) { return null; } + + public static List getFallbackFonts(FontFace primary) { + List fonts = new ArrayList<>(); + + for (String familyName : FALLBACK_FONT_FAMILIES) { + FontFace font = getFallbackFont(familyName, primary); + if (font != null) { + fonts.add(font); + break; + } + } + + return fonts; + } + + public static List readFontBuffers(FontFace primary) throws IOException { + List buffers = new ArrayList<>(); + buffers.add(primary.readToDirectByteBuffer()); + + for (FontFace fallbackFont : getFallbackFonts(primary)) { + try { + buffers.add(fallbackFont.readToDirectByteBuffer()); + } catch (IOException e) { + MeteorClient.LOG.warn("Failed to load fallback font: {}", fallbackFont, e); + } + } + + return buffers; + } + + private static FontFace getFallbackFont(String familyName, FontFace primary) { + FontFamily family = getFamily(familyName); + + if (family == null) { + String needle = familyName.toLowerCase(); + + for (FontFamily fontFamily : FONT_FAMILIES) { + if (fontFamily.getName().toLowerCase().contains(needle)) { + family = fontFamily; + break; + } + } + } + + if (family == null) return null; + + for (FontInfo.Type type : FontInfo.Type.values()) { + FontFace font = family.get(type); + if (font != null && !font.info.equals(primary.info)) return font; + } + + return null; + } } diff --git a/src/main/java/meteordevelopment/meteorclient/renderer/text/CustomTextRenderer.java b/src/main/java/meteordevelopment/meteorclient/renderer/text/CustomTextRenderer.java index c2e3d307ba..0ad7fe68e3 100644 --- a/src/main/java/meteordevelopment/meteorclient/renderer/text/CustomTextRenderer.java +++ b/src/main/java/meteordevelopment/meteorclient/renderer/text/CustomTextRenderer.java @@ -5,6 +5,7 @@ package meteordevelopment.meteorclient.renderer.text; +import meteordevelopment.meteorclient.renderer.Fonts; import meteordevelopment.meteorclient.renderer.MeshBuilder; import meteordevelopment.meteorclient.renderer.MeshRenderer; import meteordevelopment.meteorclient.renderer.MeteorRenderPipelines; @@ -13,6 +14,7 @@ import java.io.IOException; import java.nio.ByteBuffer; +import java.util.List; public class CustomTextRenderer implements TextRenderer { public static final Color SHADOW_COLOR = new Color(60, 60, 60, 180); @@ -32,11 +34,11 @@ public class CustomTextRenderer implements TextRenderer { public CustomTextRenderer(FontFace fontFace) throws IOException { this.fontFace = fontFace; - ByteBuffer buffer = fontFace.readToDirectByteBuffer(); + List buffers = Fonts.readFontBuffers(fontFace); fonts = new Font[5]; for (int i = 0; i < fonts.length; i++) { - fonts[i] = new Font(buffer, (int) Math.round(27 * ((i * 0.5) + 1))); + fonts[i] = new Font(buffers, (int) Math.round(27 * ((i * 0.5) + 1))); } } @@ -120,6 +122,7 @@ public void end() { if (!scaleOnly) { mesh.end(); + font.uploadPendingGlyphs(); MeshRenderer.begin() .attachments(Minecraft.getInstance().getMainRenderTarget()) diff --git a/src/main/java/meteordevelopment/meteorclient/renderer/text/Font.java b/src/main/java/meteordevelopment/meteorclient/renderer/text/Font.java index 7886610014..27bedeadcb 100644 --- a/src/main/java/meteordevelopment/meteorclient/renderer/text/Font.java +++ b/src/main/java/meteordevelopment/meteorclient/renderer/text/Font.java @@ -17,24 +17,50 @@ import java.nio.ByteBuffer; import java.nio.IntBuffer; +import java.util.ArrayList; +import java.util.List; public class Font { public final Texture texture; private final int height; private final float scale; private final float ascent; + private final ByteBuffer bitmap; + private final List fonts = new ArrayList<>(); private final Int2ObjectOpenHashMap charMap = new Int2ObjectOpenHashMap<>(); private static final int size = 2048; + private static final int padding = 2; + + private int packX; + private int packY; + private int rowHeight; + private boolean textureFull; + private boolean dirty; public Font(ByteBuffer buffer, int height) { + this(List.of(buffer), height); + } + + public Font(List buffers, int height) { this.height = height; - // Initialize font - STBTTFontinfo fontInfo = STBTTFontinfo.create(); - STBTruetype.stbtt_InitFont(fontInfo, buffer); + // Initialize fonts + for (ByteBuffer buffer : buffers) { + STBTTFontinfo fontInfo = STBTTFontinfo.create(); + if (STBTruetype.stbtt_InitFont(fontInfo, buffer)) { + fonts.add(new FontData(buffer, fontInfo, STBTruetype.stbtt_ScaleForPixelHeight(fontInfo, height))); + } + } + + if (fonts.isEmpty()) { + throw new IllegalArgumentException("No valid fonts were provided."); + } + + FontData primaryFont = fonts.get(0); + STBTTFontinfo fontInfo = primaryFont.info; // Allocate buffers - ByteBuffer bitmap = BufferUtils.createByteBuffer(size * size); + bitmap = BufferUtils.createByteBuffer(size * size); STBTTPackedchar.Buffer[] cdata = { STBTTPackedchar.create(95), // Basic Latin STBTTPackedchar.create(96), // Latin 1 Supplement @@ -59,13 +85,13 @@ public Font(ByteBuffer buffer, int height) { packRange.flip(); // write and finish - STBTruetype.stbtt_PackFontRanges(packContext, buffer, 0, packRange); + STBTruetype.stbtt_PackFontRanges(packContext, primaryFont.buffer, 0, packRange); STBTruetype.stbtt_PackEnd(packContext); // Create texture object and get font scale texture = new Texture(size, size, TextureFormat.RED8, FilterMode.LINEAR, FilterMode.LINEAR); texture.upload(bitmap); - scale = STBTruetype.stbtt_ScaleForPixelHeight(fontInfo, height); + scale = primaryFont.scale; // Get font vertical ascent try (MemoryStack stack = MemoryStack.stackPush()) { @@ -74,6 +100,7 @@ public Font(ByteBuffer buffer, int height) { this.ascent = ascent.get(0); } + int usedY = 0; for (int i = 0; i < cdata.length; i++) { STBTTPackedchar.Buffer cbuf = cdata[i]; int offset = packRange.get(i).first_unicode_codepoint_in_range(); @@ -95,37 +122,63 @@ public Font(ByteBuffer buffer, int height) { packedChar.y1() * iph, packedChar.xadvance() )); + + usedY = Math.max(usedY, packedChar.y1()); } } + + packY = Math.min(usedY + padding, size); } public double getWidth(String string, int length) { double width = 0; + int end = Math.min(length, string.length()); - for (int i = 0; i < length; i++) { - int cp = string.charAt(i); - CharData c = charMap.get(cp); + for (int i = 0; i < end; ) { + int cp = codePointAt(string, i, end); + CharData c = getCharData(cp); if (c == null) c = charMap.get(32); width += c.xAdvance; + i += Character.charCount(cp); } return width; } + public boolean hasGlyphs(String string, int length) { + int end = Math.min(length, string.length()); + + for (int i = 0; i < end; ) { + int cp = codePointAt(string, i, end); + if (getCharData(cp) == null) return false; + + i += Character.charCount(cp); + } + + return true; + } + public int getHeight() { return height; } + public void uploadPendingGlyphs() { + if (!dirty) return; + + texture.upload(bitmap); + dirty = false; + } + public double render(MeshBuilder mesh, String string, double x, double y, Color color, double scale) { y += ascent * this.scale * scale; int length = string.length(); mesh.ensureCapacity(length * 4, length * 6); - for (int i = 0; i < length; i++) { - int cp = string.charAt(i); - CharData c = charMap.get(cp); + for (int i = 0; i < length; ) { + int cp = string.codePointAt(i); + CharData c = getCharData(cp); if (c == null) c = charMap.get(32); mesh.quad( @@ -136,10 +189,129 @@ public double render(MeshBuilder mesh, String string, double x, double y, Color ); x += c.xAdvance * scale; + i += Character.charCount(cp); } return x; } + private CharData getCharData(int cp) { + CharData c = charMap.get(cp); + if (c != null) return c; + + c = loadGlyph(cp); + if (c != null) charMap.put(cp, c); + + return c; + } + + private CharData loadGlyph(int cp) { + if (textureFull) return null; + + FontData font = findFont(cp); + if (font == null) return null; + + try (MemoryStack stack = MemoryStack.stackPush()) { + IntBuffer advanceWidth = stack.mallocInt(1); + IntBuffer x0 = stack.mallocInt(1); + IntBuffer y0 = stack.mallocInt(1); + IntBuffer x1 = stack.mallocInt(1); + IntBuffer y1 = stack.mallocInt(1); + + STBTruetype.stbtt_GetCodepointHMetrics(font.info, cp, advanceWidth, null); + STBTruetype.stbtt_GetCodepointBitmapBox(font.info, cp, font.scale, font.scale, x0, y0, x1, y1); + + int bitmapWidth = x1.get(0) - x0.get(0); + int bitmapHeight = y1.get(0) - y0.get(0); + float xAdvance = advanceWidth.get(0) * font.scale; + + if (bitmapWidth <= 0 || bitmapHeight <= 0) { + return new CharData(0, 0, 0, 0, 0, 0, 0, 0, xAdvance); + } + + if (!reserve(bitmapWidth, bitmapHeight)) return null; + + int glyphX = packX + padding; + int glyphY = packY + padding; + ByteBuffer glyphBitmap = BufferUtils.createByteBuffer(bitmapWidth * bitmapHeight); + + STBTruetype.stbtt_MakeCodepointBitmap(font.info, glyphBitmap, bitmapWidth, bitmapHeight, bitmapWidth, font.scale, font.scale, cp); + + for (int row = 0; row < bitmapHeight; row++) { + int src = row * bitmapWidth; + int dst = (glyphY + row) * size + glyphX; + + for (int col = 0; col < bitmapWidth; col++) { + bitmap.put(dst + col, glyphBitmap.get(src + col)); + } + } + + float ipw = 1f / size; + float iph = 1f / size; + + CharData charData = new CharData( + x0.get(0), + y0.get(0), + x1.get(0), + y1.get(0), + glyphX * ipw, + glyphY * iph, + (glyphX + bitmapWidth) * ipw, + (glyphY + bitmapHeight) * iph, + xAdvance + ); + + packX += bitmapWidth + padding * 2; + rowHeight = Math.max(rowHeight, bitmapHeight + padding * 2); + + dirty = true; + return charData; + } + } + + private boolean reserve(int bitmapWidth, int bitmapHeight) { + int requiredWidth = bitmapWidth + padding * 2; + int requiredHeight = bitmapHeight + padding * 2; + + if (requiredWidth > size || requiredHeight > size) { + textureFull = true; + return false; + } + + if (packX + requiredWidth > size) { + packX = 0; + packY += rowHeight; + rowHeight = 0; + } + + if (packY + requiredHeight > size) { + textureFull = true; + return false; + } + + return true; + } + + private FontData findFont(int cp) { + for (FontData font : fonts) { + if (STBTruetype.stbtt_FindGlyphIndex(font.info, cp) != 0) return font; + } + + return null; + } + + private static int codePointAt(String string, int index, int end) { + char c = string.charAt(index); + + if (Character.isHighSurrogate(c) && index + 1 < end) { + char c2 = string.charAt(index + 1); + if (Character.isLowSurrogate(c2)) return Character.toCodePoint(c, c2); + } + + return c; + } + + private record FontData(ByteBuffer buffer, STBTTFontinfo info, float scale) {} + private record CharData(float x0, float y0, float x1, float y1, float u0, float v0, float u1, float v1, float xAdvance) {} } diff --git a/src/main/java/meteordevelopment/meteorclient/systems/hud/HudRenderer.java b/src/main/java/meteordevelopment/meteorclient/systems/hud/HudRenderer.java index 4f13c5bcba..ce40d80259 100644 --- a/src/main/java/meteordevelopment/meteorclient/systems/hud/HudRenderer.java +++ b/src/main/java/meteordevelopment/meteorclient/systems/hud/HudRenderer.java @@ -85,6 +85,8 @@ public void end() { FontHolder fontHolder = it.next(); if (fontHolder.visited) { + fontHolder.font.uploadPendingGlyphs(); + MeshRenderer.begin() .attachments(mc.getMainRenderTarget()) .pipeline(MeteorRenderPipelines.UI_TEXT) @@ -302,8 +304,8 @@ private void onCustomFontChanged(CustomFontChangedEvent event) { private static FontHolder loadFont(int height) { try { - ByteBuffer buffer = Fonts.RENDERER.fontFace.readToDirectByteBuffer(); - return new FontHolder(new Font(buffer, height)); + List buffers = Fonts.readFontBuffers(Fonts.RENDERER.fontFace); + return new FontHolder(new Font(buffers, height)); } catch (IOException e) { throw new RuntimeException("Failed to load font: " + Fonts.RENDERER.fontFace, e); }