fix: bake UI font atlas at physical DPI size so text isn't skinny on standard-DPI

The font atlas was rasterized once at a fixed 16px and point-filtered, then
scaled to a logical size at draw time. On HiDPI it got upscaled (acceptable);
on standard-DPI it got downscaled, and point filtering dropped rows/columns of
the anti-aliased glyphs, thinning stems into a faint "hyper-skinny" look.

Bake the atlas at the real physical size text is drawn at
(16 * GetUIScale() * GetWindowScaleDPI().y), quantized to 4px steps, and rebuild
it via EnsureUIFont() once per frame when that density changes (resize, or
moving between monitors of different DPI). Use mipmaps + trilinear filtering so
downsampling stays smooth. Call sites are unchanged: DrawTextEx still scales the
atlas glyph to the same logical size, only the backing resolution differs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 18:27:35 -07:00
parent 83277e5086
commit 724956278d
3 changed files with 73 additions and 4 deletions
+54
View File
@@ -22,6 +22,60 @@ float GetUIScale(void)
return (scaleX + scaleY) / 2.0f;
}
// ===== DPI-aware UI font atlas =====
// The font is drawn at a *logical* size (baseSize * GetUIScale()), but the
// FLAG_WINDOW_HIGHDPI framebuffer then scales that to physical pixels by the
// monitor's DPI factor. So the glyphs a user actually sees are sized
// baseSize * GetUIScale() * dpiScale physical pixels. If the atlas is rasterized
// at a fixed size (the old behavior: a single 16px atlas) it gets upscaled on
// HiDPI (soft but acceptable) and *downscaled* on standard DPI, which thins the
// stems into a faint, "hyper-skinny" look. To keep text crisp on every display
// we bake the atlas at that physical size and rebuild it when the effective
// density changes. Call sites are unchanged: DrawTextEx still scales the atlas
// glyph (font.baseSize px) down to the logical draw size, so only the backing
// resolution differs.
static char s_fontPath[1024] = { 0 };
static int s_fontAtlasBase = 0; // pixel size the current atlas is baked at
// Largest base size any DrawTextScaled() call passes; the atlas is sized for
// this so all smaller text only ever downsamples (which is clean) from it.
#define UI_FONT_REF_SIZE 16.0f
void InitUIFont(const char* path)
{
snprintf(s_fontPath, sizeof(s_fontPath), "%s", path);
s_fontAtlasBase = 0;
EnsureUIFont();
}
void EnsureUIFont(void)
{
if (s_fontPath[0] == 0) return; // InitUIFont not called yet
Vector2 dpi = GetWindowScaleDPI();
float density = GetUIScale() * (dpi.y > 0.0f ? dpi.y : 1.0f);
if (density < 0.1f) density = 1.0f;
// Quantize to 4px steps so a live resize drag crosses a rebuild boundary
// only every ~320px instead of every frame; round up so we never undersample.
int target = (int)(UI_FONT_REF_SIZE * density + 0.5f);
target = (target + 3) & ~3;
if (target < 16) target = 16;
if (target > 128) target = 128; // sanity cap on atlas size
if (target == s_fontAtlasBase) return;
Font f = LoadFontEx(s_fontPath, target, 0, 0);
if (f.texture.id == 0) return; // keep the existing atlas on failure
// Mipmaps + trilinear so downsampling to small on-screen sizes stays smooth
// rather than aliasing the way the default point filter does.
GenTextureMipmaps(&f.texture);
SetTextureFilter(f.texture, TEXTURE_FILTER_TRILINEAR);
if (mainFont.texture.id != 0) UnloadFont(mainFont);
mainFont = f;
s_fontAtlasBase = target;
}
void DrawTextScaled(const char* text, float x, float y, float baseSize, Color color)
{
if (mainFont.texture.id == 0) {
+9
View File
@@ -9,6 +9,15 @@ float GetUIScale(void);
void DrawTextScaled(const char* text, float x, float y, float baseSize, Color color);
float MeasureTextScaled(const char* text, float baseSize);
// --- DPI-aware UI font ---
// InitUIFont remembers the TTF path and bakes the first atlas; EnsureUIFont
// rebuilds it (cheap no-op when unchanged) whenever the effective pixel density
// (window UI scale * monitor DPI scale) shifts, keeping text crisp on any
// display instead of thinning out on standard-DPI screens. Call EnsureUIFont
// once per frame before drawing text.
void InitUIFont(const char* path);
void EnsureUIFont(void);
// --- Colormaps ---
void GenerateColormapTexture(void);
const char* ColormapName(ColormapType type);
+10 -4
View File
@@ -779,10 +779,11 @@ int main(int argc, char* argv[])
SearchAndSetResourceDir("resources");
// Load TTF font at a fixed base size. Scaling is handled uniformly by
// GetUIScale() for both layout and DrawTextScaled(), so the font scales
// naturally with the window size on any DPI monitor.
mainFont = LoadFontEx("fonts/DejaVuSansMono.ttf", 16, 0, 0);
// Load the TTF font. The atlas is rasterized at the physical pixel size text
// is actually drawn at (window UI scale * monitor DPI scale) and rebuilt by
// EnsureUIFont() when that density changes, so glyphs stay crisp on both
// HiDPI and standard-DPI displays instead of thinning out when downscaled.
InitUIFont("fonts/DejaVuSansMono.ttf");
if (mainFont.texture.id == 0) {
TraceLog(LOG_WARNING, "Failed to load TTF font, using default bitmap font");
}
@@ -1491,6 +1492,11 @@ int main(int argc, char* argv[])
app.showAbout = false;
}
// Keep the font atlas baked at the current display density (window scale
// * DPI). Cheap no-op unless the density actually changed (resize, or
// dragging the window between monitors of different DPI).
EnsureUIFont();
// Rendering
BeginDrawing();
ClearBackground((Color){ 30, 30, 30, 255 });