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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user