ui_pass: a resized image re-prepares the materials that sample it

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-16 07:23:18 -06:00
co-authored by Claude Opus 5
parent 77ffc36fbd
commit fc63dd022a
+109 -12
View File
@@ -741,21 +741,46 @@ fn rebuild_ui_mesh(
Res<crate::ui_script::UiCostWanted>,
),
) {
// 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<AssetId<Image>> = 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<AssetId<Image>>, Vec<AssetId<Image>>) =
(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<UiQuadMaterial>` 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::<Assets<Image>>()
.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::<UiQuads>();
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::<AssetEvent<UiQuadMaterial>>::default();
let mut touched = |app: &App| {
cursor
.read(
app.world()
.resource::<Messages<AssetEvent<UiQuadMaterial>>>(),
)
.filter(|e| matches!(e, AssetEvent::Modified { .. }))
.count()
};
touched(&app);
// The booth's resize, as `resize_pane_targets` makes it.
app.world_mut()
.resource_mut::<Assets<Image>>()
.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<Image>) -> bool {
app.world()