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 <noreply@anthropic.com>
This commit is contained in:
1 parent
10267dec27
commit
ba2afbaedb
4 files changed
+108
No files matched your search
@@ -601,6 +601,11 @@ pub struct RenderedText {
|
|||||||
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
|
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
|
||||||
pub size: Vec2,
|
pub size: Vec2,
|
||||||
pub color: UiColor,
|
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 {
|
impl TextData {
|
||||||
@@ -619,6 +624,7 @@ impl TextData {
|
|||||||
glyphs: std::sync::Arc::new(glyphs),
|
glyphs: std::sync::Arc::new(glyphs),
|
||||||
size: buffer.size(),
|
size: buffer.size(),
|
||||||
color: attrs.color,
|
color: attrs.color,
|
||||||
|
generation: self.atlas.generation(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,10 @@ struct Page {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct GlyphAtlas {
|
pub struct GlyphAtlas {
|
||||||
pages: Vec<Page>,
|
pages: Vec<Page>,
|
||||||
|
/// 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
|
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached
|
||||||
/// too, so it is not re-rasterised on every layout.
|
/// too, so it is not re-rasterised on every layout.
|
||||||
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
|
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
|
||||||
@@ -166,6 +170,13 @@ impl GlyphAtlas {
|
|||||||
self.entries.insert(key, None);
|
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 {
|
pub fn page_count(&self) -> usize {
|
||||||
self.pages.len()
|
self.pages.len()
|
||||||
}
|
}
|
||||||
@@ -188,9 +199,20 @@ impl GlyphAtlas {
|
|||||||
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
|
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
|
||||||
/// free message back through their `Textures`; see `Textures::reset`'s
|
/// free message back through their `Textures`; see `Textures::reset`'s
|
||||||
/// doc for why that is harmless here.
|
/// 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) {
|
pub fn clear(&mut self) {
|
||||||
self.pages.clear();
|
self.pages.clear();
|
||||||
self.entries.clear();
|
self.entries.clear();
|
||||||
|
self.generation += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,12 +200,31 @@ impl<'a> Painter<'a> {
|
|||||||
.render(buffer, attrs, width, &mut ui.textures, density)
|
.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.
|
/// 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
|
/// `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
|
/// absolute pixel offset from it, so re-drawing after a resize is this loop
|
||||||
/// and nothing else.
|
/// and nothing else.
|
||||||
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
|
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| {
|
let flags_for = |is_color| {
|
||||||
if is_color {
|
if is_color {
|
||||||
GlyphPrimitive::IS_COLOR
|
GlyphPrimitive::IS_COLOR
|
||||||
|
|||||||
@@ -60,8 +60,17 @@ impl TextView {
|
|||||||
} else {
|
} else {
|
||||||
None
|
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
|
if width == self.width
|
||||||
&& let Some(tex) = &self.tex
|
&& let Some(tex) = &self.tex
|
||||||
|
&& tex.generation == generation
|
||||||
&& !self.attrs.changed
|
&& !self.attrs.changed
|
||||||
&& !self.buf.changed
|
&& !self.buf.changed
|
||||||
{
|
{
|
||||||
@@ -157,3 +166,55 @@ impl DerefMut for TextView {
|
|||||||
&mut self.attrs
|
&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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user