portrait booths wait for their materials instead of freezing a bad frame
- A real defect behind black character-frame portraits: the portrait booth could catch a model whose material had not finished loading, bake that one unlucky frame, and then sleep — freezing a black or world-lit pane until something else forced a re-bake. A booth now refuses to bake until every part is resident, and retries next frame. - The zoomed-to-max paperdoll had the same shape: the pane's framing anchors were fabricated while a display was still loading — a zero pivot height floored to 0.1, aiming the camera at a 0.1-unit subject — and the bad framing latched. Anchors now wait for the real model too. - The reported black portrait itself is reproduced headlessly at last, and the trigger is gear arriving late, not the map change. Along the way the booth's own debug camera turned out to be photographing nothing — it shot before waking the booth, so every dump it ever took was blank. Fixed; the hunt continues on a working instrument. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -761,8 +761,12 @@ pub(super) fn sync_glue_booth(
|
||||
booth_light: &mut BoothLight,
|
||||
materials: &mut Assets<WowModelMaterial>| {
|
||||
match (&scene_buf, scene.as_deref_mut()) {
|
||||
// The glue screens keep the old fallback deliberately: the create/select scene has
|
||||
// its own lifecycle (no map-scope teardown under it) and no parts-key retry to
|
||||
// ride, so waiting here would leave the pane empty instead of merely mislit.
|
||||
(Some(buf), Some(s)) => {
|
||||
super::material_variant(&mut s.variants, buf, material, materials, true)
|
||||
.unwrap_or_else(|| material.clone())
|
||||
}
|
||||
_ => booth_light.studio.variant(material, materials),
|
||||
}
|
||||
|
||||
@@ -24,20 +24,47 @@ use crate::terrain::WowModelMaterial;
|
||||
pub(super) struct BoothRig {
|
||||
pub(super) buffer: Option<bevy::render::render_resource::Buffer>,
|
||||
variants: HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
|
||||
/// Set by [`Self::variant`] when it could NOT build a real booth twin because the source
|
||||
/// material was not resident yet — see [`Self::take_unready`].
|
||||
unready: bool,
|
||||
}
|
||||
|
||||
impl BoothRig {
|
||||
/// The booth twin of a world-built material: same everything, this rig's light buffer. Cached
|
||||
/// per source material so twins dedup exactly like their sources.
|
||||
///
|
||||
/// **A caller that is about to COMMIT a bake must consult [`Self::take_unready`] first.** When
|
||||
/// the source material is not resident this returns the world material itself, which is bound
|
||||
/// to the WORLD light buffer — and per this module's own header that is what "would render a
|
||||
/// night portrait pitch black". Latching one into a booth is never right: the bake is stored
|
||||
/// in a `MeshMaterial3d` and the booth then sleeps (0540), so a single unlucky frame freezes a
|
||||
/// world-lit — or unlit — pane until something else forces a re-bake.
|
||||
pub(super) fn variant(
|
||||
&mut self,
|
||||
world: &Handle<WowModelMaterial>,
|
||||
materials: &mut Assets<WowModelMaterial>,
|
||||
) -> Handle<WowModelMaterial> {
|
||||
let Some(buffer) = self.buffer.clone() else {
|
||||
return world.clone(); // no booth buffer (headless tests) — fall back to the world light
|
||||
// No booth buffer at all (headless tests) — there is nothing to wait FOR, so this
|
||||
// stays a plain fallback rather than an unready signal.
|
||||
return world.clone();
|
||||
};
|
||||
material_variant(&mut self.variants, &buffer, world, materials, false)
|
||||
match material_variant(&mut self.variants, &buffer, world, materials, false) {
|
||||
Some(twin) => twin,
|
||||
None => {
|
||||
self.unready = true;
|
||||
world.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the "a twin could not be built" flag accumulated since the last call. A bake site
|
||||
/// checks this after collecting its parts and, if set, **abandons the bake for this frame**
|
||||
/// without touching `Booth::baked` — the parts-key compare then re-fires next frame and the
|
||||
/// bake retries once the material lands. This is the same law `Booth::pending` already applies
|
||||
/// to not-yet-resident textures, moved one level up to the material itself.
|
||||
pub(super) fn take_unready(&mut self) -> bool {
|
||||
std::mem::take(&mut self.unready)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,19 +112,21 @@ pub(super) fn reap_dead_variants(
|
||||
/// stages none, its collector fog stays zeroed — wow-re `glue-model-lighting.md §5`; the
|
||||
/// background scene model, built by `sync_create_scene` with its own fog policy, keeps the
|
||||
/// `CharModelFogInfo` fog).
|
||||
///
|
||||
/// **`None` means the source material is not resident yet** — there is no twin to hand back, and
|
||||
/// the world material is NOT an acceptable substitute in a booth (wrong light buffer; see
|
||||
/// [`BoothRig::variant`]). Callers wait and retry rather than baking something wrong.
|
||||
pub(super) fn material_variant(
|
||||
variants: &mut HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
|
||||
buffer: &bevy::render::render_resource::Buffer,
|
||||
world: &Handle<WowModelMaterial>,
|
||||
materials: &mut Assets<WowModelMaterial>,
|
||||
rig: bool,
|
||||
) -> Handle<WowModelMaterial> {
|
||||
) -> Option<Handle<WowModelMaterial>> {
|
||||
if let Some(twin) = variants.get(&world.id()) {
|
||||
return twin.clone();
|
||||
return Some(twin.clone());
|
||||
}
|
||||
let Some(mat) = materials.get(world) else {
|
||||
return world.clone();
|
||||
};
|
||||
let mat = materials.get(world)?;
|
||||
let mut twin = mat.clone();
|
||||
twin.extension.light_buf = buffer.clone();
|
||||
if rig {
|
||||
@@ -113,7 +142,7 @@ pub(super) fn material_variant(
|
||||
}
|
||||
let handle = materials.add(twin);
|
||||
variants.insert(world.id(), handle.clone());
|
||||
handle
|
||||
Some(handle)
|
||||
}
|
||||
|
||||
/// The **model pane's** light rows — the reference's own, for the `<PlayerModel>` panes (the
|
||||
|
||||
@@ -248,6 +248,57 @@ struct Booth {
|
||||
/// command-applied spawn, the billboard re-face's one-frame lag, and GPU upload of fresh meshes.
|
||||
const BOOTH_SETTLE_FRAMES: u32 = 4;
|
||||
|
||||
/// `WOW_BOOTH_LOG=1` — is the booth instrument armed? (Read once.)
|
||||
fn booth_log() -> bool {
|
||||
static LOG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*LOG.get_or_init(|| std::env::var_os("WOW_BOOTH_LOG").is_some())
|
||||
}
|
||||
|
||||
/// `WOW_BOOTH_LOG=1` — one line per BAKE DECISION, which is the half the gate timeline cannot
|
||||
/// show: the gate says a camera *drew*, never *what it drew*. B106's first-login shot is a
|
||||
/// portrait wearing no armour beside a fully-equipped character, so the question is exactly "how
|
||||
/// many parts/riders did the bake see, and did it ever re-bake when the rest arrived".
|
||||
/// `verb`: `bake` committed · `wait` abandoned (a source material was not resident, 0744) ·
|
||||
/// `stand-in` the 2D fallback while no parts are attached · `empty` the booth was cleared.
|
||||
fn log_bake(token: &str, verb: &str, parts: usize, riders: usize, billboards: usize) {
|
||||
if booth_log() {
|
||||
eprintln!("[booth] {token} {verb} parts={parts} riders={riders} billboards={billboards}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The framing anchors for a bake — **`None` means "not ready, come back next frame", never
|
||||
/// "this display has no anchors".**
|
||||
///
|
||||
/// `Creatures::display_anchors` returns `None` in exactly two states, both transient: the display
|
||||
/// is not in the cache yet, or its `parts` are *"not yet built"* (its own comment). Once built it
|
||||
/// always answers `Some`; a display that genuinely has no authored portrait camera answers `Some`
|
||||
/// with `camera: None` inside, which the framing heuristics are built to handle.
|
||||
///
|
||||
/// Both bake sites used to paper over that with `unwrap_or(PortraitAnchors { .., pivot_height: 0.0,
|
||||
/// .. })`. That is not a neutral default: [`framing::body_frame`] floors the head signal at `0.1`
|
||||
/// for "a hypothetical bounds-less display", so zero anchors aim the camera at a 0.1-unit-tall
|
||||
/// subject — the paper doll "zoomed into the max" and the wrong-size portrait of `#bugs` B106. And
|
||||
/// it latches: the camera is aimed once per bake, and the parts key cannot change afterwards
|
||||
/// (handle ids are stable from `load()`, so mesh/model arrival is invisible to it), so the frame
|
||||
/// stays wrong until some unrelated content edge forces a re-bake.
|
||||
///
|
||||
/// A display id of `None` is a different case — there is nothing to wait for — and keeps the
|
||||
/// bounds-less anchors it always had.
|
||||
fn booth_anchors(
|
||||
creatures: Option<&Creatures>,
|
||||
display_id: Option<u32>,
|
||||
) -> Option<PortraitAnchors> {
|
||||
let Some(display_id) = display_id else {
|
||||
return Some(PortraitAnchors {
|
||||
camera: None,
|
||||
head: None,
|
||||
pivot_height: 0.0,
|
||||
ground_radius: 0.0,
|
||||
});
|
||||
};
|
||||
creatures?.display_anchors(display_id)
|
||||
}
|
||||
|
||||
/// Arm `booth` after a content edge: render the settle window, plus every frame until each twin
|
||||
/// material's texture is resident. `twins` = the material handles the bake just installed.
|
||||
fn wake_booth<'a>(
|
||||
@@ -673,6 +724,20 @@ fn sync_portraits(
|
||||
// The look changed — re-bake: studio-lit twins of the exact dressed materials, posed
|
||||
// at Stand on the booth's own throwaway skeleton (the ref bake — riders ride their
|
||||
// bone's joint, exactly like the world instance).
|
||||
// Resolve the framing anchors FIRST — before anything is despawned or spawned. A
|
||||
// `None` here means the display is still loading (see `booth_anchors`), and a bake
|
||||
// committed now would aim the camera at fabricated zero bounds and never re-aim.
|
||||
let Some(anchors) = booth_anchors(creatures.as_deref(), display_id) else {
|
||||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||||
log_bake(
|
||||
token,
|
||||
"wait-anchors",
|
||||
parts.len(),
|
||||
riders.len(),
|
||||
billboards.len(),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let rig = creatures
|
||||
.as_deref()
|
||||
.zip(display_id)
|
||||
@@ -705,6 +770,15 @@ fn sync_portraits(
|
||||
kind: b.kind,
|
||||
})
|
||||
.collect();
|
||||
// A source material was not resident, so at least one "twin" above is the WORLD
|
||||
// material — bound to the world light buffer, which this booth's whole point is to
|
||||
// avoid ("would render a night portrait pitch black", `light.rs`). Abandon the bake:
|
||||
// `booth.baked` stays untouched, so the parts-key compare re-fires next frame and we
|
||||
// retry once the material lands. The previous bake stays on screen meanwhile.
|
||||
if booth_light.studio.take_unready() {
|
||||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||||
continue;
|
||||
}
|
||||
commands.entity(booth.root).despawn_related::<Children>();
|
||||
spawn_booth_model(
|
||||
&mut commands,
|
||||
@@ -724,18 +798,9 @@ fn sync_portraits(
|
||||
&booth_billboards,
|
||||
);
|
||||
// Frame through the display's authored portrait camera (heuristic anchors for the
|
||||
// camera-less few; generic humanoid framing when the display cache has nothing).
|
||||
let anchors = creatures
|
||||
.as_deref()
|
||||
.zip(display_id)
|
||||
.and_then(|(c, d)| c.display_anchors(d))
|
||||
.unwrap_or(PortraitAnchors {
|
||||
camera: None,
|
||||
head: None,
|
||||
pivot_height: 0.0,
|
||||
ground_radius: 0.0,
|
||||
});
|
||||
// camera-less few), resolved above before anything was torn down.
|
||||
aim(&mut cams, token, &frame(&anchors));
|
||||
log_bake(token, "bake", parts.len(), riders.len(), billboards.len());
|
||||
wake_booth(
|
||||
booth,
|
||||
&wow_mats,
|
||||
@@ -907,6 +972,19 @@ fn sync_body_booth(
|
||||
let parts_changed = booth.baked.as_ref() != Some(&key);
|
||||
if parts_changed {
|
||||
let display_id = ent_q.get(unit).ok().and_then(|n| n.display_id);
|
||||
// Anchors first, before any teardown — a still-loading display must not be framed from
|
||||
// fabricated zero bounds (see the portrait site, and `booth_anchors`).
|
||||
let Some(anchors) = booth_anchors(creatures, display_id) else {
|
||||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||||
log_bake(
|
||||
slot,
|
||||
"wait-anchors",
|
||||
parts.len(),
|
||||
riders.len(),
|
||||
billboards.len(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let rig = creatures
|
||||
.zip(display_id)
|
||||
.and_then(|(c, d)| c.display_rig(d));
|
||||
@@ -938,6 +1016,12 @@ fn sync_body_booth(
|
||||
kind: b.kind,
|
||||
})
|
||||
.collect();
|
||||
// Same law as the portrait bake: never latch a world-lane material into the pane. Leave
|
||||
// `booth.baked` alone and retry next frame (see the portrait site for the full note).
|
||||
if booth_light.pane.take_unready() {
|
||||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||||
return;
|
||||
}
|
||||
commands.entity(booth.root).despawn_related::<Children>();
|
||||
spawn_booth_model(
|
||||
commands,
|
||||
@@ -957,16 +1041,9 @@ fn sync_body_booth(
|
||||
&booth_billboards,
|
||||
);
|
||||
// Body framing from the display's bounds — the full standing figure, feet-to-crown.
|
||||
let anchors = creatures
|
||||
.zip(display_id)
|
||||
.and_then(|(c, d)| c.display_anchors(d))
|
||||
.unwrap_or(PortraitAnchors {
|
||||
camera: None,
|
||||
head: None,
|
||||
pivot_height: 0.0,
|
||||
ground_radius: 0.0,
|
||||
});
|
||||
// Resolved before the teardown above; see the portrait site for why it cannot be faked.
|
||||
aim(cams, slot, &body_frame(&anchors));
|
||||
log_bake(slot, "bake", parts.len(), riders.len(), billboards.len());
|
||||
wake_booth(
|
||||
booth,
|
||||
wow_mats,
|
||||
@@ -1002,6 +1079,7 @@ fn gate_booth_cameras(
|
||||
mut booths: ResMut<Booths>,
|
||||
preview: Res<GluePreview>,
|
||||
images: Res<Assets<Image>>,
|
||||
time: Res<Time<bevy::time::Real>>,
|
||||
mut cams: Query<(&BoothCam, &mut Camera)>,
|
||||
mut env_cache: Local<Option<bool>>,
|
||||
) {
|
||||
@@ -1025,7 +1103,8 @@ fn gate_booth_cameras(
|
||||
&& (cam.is_active != active || active)
|
||||
{
|
||||
eprintln!(
|
||||
"[booth] {} active={} wake={} pending={}",
|
||||
"[booth] t={:7.2} {} active={} wake={} pending={}",
|
||||
time.elapsed_secs(),
|
||||
token.as_str(),
|
||||
active,
|
||||
booth.wake,
|
||||
|
||||
@@ -221,12 +221,32 @@ fn bake_test(
|
||||
/// named booth's render target (e.g. `paperdoll`) to `path`. A probe run can then look at the
|
||||
/// pane a live session would see under the character window — without a UI click path (the
|
||||
/// first-login black-pane hunt). One shot per run; inert without the env.
|
||||
///
|
||||
/// **It must WAKE the booth before it shoots, and that is the whole subtlety.** Bevy's
|
||||
/// `Screenshot::image` does not read the target texture's existing contents: it substitutes a
|
||||
/// fresh `screenshot-capture-rendertarget` as that target's output attachment and hands back
|
||||
/// whatever is *rendered into it during the capture frame* (`bevy_render`'s
|
||||
/// `prepare_screenshots`). Under the 0540 demand gate a settled booth's camera is inactive and
|
||||
/// its target simply "keeps the last render" — so shooting it while it sleeps renders nothing
|
||||
/// into the substituted attachment and the PNG comes back a uniform `RGBA(0,0,0,0)`, which is
|
||||
/// exactly the `Image::new_fill` pattern in `new_target_image` and exactly what this
|
||||
/// instrument produced for its entire first life (every leg of the B106 hunt, control included,
|
||||
/// byte-identical). So: arm `Booth::wake` first, hold it armed, and take the shot a few frames
|
||||
/// later while the camera is still rendering. A dump that ever comes back uniformly transparent
|
||||
/// again means the wake is not reaching the gate — treat it as a broken instrument, not a black
|
||||
/// pane.
|
||||
pub(super) fn dump_booth_target(
|
||||
mut commands: Commands,
|
||||
booths: Res<Booths>,
|
||||
mut booths: ResMut<Booths>,
|
||||
time: Res<Time<bevy::time::Real>>,
|
||||
mut fired: Local<bool>,
|
||||
mut phase: Local<u32>,
|
||||
) {
|
||||
/// Frames to hold the booth awake before taking the shot: the gate reads `wake` and flips
|
||||
/// `Camera::is_active` in the same frame, so one would do — a small margin covers the
|
||||
/// command-applied camera flip and the render-app extract behind it.
|
||||
const WAKE_LEAD: u32 = 3;
|
||||
const DONE: u32 = u32::MAX;
|
||||
|
||||
static SPEC: std::sync::OnceLock<Option<(String, String, f32)>> = std::sync::OnceLock::new();
|
||||
let Some((token, path, secs)) = SPEC.get_or_init(|| {
|
||||
let v = std::env::var("WOW_BOOTH_DUMP").ok()?;
|
||||
@@ -239,14 +259,24 @@ pub(super) fn dump_booth_target(
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
if *fired || time.elapsed_secs() < *secs {
|
||||
if *phase == DONE || (*phase == 0 && time.elapsed_secs() < *secs) {
|
||||
return;
|
||||
}
|
||||
*fired = true;
|
||||
let Some(booth) = booths.0.get(token.as_str()) else {
|
||||
let Some(booth) = booths.0.get_mut(token.as_str()) else {
|
||||
warn!("WOW_BOOTH_DUMP: no booth named {token:?}");
|
||||
*phase = DONE;
|
||||
return;
|
||||
};
|
||||
// Hold the gate open across the lead AND the capture frame itself.
|
||||
booth.wake = booth.wake.max(super::BOOTH_SETTLE_FRAMES);
|
||||
if *phase < WAKE_LEAD {
|
||||
if *phase == 0 {
|
||||
info!("WOW_BOOTH_DUMP: waking booth {token:?} for the shot");
|
||||
}
|
||||
*phase += 1;
|
||||
return;
|
||||
}
|
||||
*phase = DONE;
|
||||
use bevy::render::view::window::screenshot::{Screenshot, ScreenshotCaptured};
|
||||
info!("WOW_BOOTH_DUMP: shooting booth {token:?} -> {path}");
|
||||
let out = std::path::PathBuf::from(path.clone());
|
||||
|
||||
Reference in New Issue
Block a user