diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index bbb465d4..a594c4d3 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -822,7 +822,14 @@ pub(crate) mod schedule_tests { /// resolved 261 pairs that `sync_body_booths` previously had no declared order against at /// all. One honest edge in a busy part of the graph is worth more than a long allowance, /// which is the whole reason this number ratchets. - const UPDATE_ACTIONABLE_CEILING: usize = 4_958; + /// +4 for `portrait::resize_pane_targets`, which steps a pooled pane's bake between 256² and + /// 512² as the pane drawing it changes size. It holds `Assets`, so it pairs with the + /// other holders — two debug observers (`log_booth_pose`, `dump_booth_target`), the UI paint + /// that only *reads* the target to draw it, and a collider finisher that shares the collection + /// and nothing in it. It is the sole writer of a booth target's size, and it reaches for + /// `get_mut` only when the size actually changes, so in every other frame it writes nothing at + /// all for an order to matter to. + const UPDATE_ACTIONABLE_CEILING: usize = 4_962; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 07fe5b76..ad8f258c 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -726,6 +726,41 @@ fn trace_booth_churn( #[derive(Resource, Default)] pub(crate) struct BoothPanes(pub(crate) HashMap); +/// The bake a pane larger than [`UNIT_PANE_SIZE`] gets instead. +/// +/// The pool was built for unit-frame portrait boxes, where 256² is generous. It now also serves +/// whole windows: Tmog's full-body preview is ~500 physical pixels tall, and drawn from a 256² +/// bake it is visibly soft beside its own grid of cards — which 256² oversamples. One size cannot +/// serve both. 512² is one step, taken only by panes big enough to show it, so a page of fifteen +/// cards plus one large preview pays for a single upgrade (2 MB), not sixteen. +const UNIT_PANE_MAX_SIZE: u32 = 512; + +/// The bake size a pane `longest_px` physical pixels along its longest side deserves. +/// +/// A step rather than a fit, so dragging a window or moving the UI-scale slider cannot reallocate +/// a render target every frame. +pub(crate) fn pane_bake_size(longest_px: u32) -> u32 { + if longest_px > UNIT_PANE_SIZE { + UNIT_PANE_MAX_SIZE + } else { + UNIT_PANE_SIZE + } +} + +/// The bake size each **drawn** pooled pane asks for, written by the UI extract and read by +/// [`resize_pane_targets`] alone. +/// +/// A channel of its own rather than a field on [`BoothPanes`], deliberately. The first attempt at +/// this widened `BoothPanes`' value type, which `sync_body_booth` and the dressing room read to +/// frame their cameras — so a change meant for resolution reached into the path that decides what +/// a pane *shows*, and a page of previews stopped showing their own looks. What size a booth bakes +/// at is nobody's business but the booth's. +/// +/// Only drawn panes appear: an absent entry means "not on screen this frame", never "wants the +/// default", so a paced pane cannot shrink its own target between renders. +#[derive(Resource, Default)] +pub(crate) struct PaneBakeSizes(pub(crate) HashMap); + /// The dynamic unit-pane booths' slot names, one per pool slot. Fixed strings, not minted per /// pane, so the whole String-keyed booth machinery ([`Booths`], [`BoothCam`], [`PortraitImages`], /// [`BoothPanes`]) carries them with no second rendering path: a slot is a booth like any other, @@ -769,6 +804,79 @@ pub(crate) const UNIT_PANE_SLOTS: [&str; 24] = [ "unitpane23", ]; +/// A booth standing on `target` and nothing else, for the resize tests. +#[cfg(test)] +fn test_booth(world: &mut World, target: Handle) -> Booth { + Booth { + layer: RenderLayers::layer(0), + root: world.spawn_empty().id(), + target, + baked: None, + baked_guid: None, + staged: None, + bakes: 0, + snap: None, + shown: false, + show_rev: 0, + wake: 0, + live: false, + pool_paced: false, + pace_seen: 0.0, + pending: Vec::new(), + pending_since: None, + pipes_settling: false, + pipes_since: None, + aspect: 1.0, + rigged: false, + turn: Turn::default(), + parked: false, + } +} + +/// Size each pooled pane's bake to the pane that draws it. +/// +/// **The size is probed read-only and `get_mut` is reached for only when it must change.** That is +/// the whole trick, and getting it wrong is what sank the first attempt: `Assets::get_mut` marks +/// the asset modified, a modified `Image` is re-uploaded from its CPU pixels, and those pixels are +/// the zero-fill the target was created with — so calling it every frame quietly wiped whatever +/// the booth had just rendered into the target, and every pane showed the same nothing. +/// +/// A resize does drop what the target held, which is why the booth is woken after one: otherwise a +/// paced pane would sit on an empty box until its next cadence tick. +fn resize_pane_targets( + sizes: Res, + mut booths: ResMut, + mut images: ResMut>, +) { + for slot in UNIT_PANE_SLOTS { + // Only a pane that is on screen may size its bake. + let Some(&want) = sizes.0.get(slot) else { + continue; + }; + let Some(booth) = booths.0.get_mut(slot) else { + continue; + }; + let have = images + .get(&booth.target) + .map(|image| image.texture_descriptor.size.width); + if have.is_none_or(|have| have == want) { + continue; + } + let Some(image) = images.get_mut(&booth.target) else { + continue; + }; + image.resize(Extent3d { + width: want, + height: want, + depth_or_array_layers: 1, + }); + booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES); + if booth_log() { + eprintln!("[booth] pane target slot={slot} resized {have:?} -> {want}"); + } + } +} + /// The dynamic unit-pane bake size, square. Deviation: the reference draws a `` /// straight into the back buffer at the pane's own rect (`0x76d240`), so it has no bake size at /// all; here a pane needs a render target, and 256² is the round portraits' size rather than the @@ -939,6 +1047,8 @@ pub(crate) struct BoothBridge<'w> { pub(crate) tiles: ResMut<'w, crate::ui_models::UiModelTiles>, /// The unit panes' half: which pooled booth each drawn `SetUnit` pane samples. pub(crate) unit_panes: ResMut<'w, UnitPaneBooths>, + /// How big a bake each drawn pooled pane wants ([`PaneBakeSizes`]). + pub(crate) bake_sizes: ResMut<'w, PaneBakeSizes>, } impl BoothBridge<'_> { @@ -1158,6 +1268,7 @@ impl Plugin for PortraitPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -1195,6 +1306,9 @@ impl Plugin for PortraitPlugin { // After the syncs, the only writers of the yaw it spends. booth::drive_booth_turn, // Last: it reads the wake/pending state every sync above may have armed. + // Before the gate, so a pane that just changed size is repainted on the + // same frame its booth is woken for it. + resize_pane_targets, gate_booth_cameras, ) .chain(), @@ -3574,3 +3688,118 @@ mod stage_latch_tests { assert!(needs_restage(None, pane, 1, 1.0, 1.0)); } } + +#[cfg(test)] +mod pane_bake_size_tests { + use bevy::asset::{AssetApp, AssetEvent, AssetPlugin, Assets}; + use bevy::prelude::*; + + use super::{ + new_target_image, pane_bake_size, resize_pane_targets, Booths, PaneBakeSizes, + UNIT_PANE_MAX_SIZE, UNIT_PANE_SIZE, UNIT_PANE_SLOTS, + }; + + /// A portrait box keeps the small bake; 256² already oversamples one. + #[test] + fn a_portrait_box_keeps_the_small_bake() { + assert_eq!(pane_bake_size(36), UNIT_PANE_SIZE); + assert_eq!(pane_bake_size(140), UNIT_PANE_SIZE); + // Exactly the bake's size is a 1:1 sample, so it does not step up. + assert_eq!(pane_bake_size(UNIT_PANE_SIZE), UNIT_PANE_SIZE); + } + + /// A pane with more pixels than its bake steps up — Tmog's preview, the case this exists for. + #[test] + fn a_pane_larger_than_its_bake_steps_up() { + assert_eq!(pane_bake_size(UNIT_PANE_SIZE + 1), UNIT_PANE_MAX_SIZE); + assert_eq!(pane_bake_size(520), UNIT_PANE_MAX_SIZE); + // One step only: a third size would cost every slot that took it. + assert_eq!(pane_bake_size(4096), UNIT_PANE_MAX_SIZE); + } + + /// Build an app holding one pooled booth whose target is `size`² . + fn app_with_booth(size: u32) -> (App, Handle) { + let mut app = App::new(); + app.add_plugins(AssetPlugin::default()) + .init_asset::() + .init_resource::() + .init_resource::(); + let target = app + .world_mut() + .resource_mut::>() + .add(new_target_image(size)); + let booth = super::test_booth(app.world_mut(), target.clone()); + app.world_mut() + .resource_mut::() + .0 + .insert(UNIT_PANE_SLOTS[0].to_string(), booth); + app.add_systems(Update, resize_pane_targets); + (app, target) + } + + /// Whether the image asset was announced as modified during the last update. + fn was_modified(app: &mut App, target: &Handle) -> bool { + let id = target.id(); + app.world_mut() + .resource_mut::>>() + .drain() + .any(|e| matches!(e, AssetEvent::Modified { id: got } if got == id)) + } + + /// **The bug that sank the first attempt.** A target already at the right size must not be + /// touched: `Assets::get_mut` marks the asset modified, a modified `Image` is re-uploaded from + /// its CPU pixels — the zero-fill it was created with — and that silently erases whatever the + /// booth rendered into it. Reaching for `get_mut` to *read* a size cost a page of previews + /// their content. + #[test] + fn a_target_already_the_right_size_is_not_touched() { + let (mut app, target) = app_with_booth(UNIT_PANE_SIZE); + app.world_mut() + .resource_mut::() + .0 + .insert(UNIT_PANE_SLOTS[0].to_string(), UNIT_PANE_SIZE); + app.update(); + assert!( + !was_modified(&mut app, &target), + "the target was marked modified without changing size; its pixels will be re-uploaded \ + over the booth's render" + ); + } + + /// A pane that is not drawn publishes no size, so nothing is touched at all. + #[test] + fn an_undrawn_pane_touches_nothing() { + let (mut app, target) = app_with_booth(UNIT_PANE_SIZE); + app.update(); // `PaneBakeSizes` is empty + assert!(!was_modified(&mut app, &target)); + let size = app + .world() + .resource::>() + .get(&target) + .expect("target") + .texture_descriptor + .size + .width; + assert_eq!(size, UNIT_PANE_SIZE, "an undrawn pane must not resize"); + } + + /// And when the size really differs, it does resize — the feature still works. + #[test] + fn a_pane_that_grew_gets_a_bigger_bake() { + let (mut app, target) = app_with_booth(UNIT_PANE_SIZE); + app.world_mut() + .resource_mut::() + .0 + .insert(UNIT_PANE_SLOTS[0].to_string(), UNIT_PANE_MAX_SIZE); + app.update(); + let size = app + .world() + .resource::>() + .get(&target) + .expect("target") + .texture_descriptor + .size + .width; + assert_eq!(size, UNIT_PANE_MAX_SIZE); + } +} diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index d694e810..cf47d570 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -438,11 +438,15 @@ pub(super) fn tick_script( // rendering behind a dead UI, and a stranded claim would hold a pooled booth for a // `FrameHandle` the next VM will reuse for something else. booths.panes.0.clear(); + booths.bake_sizes.0.clear(); + booths.bake_sizes.0.clear(); booths.unit_panes.release_all(); return; }; let Ok(window) = window.single() else { booths.panes.0.clear(); + booths.bake_sizes.0.clear(); + booths.bake_sizes.0.clear(); booths.unit_panes.release_all(); return; }; @@ -978,6 +982,7 @@ pub(super) fn paint_script( // drawn pane, so it is the only one that can tell a pane that stopped drawing from one whose // entry simply did not change ([`crate::portrait::UnitPaneBooths::sweep_begin`]). booths.panes.0.clear(); + booths.bake_sizes.0.clear(); booths.unit_panes.sweep_begin(); let mut spans: Vec = Vec::with_capacity(prev.extracted.len()); for eq in extracted { @@ -1239,6 +1244,16 @@ fn convert_entry( .panes .0 .insert(slot.to_string(), rect.width() / rect.height()); + // Separately from the aspect, and read by nothing that decides what the pane + // shows: the bake's resolution in physical pixels, as the model tiles measure + // themselves. The rect is in FrameXML units; a bake has to be sized in real ones. + let dpi = booths.tiles.dpi.max(0.01); + let longest = (rect.width().max(rect.height()) * dpi).round().max(1.0); + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + booths.bake_sizes.0.insert( + slot.to_string(), + crate::portrait::pane_bake_size(longest as u32), + ); } // The bake is premultiplied; the 2D stand-in while a model streams is straight alpha. let (handle, premultiplied) = match booths.images.0.get(slot) { @@ -1717,6 +1732,7 @@ mod clip_plumb_tests { app.init_resource::>(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::(); @@ -1804,6 +1820,7 @@ mod clip_plumb_tests { app.init_resource::>(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::(); @@ -1999,6 +2016,7 @@ mod extract_gate_tests { app.init_resource::>(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::(); diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index 566efe6a..0fdd7d68 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -462,6 +462,7 @@ mod caret_tests { app.init_resource::>(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::(); diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs index f912b27a..edf29fdc 100644 --- a/crates/benilla-app/src/ui_script/minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/minimap_tests.rs @@ -480,6 +480,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() { app.init_resource::>(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); app.init_resource::();