From 1e4b1f23a32c025714ae840fb0d7199f2c857269 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 12 Jul 2026 11:36:33 +0300 Subject: [PATCH] fix(text): grow the SDF glyph atlas instead of dropping glyphs (#347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text and dimensions vanished on a text-heavy, multi-font DWG. The global SDF glyph atlas was a fixed 1024² page shared across drawings; once full, pack() returned None and every further (font, char, bold) glyph was silently skipped — so late-baked glyphs (dimension text especially) never rendered. Initialise the atlas at 2048² (covers real multi-font/CJK drawings with no growth, so no stale UVs) and add a grow_height() fallback that doubles height, appends zeroed rows, rescales cached entries' V/solid_uv and marks the texture dirty (up to MAX_ATLAS_PX = 8192) rather than dropping glyphs. Co-Authored-By: Claude Opus 4.8 --- src/scene/text/sdf_atlas.rs | 74 ++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index 4d2922b9c..68fd7e924 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -36,7 +36,10 @@ use crate::scene::text::lff::Glyph; /// Mirrors the existing global font-glyph caches (see `ttf_glyph`). pub fn text_atlas() -> &'static Mutex { static ATLAS: OnceLock> = OnceLock::new(); - ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(1024, 1024))) + // 2048² (~780 glyphs) covers real text-heavy drawings (many fonts + CJK / + // Vietnamese diacritics) without ever growing; `pack()` still grows as a + // fallback beyond that. See issue #347. + ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(2048, 2048))) } /// TEXTFILL system variable — `true` fills TrueType glyphs (default), `false` @@ -87,6 +90,11 @@ const PEN_HALF_UNITS: f32 = 0.35; /// pathologically wide/tall glyph blowing up the atlas. const MAX_TILE_PX: u32 = 512; +/// Largest atlas dimension the packer will grow the texture to, kept within the +/// common wgpu `max_texture_dimension_2d` limit (8192). The atlas starts at +/// 1024² and doubles its height on demand up to this bound. +const MAX_ATLAS_PX: u32 = 8192; + // ── Public types ───────────────────────────────────────────────────────────── /// Placement of one baked glyph within the atlas. @@ -222,8 +230,33 @@ impl GlyphAtlas { entry } - /// Blit a baked tile into the atlas via a simple shelf packer. Returns the - /// placement, or `None` if the tile does not fit (atlas full). + /// Grow the atlas taller (keeping the width, so packed X positions and their + /// normalized U stay valid) and rescale every cached entry's V for the new + /// height. Returns false once the height hits `MAX_ATLAS_PX` — text-heavy + /// drawings would otherwise silently drop glyphs once the single page filled + /// (issue #347). Width is unchanged, so the row-major data just grows with + /// zeroed rows appended at the bottom. + fn grow_height(&mut self) -> bool { + if self.height >= MAX_ATLAS_PX { + return false; + } + let old_h = self.height; + let new_h = (self.height * 2).min(MAX_ATLAS_PX); + self.data.resize((self.width * new_h) as usize, 0); + let sy = old_h as f32 / new_h as f32; + for entry in self.entries.values_mut().flatten() { + entry.uv_min[1] *= sy; + entry.uv_max[1] *= sy; + } + self.solid_uv[1] *= sy; + self.height = new_h; + self.dirty = true; + true + } + + /// Blit a baked tile into the atlas via a simple shelf packer. Grows the + /// atlas when a tile doesn't fit; returns `None` only once it can grow no + /// further (or the tile is wider than the atlas, which the tile cap prevents). fn pack(&mut self, tile: BakedTile) -> Option { // Wrap to a new shelf if the tile overflows the current row. if self.cursor_x + tile.w > self.width { @@ -231,8 +264,13 @@ impl GlyphAtlas { self.cursor_x = 0; self.shelf_h = 0; } - if self.cursor_x + tile.w > self.width || self.cursor_y + tile.h > self.height { - return None; // atlas full + if self.cursor_x + tile.w > self.width { + return None; // tile wider than the atlas (MAX_TILE_PX guards this) + } + while self.cursor_y + tile.h > self.height { + if !self.grow_height() { + return None; // atlas full and cannot grow further + } } let (ox, oy) = (self.cursor_x, self.cursor_y); for row in 0..tile.h { @@ -481,11 +519,35 @@ mod tests { #[test] fn full_atlas_returns_none() { - // A tiny atlas cannot fit even one padded tile. + // A tiny atlas cannot fit even one padded tile (wider than the atlas — + // the width the packer does not grow). let mut atlas = GlyphAtlas::new(4, 4); assert!(atlas.pack(bake_glyph(&filled_square(), PEN_HALF_UNITS).unwrap()).is_none()); } + #[test] + fn atlas_grows_instead_of_dropping_glyphs() { + // Text-heavy drawings need more unique glyphs than one 1024² page holds; + // the packer must grow taller rather than start returning None, which + // silently dropped text and dimensions (issue #347). + let mut atlas = GlyphAtlas::new(1024, 1024); + let start_h = atlas.height(); + let mut packed = 0; + for _ in 0..1000 { + match atlas.pack(bake_glyph(&filled_square(), PEN_HALF_UNITS).unwrap()) { + Some(e) => { + // UVs stay normalized after any growth-driven rescale. + assert!(e.uv_min[1] >= 0.0 && e.uv_max[1] <= 1.0); + packed += 1; + } + None => break, + } + } + assert_eq!(packed, 1000, "every tile must fit once the atlas grows"); + assert!(atlas.height() > start_h, "atlas should have grown taller"); + assert!(atlas.height() <= MAX_ATLAS_PX); + } + // Full path over a real, embedded stroke font (no system fonts needed): // Face::resolve -> glyph -> bake -> pack, plus the (font, char) cache. #[test]