Improve truetype font text positioning with fractional glyph advances and DPI-scaled coordinates.

Disallow Font:setFallbacks for fonts that have different DPI scales.
It's still too easy to render text at a non-integer pixel when fractional DPI scales are used, so maybe a more thorough redesign would be useful.

Fixes #2318.
Fixes #2168.
This commit is contained in:
Sasha Szpakowski
2026-06-21 16:30:43 -03:00
parent a7679a5509
commit 7d49d880d7
7 changed files with 52 additions and 27 deletions
+5 -6
View File
@@ -84,7 +84,7 @@ love::Type TextShaper::type("TextShaper", &Object::type);
TextShaper::TextShaper(Rasterizer *rasterizer) TextShaper::TextShaper(Rasterizer *rasterizer)
: rasterizers{rasterizer} : rasterizers{rasterizer}
, dpiScales{rasterizer->getDPIScale()} , dpiScale(rasterizer->getDPIScale())
, height(floorf(rasterizer->getHeight() / rasterizer->getDPIScale() + 0.5f)) , height(floorf(rasterizer->getHeight() / rasterizer->getDPIScale() + 0.5f))
, pixelHeight(rasterizer->getHeight()) , pixelHeight(rasterizer->getHeight())
, lineHeight(1) , lineHeight(1)
@@ -371,7 +371,10 @@ void TextShaper::setFallbacks(const std::vector<Rasterizer*> &fallbacks)
for (Rasterizer *r : fallbacks) for (Rasterizer *r : fallbacks)
{ {
if (r->getDataType() != rasterizers[0]->getDataType()) if (r->getDataType() != rasterizers[0]->getDataType())
throw love::Exception("Font fallbacks must be of the same font type."); throw love::Exception("Font fallbacks must be of the same font type as the primary Font.");
if (r->getDPIScale() != rasterizers[0]->getDPIScale())
throw love::Exception("Font fallbacks must have the same DPI scales as the primary Font.");
} }
// Clear caches. // Clear caches.
@@ -379,13 +382,9 @@ void TextShaper::setFallbacks(const std::vector<Rasterizer*> &fallbacks)
glyphAdvances.clear(); glyphAdvances.clear();
rasterizers.resize(1); rasterizers.resize(1);
dpiScales.resize(1);
for (Rasterizer *r : fallbacks) for (Rasterizer *r : fallbacks)
{
rasterizers.push_back(r); rasterizers.push_back(r);
dpiScales.push_back(r->getDPIScale());
}
} }
} // font } // font
+1 -1
View File
@@ -138,7 +138,7 @@ protected:
static inline bool isWhitespace(uint32 codepoint) { return codepoint == ' ' || codepoint == '\t'; } static inline bool isWhitespace(uint32 codepoint) { return codepoint == ' ' || codepoint == '\t'; }
std::vector<StrongRef<Rasterizer>> rasterizers; std::vector<StrongRef<Rasterizer>> rasterizers;
std::vector<float> dpiScales; float dpiScale;
private: private:
+38 -12
View File
@@ -36,6 +36,24 @@ namespace font
namespace freetype namespace freetype
{ {
static inline float fixed26_6ToFloat(int32 v)
{
return (v >> 6) + (v & 63) / 64.0f;
}
static int32 floatToFixed26_6(float v)
{
float integer = 0;
float frac = modf(v, &integer);
return (int32)(((uint32)integer << 6) | (uint32)(frac * 64));
}
static float snapToPixel(float v, float dpiScale, float dpiScaleInverse)
{
return roundf(v * dpiScale) * dpiScaleInverse;
}
HarfbuzzShaper::HarfbuzzShaper(TrueTypeRasterizer *rasterizer) HarfbuzzShaper::HarfbuzzShaper(TrueTypeRasterizer *rasterizer)
: TextShaper(rasterizer) : TextShaper(rasterizer)
, spaceGlyphIndex() , spaceGlyphIndex()
@@ -97,8 +115,8 @@ void HarfbuzzShaper::updateSpacesForTabInfo()
{ {
spaceGlyphIndex.index = glyphid; spaceGlyphIndex.index = glyphid;
spaceGlyphIndex.rasterizerIndex = i; spaceGlyphIndex.rasterizerIndex = i;
tabSpacesAdvanceX = hb_font_get_glyph_h_advance(hbfont, glyphid) * SPACES_PER_TAB; tabSpacesAdvanceX = floatToFixed26_6(fixed26_6ToFloat(hb_font_get_glyph_h_advance(hbfont, glyphid)) * SPACES_PER_TAB);
tabSpacesAdvanceY = hb_font_get_glyph_v_advance(hbfont, glyphid) * SPACES_PER_TAB; tabSpacesAdvanceY = floatToFixed26_6(fixed26_6ToFloat(hb_font_get_glyph_v_advance(hbfont, glyphid)) * SPACES_PER_TAB);
break; break;
} }
} }
@@ -241,6 +259,8 @@ void HarfbuzzShaper::computeGlyphPositions(const ColoredCodepoints &codepoints,
} }
} }
const float dpiScaleInverse = 1.0f / dpiScale;
std::vector<BufferRange> bufferranges; std::vector<BufferRange> bufferranges;
computeBufferRanges(codepoints, range, bufferranges); computeBufferRanges(codepoints, range, bufferranges);
@@ -317,14 +337,18 @@ void HarfbuzzShaper::computeGlyphPositions(const ColoredCodepoints &codepoints,
// Harfbuzz position coordinate systems are based on the given font. // Harfbuzz position coordinate systems are based on the given font.
// Freetype uses 26.6 fixed point coordinates, so harfbuzz does too. // Freetype uses 26.6 fixed point coordinates, so harfbuzz does too.
p.position.x += (glyphpos.x_offset >> 6) / dpiScales[bufferrange.index]; p.position.x += fixed26_6ToFloat(glyphpos.x_offset) * dpiScaleInverse;
p.position.y += (glyphpos.y_offset >> 6) / dpiScales[bufferrange.index]; p.position.y += fixed26_6ToFloat(glyphpos.y_offset) * dpiScaleInverse;
// Snap position to the current font's source pixel grid.
p.position.x = snapToPixel(p.position.x, dpiScale, dpiScaleInverse);
p.position.y = snapToPixel(p.position.y, dpiScale, dpiScaleInverse);
positions->push_back(p); positions->push_back(p);
} }
curpos.x += (glyphpos.x_advance >> 6) / dpiScales[bufferrange.index]; curpos.x += fixed26_6ToFloat(glyphpos.x_advance) * dpiScaleInverse;
curpos.y += (glyphpos.y_advance >> 6) / dpiScales[bufferrange.index]; curpos.y += fixed26_6ToFloat(glyphpos.y_advance) * dpiScaleInverse;
// Account for extra spacing given to space characters. // Account for extra spacing given to space characters.
if (clustercodepoint == ' ' && extraspacing != 0.0f) if (clustercodepoint == ' ' && extraspacing != 0.0f)
@@ -340,8 +364,8 @@ void HarfbuzzShaper::computeGlyphPositions(const ColoredCodepoints &codepoints,
if (info != nullptr) if (info != nullptr)
{ {
info->width = maxwidth - offset.x; info->width = snapToPixel(maxwidth - offset.x, dpiScale, dpiScaleInverse);
info->height = curpos.y - offset.y; info->height = snapToPixel(curpos.y - offset.y, dpiScale, dpiScaleInverse);
if (curpos.x > offset.x) if (curpos.x > offset.x)
info->height += getCombinedHeight(); info->height += getCombinedHeight();
} }
@@ -359,6 +383,8 @@ int HarfbuzzShaper::computeWordWrapIndex(const ColoredCodepoints &codepoints, Ra
uint32 prevcodepoint = 0; uint32 prevcodepoint = 0;
const float dpiScaleInverse = 1.0f / dpiScale;
std::vector<BufferRange> bufferranges; std::vector<BufferRange> bufferranges;
computeBufferRanges(codepoints, range, bufferranges); computeBufferRanges(codepoints, range, bufferranges);
@@ -393,7 +419,7 @@ int HarfbuzzShaper::computeWordWrapIndex(const ColoredCodepoints &codepoints, Ra
glyphpos.y_advance = HB_DIRECTION_IS_VERTICAL(direction) ? tabSpacesAdvanceY : 0; glyphpos.y_advance = HB_DIRECTION_IS_VERTICAL(direction) ? tabSpacesAdvanceY : 0;
} }
float newwidth = w + (glyphpos.x_advance >> 6) / dpiScales[bufferrange.index]; float newwidth = w + fixed26_6ToFloat(glyphpos.x_advance) * dpiScaleInverse;
// Don't count trailing spaces in the output width. // Don't count trailing spaces in the output width.
if (isWhitespace(clustercodepoint)) if (isWhitespace(clustercodepoint))
@@ -407,7 +433,7 @@ int HarfbuzzShaper::computeWordWrapIndex(const ColoredCodepoints &codepoints, Ra
firstindexafterspace = info.cluster; firstindexafterspace = info.cluster;
// Only wrap when there's a non-space character. // Only wrap when there's a non-space character.
if (newwidth > wraplimit) if (snapToPixel(newwidth, dpiScale, dpiScaleInverse) > wraplimit)
{ {
// If this is the first character, wrap from the next one instead of this one. // If this is the first character, wrap from the next one instead of this one.
int wrapindex = (int)info.cluster > (int)range.first ? (int)info.cluster : (int)range.first + 1; int wrapindex = (int)info.cluster > (int)range.first ? (int)info.cluster : (int)range.first + 1;
@@ -420,7 +446,7 @@ int HarfbuzzShaper::computeWordWrapIndex(const ColoredCodepoints &codepoints, Ra
} }
if (width) if (width)
*width = outwidth; *width = snapToPixel(outwidth, dpiScale, dpiScaleInverse);
return wrapindex; return wrapindex;
} }
@@ -434,7 +460,7 @@ int HarfbuzzShaper::computeWordWrapIndex(const ColoredCodepoints &codepoints, Ra
} }
if (width) if (width)
*width = outwidth; *width = snapToPixel(outwidth, dpiScale, dpiScaleInverse);
// There wasn't any wrap in the middle of the range. // There wasn't any wrap in the middle of the range.
return (int) range.last + 1; return (int) range.last + 1;
+2 -2
View File
@@ -66,8 +66,8 @@ private:
std::vector<hb_buffer_t *> hbBuffers; std::vector<hb_buffer_t *> hbBuffers;
GlyphIndex spaceGlyphIndex; GlyphIndex spaceGlyphIndex;
int tabSpacesAdvanceX; int32 tabSpacesAdvanceX;
int tabSpacesAdvanceY; int32 tabSpacesAdvanceY;
}; // HarfbuzzShaper }; // HarfbuzzShaper
+2 -2
View File
@@ -608,12 +608,12 @@ void Font::printf(graphics::Graphics *gfx, const std::vector<love::font::Colored
printv(gfx, m, drawcommands, vertices); printv(gfx, m, drawcommands, vertices);
} }
int Font::getWidth(const std::string &str) float Font::getWidth(const std::string &str)
{ {
return shaper->getWidth(str); return shaper->getWidth(str);
} }
int Font::getWidth(uint32 glyph) float Font::getWidth(uint32 glyph)
{ {
return shaper->getGlyphAdvance(glyph); return shaper->getGlyphAdvance(glyph);
} }
+2 -2
View File
@@ -99,12 +99,12 @@ public:
* *
* @param str A string of text. * @param str A string of text.
**/ **/
int getWidth(const std::string &str); float getWidth(const std::string &str);
/** /**
* Returns the width of the passed glyph. * Returns the width of the passed glyph.
**/ **/
int getWidth(uint32 glyph); float getWidth(uint32 glyph);
/** /**
* Returns the maximal width of a wrapped string * Returns the maximal width of a wrapped string
+2 -2
View File
@@ -93,12 +93,12 @@ int w_Font_getWidth(lua_State *L)
if (lua_type(L, 2) == LUA_TSTRING) if (lua_type(L, 2) == LUA_TSTRING)
{ {
const char *str = luaL_checkstring(L, 2); const char *str = luaL_checkstring(L, 2);
luax_catchexcept(L, [&](){ lua_pushinteger(L, t->getWidth(str)); }); luax_catchexcept(L, [&](){ lua_pushnumber(L, t->getWidth(str)); });
} }
else else
{ {
uint32 glyph = (uint32) luaL_checknumber(L, 2); uint32 glyph = (uint32) luaL_checknumber(L, 2);
luax_catchexcept(L, [&](){ lua_pushinteger(L, t->getWidth(glyph)); }); luax_catchexcept(L, [&](){ lua_pushnumber(L, t->getWidth(glyph)); });
} }
return 1; return 1;
} }