From 724956278df8a2d06496f4752849ade88fe82d5d Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 5 Jun 2026 18:27:35 -0700 Subject: [PATCH] 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 --- src/render.c | 54 +++++++++++++++++++++++++++++++++++++++++++++++ src/render.h | 9 ++++++++ src/spectrogram.c | 14 ++++++++---- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/render.c b/src/render.c index 08b2df1..544a1b4 100644 --- a/src/render.c +++ b/src/render.c @@ -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) { diff --git a/src/render.h b/src/render.h index 12c88e0..b3583e7 100644 --- a/src/render.h +++ b/src/render.h @@ -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); diff --git a/src/spectrogram.c b/src/spectrogram.c index 3ee89e8..9c225f6 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -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 });