From fc63dd022a20885a616178be7230cd85e1533da8 Mon Sep 17 00:00:00 2001 From: grom Date: Fri, 16 Oct 2026 07:23:18 -0600 Subject: [PATCH] ui_pass: a resized image re-prepares the materials that sample it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A booth pane's render target is resized between 256 and 512 as the pane it is lent to changes size, and `GpuImage::prepare_asset` builds a brand-new texture and view every time it runs. Nothing re-prepares a material because an image it names changed — `PreparedMaterial2d` prepares from its own asset events alone — so the UI's cached quad material went on holding the dead view and sampled a texture nothing rendered into any more. That is what "the preview is frozen" was: the first window to claim a slot bound a live texture and every later one bound a corpse, while the camera kept rendering and the bakes kept restaging into the texture that was no longer on screen. The cards never froze because a 256 card never resizes its slot; the big previews, which are the only panes that ask for 512, froze every time. `rebuild_ui_mesh` already hears the image stream for removals. It now also hears `Modified` and touches every cached material naming that image, which re-extracts it and rebuilds the bind group against the image's current view. Only the two resize sites emit this event, so a steady frame does no work. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/ui_pass.rs | 121 +++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 12 deletions(-) diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index 2b0be66c..557c033e 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -741,21 +741,46 @@ fn rebuild_ui_mesh( Res, ), ) { - // Forget the materials of removed images first, before any early return, since a missed read - // leaks: a cached material holds a strong image handle and, prepared, its GPU texture. - // `Removed`, not `Unused`: this cache is itself a strong handle, so `Unused` never fires for a - // cached image, and a producer retiring an image removes the asset explicitly. - let retired: Vec> = stores - .2 - .read() - .filter_map(|e| match e { - AssetEvent::Removed { id } => Some(*id), - _ => None, - }) - .collect(); + // Hear the image stream first, before any early return, since a missed read both leaks and + // freezes (below). + let (mut retired, mut reshaped): (Vec>, Vec>) = + (Vec::new(), Vec::new()); + for event in stores.2.read() { + match event { + AssetEvent::Removed { id } => retired.push(*id), + AssetEvent::Modified { id } => reshaped.push(*id), + _ => {} + } + } + // Forget the materials of removed images: a cached material holds a strong image handle and, + // prepared, its GPU texture. `Removed`, not `Unused`: this cache is itself a strong handle, so + // `Unused` never fires for a cached image, and a producer retiring an image removes the asset + // explicitly. if !retired.is_empty() { pools.materials.retain(|key, _| !retired.contains(&key.0)); } + // Re-prepare the materials of modified images. A modified image is a *new* GPU texture behind + // the same asset id — `GpuImage::prepare_asset` creates one every time it runs — and nothing + // re-prepares a material because an image it names changed: `PreparedMaterial2d` is prepared + // from `AssetEvent` alone. So the cached material's bind group goes on holding + // the dead view and the quad samples a texture nothing renders into any more. + // + // That is a frozen picture, and it is how a booth pane froze: a unit pane's target is resized + // between 256 and 512 as the pane it is lent to changes size + // ([`crate::portrait::resize_pane_targets`]), so the first window to claim a slot bound a live + // texture and every later one bound a corpse — the camera rendering all the while, the bakes + // restaging all the while, into a texture no longer on screen. + // + // Touching the material re-extracts it, and `as_bind_group` reads the image's current view. + // Only the two resize sites emit this event, so a steady frame does no work here. + if !reshaped.is_empty() { + for (key, material) in &pools.materials { + if reshaped.contains(&key.0) || key.7.is_some_and(|mask| reshaped.contains(&mask)) { + // The touch is the point; the value is not wanted. + let _ = stores.1.get_mut(material.id()); + } + } + } let (hidden, mesh_cost, cost_wanted) = (&hide_and_meter.0, &mut hide_and_meter.1, &hide_and_meter.2); // The meter is off unless asked (the hover recorder, `WOW_UI_COST=1`): one bool test. @@ -1312,6 +1337,78 @@ mod tests { ); } + /// A resized image is a new GPU texture behind the same id, and only a material event + /// re-prepares a material: without the touch the quad samples the dead one for good. + #[test] + fn a_resized_image_re_prepares_the_material_that_samples_it() { + let mut app = rebuild_app(); + let art = app + .world_mut() + .resource_mut::>() + .add(Image::new_fill( + Extent3d { + width: 256, + height: 256, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &[255; 4], + TextureFormat::Rgba8Unorm, + RenderAssetUsages::default(), + )); + let art_id = art.id(); + { + let mut q = app.world_mut().resource_mut::(); + q.quads.push(UiQuad { + rect: Rect::new(0.0, 0.0, 32.0, 32.0), + texture: Some(art.clone()), + ..default() + }); + q.dirty = true; + } + app.update(); + + // A cursor, not the current buffer: this harness ticks faster than the message swap. + let mut cursor = bevy::ecs::message::MessageCursor::>::default(); + let mut touched = |app: &App| { + cursor + .read( + app.world() + .resource::>>(), + ) + .filter(|e| matches!(e, AssetEvent::Modified { .. })) + .count() + }; + touched(&app); + + // The booth's resize, as `resize_pane_targets` makes it. + app.world_mut() + .resource_mut::>() + .get_mut(art_id) + .expect("the image is still there") + .resize(Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }); + // `Modified` is written in `PostUpdate` and read in `Update`, so the touch is next frame's. + app.update(); + app.update(); + assert!( + touched(&app) > 0, + "a material naming the resized image must be re-prepared, or its bind group keeps \ + the texture the image no longer has" + ); + + // And a frame that resizes nothing touches nothing: this runs before every early return. + app.update(); + assert_eq!( + touched(&app), + 0, + "a steady frame re-prepares no material (the UI cost meter asserts this too)" + ); + } + /// Does any live `UiQuadMaterial` still name this image? fn names_image(app: &App, id: AssetId) -> bool { app.world()