From ba2afbaedbb2bb11e09dee0ae12c1a277e5e5b47 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 6 Sep 2026 23:22:40 -0400 Subject: [PATCH] iris: a cleared glyph atlas must un-cache every RenderedText, not just empty itself Iris's phone, 2026-09-06 22:16: after leaving the app and returning, every glyph drawn *before* the resume came back as fragments of other letters, while the diagnostics text drawn after it was perfect. The renderer rebuild does force a full redraw -- `surface_changed` calls `render.resize(...)`, which sets `UiRenderState::resized`, which makes the next `update` take `redraw_all`. What survives that is one cache further in: `TextView::render` returns its cached `RenderedText` whenever the wrap width, buffer and attrs are unchanged, so `TextData::place` is never reached, nothing is re-rasterised into the fresh atlas, and the *previous* atlas's uv_min/uv_max/layer go straight back to the GPU. Only text whose content changed after the resume re-shapes -- exactly the split in the screenshot. One mechanism rather than a per-holder invalidation path: `GlyphAtlas` carries a `generation`, bumped by `clear`; a `RenderedText` records the one it was placed against; and `TextView::render`'s cache key includes it, so clearing the atlas makes every cached render un-reusable at once. `Painter::glyphs` debug-asserts that a submitted quad's generation is the live one, catching the fault at the submission instead of on screen. Test `clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it` (iris/src/widget/text/mod.rs): draw, clear the atlas, resize, draw again, and assert the atlas holds the same glyph count. Confirmed to fail without the cache-key line -- it trips the new debug_assert with "glyphs placed against atlas generation 0 submitted against 1". Co-Authored-By: Claude Fable 5.1 --- iris/core/src/primitive/text.rs | 6 ++++ iris/core/src/render/atlas.rs | 22 ++++++++++++ iris/core/src/ui/painter.rs | 19 ++++++++++ iris/src/widget/text/mod.rs | 61 +++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+) diff --git a/iris/core/src/primitive/text.rs b/iris/core/src/primitive/text.rs index 2b67b81..62dbf69 100644 --- a/iris/core/src/primitive/text.rs +++ b/iris/core/src/primitive/text.rs @@ -601,6 +601,11 @@ pub struct RenderedText { pub glyphs: std::sync::Arc>, pub size: Vec2, pub color: UiColor, + /// The [`GlyphAtlas::generation`] the glyphs above were placed against. + /// A holder must re-render rather than re-emit these quads once the + /// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens + /// otherwise); `Painter::glyphs` debug-asserts it. + pub generation: u64, } impl TextData { @@ -619,6 +624,7 @@ impl TextData { glyphs: std::sync::Arc::new(glyphs), size: buffer.size(), color: attrs.color, + generation: self.atlas.generation(), } } } diff --git a/iris/core/src/render/atlas.rs b/iris/core/src/render/atlas.rs index 4578639..320b81b 100644 --- a/iris/core/src/render/atlas.rs +++ b/iris/core/src/render/atlas.rs @@ -71,6 +71,10 @@ struct Page { #[derive(Default)] pub struct GlyphAtlas { pages: Vec, + /// Bumped by [`GlyphAtlas::clear`], so anything holding placed glyphs + /// from an earlier atlas can tell that its coordinates are stale -- + /// see that method's doc for what goes wrong without it. + generation: u64, /// `None` for a glyph that rasterised to nothing -- a space, say. Cached /// too, so it is not re-rasterised on every layout. entries: HashMap>, @@ -166,6 +170,13 @@ impl GlyphAtlas { self.entries.insert(key, None); } + /// Which atlas the entries handed out right now belong to. A + /// [`crate::RenderedText`] records this when it is built and is only + /// reusable while it still matches. + pub fn generation(&self) -> u64 { + self.generation + } + pub fn page_count(&self) -> usize { self.pages.len() } @@ -188,9 +199,20 @@ impl GlyphAtlas { /// new. Dropping `pages` also drops its `TextureHandle`s, which send a /// free message back through their `Textures`; see `Textures::reset`'s /// doc for why that is harmless here. + /// Bumping `generation` here is the other half of the same + /// invalidation: emptying this atlas does nothing about the + /// `RenderedText`s widgets are *already holding* + /// (`iris::widget::TextView`'s `tex` cache), whose `PlacedGlyph`s carry + /// `uv_min`/`uv_max`/`layer` into the atlas that has just been thrown + /// away. Those redraw perfectly happily and sample whatever now sits at + /// those coordinates -- the fragments-of-other-glyphs Iris photographed + /// after resuming the app on 2026-09-06. One counter, checked where the + /// cache is read, is what makes a cached render un-reusable across a + /// renderer rebuild. pub fn clear(&mut self) { self.pages.clear(); self.entries.clear(); + self.generation += 1; } } diff --git a/iris/core/src/ui/painter.rs b/iris/core/src/ui/painter.rs index 30316da..4b7e6aa 100644 --- a/iris/core/src/ui/painter.rs +++ b/iris/core/src/ui/painter.rs @@ -200,12 +200,31 @@ impl<'a> Painter<'a> { .render(buffer, attrs, width, &mut ui.textures, density) } + /// Which glyph atlas the glyphs handed out right now belong to -- + /// what a widget caching a [`RenderedText`] across frames has to + /// compare against before re-emitting it (`GlyphAtlas::clear`). + pub fn atlas_generation(&mut self) -> u64 { + self.rsc.ui_mut().text.atlas.generation() + } + /// Draw a laid-out string: one quad per glyph, all sampling the atlas. /// /// `origin` is where the text's top-left goes; every glyph is placed at an /// absolute pixel offset from it, so re-drawing after a resize is this loop /// and nothing else. pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) { + // A caller re-emitting quads placed against an atlas that has since + // been cleared draws every glyph from coordinates now holding + // something else. Caught at the submission rather than on screen, + // where it reads as fragments of unrelated letters. + debug_assert_eq!( + text.generation, + self.atlas_generation(), + "glyphs placed against atlas generation {} submitted against {}: the holder did not \ + re-render after the atlas was cleared", + text.generation, + self.atlas_generation(), + ); let flags_for = |is_color| { if is_color { GlyphPrimitive::IS_COLOR diff --git a/iris/src/widget/text/mod.rs b/iris/src/widget/text/mod.rs index 39fdd33..cc9268d 100644 --- a/iris/src/widget/text/mod.rs +++ b/iris/src/widget/text/mod.rs @@ -60,8 +60,17 @@ impl TextView { } else { None }; + // The atlas generation is part of the cache key, not a separate + // invalidation path: a `RenderedText` is only meaningful against the + // atlas its glyphs were placed in, and a renderer rebuild clears + // that atlas out from under every widget at once + // (`GlyphAtlas::clear`). Without this the text drawn before the + // rebuild is re-emitted with the old atlas's coordinates and comes + // back as fragments of whatever now occupies them. + let generation = painter.atlas_generation(); if width == self.width && let Some(tex) = &self.tex + && tex.generation == generation && !self.attrs.changed && !self.buf.changed { @@ -157,3 +166,55 @@ impl DerefMut for TextView { &mut self.attrs } } + +#[cfg(test)] +mod tests { + use crate::layout_tests::TestRsc; + use crate::prelude::*; + + /// A renderer rebuild empties the glyph atlas under every widget at + /// once (`iris_core::GlyphAtlas::clear`, called from + /// `IrisViewPeer::surface_changed`'s new-renderer branch). Anything + /// still holding a `RenderedText` from before then owns UV rectangles + /// into a texture that no longer exists -- what Iris photographed on + /// 2026-09-06 as every pre-resume glyph coming back as fragments while + /// the text drawn after the resume was perfect. + /// + /// The check is the atlas repopulating: `TextView::render`'s cache + /// short-circuits before `TextData::place`, so without the generation + /// in its key the second frame rasterises nothing and the atlas stays + /// empty. (`Painter::glyphs`'s `debug_assert!` fires here too, which is + /// the same finding from the submission side.) + #[test] + fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let root = wtext("hello there") + .size(18) + .color(UiColor::WHITE) + .add_strong(&mut rsc) + .any(); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + + let rasterised = rsc.ui.text.atlas.glyph_count(); + assert!(rasterised > 0, "the first frame rasterised no glyphs"); + + // Exactly what the new-renderer branch does, in order: empty the + // atlas, then redraw everything (`resize` is what marks the tree + // for a full redraw, and a real `surface_changed` always calls it). + rsc.ui.text.atlas.clear(); + assert_eq!(rsc.ui.text.atlas.glyph_count(), 0); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + + assert_eq!( + rsc.ui.text.atlas.glyph_count(), + rasterised, + "the second frame re-emitted its cached glyphs instead of \ + re-rendering them against the fresh atlas" + ); + } +}