From 08e1b2bc9f836e017571ceaec7d844db3e2a964c Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Thu, 30 Jul 2026 15:28:00 +0200 Subject: [PATCH 1/2] BasicFontRenderer: Fix various sizing and placement issues In particular: - `getStringHeight` returned the height of the biggest character, but while `g` and `h` have similar height, the height of `gh` should be 1.5x that. - `getStringWidth` failed to include the width of glyphs without atlas bounds (such as the space character). - `getStringWidth` incorrectly adds between-letter spacing when the last character has no atlas bounds (e.g. space or unmapped or color code) - All glyphs were incorrectly offset on the y axis by 0.025em, and all their atlas coordinates on both axes by 0.5px. Together with other bugs these happened to cancel out with the Minecraft Five font, but not for any other font. Afaict these stem from an incorrect understanding of why the raw values did not match what one expected. See the docs on the newly added `shrinkGlyphsByHalfAPixel` method and the following point for where this confusion likely came from. - The provided Minecraft Five font actually has a base line height of 6 because most regular letters are actually placed at 0.5px above the baseline. This does not affect other fonts and must be an issue with the original font. - The string overall was rendered one higher than it should have been. Likely as a workaround to the above two points and the following pointwm. This magic offset has been removed now. - Glyphs were drawn at the wrong y position. The font file has its y origin at the bottom, while Minecraft has it at the top; this was taken into account in other places, but not for the y positioning. The old code happened to produce close to correct y positions when taking into account all the other bugs for most characters, but completely failed to do so for characters that aren't close to full-height, such as most punctuation. - The `_` in Minecraft Five was manually changed in 274b2d16 to appear at the correct Y position. This was done by assigning it an incorrect position in the file, which happened to come out at roughly the correct position after all it went through all the other bugs. With the other bugs fixed, this has now been reverted. Further issues that are not addressed by this commit: - The renderer almost completely ignores the x position and `advance` values of glyphs, hard-coding a single pixel spacing instead. This is kind of required given that the provided Minecraft Five font places glyphs in the middle of their allocated space instead of left-aligning them, and uses non-integer spacing. It does however ofc make it impossible to use a font where some characters have extra space around them. - The Minecraft Five font has some characters (e.g. `$`, `@`) which extend far below and/or above the regular line height. These are presently simply drawn out of bounds and may be cut off by scissor effects and similar. Elementa doesn't have any robust way to handle them (without substantially affecting layout), so there's no easy solution for them. - The font file format technically allows setting the y origin to be at the top. This continues to be unsupported by the renderer though. --- .../elementa/font/BasicFontRenderer.kt | 101 +++++++++--------- .../essential/elementa/font/data/FontInfo.kt | 53 +++++++-- src/main/resources/fonts/Minecraft-Five.json | 6 +- 3 files changed, 98 insertions(+), 62 deletions(-) diff --git a/src/main/kotlin/gg/essential/elementa/font/BasicFontRenderer.kt b/src/main/kotlin/gg/essential/elementa/font/BasicFontRenderer.kt index 6972dbe1..c28ce176 100644 --- a/src/main/kotlin/gg/essential/elementa/font/BasicFontRenderer.kt +++ b/src/main/kotlin/gg/essential/elementa/font/BasicFontRenderer.kt @@ -5,7 +5,9 @@ import gg.essential.elementa.UIComponent import gg.essential.elementa.constraints.ConstraintType import gg.essential.elementa.constraints.resolution.ConstraintVisitor import gg.essential.elementa.font.data.Font +import gg.essential.elementa.font.data.FontInfo import gg.essential.elementa.font.data.Glyph +import gg.essential.elementa.font.data.shrinkGlyphsByHalfAPixel import gg.essential.universal.UGraphics import gg.essential.universal.UMatrixStack import gg.essential.universal.render.UGpuSampler @@ -15,10 +17,14 @@ import gg.essential.universal.vertex.UBufferBuilder import gg.essential.universal.vertex.UVertexConsumer import java.awt.Color import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt class BasicFontRenderer( - private val regularFont: Font + regularFont: Font ) : FontProvider { + private val regularFontInfo = regularFont.fontInfo.shrinkGlyphsByHalfAPixel() + private val regularFontTexture by lazy { regularFont.getTexture() } /* Required by Elementa but unused for this type of constraint */ override var cachedValue: FontProvider = this @@ -35,14 +41,15 @@ class BasicFontRenderer( } private fun getStringDimensions(string: String, pointSize: Float): Pair { - var width = 0f - var height = 0f + var currentX = 0f + var top = Float.NEGATIVE_INFINITY + var bottom = Float.POSITIVE_INFINITY /* 10 point font is the default used in Elementa. Adjust the point size based on this font's size. */ - val currentPointSize = pointSize / 10 * regularFont.fontInfo.atlas.size + val currentPointSize = pointSize / 10 * regularFontInfo.atlas.size var i = 0 while (i < string.length) { @@ -55,34 +62,34 @@ class BasicFontRenderer( continue } - val glyph = regularFont.fontInfo.glyphs[char.code] - if (glyph?.atlasBounds == null) { + val glyph = regularFontInfo.glyphs[char.code] + if (glyph == null) { i++ continue } + val planeBounds = glyph.planeBounds if (planeBounds != null) { - height = max((planeBounds.top - planeBounds.bottom) * currentPointSize, height) + top = max(top, planeBounds.t) + bottom = min(bottom, planeBounds.b) } - //The last character should not have the whitespace to the right of it - //Added to the width. Instead, we only add the width of the character - val lastCorrection = if (i < string.length - 1) 0 else 1 - - //The texture atlas is used here because in the context of this implementation of the font renderer - //we do not need or want the full precision the msdf font renderer exports in. Instead, we care about - //calculating width based on the texture pixels - width += (((glyph.atlasBounds.right - glyph.atlasBounds.left - lastCorrection) / regularFont.fontInfo.atlas.size) * currentPointSize) - + currentX += computeAdvance(regularFontInfo, glyph) i++ } - return Pair(width, height) + + // undo letter spacing after final letter + currentX -= 1 / regularFontInfo.atlas.size + + val width = currentX.coerceAtLeast(0f) + val height = if (top.isInfinite() || bottom.isInfinite()) 0f else top - bottom + return Pair(width * currentPointSize, height * currentPointSize) } fun getLineHeight(pointSize: Float): Float { - return regularFont.fontInfo.metrics.lineHeight * pointSize + return regularFontInfo.metrics.lineHeight * pointSize } override fun drawString( @@ -100,7 +107,7 @@ class BasicFontRenderer( val bufferBuilder = UBufferBuilder.create(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_TEXTURE_COLOR) drawString(bufferBuilder, matrixStack, string, color, x, y, originalPointSize / 10 * scale, shadow, shadowColor) bufferBuilder.build()?.drawAndClose(if (ElementaVersion.atLeastV10Active) PIPELINE2 else PIPELINE) { - texture(0, regularFont.getTexture().gpuTextureView, UGpuSampler( + texture(0, regularFontTexture.gpuTextureView, UGpuSampler( UGpuSampler.AddressMode.CLAMP_TO_EDGE, UGpuSampler.AddressMode.CLAMP_TO_EDGE, UGpuSampler.FilterMode.NEAREST, @@ -109,7 +116,7 @@ class BasicFontRenderer( )) } } else { - UGraphics.bindTexture(0, regularFont.getTexture().dynamicGlId) + UGraphics.bindTexture(0, regularFontTexture.dynamicGlId) val bufferBuilder = UGraphics.getFromTessellator() @Suppress("DEPRECATION") bufferBuilder.beginWithDefaultShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_TEXTURE_COLOR) @@ -129,14 +136,8 @@ class BasicFontRenderer( shadow: Boolean, shadowColor: Color? ) { - val scaledPointSize = scale * regularFont.fontInfo.atlas.size + val scaledPointSize = scale * regularFontInfo.atlas.size - /* - Moved one pixel up so that the main body of the text is in - the top left of the component. This change keeps text location - in the same location as the vanilla font renderer relative to - a UIText component. - */ if (shadow) { drawStringNow( vertexConsumer, @@ -146,7 +147,7 @@ class BasicFontRenderer( ((color.rgb and 16579836).shr(2)).or((color.rgb).and(-16777216)) ), x + 1, - y, + y + 1, scaledPointSize, ) } @@ -156,21 +157,21 @@ class BasicFontRenderer( string, color, x, - y - 1, + y, scaledPointSize, ) } override fun getBaseLineHeight(): Float { - return regularFont.fontInfo.atlas.baseCharHeight + return regularFontInfo.atlas.baseCharHeight } override fun getShadowHeight(): Float { - return regularFont.fontInfo.atlas.shadowHeight + return regularFontInfo.atlas.shadowHeight } override fun getBelowLineHeight(): Float { - return regularFont.fontInfo.atlas.belowLineHeight + return regularFontInfo.atlas.belowLineHeight } private fun drawStringNow( @@ -194,7 +195,7 @@ class BasicFontRenderer( } - val glyph = regularFont.fontInfo.glyphs[char.code] + val glyph = regularFontInfo.glyphs[char.code] if (glyph == null) { i++ continue @@ -203,8 +204,8 @@ class BasicFontRenderer( val planeBounds = glyph.planeBounds if (planeBounds != null) { - val width = (planeBounds.right - planeBounds.left) * originalPointSize - val height = (planeBounds.top - planeBounds.bottom) * originalPointSize + val width = (planeBounds.r - planeBounds.l) * originalPointSize + val height = (planeBounds.t - planeBounds.b) * originalPointSize drawGlyph( vertexConsumer, @@ -212,25 +213,25 @@ class BasicFontRenderer( glyph, color, currentX, - y + planeBounds.bottom * originalPointSize, + y + regularFontInfo.atlas.baseCharHeight - planeBounds.t * originalPointSize, width, height ) } - //The texture atlas is used here because in the context of this implementation of the font renderer - //we do not need or want the full precision the msdf font renderer exports in. Instead, we care about - //calculating width based on the texture pixels - if (glyph.atlasBounds != null) { - currentX += (((glyph.atlasBounds.right - glyph.atlasBounds.left) / regularFont.fontInfo.atlas.size) * originalPointSize) - } else { - currentX += (glyph.advance) * originalPointSize - } + currentX += computeAdvance(regularFontInfo, glyph) * originalPointSize i++ } } + // Letter spacing for many fonts is like 1.25px, so we ignore font-provided advance values, and instead derive + // ones directly based on the actual size of the glyph. + private fun computeAdvance(fontInfo: FontInfo, glyph: Glyph): Float = + if (glyph.atlasBounds != null) (glyph.atlasBounds.r - glyph.atlasBounds.l + 1) / fontInfo.atlas.size + // For empty glyphs (like ` `), we use the font-provided value but round it to pixels so we don't end up with + // sub-pixel positions. We don't use `roundToRealPixels`, so the value stays scale-independent. + else (glyph.advance * fontInfo.atlas.size).roundToInt() / fontInfo.atlas.size private fun drawGlyph( worldRenderer: UVertexConsumer, @@ -243,11 +244,11 @@ class BasicFontRenderer( height: Float ) { val atlasBounds = glyph.atlasBounds ?: return - val atlas = regularFont.fontInfo.atlas - val textureTop = 1.0 - ((atlasBounds.top) / atlas.height) - val textureBottom = 1.0 - ((atlasBounds.bottom) / atlas.height) - val textureLeft = (atlasBounds.left / atlas.width).toDouble() - val textureRight = (atlasBounds.right / atlas.width).toDouble() + val atlas = regularFontInfo.atlas + val textureTop = 1.0 - ((atlasBounds.t) / atlas.height) + val textureBottom = 1.0 - ((atlasBounds.b) / atlas.height) + val textureLeft = (atlasBounds.l / atlas.width).toDouble() + val textureRight = (atlasBounds.r / atlas.width).toDouble() val doubleX = x.toDouble() val doubleY = y.toDouble() @@ -290,4 +291,4 @@ class BasicFontRenderer( blendState = BlendState.ALPHA }.build() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/gg/essential/elementa/font/data/FontInfo.kt b/src/main/kotlin/gg/essential/elementa/font/data/FontInfo.kt index aecc9f9d..0ec24536 100644 --- a/src/main/kotlin/gg/essential/elementa/font/data/FontInfo.kt +++ b/src/main/kotlin/gg/essential/elementa/font/data/FontInfo.kt @@ -62,16 +62,21 @@ data class PlaneBounds( @SerializedName("top") private val _top: Float ) { - /** - * msdfgen exports the plane locations with .025 subtracted from the - * Y coordinate of each glyph, so we must correct for this - */ + val l: Float get() = _left + val b: Float get() = _bottom + val r: Float get() = _right + val t: Float get() = _top + + @Deprecated("Returns incorrect value") // technically this one's right, but `top` and `bottom` aren't val left: Float get() = _left + @Deprecated("Returns incorrect value") val bottom: Float get() = _bottom + 0.025f + @Deprecated("Returns incorrect value") // technically this one's right, but `top` and `bottom` aren't val right: Float get() = _right + @Deprecated("Returns incorrect value") val top: Float get() = _top + 0.025f } @@ -87,17 +92,47 @@ data class AtlasBounds( @SerializedName("top") private val _top: Float ) { - /** - * msdfgen exports UV locations in the middle of pixels. - * This causes the rendering to occur slightly of from - * where you would expect it and incorrect texel mapping. - */ + val l: Float get() = _left + val b: Float get() = _bottom + val r: Float get() = _right + val t: Float get() = _top + + @Deprecated("Returns incorrect value") val left: Float get() = _left + .5f + @Deprecated("Returns incorrect value") val bottom: Float get() = _bottom + .5f + @Deprecated("Returns incorrect value") val right: Float get() = _right + .5f + @Deprecated("Returns incorrect value") val top: Float get() = _top + .5f } + +/** + * msdfgen inflates all glyphs by half a pixel so all the edges are drawn properly when using MSDF. + * This function un-does that, because we don't need it in our pixel-font renderer + * ([gg.essential.elementa.font.BasicFontRenderer]) and it makes any sizing math more difficult. + */ +internal fun FontInfo.shrinkGlyphsByHalfAPixel(): FontInfo { + val a = 0.5f // half a pixel in atlas coordinates + val p = a / atlas.size // half a pixel in plane coordinates + return FontInfo( + atlas, + metrics, + glyphs.mapValues { (_, glyph) -> + Glyph( + glyph.unicode, + glyph.advance, + glyph.planeBounds?.let { bounds -> + PlaneBounds(bounds.l + p, bounds.b + p, bounds.r - p, bounds.t - p) + }, + glyph.atlasBounds?.let { bounds -> + AtlasBounds(bounds.l + a, bounds.b + a, bounds.r - a, bounds.t - a) + }, + ) + }, + ) +} diff --git a/src/main/resources/fonts/Minecraft-Five.json b/src/main/resources/fonts/Minecraft-Five.json index a01635ed..d05425ca 100644 --- a/src/main/resources/fonts/Minecraft-Five.json +++ b/src/main/resources/fonts/Minecraft-Five.json @@ -5,7 +5,7 @@ "width": 64, "height": 64, "yOrigin": "bottom", - "baseCharHeight": 5.0, + "baseCharHeight": 6.0, "belowLineHeight": 1.0, "shadowHeight": 1.0 }, @@ -942,9 +942,9 @@ "advance": 0.80000000000000004, "planeBounds": { "left": 0.025000000000000029, - "bottom": 0.475, + "bottom": -0.19500000000000001, "right": 0.77500000000000002, - "top": 0.72499999999999998 + "top": 0.055 }, "atlasBounds": { "left": 40.5, From 2d82e1e1ada5daa1d0ea14350910e329304fc349 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Thu, 30 Jul 2026 16:22:46 +0200 Subject: [PATCH 2/2] update api file accordingly --- api/Elementa.api | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api/Elementa.api b/api/Elementa.api index be070249..02ff7f0b 100644 --- a/api/Elementa.api +++ b/api/Elementa.api @@ -2892,9 +2892,13 @@ public final class gg/essential/elementa/font/data/AtlasBounds { public final fun copy (FFFF)Lgg/essential/elementa/font/data/AtlasBounds; public static synthetic fun copy$default (Lgg/essential/elementa/font/data/AtlasBounds;FFFFILjava/lang/Object;)Lgg/essential/elementa/font/data/AtlasBounds; public fun equals (Ljava/lang/Object;)Z + public final fun getB ()F public final fun getBottom ()F + public final fun getL ()F public final fun getLeft ()F + public final fun getR ()F public final fun getRight ()F + public final fun getT ()F public final fun getTop ()F public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -2964,9 +2968,13 @@ public final class gg/essential/elementa/font/data/PlaneBounds { public final fun copy (FFFF)Lgg/essential/elementa/font/data/PlaneBounds; public static synthetic fun copy$default (Lgg/essential/elementa/font/data/PlaneBounds;FFFFILjava/lang/Object;)Lgg/essential/elementa/font/data/PlaneBounds; public fun equals (Ljava/lang/Object;)Z + public final fun getB ()F public final fun getBottom ()F + public final fun getL ()F public final fun getLeft ()F + public final fun getR ()F public final fun getRight ()F + public final fun getT ()F public final fun getTop ()F public fun hashCode ()I public fun toString ()Ljava/lang/String;