diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index a0958c9a..2a7e964d 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -32,6 +32,7 @@ use crate::chat_bubble::BubbleConfig; use crate::minimap::MinimapZoom; use crate::nameplates::NameConfig; use crate::player::camera::{LookConfig, ZoomLimit, MOUSE_SPEED_RANGE}; +use crate::portrait::PaneRate; use crate::sound::SoundConfig; use crate::target::ClickConfig; use crate::ui_loot::LootConfig; @@ -155,6 +156,12 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[ // `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes // effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below. ("gxVSync", "1"), + // The body panes' half-rate render (decision 1444) — **benilla's own CVar**, no 1.12 + // counterpart: the reference draws its doll inside the main pass (no second view exists to + // rate-limit), while our RTT booths (1069) re-run the render graph per pane per frame. "1" = + // the doll renders at half the frame rate while its pane is open; the knob is + // [`crate::portrait::PaneRate`], and the default mirrors it (welded below). + ("boothHalfRate", "1"), ]; /// `config.toml`'s shape: a `[cvars]` table of `Name = "value"` strings (CVars are strings in @@ -228,6 +235,7 @@ struct Knobs<'a> { bubbles: &'a mut BubbleConfig, zoom: &'a mut ZoomLimit, video: &'a mut VideoConfig, + pane_rate: &'a mut PaneRate, } /// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this @@ -282,6 +290,8 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { // Vertical Sync — a flag like every other checkbox here. `video::apply_present_mode` // watches the value and pushes it to the window; nothing else reads it. "gxvsync" => knobs.video.vsync = v != 0.0, + // The body panes' half-rate render (1444) — a flag like every other checkbox here. + "boothhalfrate" => knobs.pane_rate.half = v != 0.0, _ => return false, } true @@ -312,6 +322,7 @@ fn load_config( mut bubbles: ResMut, mut zoom: ResMut, mut video: ResMut, + mut pane_rate: ResMut, ) { let mut knobs = Knobs { sound: &mut sound, @@ -326,6 +337,7 @@ fn load_config( bubbles: &mut bubbles, zoom: &mut zoom, video: &mut video, + pane_rate: &mut pane_rate, }; if std::env::var_os("WOW_UI_SCALE").is_some() { persist.env_overridden.insert("uiscale".into()); @@ -403,6 +415,7 @@ fn sync_cvars( mut bubbles: ResMut, mut zoom: ResMut, mut video: ResMut, + mut pane_rate: ResMut, ) { let Some(mut script) = script else { return; @@ -422,7 +435,7 @@ fn sync_cvars( ); script.register_cvars(REGISTERED.iter().copied()); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); - let session: [(&str, String); 24] = [ + let session: [(&str, String); 25] = [ ("MasterVolume", sound.master.to_string()), ("SoundVolume", sound.sfx.to_string()), ("MusicVolume", sound.music.to_string()), @@ -450,6 +463,7 @@ fn sync_cvars( ("minimapZoom", minimap.outdoor.to_string()), ("minimapInsideZoom", minimap.inside.to_string()), ("gxVSync", flag(video.vsync)), + ("boothHalfRate", flag(pane_rate.half)), ]; for (name, value) in session { script.set_cvar_host(name, &value); @@ -475,6 +489,7 @@ fn sync_cvars( bubbles: &mut bubbles, zoom: &mut zoom, video: &mut video, + pane_rate: &mut pane_rate, }; for (name, value) in changes { if apply_to_knobs(&name, &value, &mut knobs) { @@ -518,6 +533,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) { Option>, Option>, Option>, + Option>, )> = bevy::ecs::system::SystemState::new(world); let ( script, @@ -534,6 +550,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) { bubbles, zoom, video, + pane_rate, ) = state.get_mut(world); let (Some(mut script), Some(mut persist)) = (script, persist) else { return; @@ -556,8 +573,10 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) { Some(mut bubbles), Some(mut zoom), Some(mut video), + Some(mut pane_rate), ) = ( sound, scale, view, look, click, loot, names, clutter, minimap, bubbles, zoom, video, + pane_rate, ) { let mut knobs = Knobs { sound: &mut sound, @@ -572,6 +591,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) { bubbles: &mut bubbles, zoom: &mut zoom, video: &mut video, + pane_rate: &mut pane_rate, }; for (name, value) in changes { if apply_to_knobs(&name, &value, &mut knobs) { @@ -739,6 +759,8 @@ mod tests { // VSync welds to the video knob, which in turn welds to the window literal's boot // mode (`video::tests`) — so the registered "1" cannot drift from what we ship. assert_eq!(d["gxVSync"] != 0.0, VideoConfig::default().vsync); + // The pane half-rate (1444) welds to the portrait knob's shipped default. + assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half); } #[test] @@ -761,6 +783,7 @@ mod tests { let mut bubbles = BubbleConfig::default(); let mut zoom = ZoomLimit::default(); let mut video = VideoConfig::default(); + let mut pane_rate = PaneRate::default(); let mut knobs = Knobs { sound: &mut sound, scale: &mut scale, @@ -774,6 +797,7 @@ mod tests { bubbles: &mut bubbles, zoom: &mut zoom, video: &mut video, + pane_rate: &mut pane_rate, }; assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs)); assert_eq!(knobs.sound.music, 0.7); @@ -892,6 +916,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_plugins(CvarPlugin); app.insert_non_send_resource(UiScript::new().unwrap()); @@ -961,6 +986,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_plugins(CvarPlugin); app.insert_non_send_resource(UiScript::new().unwrap()); app.update(); diff --git a/crates/benilla-app/src/debug_panel/mod.rs b/crates/benilla-app/src/debug_panel/mod.rs index 9e5321de..34011f62 100644 --- a/crates/benilla-app/src/debug_panel/mod.rs +++ b/crates/benilla-app/src/debug_panel/mod.rs @@ -32,8 +32,8 @@ use bevy::camera::CameraOutputMode; use bevy::prelude::*; use bevy::render::render_resource::BlendState; use bevy_egui::{ - egui, EguiContexts, EguiGlobalSettings, EguiInput, EguiPlugin, EguiPreUpdateSet, - EguiPrimaryContextPass, PrimaryEguiContext, + egui, EguiContextSettings, EguiContexts, EguiGlobalSettings, EguiInput, EguiPlugin, + EguiPreUpdateSet, EguiPrimaryContextPass, PrimaryEguiContext, }; use benilla_world::lighting::{ClockSource, GameClock, WowLighting}; @@ -217,7 +217,7 @@ impl Plugin for DebugPanelPlugin { // No ordering against the UI keyboard feed: the toggle is a dev chord (1043), which no // focused EditBox can consume and which needs no capture gate — the same reason `perf`'s // and `sound`'s toggles never needed one. - .add_systems(Update, toggle_panel); + .add_systems(Update, (toggle_panel, gate_egui_lane).chain()); } } @@ -243,6 +243,37 @@ fn spawn_egui_camera(mut commands: Commands) { )); } +/// Demand-gate the whole egui lane (decision 1445): when no dev overlay is open — panel closed, +/// perf HUD hidden (chord+`P`), inspect off — the primary context goes `run_manually` (which +/// `bevy_egui`'s context-pass loop honors by skipping it outright: no begin/end pass, no +/// tessellate, no `EguiPrimaryContextPass` run) and the overlay camera sleeps (no Core2d graph +/// run, no composite, no render-side prep). A hidden dev surface then costs what a player build +/// pays — nothing; before the gate the lane drew an EMPTY overlay for ~0.33 traced ms/frame +/// (the 1445 trace). One-frame lag behind the toggles, invisible on a chord press. +/// +/// `EguiPointerOver` clears on the way down: its writer ([`track_pointer_over_ui`]) lives inside +/// the gated pass and would hold the last hover forever. +fn gate_egui_lane( + debug: Res, + hud: Res, + inspect: Res, + mut cams: Query<(&mut Camera, &mut EguiContextSettings), With>, + mut over: ResMut, +) { + let open = debug.open || hud.visible || inspect.enabled; + for (mut cam, mut settings) in &mut cams { + if cam.is_active != open { + cam.is_active = open; + if !open && over.0 { + over.0 = false; + } + } + if settings.run_manually == open { + settings.run_manually = !open; + } + } +} + fn toggle_panel(keys: Res>, mut debug: ResMut) { // The dev chord + `D` (decision 1048). It was a *bare* backtick until 1043 — backtick reads as // "not a game key", but it is one ([`crate::bindings::chord`] gives it the token `` ` ``, so the diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs index 5f328b64..227c9019 100644 --- a/crates/benilla-app/src/entities/wmo_props.rs +++ b/crates/benilla-app/src/entities/wmo_props.rs @@ -285,10 +285,9 @@ pub(super) fn spawn_wmo_gameobject_props( &mut tint_reg, &mut anim_table, card_owner, - // Never diverted into the WOW_MEGA_STATIC bracket (nor 1417's production - // merge, nor 1429's static-gx): these props parent under a MOVING gameobject, - // and every divert lane bakes world transforms. - None, + // Never diverted into 1417's production merge nor 1429's static-gx: these + // props parent under a MOVING gameobject, and every divert lane bakes world + // transforms. None, None, ); diff --git a/crates/benilla-app/src/net/motion/spline.rs b/crates/benilla-app/src/net/motion/spline.rs index 229fe9eb..0f6f286e 100644 --- a/crates/benilla-app/src/net/motion/spline.rs +++ b/crates/benilla-app/src/net/motion/spline.rs @@ -675,14 +675,31 @@ fn clamp_seat_disabled() -> bool { /// What [`mark_swimming_creatures`] reads per unit: identity, kind, pose, its own collision height /// (`None` only on a unit's first frame, before the stamp), its own WMO-room claim (whose liquid may /// answer for it — decision 0696), and whether it is already marked. +/// The gate: a stamped unit re-enters the query only when its `Transform` moved (spline ticks, +/// teleports, clamp writes); an unstamped one is retried every frame until it evaluates. +type SwimMarkGate = Or<(Changed, Without)>; + type SwimMarkQuery = ( Entity, &'static NetEntity, &'static Transform, Option<&'static CollisionHeight>, Has, + Has, ); +/// This unit has been through [`mark_swimming_creatures`] with its room claim settled — the +/// change-gate's stamp (decision 1445). A stationary unit's swim state cannot change while the +/// world stands still (1.12 water is static; the boundary depends only on the unit's own +/// position and height), so a stamped unit re-evaluates only on `Changed` — which +/// every spline tick, teleport and clamp correction raises. The world NOT standing still is the +/// [`ColliderEpoch`] (1384's law, liquid-flavoured): liquid arrives with the same streamed +/// tiles/WMOs that attach colliders, so an epoch bump drops every stamp and the population +/// re-evaluates once. Before the gate, `water_surface_at` ran for every streamed unit every +/// frame — ~0.13 traced ms/frame at the Goldshire pin, almost all of it standing idlers. +#[derive(Component)] +pub(in crate::net) struct SwimEvaluated; + /// The exit band (yd) below the swim boundary where a marked swimmer stays marked — the player /// boundary's **VERIFIED** 1/36-yd hysteresis (`0x7ff9d0`), an absolute distance independent of /// collision height, so it is subtracted from the boundary rather than scaled with it. @@ -732,10 +749,22 @@ pub(crate) struct GroundClamped { /// clamp the same frame (Bevy inserts the deferred-command sync point for the chain). pub(in crate::net) fn mark_swimming_creatures( mut commands: Commands, - units: Query, + units: Query, + stamped: Query>, world: benilla_world::world_point::WorldPoint, + epoch: Res, + mut last_epoch: Local>, ) { - for (e, net, t, collision, marked) in &units { + // A world edge (a streamed tile/WMO attached or dropped colliders — and, with them, their + // liquid) invalidates every standing answer: drop the stamps, the whole population + // re-evaluates next frame ([`SwimEvaluated`]'s doc). Rare, so the steady state stays gated. + let now = epoch.get(); + if last_epoch.replace(now) != Some(now) { + for e in &stamped { + commands.entity(e).remove::(); + } + } + for (e, net, t, collision, marked, evaluated) in &units { if net.kind != EntityKind::Unit { continue; // players carry the real flag on the wire; GameObjects don't swim } @@ -749,7 +778,10 @@ pub(in crate::net) fn mark_swimming_creatures( // It can still LEAVE: a stale mark must always be able to clear. let who = benilla_world::world_point::Subject::Unit(e); if !marked && !world.room_settled(who) { - continue; + continue; // …and stays unstamped, so it is retried until the claim lands + } + if !evaluated { + commands.entity(e).insert(SwimEvaluated); } let wow = bevy_to_wow(t.translation); let depth = world diff --git a/crates/benilla-app/src/portrait/booth.rs b/crates/benilla-app/src/portrait/booth.rs index ae0df66b..cfa1d543 100644 --- a/crates/benilla-app/src/portrait/booth.rs +++ b/crates/benilla-app/src/portrait/booth.rs @@ -2,6 +2,13 @@ //! camera shoots (the ref mechanism, wow-re portrait-render §4 D2). [`spawn_booth_model`] is the //! whole surface; the sync systems in [`super`] build [`BoothPart`]/[`BoothRider`] lists from the //! unit's mirrored children and hand them here. +//! +//! Since decision 1443 the bake rides the **collapsed rig lane** (0724/1365): the skeleton is a +//! [`RigPose`](benilla_world::rig_anim::RigPose) buffer on the booth root — no joint entities, +//! no per-bone `AnimatedBy` seats in bevy's `animate_targets` sweep (the booth doll was the last +//! consumer of that lane; ~0.35 ms/frame of the open char window, decision 1441). Consumers +//! (riders, cards, effect hosts) seat on demand-spawned anchors ([`BoothRig::anchor`]), and the +//! park is one [`AnimParked`](benilla_world::rig_anim::AnimParked) marker on the root. use benilla_formats::BillboardKind; use bevy::camera::visibility::RenderLayers; @@ -102,8 +109,58 @@ pub(super) struct BoothEffects { pub(super) emitters: Vec, } -/// Spawn `effects` into a booth bake: one host per effect model, seated under its bone's `joints` -/// entry at the model's offset, with that model's emitters owned by the host. Returns +/// The in-flight bake's rig: the booth root plus its pending [`RigPose`] buffer (`None` = the +/// boneless bake). Consumers seat on demand-spawned bone anchors via [`Self::anchor`]; the caller +/// MUST [`Self::finish`] once every consumer is seated — the pose buffer only reaches the ECS +/// then, anchors included (they are registered *in* the component). +#[must_use = "call finish(): the pose buffer reaches the booth root only then"] +pub(super) struct BoothRig { + root: Entity, + rig: Option, +} + +impl BoothRig { + /// The anchor entity standing in for `bone` (decision 1355's shape, shared with every world + /// rig): spawned on first demand, seated at the current composed pose, re-seated by the + /// compose pass every animated frame. `None` = boneless bake or a bone outside the skeleton — + /// the consumer misses, exactly as a bad joint index always did. + pub(super) fn anchor(&mut self, commands: &mut Commands, bone: u16) -> Option { + let root = self.root; + self.rig.as_mut()?.anchor_for(commands, root, bone) + } + + /// Whether a rig stands at all — the park gate's "is there anything to park". + pub(super) fn rigged(&self) -> bool { + self.rig.is_some() + } + + /// Commit the pose buffer onto the booth root — after every consumer is seated. + pub(super) fn finish(self, commands: &mut Commands) { + if let Some(rig) = self.rig { + commands.entity(self.root).insert(rig); + } + } +} + +/// Strip a booth root's whole rig state — the bake teardown twin of the child despawn. Every +/// empty/re-bake arm must run this: `despawn_related::` reaps the meshes and anchors, +/// but the pose buffer, the player, the palette slot and the park marker all live ON the root, +/// and a leftover `RigPose`+`AnimationPlayer` pair keeps evaluating (and re-writing palette rows +/// through a leaked `RigSkin` slot) for as long as the booth stands empty. +pub(super) fn clear_booth_rig(commands: &mut Commands, root: Entity) { + commands.entity(root).remove::<( + AnimationPlayer, + AnimationGraphHandle, + benilla_assets::ModelAnimations, + benilla_world::rig_anim::GlobalSeqDrive, + benilla_world::rig_anim::RigPose, + benilla_world::rig_anim::AnimParked, + benilla_world::rig_palette::RigSkin, + )>(); +} + +/// Spawn `effects` into a booth bake: one host per effect model, seated on its bone's anchor +/// at the model's offset, with that model's emitters owned by the host. Returns /// `(emitters, frames)` — how many emitters went up, and how many of them ride a billboard frame. /// /// `light` is the booth's own light-storage buffer, bound onto every emitter @@ -122,7 +179,7 @@ pub(super) struct BoothEffects { /// never need to do. pub(super) fn spawn_booth_effects( commands: &mut Commands, - joints: &[Entity], + rig: &mut BoothRig, layer: &RenderLayers, light: Option<&bevy::render::render_resource::Buffer>, effects: &[BoothEffects], @@ -130,7 +187,7 @@ pub(super) fn spawn_booth_effects( let mut spawned = 0usize; let mut frames = 0usize; for fx in effects { - let Some(&joint) = joints.get(usize::from(fx.bone)) else { + let Some(joint) = rig.anchor(commands, fx.bone) else { continue; // bad bone index — bake the body without this model's effects }; let host = commands @@ -211,17 +268,19 @@ pub(super) enum BoothMotion { /// Spawn a booth bake under `root` on the booth's layer — the ref mechanism (wow-re §4 D2): a /// **fresh throwaway instance posed at Stand**, never the unit's live world pose. /// -/// With a rig (skeleton + inverse bindposes; every M2 display), the booth builds a joint hierarchy, -/// draws each part's **skinned** twin bound to it, seats riders under their bone's joint, and arms -/// the model's own Stand (anim id 0 through its baked resolution — the ref's loader-idle seed): -/// `motion` decides whether that Stand is **frozen at t = 0** (a portrait still) or **looping** (the -/// live glue scenes/preview — decisions 0423 + 0539). (The ref's own sampling clock is the one -/// unsettled INFERRED point of the verdict — t≈0 vs live phase; a frozen t=0 is inside its -/// envelope either way.) Without a rig (boneless / WMO-display / rig not built), the static -/// bind-pose bake: parts at identity, riders dropped (no bones to seat them on). +/// With a rig (skeleton + inverse bindposes; every M2 display), the booth builds a collapsed +/// [`RigPose`](benilla_world::rig_anim::RigPose) buffer (decision 1443 — no joint entities), +/// draws each part's **skinned** twin against its palette slot, seats riders on their bone's +/// anchor, and arms the model's own Stand (anim id 0 through its baked resolution — the ref's +/// loader-idle seed): `motion` decides whether that Stand is **frozen at t = 0** (a portrait +/// still) or **looping** (the live glue scenes/preview — decisions 0423 + 0539). (The ref's own +/// sampling clock is the one unsettled INFERRED point of the verdict — t≈0 vs live phase; a +/// frozen t=0 is inside its envelope either way.) Without a rig (boneless / WMO-display / rig +/// not built), the static bind-pose bake: parts at identity, riders dropped (no bones to seat +/// them on). /// -/// Returns the joint entities (empty for the boneless bake) — the glue scene seats its particle -/// emitters on them (decision 0539 §5). +/// Returns the [`BoothRig`] handle — seat any remaining consumers on it (the effect hosts, the +/// glue scene's emitters), then `finish()` it. #[allow(clippy::too_many_arguments)] pub(super) fn spawn_booth_model( commands: &mut Commands, @@ -246,16 +305,11 @@ pub(super) fn spawn_booth_model( // joint and re-faced to the booth camera by [`face_booth_billboards`]. Needs the rig (no bones = // no eye bone); the boneless bake below drops them. `&[]` for booths that dress none. billboards: &[BoothBillboardSpec], -) -> Vec { +) -> BoothRig { // A re-bake must not inherit the previous model's animation state on the shared root — nor a - // stale global-sequence drive holding the despawned joints, nor the previous rig's palette + // stale pose buffer (its anchors died with the child despawn), nor the previous rig's palette // slot (`RigSkin`'s on_replace hook frees it; the boneless bake below has no new one). - commands.entity(root).remove::<( - AnimationPlayer, - AnimationGraphHandle, - benilla_world::rig_anim::GlobalSeqDrive, - benilla_world::rig_palette::RigSkin, - )>(); + clear_booth_rig(commands, root); let Some((skeleton, ibp, anims)) = rig.filter(|(s, _, _)| !s.joints.is_empty()) else { for p in parts { let mut child = commands.spawn(( @@ -277,22 +331,30 @@ pub(super) fn spawn_booth_model( )); } } - return Vec::new(); + return BoothRig { root, rig: None }; }; - let joints = benilla_world::rig_palette::spawn_joints(commands, root, root, skeleton); - // The owned palette rig (decision 0720): the booth's skinned parts tag this slot; the palette - // compute reads these joints like any world rig, and the booth's studio light buffer mirrors + // The collapsed pose buffer (decision 1443, replaying 0724/1365 for the last entity-joint + // rig lane): bind-pose composed, evaluated in place by the 0712 evaluator off the player + // below. `without_camera_billboards` because a booth is not the world camera — see its doc; + // the booth's cards counter-rotate to their own camera instead. + let mut pose = + benilla_world::rig_anim::RigPose::new(root, skeleton).without_camera_billboards(); + // The owned palette rig (decision 0720): the booth's skinned parts tag this slot; the world + // pass writes its rows from the composed pose, and the booth's studio light buffer mirrors // the palette region (`rig_palette::RigPaletteMirrors`), so the booth camera sees the pose. - let rig_slot = - benilla_world::rig_palette::RigSkin::allocate(palettes, joints.clone(), ibp.clone()) - .map_or(0, |rig| { - let slot = rig.slot; - commands.entity(root).insert(rig); - // Booth materials bind a STUDIO light buffer, not the shared one — route this - // rig's rows to the registered mirrors too. - palettes.mark_mirrored(slot); - slot - }); + let rig_slot = benilla_world::rig_palette::RigSkin::allocate_bones( + palettes, + skeleton.joints.len() as u32, + ibp.clone(), + ) + .map_or(0, |rig| { + let slot = rig.slot; + commands.entity(root).insert(rig); + // Booth materials bind a STUDIO light buffer, not the shared one — route this + // rig's rows to the registered mirrors too. + palettes.mark_mirrored(slot); + slot + }); // The model's global-sequence bone channels, by motion (decision 0539 §5): // - **Loop** (the glue scenes + the create/select character): LIVE, on the world's own // clock-driven sampler — the login gate's fires flicker, the Tauren windmill turns, the @@ -304,22 +366,20 @@ pub(super) fn spawn_booth_model( if let Some(anims) = anims { match motion { BoothMotion::Loop => { - if let Some(drive) = - benilla_world::rig_anim::GlobalSeqDrive::new(&anims.global_bones, &joints) - { + if let Some(drive) = benilla_world::rig_anim::GlobalSeqDrive::new_rig( + &anims.global_bones, + pose.locals.len(), + ) { commands.entity(root).insert(drive); } } BoothMotion::Frozen => { for gb in &anims.global_bones { - let Some(&j) = joints.get(gb.bone as usize) else { + // `locals` are seeded at bind pose, so the untouched properties keep the + // rest transform — the same base the joint-entity form built from. + let Some(tf) = pose.locals.get_mut(gb.bone as usize) else { continue; }; - let rest = skeleton - .joints - .get(gb.bone as usize) - .map_or(Vec3::ZERO, |jt| jt.local_translation); - let mut tf = Transform::from_translation(rest); if let Some(c) = &gb.translation { tf.translation = c.sample(0.0); } @@ -329,7 +389,6 @@ pub(super) fn spawn_booth_model( if let Some(c) = &gb.scale { tf.scale = c.sample(0.0); } - commands.entity(j).insert(tf); } } } @@ -374,8 +433,12 @@ pub(super) fn spawn_booth_model( child.insert(benilla_world::rig_palette::RigPart(root)); } } + let mut booth_rig = BoothRig { + root, + rig: Some(pose), + }; for r in riders { - let Some(&joint) = joints.get(usize::from(r.bone)) else { + let Some(anchor) = booth_rig.anchor(commands, r.bone) else { continue; // bad bone index — bake the body without this rider }; commands.spawn(( @@ -383,17 +446,17 @@ pub(super) fn spawn_booth_model( MeshMaterial3d(r.material.clone()), Transform::from_translation(r.offset), layer.clone(), - ChildOf(joint), + ChildOf(anchor), )); } // The camera-facing batches (the eye-glow, an item's gem, a glow model's quad): seat the centred - // quad under its bone's joint at the spec's offset — zero for the body's own batches, whose joint - // frame already bakes the bone pivot, so the quad lands at the eye — and tag it for - // [`face_booth_billboards`], which rewrites its rotation to the booth camera each frame. The - // rotation the joint carries here (its Stand pose) is countered there. A bone the rig lacks - // drops the card, like a rider. + // quad under its bone's anchor at the spec's offset — zero for the body's own batches, whose + // anchor frame already bakes the bone pivot (the 0130 rig identity), so the quad lands at the + // eye — and tag it for [`face_booth_billboards`], which rewrites its rotation to the booth + // camera each frame. The rotation the anchor carries here (its Stand pose) is countered there. + // A bone the rig lacks drops the card, like a rider. for bb in billboards { - let Some(&joint) = joints.get(usize::from(bb.bone)) else { + let Some(anchor) = booth_rig.anchor(commands, bb.bone) else { continue; }; commands.spawn(( @@ -401,12 +464,15 @@ pub(super) fn spawn_booth_model( MeshMaterial3d(bb.material.clone()), Transform::from_translation(bb.offset), layer.clone(), - ChildOf(joint), + ChildOf(anchor), BoothBillboard { kind: bb.kind }, )); } // Arm Stand and freeze: the player is configured *before* insertion (plain component data), // so the pose lands with the first animation pass — no play-after-spawn ordering dance. + // `ModelAnimations` goes up beside it: the 0712 evaluator reads the player THROUGH the baked + // pose source (`(&AnimationPlayer, &ModelAnimations, &mut RigPose)`) — no `AnimatedBy` + // targets exist to route bevy's own sweep here, which is the point (decision 1443). if let Some(anims) = anims { let stand = catalog.map_or(0, |c| anims.resolve(0, c).id); if let Some(clip) = anims.find(stand) { @@ -432,18 +498,14 @@ pub(super) fn spawn_booth_model( active.set_weight(crate::creature_anim::HAND_GRIP_WEIGHT); } } - commands - .entity(root) - .insert((player, AnimationGraphHandle(anims.graph.clone()))); - for (i, &j) in joints.iter().enumerate() { - commands.entity(j).insert(( - benilla_assets::bone_target_id(i as u16), - bevy::animation::AnimatedBy(root), - )); - } + commands.entity(root).insert(( + player, + AnimationGraphHandle(anims.graph.clone()), + anims.clone(), + )); } } - joints + booth_rig } /// Re-face each booth billboard card ([`BoothBillboard`]) to its booth's camera — the booth twin of diff --git a/crates/benilla-app/src/portrait/dressup.rs b/crates/benilla-app/src/portrait/dressup.rs index b1932333..7908cc4c 100644 --- a/crates/benilla-app/src/portrait/dressup.rs +++ b/crates/benilla-app/src/portrait/dressup.rs @@ -160,7 +160,7 @@ pub(super) fn spawn_dressup_booth( pending: Vec::new(), pending_since: None, aspect: 1.0, - joints: Vec::new(), + rigged: false, parked: false, }, ); @@ -222,8 +222,10 @@ pub(super) fn sync_dressup_booth( booth.wake = BOOTH_SETTLE_FRAMES; booth.live = false; booth.pending.clear(); - // The despawn above took the park set with it. - booth.joints.clear(); + // The despawn reaped meshes and anchors; the rig state on the ROOT needs its + // own strip ([`super::clear_booth_rig`]). + super::clear_booth_rig(&mut commands, booth.root); + booth.rigged = false; booth.parked = false; *staged = false; } @@ -282,7 +284,7 @@ pub(super) fn sync_dressup_booth( return; } commands.entity(booth.root).despawn_related::(); - let joints = spawn_booth_model( + let mut booth_rig = spawn_booth_model( &mut commands, &mut palettes, booth.root, @@ -301,7 +303,7 @@ pub(super) fn sync_dressup_booth( ); let (fx_emitters, _) = spawn_booth_effects( &mut commands, - &joints, + &mut booth_rig, &booth.layer, booth_light.pane.buffer.as_ref(), &bake @@ -317,8 +319,9 @@ pub(super) fn sync_dressup_booth( // The bake animates, so its camera can't sleep — `gate_booth_cameras` runs it every frame // the window is drawing this pane, and none once it closes. booth.live = true; - // A fresh bake is animated by construction; the park set is the new skeleton's. - booth.joints = joints; + // A fresh bake is animated by construction; the park state is the new rig's. + booth.rigged = booth_rig.rigged(); + booth_rig.finish(&mut commands); booth.parked = false; *staged = true; aim(&mut cams, DRESSUP_SLOT, &body_frame(&anchors, aspect)); diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index 7e314629..5eee8e36 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -422,7 +422,7 @@ pub(super) fn spawn_glue_booth( pending: Vec::new(), pending_since: None, aspect: 1.0, - joints: Vec::new(), + rigged: false, parked: false, }, ); @@ -483,11 +483,9 @@ pub(super) fn sync_glue_scene( // Off the glue screens: tear the scene down, free the model, square target back. if scene.token.is_some() { commands.entity(scene.root).despawn_related::(); - commands.entity(scene.root).remove::<( - AnimationPlayer, - AnimationGraphHandle, - benilla_world::rig_anim::GlobalSeqDrive, - )>(); + // The whole rig-state strip (pose buffer, player, palette slot — `clear_booth_rig`): + // the scene root outlives its bakes. + super::clear_booth_rig(&mut commands, scene.root); scene.token = None; scene.handle = None; scene.spawned = false; @@ -529,6 +527,9 @@ pub(super) fn sync_glue_scene( scene.cam = None; scene.char_spot = Vec3::ZERO; commands.entity(scene.root).despawn_related::(); + // The model may still be loading — strip the old rig state off the outliving root now, + // not only at the next spawn (which may be many frames away). + super::clear_booth_rig(&mut commands, scene.root); } else if scene.spawned && scene.fog != fog { // Same scene, other screen: rebuild in place (the handle is cached — same-frame respawn). scene.spawned = false; @@ -610,7 +611,7 @@ pub(super) fn sync_glue_scene( } }) .collect(); - let joints = spawn_booth_model( + let mut scene_rig = spawn_booth_model( &mut commands, &mut palettes, scene.root, @@ -631,14 +632,14 @@ pub(super) fn sync_glue_scene( // UI_* scene carries (MainMenu 28, Orc 11, NightElf 12…), dead until now. The world spawn // path, on the booth's layer, lit/fogged by the SCENE's own light buffer (the ModelFFX fog // covers the whole model, emitters included — carried by `EffectLightOverride` into the - // shared lane's per-draw bind group); owner = the emitter bone's joint, so fire flicker + // shared lane's per-draw bind group); owner = the emitter bone's anchor, so fire flicker // rides the animated bones; anchored at the static scene root. No `EmitterFade`: a glue // scene always ticks. Parented under the scene root so teardown cascades. let mut emitters = 0usize; for em in &model.emitters { - let owner = joints - .get(em.def.bone as usize) - .map_or((scene.root, [0.0; 3]), |&j| (j, em.bone_pivot)); + let owner = scene_rig + .anchor(&mut commands, em.def.bone) + .map_or((scene.root, [0.0; 3]), |j| (j, em.bone_pivot)); if let Some(e) = benilla_world::particles::spawn_emitter( &mut commands, em, @@ -670,6 +671,7 @@ pub(super) fn sync_glue_scene( if emitters > 0 { info!("create scene: UI_{token} — {emitters} particle emitter(s) up"); } + scene_rig.finish(&mut commands); scene.spawned = true; scene.cam = model.camera0; scene.char_spot = stage; @@ -780,8 +782,10 @@ pub(super) fn sync_glue_booth( // Empty parts (nothing selected, or the model failed) → clear the booth. if bake.parts.is_empty() { commands.entity(booth.root).despawn_related::(); - // The despawn above took the park set with it. - booth.joints.clear(); + // The despawn reaped meshes and anchors; the rig state on the ROOT needs its own + // strip ([`super::clear_booth_rig`]). + super::clear_booth_rig(&mut commands, booth.root); + booth.rigged = false; booth.parked = false; *last = Some((bake.revision, preview.yaw, scene_rev)); apply_yaw(&mut commands, booth.root, spot, preview.yaw); @@ -856,7 +860,7 @@ pub(super) fn sync_glue_booth( }) .collect(); commands.entity(booth.root).despawn_related::(); - let joints = spawn_booth_model( + let mut booth_rig = spawn_booth_model( &mut commands, &mut palettes, booth.root, @@ -881,7 +885,7 @@ pub(super) fn sync_glue_booth( // no enchant, so what shows here is each item's intrinsic effects only. let (fx_emitters, fx_frames) = spawn_booth_effects( &mut commands, - &joints, + &mut booth_rig, &booth.layer, scene_buf.as_ref(), &bake @@ -901,8 +905,9 @@ pub(super) fn sync_glue_booth( bake.effects.len() ); } - // A fresh bake is animated by construction; the park set is the new skeleton's. - booth.joints = joints; + // A fresh bake is animated by construction; the park state is the new rig's. + booth.rigged = booth_rig.rigged(); + booth_rig.finish(&mut commands); booth.parked = false; // The character-only fallback framing (no scene up — art missing / still loading): the // body-frame transform with an aspect-aware projection, because the booth target is diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 1eff33d1..853e7e73 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -82,8 +82,8 @@ pub(crate) use framing::{attachment_point, head_anchor, PortraitAnchors}; use framing::{body_frame, frame, PORTRAIT_FOV}; mod booth; use booth::{ - spawn_booth_effects, spawn_booth_model, BoothBillboardSpec, BoothEffects, BoothMotion, - BoothPart, BoothRider, + clear_booth_rig, spawn_booth_effects, spawn_booth_model, BoothBillboardSpec, BoothEffects, + BoothMotion, BoothPart, BoothRider, }; mod dressup; pub(crate) use dressup::{DressUpBake, DressUpLook, DressUpPreview}; @@ -431,18 +431,17 @@ struct Booth { /// ([`framing::WowPortraitProjection::aspect`], decision 1069) — 1.0 until the UI has drawn the /// pane once, then sticky: a hidden window must not re-frame the bake back to square. aspect: f32, - /// The bake's joint entities ([`booth::spawn_booth_model`]'s return) — the park set. Every - /// joint carries `AnimationTargetId` + `AnimatedBy`, which is a seat in bevy's per-frame - /// `animate_targets` par sweep whether or not anything renders the result (a *paused* - /// animation still evaluates its held pose every frame). Replaced wholesale by each bake; - /// cleared with the emptied booth. - joints: Vec, - /// The booth scene is parked: its camera is asleep, so [`gate_booth_cameras`] stripped the - /// joints' `AnimatedBy` (the pose HOLDS — transforms are state), paused the root's - /// [`benilla_world::rig_anim::GlobalSeqDrive`], and the booth-lane emitters freeze under the - /// draw-set law (`particles::sim` — the reference only ticks an emitter its frame draws). - /// A camera wake reverses all three before the next render. Costs nothing while a window - /// is open; retires the whole idle lane the rest of the session. + /// A rig stands in this booth ([`booth::BoothRig::rigged`] at the last bake) — the park + /// gate's "is there anything to park". Cleared with the emptied booth. + rigged: bool, + /// The booth scene is parked: its camera is asleep, so [`gate_booth_cameras`] put + /// [`benilla_world::rig_anim::AnimParked`] on the root — the 0712 evaluator, the pose + /// composes, the palette writes and the global-sequence writes all skip (the pose HOLDS — + /// the buffer is state), and the booth-lane emitters freeze under the draw-set law + /// (`particles::sim` — the reference only ticks an emitter its frame draws). A camera wake + /// drops the marker before the animation lane runs, so the wake window's first render + /// already animates. Costs nothing while a window is open; retires the whole idle lane the + /// rest of the session. parked: bool, } @@ -643,6 +642,34 @@ fn feed_gx_aspect( } } +/// The body panes' render rate (decision 1444): while a ``-family pane (paper +/// doll, dressing room, inspect, pet) is on screen, its booth camera renders every OTHER frame +/// instead of every frame. The pane's per-frame bill is the **second render pass itself** — +/// 1441's trace put ~0.9 ms/frame in graph re-run + drawable pressure, and 1443 measured the +/// open-pane delta at +2.7 with the pass running every frame — so a 512²-class doll at 30 fps +/// is the cheapest half of that back. The pose keeps evaluating at full rate (the park is not +/// touched); only the camera skips, so the held frame is always one the pose just produced. +/// +/// `boothHalfRate` is **benilla's own CVar** (the reference has no second view to rate-limit — +/// its doll draws in the main pass, 1069's known rent). Default ON; whether a 30 fps doll +/// *reads* right is the director's call (§7) — flip it live with +/// `/script SetCVar("boothHalfRate", 0)` to A/B. Booth-lane item emitters tick only on drawn +/// frames (the draw-set law, `particles::sim`), so at half rate a sparkle cloud advances at +/// half speed — the law's own honest consequence, flagged for the same morning eye. +/// +/// The glue screens (`live_scene`) are exempt: a fullscreen create/select scene at 30 fps is +/// not a pane crop, and those screens have no world behind them to pay for. +#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) struct PaneRate { + pub(crate) half: bool, +} + +impl Default for PaneRate { + fn default() -> Self { + Self { half: true } + } +} + /// The two **framing inputs** a body booth reads, in one param: the pane geometry the UI extract /// publishes ([`BoothPanes`]) and the display aspect ([`GxAspect`]). /// @@ -660,6 +687,7 @@ pub(crate) struct PortraitPlugin; impl Plugin for PortraitPlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -895,7 +923,7 @@ fn setup_booths( pending: Vec::new(), pending_since: None, aspect: 1.0, - joints: Vec::new(), + rigged: false, parked: false, }, ); @@ -968,7 +996,7 @@ fn setup_booths( pending: Vec::new(), pending_since: None, aspect: 1.0, - joints: Vec::new(), + rigged: false, parked: false, }, ); @@ -1131,8 +1159,11 @@ fn sync_portraits( // backdrop, not the departed unit's face, before the camera sleeps. booth.wake = BOOTH_SETTLE_FRAMES; booth.pending.clear(); - // The despawn above took the park set with it. - booth.joints.clear(); + // The despawn reaped meshes and anchors; the rig state on the ROOT (pose buffer, + // player, palette slot) needs its own strip or it evaluates behind the emptied + // stage for as long as the booth stands empty ([`booth::clear_booth_rig`]). + clear_booth_rig(&mut commands, booth.root); + booth.rigged = false; booth.parked = false; } let live = PortraitSource::Live(booth.target.clone()); @@ -1225,7 +1256,7 @@ fn sync_portraits( continue; } commands.entity(booth.root).despawn_related::(); - let joints = spawn_booth_model( + let booth_rig = spawn_booth_model( &mut commands, &mut palettes, booth.root, @@ -1242,10 +1273,11 @@ fn sync_portraits( [false, false], // a still portrait sheaths its weapons — no in-hand grip &booth_billboards, ); - // Even a Frozen still is a sweep seat per joint (a paused clip re-evaluates its held - // pose every frame) — the park set matters MOST here, since a portrait's camera - // sleeps for the whole session outside its bake window. - booth.joints = joints; + // Even a Frozen still re-evaluates its paused pose every frame — the park matters + // MOST here, since a portrait's camera sleeps for the whole session outside its + // bake window. + booth.rigged = booth_rig.rigged(); + booth_rig.finish(&mut commands); booth.parked = false; // **No emitters here, and that is the reference's own answer** (decision 0822): the // round portrait is a ONE-SHOT bake — a fresh M2 scene + instance, one `0x707680` draw, @@ -1478,8 +1510,10 @@ fn sync_body_booth( booth.wake = BOOTH_SETTLE_FRAMES; booth.live = false; booth.pending.clear(); - // The despawn above took the park set with it. - booth.joints.clear(); + // The despawn reaped meshes and anchors; the rig state on the ROOT needs its own + // strip ([`booth::clear_booth_rig`]). + clear_booth_rig(commands, booth.root); + booth.rigged = false; booth.parked = false; } return; @@ -1562,7 +1596,7 @@ fn sync_body_booth( return; } commands.entity(booth.root).despawn_related::(); - let joints = spawn_booth_model( + let mut booth_rig = spawn_booth_model( commands, palettes, booth.root, @@ -1582,12 +1616,12 @@ fn sync_body_booth( [false, false], // the pane sheaths its weapons — no in-hand grip &booth_billboards, ); - // The item effects go up on the posed skeleton's joints — the body pane is the lane that gets - // them (the reference's `` widget renders live; the round portraits are a - // one-shot cached bake — [`spawn_booth_effects`]). + // The item effects go up on the posed skeleton's anchors — the body pane is the lane that + // gets them (the reference's `` widget renders live; the round portraits are + // a one-shot cached bake — [`spawn_booth_effects`]). spawn_booth_effects( commands, - &joints, + &mut booth_rig, &booth.layer, booth_light.pane.buffer.as_ref(), &booth_effects, @@ -1595,8 +1629,9 @@ fn sync_body_booth( // So the whole bake is live, emitters or not — `wake` can't gate a looping animation. // `gate_booth_cameras` renders it every frame its pane is on screen, and none when it isn't. booth.live = true; - // A fresh bake is animated by construction; the park set is the new skeleton's. - booth.joints = joints; + // A fresh bake is animated by construction; the park state is the new rig's. + booth.rigged = booth_rig.rigged(); + booth_rig.finish(commands); booth.parked = false; // Body framing from the display's bounds — the full standing figure, feet-to-crown, at the // destination pane's aspect. Resolved before the teardown above; see the portrait site for @@ -1662,7 +1697,8 @@ fn gate_booth_cameras( warm: Res, time: Res>, mut cams: Query<(&BoothCam, &mut Camera)>, - mut seq_drives: Query<&mut benilla_world::rig_anim::GlobalSeqDrive>, + rate: Res, + frames: Res, mut env_cache: Local>, ) { let test = test_mode(&mut env_cache); @@ -1709,44 +1745,54 @@ fn gate_booth_cameras( || live_pane || booth.wake > 0 || !booth.pending.is_empty(); + // Half-rate (decision 1444, [`PaneRate`]): when the live pane is the ONLY thing keeping + // this camera rendering — no wake window settling a fresh bake, no pending texture hold, + // no fullscreen glue scene — skip every other frame. `active` stays the LOGICAL state: + // the park bookkeeping below keys off it (the pose keeps evaluating; only the render + // skips), and the wake counter keeps draining per real frame. + let throttled = rate.half + && live_pane + && !(test || warming || live_scene) + && booth.wake == 0 + && booth.pending.is_empty() + && frames.0 % 2 == 1; + let render = active && !throttled; // `WOW_BOOTH_LOG=1`: the gate's timeline — every activity flip and every armed frame, // wall-stamped (the first-login black-pane hunt). static LOG: std::sync::OnceLock = std::sync::OnceLock::new(); if *LOG.get_or_init(|| std::env::var_os("WOW_BOOTH_LOG").is_some()) - && (cam.is_active != active || active) + && (cam.is_active != render || active) { eprintln!( - "[booth] t={:7.2} {} active={} wake={} pending={}", + "[booth] t={:7.2} {} active={} render={} wake={} pending={}", time.elapsed_secs(), token.as_str(), active, + render, booth.wake, booth.pending.len(), ); } - if cam.is_active != active { - cam.is_active = active; + if cam.is_active != render { + cam.is_active = render; } // Park/unpark the standing scene with its camera (director report 2026-08-19: the doll - // bake animated at full cost from the LOGIN bake to quit, window opened or not). - // Parking strips each joint's `AnimatedBy` — its seat in bevy's per-frame - // `animate_targets` par sweep; the pose HOLDS, transforms are state — and pauses the - // root's `GlobalSeqDrive` (cursor = shared clock − anchor, so resume needs no re-seek). - // Booth-lane emitters freeze on the camera bit itself (`particles::sim`). This system - // runs before the PostUpdate animation lane, so an unpark's re-inserted `AnimatedBy` is - // evaluated the same frame — the wake window's first render already animates. - if booth.parked == active && !booth.joints.is_empty() { - for &j in &booth.joints { - if active { - commands - .entity(j) - .insert(bevy::animation::AnimatedBy(booth.root)); - } else { - commands.entity(j).remove::(); - } - } - if let Ok(mut drive) = seq_drives.get_mut(booth.root) { - drive.set_paused(!active); + // bake animated at full cost from the LOGIN bake to quit, window opened or not). The + // park is one `AnimParked` marker on the root (decision 1443): the 0712 evaluator, the + // pose composes, the palette writes and the global-sequence writes all check it — the + // pose HOLDS, the buffer is state. Booth-lane emitters freeze on the camera bit itself + // (`particles::sim`). This system runs before the PostUpdate animation lane, so an + // unpark's marker drop is seen the same frame — the wake window's first render already + // animates (0739's wake law, same as every world rig). + if booth.parked == active && booth.rigged { + if active { + commands + .entity(booth.root) + .remove::(); + } else { + commands + .entity(booth.root) + .insert(benilla_world::rig_anim::AnimParked); } booth.parked = !active; } diff --git a/crates/benilla-app/src/portrait/test_bake.rs b/crates/benilla-app/src/portrait/test_bake.rs index 403be088..248dd651 100644 --- a/crates/benilla-app/src/portrait/test_bake.rs +++ b/crates/benilla-app/src/portrait/test_bake.rs @@ -184,7 +184,8 @@ fn bake_test( BoothMotion::Frozen, [false, false], // the WOW_PORTRAIT_TEST bake dresses no weapons &[], // …nor an eye-glow - ); + ) + .finish(commands); aim(cams, token, &rig); } // Also drive the paper-doll booth from the same model, so `WOW_PORTRAIT_TEST` eyeballs the @@ -209,7 +210,8 @@ fn bake_test( BoothMotion::Frozen, [false, false], // the paper-doll still sheaths its weapons — no in-hand grip &[], // eye-glow in the paper doll is the same follow-up (see above) - ); + ) + .finish(commands); // The eyeball harness has no live UI publishing a pane, so it bakes square. aim(cams, PAPERDOLL_SLOT, &body_frame(&anchors, 1.0)); commands diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index 1defe94d..8c83e286 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -32,11 +32,11 @@ pub use world_assets::*; mod model; pub use model::{ - bone_target_id, merged_static_mesh, merged_static_mesh_faded, submesh_to_skinned_mesh, - submesh_to_static_mesh, AnimClip, BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations, - ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip, - PoseNode, PoseSource, PoseTrack, ATTRIBUTE_WOW_FADE_SPHERE, ATTRIBUTE_WOW_JOINT_INDEX, - ATTRIBUTE_WOW_JOINT_WEIGHT, ATTRIBUTE_WOW_MERGED_SLOT, + bone_target_id, merged_static_mesh_faded, submesh_to_skinned_mesh, submesh_to_static_mesh, + AnimClip, BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations, ModelAttachment, + ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip, PoseNode, PoseSource, + PoseTrack, ATTRIBUTE_WOW_FADE_SPHERE, ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT, + ATTRIBUTE_WOW_MERGED_SLOT, }; mod adt; mod terrain; diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 930cdddf..c2cfc120 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -807,11 +807,14 @@ pub(crate) fn build_global_bones( .collect() } -/// Build ONE mesh from many placed static submeshes — the transforms baked into the vertices. -/// The `WOW_MEGA_STATIC` consolidation bracket's builder (2026-08-18 drastic-options census): -/// same attribute recipe as [`submesh_to_static_mesh`], concatenated, with each part's placement -/// transform applied to positions and its rotation to normals (placements scale uniformly, so -/// directions survive). Parts lacking vertex colours pad white when any part has them — one +/// Build ONE mesh from many placed static submeshes — the transforms baked into the vertices +/// (the production blob build, 1418; the retired `WOW_MEGA_STATIC` bracket's unfaded twin died +/// with it): same attribute recipe as [`submesh_to_static_mesh`], concatenated, with each part's +/// placement transform applied to positions and its rotation to normals (placements scale +/// uniformly, so directions survive), plus the per-vertex [`ATTRIBUTE_WOW_FADE_SPHERE`] (one +/// sphere per PART, replicated onto each of its vertices). `slots` (index-parallel with `parts` +/// when `Some`) additionally bakes each part's SH-probe slot as [`ATTRIBUTE_WOW_MERGED_SLOT`] — +/// the interior-prop lane. Parts lacking vertex colours pad white when any part has them — one /// attribute set must cover the whole concatenation. /// /// **The mesh is recentred on its union-AABB centre** and that world centre is returned last: @@ -822,19 +825,9 @@ pub(crate) fn build_global_bones( /// pixels depth-killed every transparent entity drawn after it (a per-entity fader behind a /// 20 %-alpha merged fence vanished outright, popping in only when the sightline cleared the /// segment — decision 1422). Centre-sort restores the same location-sort semantics every -/// per-entity draw has (whose sort key is its placement origin). -pub fn merged_static_mesh( - parts: &[(std::sync::Arc, Transform)], -) -> (Mesh, Vec3, Vec3, Vec3) { - merged_static_mesh_impl(parts, None) -} - -/// [`merged_static_mesh`] plus the per-vertex [`ATTRIBUTE_WOW_FADE_SPHERE`] (one sphere per -/// PART, replicated onto each of its vertices) — the production blob build (1418). `slots` -/// (index-parallel with `parts` when `Some`) additionally bakes each part's SH-probe slot as -/// [`ATTRIBUTE_WOW_MERGED_SLOT`] — the interior-prop lane. The spheres stay WORLD-space — -/// the shader compares them against the camera, never against mesh-local positions — so the -/// recentring (see [`merged_static_mesh`]) does not touch them. +/// per-entity draw has (whose sort key is its placement origin). The fade spheres stay +/// WORLD-space — the shader compares them against the camera, never against mesh-local +/// positions — so the recentring does not touch them. pub fn merged_static_mesh_faded( parts: &[(std::sync::Arc, Transform)], spheres: &[Vec4], diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 0c4d1536..26f9ec34 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -257,6 +257,12 @@ pub(crate) struct Model { /// Which handler kinds each frame has a script for (presence mirror; the closures live Lua-side /// in the `REG_SCRIPTS` table). Lets `tick` find OnUpdate frames without scanning Lua. pub(crate) scripts: HashMap>, + /// The frames carrying an `OnUpdate` script, maintained by `SetScript` — `scripts`' one + /// writer — so the tick iterates exactly the OnUpdate population instead of re-filtering + /// (and re-allocating from) the whole scripts map every frame (decision 1446). Order is + /// irrelevant here: the tick sorts its visible subset by frame id every pass (the + /// deterministic-dispatch law stays where it was). + pub(crate) on_update_frames: Vec, /// `event name → frames registered for it` (RegisterEvent), in **registration order** — an /// ordered Vec, never a set: the client's `SignalEvent 0x703e50` walks a per-event listener /// LIST, so cross-frame dispatch order is a law, not an accident (the abbey territory-line @@ -1225,6 +1231,7 @@ impl Model { region_to_id: HashMap::new(), region_names: HashMap::new(), scripts: HashMap::new(), + on_update_frames: Vec::new(), event_to_frames: HashMap::new(), frame_events: HashMap::new(), focused_editbox: None, diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index c4462f1b..f5de14b6 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -358,13 +358,19 @@ fn set_script(lua: &Lua, this: &Table, name: &str, func: Option) -> ml Some(f) => { per.set(kind, f)?; let mut model = lua.app_data_mut::().expect("model"); - model.scripts.entry(h).or_default().insert(kind); + // `insert` answering true = this frame did not have the kind before — the tick's + // OnUpdate list rides that edge (decision 1446; `scripts` has no other writer). + if model.scripts.entry(h).or_default().insert(kind) && kind == "OnUpdate" { + model.on_update_frames.push(h); + } } None => { per.set(kind, Value::Nil)?; let mut model = lua.app_data_mut::().expect("model"); if let Some(set) = model.scripts.get_mut(&h) { - set.remove(&kind); + if set.remove(&kind) && kind == "OnUpdate" { + model.on_update_frames.retain(|&x| x != h); + } } } } diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index a2e0e769..5a5dd719 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -95,13 +95,30 @@ impl UiScript { } let ids: Vec = { let mut model = self.model_mut(); + // The OnUpdate population is its own list (decision 1446) — maintained by + // `SetScript`, `scripts`' one writer — so this reads a few hundred handles instead + // of re-filtering the whole scripts map. A destroyed frame's handle stays until its + // liveness check fails once, then compacts here. let frames: Vec = model - .scripts + .on_update_frames .iter() - .filter(|(_, kinds)| kinds.contains("OnUpdate")) - .map(|(&h, _)| h) + .copied() .filter(|&h| model.arena.frame(h).is_some_and(|f| f.effective_visible)) .collect(); + if model + .on_update_frames + .iter() + .any(|&h| model.arena.frame(h).is_none()) + { + let arena = &model.arena; + let live: Vec = model + .on_update_frames + .iter() + .copied() + .filter(|&h| arena.frame(h).is_some()) + .collect(); + model.on_update_frames = live; + } let mut ids: Vec = frames.into_iter().map(|h| model.frame_id(h)).collect(); // **Deterministic order, by frame id — i.e. creation order.** // @@ -132,13 +149,18 @@ impl UiScript { // plus the capacity law that is this class's stand-in for `maxLines` — the cap is what fits // vertically, so it needs the frame's resolved rect and is collected first (the arena walk // below holds a mutable borrow that cannot also read `model.resolved`). - let message_frames: Vec<(FrameHandle, usize)> = model - .arena - .iter_frames() - .filter(|(_, f)| matches!(f.kind_state, crate::widget::KindState::Message(_))) - .map(|(h, _)| h) - .collect::>() - .into_iter() + // Both per-kind walks below ride the arena's ticked-kind registry (decision 1446): + // dozens of handles instead of two full-arena sweeps per tick. + let ticked: Vec = model.arena.ticked_kinds().to_vec(); + let message_frames: Vec<(FrameHandle, usize)> = ticked + .iter() + .copied() + .filter(|&h| { + model + .arena + .frame(h) + .is_some_and(|f| matches!(f.kind_state, crate::widget::KindState::Message(_))) + }) .map(|h| (h, Self::message_viewport_rows(&model, h))) .collect(); for (h, viewport_rows) in message_frames { @@ -150,7 +172,10 @@ impl UiScript { } } let mut finished_cooldowns: Vec = Vec::new(); - for (h, frame) in model.arena.iter_frames_mut() { + for &h in &ticked { + let Some(frame) = model.arena.frame_mut(h) else { + continue; + }; if let crate::widget::KindState::ScrollingMessage(smf) = &mut frame.kind_state { smf.tick(elapsed); } diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 57e6a4ea..80b117e8 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -377,6 +377,13 @@ pub struct WidgetArena { /// exists" signal, so the per-frame state feed (`set_minimap_inside`'s caller) re-pushes /// exactly when one appears instead of walking every frame to find out. minimap_created: u64, + /// Frames whose kind carries **engine-side per-tick behavior** — ScrollingMessageFrame / + /// MessageFrame (the line fades) and Cooldown (the flash-finished hide). The tick's registry + /// (decision 1446, the `minimap_created` disposition at list size): the per-frame advance + /// walks these few dozen instead of the whole arena, which it used to do TWICE per tick — a + /// corpus UI is thousands of frames. Appended at creation (kinds never change after); + /// `destroy` removes its own handle, so the list never carries dead entries. + ticked_kinds: Vec, } impl Default for WidgetArena { @@ -394,6 +401,7 @@ impl WidgetArena { names: HashMap::new(), next_insertion: 0, minimap_created: 0, + ticked_kinds: Vec::new(), } } @@ -402,6 +410,11 @@ impl WidgetArena { self.minimap_created } + /// The frames whose kind the host must advance each tick (see the field note). + pub fn ticked_kinds(&self) -> &[FrameHandle] { + &self.ticked_kinds + } + // ── Read access ──────────────────────────────────────────────────────────────────────────── /// Borrow a frame, or `None` if the handle is stale/unknown. @@ -598,8 +611,15 @@ impl WidgetArena { if matches!(kind, FrameKind::Minimap) { self.minimap_created += 1; } + let ticked = matches!( + kind, + FrameKind::ScrollingMessageFrame | FrameKind::MessageFrame | FrameKind::Cooldown + ); let (index, generation) = self.frames.insert(frame); let handle = FrameHandle { index, generation }; + if ticked { + self.ticked_kinds.push(handle); + } if let Some(p) = parent { self.frame_mut(p) @@ -624,6 +644,8 @@ impl WidgetArena { let regions = frame.regions.clone(); let parent = frame.parent; let name = frame.name.clone(); + // Each (recursive) destroy removes its own handle — the tick registry stays dead-free. + self.ticked_kinds.retain(|&t| t != h); for c in children { self.destroy(c); diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index 5c71c45a..6646ff31 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -147,7 +147,6 @@ pub mod lighting; pub mod liquid; pub mod map_proj; pub mod mat_anim_table; -pub mod mega_static; pub mod mesh_tag; pub mod model_fade; pub mod model_forms; diff --git a/crates/benilla-world/src/mega_static.rs b/crates/benilla-world/src/mega_static.rs deleted file mode 100644 index 9468356b..00000000 --- a/crates/benilla-world/src/mega_static.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! **`WOW_MEGA_STATIC=1` — the consolidation bracket** (the drastic-options census, 2026-08-18). -//! -//! An EXPERIMENT lever, not a landing: when armed, the placement assembler diverts every fully -//! static batch (no anim host, no billboard, no animated material) into this buffer instead of -//! spawning an entity, and the flush below merges each material's backlog into ONE mesh with the -//! placement transforms baked into the vertices. Grouping by the deduped material handle is what -//! keeps every material semantic (blend, shade selector, batch order, two-sidedness, fog policy) -//! intact — the group IS the batch key. -//! -//! **What the bracket deliberately breaks, and why that is honest:** merged geometry has no -//! per-placement distance fade, no pick identity, no exterior-window gating, no per-group WMO -//! portal visibility, and never despawns with its tile. Those are exactly the constraints a real -//! consolidation must solve (decision 1413 enumerates them); the bracket exists to measure the -//! CEILING — what the frame costs when the static world is a few hundred rows instead of tens of -//! thousands — so the design work knows what it is buying before anyone pays for it. Parked-pin -//! measurements only; the world it draws is visually wrong in motion (pop-in never fades, culling -//! is coarse) and the flag must never default on. - -use bevy::camera::primitives::Aabb; -use bevy::camera::visibility::NoAutoAabb; -use bevy::mesh::MeshTag; -use bevy::prelude::*; - -use crate::mesh_tag::alpha_bits; -use crate::model_render::{ModelKind, ModelPart}; -use benilla_assets::materials::WowModelMaterial; -use benilla_assets::merged_static_mesh; -use benilla_formats::{ModelBlend, RenderSubmesh}; -use std::sync::Arc; - -/// Is the bracket armed? Read once; the assembler and the flush both key on it. -pub fn enabled() -> bool { - static ON: std::sync::OnceLock = std::sync::OnceLock::new(); - *ON.get_or_init(|| std::env::var("WOW_MEGA_STATIC").as_deref() == Ok("1")) -} - -/// One diverted batch: the shared geometry, this placement's transform, and the deduped material -/// it would have drawn with. -pub struct PendingPart { - pub geometry: Arc, - pub transform: Transform, - pub blend: ModelBlend, - pub kind: ModelKind, -} - -/// The divert buffer. Keyed by material handle — the merge group and the draw batch are the same -/// thing by construction. -#[derive(Resource, Default)] -pub struct MegaStaticPending { - pub parts: Vec<(Handle, PendingPart)>, -} - -/// Merge and spawn once the stream has been quiet for [`FLUSH_QUIET_SECS`]. One entity per -/// material group per flush; a later burst (a new tile) makes a new generation of blobs rather -/// than rebuilding the old ones — acceptable for a bracket whose pins are parked. -const FLUSH_QUIET_SECS: f32 = 2.0; - -/// (material, spatial cell) → the placements merging into that blob. -type MergeGroups = - std::collections::HashMap<(Handle, (i32, i32)), Vec>; - -pub fn flush_mega_static( - mut commands: Commands, - mut pending: ResMut, - mut meshes: ResMut>, - time: Res