diff --git a/src/main/java/noppes/npcs/config/GlyphCache.java b/src/main/java/noppes/npcs/config/GlyphCache.java index f978f52f5..8cc890869 100644 --- a/src/main/java/noppes/npcs/config/GlyphCache.java +++ b/src/main/java/noppes/npcs/config/GlyphCache.java @@ -78,13 +78,13 @@ public class GlyphCache { private static final int GLYPH_PADDING = 2; /** - * Spacing (in pixels) inserted between adjacent glyphs in the temporary string image during rasterization. - * Each glyph is shifted right by GLYPH_SPACING * glyphIndex to prevent kerning from merging adjacent glyphs. - * This must be >= 2 * GLYPH_PADDING + 1 so that when padded bounding boxes are extracted for adjacent glyphs, - * they never overlap. Without sufficient spacing, bold/italic glyphs (which have wider ink bounds) can bleed - * fragments of neighbouring glyphs into each other's cached texture regions. + * Minimum spacing (in pixels) inserted between adjacent glyphs in the temporary string image during + * rasterization. Each glyph is shifted right by glyphSpacing * glyphIndex to prevent kerning from + * merging adjacent glyphs. This base value is used for plain/bold styles. For italic fonts, the + * spacing is increased dynamically by {@link #computeGlyphSpacing(Font)} to account for the + * horizontal overhang caused by the italic slant. */ - private static final int GLYPH_SPACING = 2 * GLYPH_PADDING + 1; + private static final int GLYPH_SPACING_BASE = 2 * GLYPH_PADDING + 1; /** * Transparent (alpha zero) white background color for use with BufferedImage.clearRect(). @@ -158,6 +158,36 @@ public class GlyphCache { return GLYPH_PADDING; } + /** + * Compute the inter-glyph spacing needed for a given font when rasterizing into the temporary + * string image. For plain and bold fonts, the base spacing (2*GLYPH_PADDING+1) is sufficient. + * For italic fonts, the slant causes the top of each glyph to lean horizontally, and at large + * raster sizes (e.g. 54pt at scale 3) this overhang can be 8+ pixels — far exceeding the base + * spacing's 1-pixel effective gap. Without adequate spacing, adjacent italic glyphs' rendered + * pixels overlap in the string image, and narrow glyphs like 'I'/'i' capture neighboring + * fragments as visible dot artifacts in their cached texture. + * + * The italic overhang is computed as: lineHeight * |italicAngle|. Java2D's standard italic + * angle is ~0.2 (approx 12°). A safety factor of 1.5× is applied since antialiasing can + * produce faint alpha slightly beyond the geometric slant boundary. + */ + private int computeGlyphSpacing(Font font) { + float italicAngle = font.getItalicAngle(); + if (italicAngle == 0 && font.isItalic()) { + italicAngle = -0.2f; // Java2D synthetic italic default shear + } + + if (italicAngle == 0) { + return GLYPH_SPACING_BASE; + } + + java.awt.font.LineMetrics lm = font.getLineMetrics("I", fontRenderContext); + float lineHeight = lm.getAscent() + lm.getDescent(); + int italicOverhang = (int) Math.ceil(lineHeight * Math.abs(italicAngle) * 1.5f); + + return Math.max(GLYPH_SPACING_BASE, 2 * GLYPH_PADDING + italicOverhang + 1); + } + /** * Intermediate data array for use with textureImage.getRgb(). @@ -481,6 +511,7 @@ public class GlyphCache { int numGlyphs = vector.getNumGlyphs(); /* Length of the GlyphVector */ Rectangle dirty = null; /* Total area within texture that needs to be updated with glTexSubImage2D() */ boolean vectorRendered = false; /* True if entire GlyphVector was rendered into stringImage */ + int glyphSpacing = computeGlyphSpacing(font); for (int index = 0; index < numGlyphs; index++) { int charIndex = start + vector.getGlyphCharIndex(index); @@ -497,6 +528,20 @@ public class GlyphCache { continue; } + /* + * Skip whitespace glyphs that have no visible ink (spaces, tabs, etc.). These characters + * contribute only advance width, not visible pixels. Without this check, GLYPH_PADDING + * expansion would create a small non-zero rect for the empty glyph, and the blit from + * stringImage could capture stray antialiased fringe pixels from neighboring glyphs, + * causing whitespace to render as visible dots. + */ + { + Rectangle glyphBounds = vector.getGlyphPixelBounds(index, fontRenderContext, 0, 0); + if (glyphBounds.width == 0 || glyphBounds.height == 0) { + continue; + } + } + /* * The only way to get glyph shapes with font hinting is to draw the entire glyph vector into a * temporary BufferedImage, and then bit blit the individual glyphs based on their bounding boxes @@ -515,7 +560,7 @@ public class GlyphCache { */ for (int i = 0; i < numGlyphs; i++) { Point2D pos = vector.getGlyphPosition(i); - pos.setLocation(pos.getX() + GLYPH_SPACING * i, pos.getY()); + pos.setLocation(pos.getX() + glyphSpacing * i, pos.getY()); vector.setGlyphPosition(i, pos); } @@ -537,8 +582,16 @@ public class GlyphCache { allocateStringImage(width, height); } - /* Erase the region where the string will get drawn (expanded for hinting overflow) */ - stringGraphics.clearRect(0, 0, vectorBounds.width, vectorBounds.height); + /* + * Clear the ENTIRE stringImage, not just the vectorBounds area. The stringImage is reused + * across calls and may be larger than the current vectorBounds. Individual glyph pixel bounds + * (from getGlyphPixelBounds()) can differ slightly from the aggregate vector bounds + * (from getPixelBounds()) due to integer rounding. After GLYPH_PADDING expansion, a glyph's + * padded rect could extend 1-2 pixels beyond the vectorBounds area, capturing stale pixels + * from a previous render. This caused tiny dot artifacts above capital letters whose ink + * extends to the top of the line bounds. Clearing the full image eliminates this class of bugs. + */ + stringGraphics.clearRect(0, 0, stringImage.getWidth(), stringImage.getHeight()); /* Draw string with opaque white color and baseline adjustment so the upper-left corner of the image is at (0,0) */ stringGraphics.drawGlyphVector(vector, -vectorBounds.x, -vectorBounds.y); @@ -555,6 +608,30 @@ public class GlyphCache { Rectangle rect = vector.getGlyphPixelBounds(index, fontRenderContext, -vectorBounds.x, -vectorBounds.y); rect.grow(GLYPH_PADDING, GLYPH_PADDING); + /* + * Clamp the padded rect to the stringImage bounds. Due to rounding differences between + * getPixelBounds() and getGlyphPixelBounds(), a padded glyph rect can occasionally extend + * beyond the image edge. Reading pixels outside the image would cause an exception or + * capture garbage data. + */ + if (rect.x < 0) { + rect.width += rect.x; // shrink width by the overshoot + rect.x = 0; + } + if (rect.y < 0) { + rect.height += rect.y; // shrink height by the overshoot + rect.y = 0; + } + if (rect.x + rect.width > stringImage.getWidth()) { + rect.width = stringImage.getWidth() - rect.x; + } + if (rect.y + rect.height > stringImage.getHeight()) { + rect.height = stringImage.getHeight() - rect.y; + } + if (rect.width <= 0 || rect.height <= 0) { + continue; + } + /* If the current line in cache image is full, then advance to the next line */ if (cachePosX + rect.width + GLYPH_BORDER > TEXTURE_WIDTH) { cachePosX = GLYPH_BORDER; diff --git a/src/main/java/noppes/npcs/config/StringCache.java b/src/main/java/noppes/npcs/config/StringCache.java index dcf3ecadb..63c0b6a91 100644 --- a/src/main/java/noppes/npcs/config/StringCache.java +++ b/src/main/java/noppes/npcs/config/StringCache.java @@ -611,6 +611,12 @@ public class StringCache { /* Select the current glyph's texture information and horizontal layout position within this string */ Glyph glyph = entry.glyphs[glyphIndex]; GlyphCache.Entry texture = glyph.texture; + + /* Skip glyphs with no cached texture (whitespace characters like spaces and tabs) */ + if (texture == null) { + continue; + } + int glyphX = glyph.x + glyph.inkOffsetX; /* @@ -698,7 +704,7 @@ public class StringCache { Glyph glyph = entry.glyphs[glyphIndex]; /* The strike/underlines are drawn beyond the glyph's width to include the extra space between glyphs */ - int glyphSpace = glyph.advance - glyph.texture.width; + int glyphSpace = glyph.texture != null ? glyph.advance - glyph.texture.width : 0; /* Draw underline under glyph if the style is enabled */ if ((renderStyle & ColorCode.UNDERLINE) != 0) {