portrait: size a pooled pane's bake to the pane, without touching what it shows

Tmog's full-body preview is ~500 physical pixels tall and was drawn from a
256² bake, so it read soft beside its own grid of cards — which 256²
oversamples. One size cannot serve both. A pane larger than its bake now steps
up to 512², so a page of fifteen cards plus one large preview pays for a single
upgrade rather than sixteen.

This is the second attempt. The first widened `BoothPanes`' value type, which
`sync_body_booth` and the dressing room read to frame their cameras: a change
meant for resolution reached into the path deciding what a pane *shows*, and a
page of previews stopped showing their own looks. The size now travels in
`PaneBakeSizes`, written by the extract and read by `resize_pane_targets`
alone. Nothing on the content path sees it.

The first attempt also called `Assets::get_mut` to *read* a target's size. That
marks the asset modified, and a modified `Image` is re-uploaded from its CPU
pixels — the zero-fill it was created with — silently erasing whatever the
booth had just rendered. The size is now probed read-only and `get_mut` is
reached for only when it must change, pinned by
`a_target_already_the_right_size_is_not_touched`.

An absent entry means "not drawn this frame", never "wants the default", so a
paced pane cannot shrink its own target between renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-15 15:37:13 -06:00
co-authored by Claude Opus 5
parent 81d96af35d
commit da0f647be6
5 changed files with 257 additions and 1 deletions
+8 -1
View File
@@ -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<Image>`, 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) {
+229
View File
@@ -726,6 +726,41 @@ fn trace_booth_churn(
#[derive(Resource, Default)]
pub(crate) struct BoothPanes(pub(crate) HashMap<String, f32>);
/// 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<String, u32>);
/// 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<Image>) -> 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<PaneBakeSizes>,
mut booths: ResMut<Booths>,
mut images: ResMut<Assets<Image>>,
) {
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 `<PlayerModel>`
/// 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::<dressup::DressUpBakes>()
.init_resource::<Booths>()
.init_resource::<BoothPanes>()
.init_resource::<PaneBakeSizes>()
.init_resource::<UnitPaneBooths>()
.init_resource::<GxAspect>()
.init_resource::<BoothLight>()
@@ -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<Image>) {
let mut app = App::new();
app.add_plugins(AssetPlugin::default())
.init_asset::<Image>()
.init_resource::<Booths>()
.init_resource::<PaneBakeSizes>();
let target = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(new_target_image(size));
let booth = super::test_booth(app.world_mut(), target.clone());
app.world_mut()
.resource_mut::<Booths>()
.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<Image>) -> bool {
let id = target.id();
app.world_mut()
.resource_mut::<Messages<AssetEvent<Image>>>()
.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::<PaneBakeSizes>()
.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::<Assets<Image>>()
.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::<PaneBakeSizes>()
.0
.insert(UNIT_PANE_SLOTS[0].to_string(), UNIT_PANE_MAX_SIZE);
app.update();
let size = app
.world()
.resource::<Assets<Image>>()
.get(&target)
.expect("target")
.texture_descriptor
.size
.width;
assert_eq!(size, UNIT_PANE_MAX_SIZE);
}
}
@@ -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<u32> = 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::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::portrait::PaneBakeSizes>();
app.init_resource::<crate::portrait::UnitPaneBooths>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
@@ -1804,6 +1820,7 @@ mod clip_plumb_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::portrait::PaneBakeSizes>();
app.init_resource::<crate::portrait::UnitPaneBooths>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
@@ -1999,6 +2016,7 @@ mod extract_gate_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::portrait::PaneBakeSizes>();
app.init_resource::<crate::portrait::UnitPaneBooths>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
@@ -462,6 +462,7 @@ mod caret_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::portrait::PaneBakeSizes>();
app.init_resource::<crate::portrait::UnitPaneBooths>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
@@ -480,6 +480,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
app.init_resource::<Assets<Image>>();
app.init_resource::<crate::portrait::PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::portrait::PaneBakeSizes>();
app.init_resource::<crate::portrait::UnitPaneBooths>();
app.init_resource::<UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();