From 80978df9839c34558cdaa863ff6ac37cdafa8c81 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 28 Jul 2026 21:56:09 +0700 Subject: [PATCH] the loading screen gets a lifecycle, and stops lying at both ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entering the world used to show an uncovered, movable world before the screen came up — seconds of it on a slower machine ("like the Stormwind intro video", as one report with video put it). The screen also cleared onto bare terrain with the buildings still streaming in, and could hang forever if you arrived swimming or flying. It has a real lifecycle now: it rises at the entry edges (character pick, transfer, teleport), clears only when the scene is actually presentable — terrain, buildings, props and their collision all resident — and its physics hold releases in every movement mode, not just walking. The progress bar counts buildings too. - A far teleport flashed a frame of the destination before the screen rose. The "swap never flashes" guarantee existed only in comments — a streamer rework had silently dropped the scheduling constraint that enforced it; it is structural now. And logout got its missing cover: the original's plain black cut, not a frame of torn-down world. - Animation parking now governs everything with a rig. ~350 animated objects (braziers, doors, chests) were sampling their loops off-screen forever, and a patrolling parked mob kept writing pose data nothing could read — ~620 rig refreshes a frame became 7, and creatures in rooms the camera can't reach through any portal park too. - The engine's own tax got measured: bare Bevy spends ~3.9 CPU ms a frame on this machine drawing nothing at all, which resets what "fast enough" honestly means for everything built on top of it. A shipping build profile (fat LTO) landed alongside — worth about −1 ms at the pin, kept out of everyday builds. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 10 + crates/benilla-assets/src/wmo.rs | 3 +- crates/benilla/src/char_select/mod.rs | 9 + crates/benilla/src/creature_anim/compose.rs | 25 +- crates/benilla/src/creature_anim/lod.rs | 255 ++++++++++++++++- crates/benilla/src/loading_screen.rs | 270 +++++++++++++++--- crates/benilla/src/model_fade.rs | 35 ++- crates/benilla/src/player.rs | 11 +- crates/benilla/src/player/move_trace.rs | 31 +- crates/benilla/src/player/mover.rs | 48 +--- crates/benilla/src/player/state.rs | 60 ++-- crates/benilla/src/player/swim.rs | 61 ++-- crates/benilla/src/player/wire_in.rs | 12 +- crates/benilla/src/rig_palette.rs | 78 ++++- crates/benilla/src/terrain_stream.rs | 123 +++++--- crates/benilla/src/terrain_stream/collider.rs | 21 +- crates/benilla/src/wmo_portal/interior.rs | 11 + 17 files changed, 822 insertions(+), 241 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 66a817ca..79498609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,3 +38,13 @@ opt-level = 1 # No debuginfo trimming: `debug = "line-tables-only"` was A/B-measured (decision 0192) at zero # recurring win on this stack (macOS unpacked split-debuginfo; sccache; fast ld) — not worth # invalidating every warm target and the sccache cache for. + +# Shipping-grade optimization, A/B-graded at the pin (decision 0736): −1.0 cpu_ms (−5 %) vs +# default release, flat at the empty-map and bare-Bevy floors. The campaign keeps grading on +# default `release` — same-day A/B is the law, and fat LTO's link time would tax every +# iteration for a constant offset. Build with `--profile ship` for actual releases (add +# `RUSTFLAGS=-Ctarget-cpu=native` for a machine-local binary; not set here — it isn't portable). +[profile.ship] +inherits = "release" +lto = "fat" +codegen-units = 1 diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index d5b4ab2a..43c65559 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -48,7 +48,8 @@ pub struct WmoGroupNav { /// A loaded WMO building: the render batches across all its groups, flattened, plus the flattened /// collidable triangles (the app bakes a collider from these per placement). -#[derive(Asset, TypePath, Clone)] +/// `Default` = the empty building (every field vacuous) — a test scaffold, never a loader product. +#[derive(Asset, TypePath, Clone, Default)] pub struct WmoModel { /// The building's `WMOAreaTable.WMOID` key (root `MOHD.wmoID`; NSabbey → 59). pub wmo_id: u32, diff --git a/crates/benilla/src/char_select/mod.rs b/crates/benilla/src/char_select/mod.rs index 033b43ea..512f1681 100644 --- a/crates/benilla/src/char_select/mod.rs +++ b/crates/benilla/src/char_select/mod.rs @@ -141,6 +141,15 @@ impl Roster { pub(super) fn selected_char(&self) -> Option<&Character> { self.selected.and_then(|i| self.chars.get(i)) } + + /// The pending pick's map — the loading screen resolves the *destination's* art from the + /// roster at the entry edge, before the server's `SMSG_LOGIN_VERIFY_WORLD` snap lands + /// (decision 0737). + pub(crate) fn pending_map(&self) -> Option { + self.pending_pick + .and_then(|g| self.chars.iter().find(|c| c.guid == g)) + .map(|c| c.map) + } } /// Ask the parked IO thread to log in as `guid` (the pick channel) and remember it as pending. diff --git a/crates/benilla/src/creature_anim/compose.rs b/crates/benilla/src/creature_anim/compose.rs index c72111ba..2abbe3ba 100644 --- a/crates/benilla/src/creature_anim/compose.rs +++ b/crates/benilla/src/creature_anim/compose.rs @@ -133,14 +133,21 @@ pub(crate) fn finalize_rig_worlds( .single() .ok() .map(|t| (*t.forward(), *t.right(), *t.up())); - // Which rigs refresh this frame: pose-dirty, model frame moved, or camera-faced live. + // Which rigs refresh this frame: pose-dirty, model frame moved, or camera-faced — and never + // a parked one (decision 0739). The LOD gate ruled a parked rig un-viewable, so neither an + // idle's pose_dirty, a patrol's root motion, nor a billboard face can matter until the wake + // — which drops the marker before the same frame's pose evaluation, re-raising `pose_dirty` + // before this pass runs, so a woken rig's rows are current before anything draws them. (The + // world-space rows of a moving parked rig go stale on purpose — nothing reads them; at the + // LBRS pin the parked-but-patrolling re-writes were ~260 of the ~620 per-frame refreshes.) let refresh: Vec = { let roots_changed = worlds_params.p0(); rigs.iter() .filter(|(_, rig, _, parked)| { - rig.pose_dirty - || roots_changed.contains(rig.joints_root) - || (rig.has_billboard && !parked) + !parked + && (rig.pose_dirty + || roots_changed.contains(rig.joints_root) + || rig.has_billboard) }) .map(|(holder, ..)| holder) .collect() @@ -148,6 +155,9 @@ pub(crate) fn finalize_rig_worlds( if refresh.is_empty() { return; } + // The `WOW_RIG_COST` compose meter (decision 0736): how many rigs refresh and what the + // whole finalize pass costs, beside the palette's copy/write and upload lines. + let cost_t0 = crate::rig_palette::rig_cost_enabled().then(std::time::Instant::now); let mut globals = worlds_params.p1(); // The rigid-child re-walk's do-not-enter set, exactly the entity pass's: a nested rig with // its own billboard output owns its interior (and its root keeps the propagated frame). @@ -230,6 +240,13 @@ pub(crate) fn finalize_rig_worlds( rig.pose_dirty = false; } } + if let Some(t0) = cost_t0 { + eprintln!( + "[rig-finalize] refreshed={} ms={:.3}", + refresh.len(), + t0.elapsed().as_secs_f32() * 1000.0 + ); + } } /// Register the model pass; the world pass is chained by [`crate::billboard::BillboardPlugin`] diff --git a/crates/benilla/src/creature_anim/lod.rs b/crates/benilla/src/creature_anim/lod.rs index 2a924a53..76f7ffa7 100644 --- a/crates/benilla/src/creature_anim/lod.rs +++ b/crates/benilla/src/creature_anim/lod.rs @@ -1,5 +1,8 @@ -//! The unit animation-LOD gate (decision 0448): park an off-frustum rig's **per-bone pose -//! evaluation**, keep **every clock** running. +//! The animation-LOD gate (decision 0448): park an off-frustum — or, since decision 0739, +//! portal-invisible — rig's **per-bone pose evaluation**, keep **every clock** running. Since +//! 0739 it governs the whole [`RigPose`] population — units, players, AND GameObject rigs (see +//! the query's comment for why the old `With` filter left every animated GO +//! sampling off-view). //! //! The reference has NO view cull on unit skeletons — every in-range, non-hidden unit is fully //! animated every frame (wow-re `unit-anim-visibility-gate.md`: worklist admission is the CGUnit @@ -23,18 +26,39 @@ //! gate on the same marker. Contrast the DOODAD gate ([`crate::doodad_anim`]), which faithfully //! stops the player and re-arms on the shared clock — correct there (no one consumes a doodad's //! events off-screen), the exact 0075 trap here. +//! +//! **The room leg (decision 0739).** The frustum is the wrong instrument indoors: a dungeon +//! camera's view cone passes through walls, so most of an instance's population stays "in +//! frustum" while only the current room chain is drawable. The portal PVS +//! ([`crate::wmo_portal`], the faithful WMO group cull) already knows which rooms the camera can +//! reach, and [`UnitWmoRoom`] already names each unit's room — so a unit whose room (and every +//! room one portal hop from it) is outside this frame's PVS parks too. Soundness: a unit is +//! room-parked exactly when the geometry *around* it — its room's walls, floor, furniture — is +//! portal-culled by the same bits, and a body can poke only through a portal, never a wall, so +//! the one-hop guard bounds the doorway straddle. Reveals carry no artifact by construction: a +//! mob can only become visible through a portal, and the moment that portal's screen window is +//! non-zero the flood marks its room visible (`compute_wmo_pvs` runs in `Update`, this gate in +//! `PostUpdate` before the evaluator — same-frame wake, absolute-clock pose). Fail-open at every +//! seam: no claim (outdoors, EXTERIOR groups), a despawned placement, a still-loading model, an +//! out-of-range index — all read visible, the [`crate::wmo_portal::WmoGroupVis::drawn_by`] +//! convention. +//! `WOW_NO_ROOM_LOD=1` disables just this leg (the A/B lever); `WOW_RIG_COST=1` prints the +//! per-frame `[rig-gate]` counters. use bevy::app::AnimationSystems; use bevy::camera::primitives::{Frustum, Sphere as CullSphere}; use bevy::ecs::entity::EntityHashMap; use bevy::prelude::*; +use benilla_assets::{WmoGroupNav, WmoModel, WmoPortalRef}; + use crate::entities::mount::MountBody; use crate::net::SelfPlayer; use crate::player::WorldCamera; use crate::target::SelectionRadius; +use crate::wmo_portal::{UnitWmoRoom, WmoPortalInstance, WmoRoom}; -use super::AnimDriver; +use super::RigPose; /// This rig's per-bone pose evaluation is parked (decision 0448): the pose evaluator and the pose /// post-passes skip it. The sequence clocks, the driver state machine, and the event scanner all @@ -55,12 +79,13 @@ const RADIUS_SCALE: f32 = 2.0; const RADIUS_PAD: f32 = 4.0; const FALLBACK_RADIUS: f32 = 6.0; -/// Park/wake streamed rigs by the padded sphere-vs-frustum test (decision 0448). PostUpdate, +/// Park/wake streamed rigs by the padded sphere-vs-frustum test (decision 0448) ANDed with the +/// portal-PVS room test (decision 0739 — see the module doc's "room leg"). PostUpdate, /// before [`AnimationSystems`] — a wake drops the marker in time for the same frame's pose /// evaluation, so the re-appearing unit samples the absolute-clock pose with no stale /// frame. Exempt: the self-avatar (the camera rides its attachment-17 pivot, and it must keep /// animating faded-out in first person) and its mount child. `WOW_NO_ANIM_LOD=1` disables -/// parking — the live-probe A/B lever. +/// parking — the live-probe A/B lever; `WOW_NO_ROOM_LOD=1` disables only the room leg. #[allow(clippy::type_complexity, clippy::too_many_arguments)] // one Bevy system's full input set pub(super) fn gate_rig_animation( time: Res