the paper doll goes half rate, and the parked frame diets

- the paper doll joins the collapsed rig lane and its body panes render every other frame while open, the pose still evaluating at full rate (boothHalfRate CVar) — the open character window's cost at Goldshire ~+2.7 → ~+1.0 ms
- the parked frame's idle diet: avian's dynamics pipeline goes (the world has zero dynamic bodies), the hidden dev overlay stops drawing an empty pass, and a unit's swim state is re-checked only when it moved — 12.8–13.3 → 12.0 ms parked at Goldshire
- the UI tick stops sweeping the whole frame arena three times over: OnUpdate frames and the message and cooldown kinds live on registries kept at their one write site — idle tick 530 → ~195 µs a frame
- the WOW_MEGA_STATIC bracket retires, past its recorded condition now that the retained pass carries its own

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 19:02:44 +07:00
co-authored by Claude Fable 5
parent 74c3b47a7f
commit 67f9310364
23 changed files with 516 additions and 363 deletions
+27 -1
View File
@@ -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<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
mut video: ResMut<VideoConfig>,
mut pane_rate: ResMut<PaneRate>,
) {
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<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
mut video: ResMut<VideoConfig>,
mut pane_rate: ResMut<PaneRate>,
) {
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<ResMut<BubbleConfig>>,
Option<ResMut<ZoomLimit>>,
Option<ResMut<VideoConfig>>,
Option<ResMut<PaneRate>>,
)> = 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::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<VideoConfig>()
.init_resource::<PaneRate>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
@@ -961,6 +986,7 @@ mod tests {
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<VideoConfig>()
.init_resource::<PaneRate>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
app.update();
+34 -3
View File
@@ -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<DebugState>,
hud: Res<crate::perf::PerfHud>,
inspect: Res<crate::ui_script::InspectMode>,
mut cams: Query<(&mut Camera, &mut EguiContextSettings), With<PrimaryEguiContext>>,
mut over: ResMut<EguiPointerOver>,
) {
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<ButtonInput<KeyCode>>, mut debug: ResMut<DebugState>) {
// 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
+3 -4
View File
@@ -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,
);
+35 -3
View File
@@ -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<Transform>, Without<SwimEvaluated>)>;
type SwimMarkQuery = (
Entity,
&'static NetEntity,
&'static Transform,
Option<&'static CollisionHeight>,
Has<CreatureSwimming>,
Has<SwimEvaluated>,
);
/// 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<Transform>` — 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<SwimMarkQuery>,
units: Query<SwimMarkQuery, SwimMarkGate>,
stamped: Query<Entity, With<SwimEvaluated>>,
world: benilla_world::world_point::WorldPoint,
epoch: Res<benilla_world::collision::ColliderEpoch>,
mut last_epoch: Local<Option<u64>>,
) {
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::<SwimEvaluated>();
}
}
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
+127 -65
View File
@@ -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<benilla_assets::ModelEmitter>,
}
/// 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<benilla_world::rig_anim::RigPose>,
}
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<Entity> {
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::<Children>` 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<Entity> {
) -> 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
+10 -7
View File
@@ -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::<Children>();
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));
+22 -17
View File
@@ -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::<Children>();
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::<Children>();
// 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::<Children>();
// 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::<Children>();
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
+102 -56
View File
@@ -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<Entity>,
/// 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 `<PlayerModel>`-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::<PortraitImages>()
.init_resource::<PaneRate>()
.init_resource::<PaperDollBooth>()
.init_resource::<InspectBooth>()
.init_resource::<PetDollBooth>()
@@ -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::<Children>();
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::<Children>();
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 `<PlayerModel>` 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 `<PlayerModel>` 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<crate::pipe_warm::WarmPass>,
time: Res<Time<bevy::time::Real>>,
mut cams: Query<(&BoothCam, &mut Camera)>,
mut seq_drives: Query<&mut benilla_world::rig_anim::GlobalSeqDrive>,
rate: Res<PaneRate>,
frames: Res<bevy::diagnostic::FrameCount>,
mut env_cache: Local<Option<bool>>,
) {
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<bool> = 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::<bevy::animation::AnimatedBy>();
}
}
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::<benilla_world::rig_anim::AnimParked>();
} else {
commands
.entity(booth.root)
.insert(benilla_world::rig_anim::AnimParked);
}
booth.parked = !active;
}
+4 -2
View File
@@ -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
+5 -5
View File
@@ -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;
+11 -18
View File
@@ -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<RenderSubmesh>, 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<RenderSubmesh>, Transform)],
spheres: &[Vec4],
+7
View File
@@ -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<FrameHandle, HashSet<&'static str>>,
/// 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<FrameHandle>,
/// `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,
@@ -358,13 +358,19 @@ fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> ml
Some(f) => {
per.set(kind, f)?;
let mut model = lua.app_data_mut::<Model>().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::<Model>().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);
}
}
}
}
+36 -11
View File
@@ -95,13 +95,30 @@ impl UiScript {
}
let ids: Vec<u32> = {
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<FrameHandle> = 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<FrameHandle> = model
.on_update_frames
.iter()
.copied()
.filter(|&h| arena.frame(h).is_some())
.collect();
model.on_update_frames = live;
}
let mut ids: Vec<u32> = 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::<Vec<_>>()
.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<FrameHandle> = 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<FrameHandle> = 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);
}
+22
View File
@@ -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<FrameHandle>,
}
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);
-1
View File
@@ -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;
-120
View File
@@ -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<bool> = 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<RenderSubmesh>,
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<WowModelMaterial>, 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<WowModelMaterial>, (i32, i32)), Vec<PendingPart>>;
pub fn flush_mega_static(
mut commands: Commands,
mut pending: ResMut<MegaStaticPending>,
mut meshes: ResMut<Assets<Mesh>>,
time: Res<Time>,
// (backlog length at last look, when it last moved) — the quiet timer, kept here so the
// assembler needs no clock.
mut prev: Local<(usize, f32)>,
) {
let (prev_len, moved_at) = &mut *prev;
if pending.parts.len() != *prev_len {
*prev_len = pending.parts.len();
*moved_at = time.elapsed_secs();
return;
}
if pending.parts.is_empty() || time.elapsed_secs() - *moved_at < FLUSH_QUIET_SECS {
return;
}
*prev_len = 0;
let parts = std::mem::take(&mut pending.parts);
let total = parts.len();
// Group key = (material, SPATIAL CELL). Round 1 of this bracket grouped by material alone
// and LOST (+1.38 cpu_ms at SW, drawn 400 → ~830): one blob per material spans the whole
// streamed scene, so its Aabb defeats the frustum cull and every blob's full vertex load
// encodes every frame. The cell restores locality; the distinct-material count (~5.6k at
// SW) remains the blob-count floor either way — the census finding that per-material
// merging alone cannot reach the few-hundred-row regime (1413).
const CELL: f32 = 133.333;
let cell_of = |t: &Transform| {
(
(t.translation.x / CELL).floor() as i32,
(t.translation.z / CELL).floor() as i32,
)
};
let mut by_mat: MergeGroups = std::collections::HashMap::new();
for (mat, p) in parts {
let cell = cell_of(&p.transform);
by_mat.entry((mat, cell)).or_default().push(p);
}
let groups = by_mat.len();
for ((mat, _cell), group) in by_mat {
let (blend, kind) = (group[0].blend, group[0].kind);
let geo: Vec<(Arc<RenderSubmesh>, Transform)> = group
.into_iter()
.map(|p| (p.geometry, p.transform))
.collect();
let (mesh, mn, mx, center) = merged_static_mesh(&geo);
commands.spawn((
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(mat),
Transform::from_translation(center),
ModelPart { kind, blend },
MeshTag(alpha_bits(1.0)),
Aabb::from_min_max(mn, mx),
NoAutoAabb,
));
}
info!("mega-static: merged {total} diverted batches into {groups} blob(s)");
}
+14
View File
@@ -141,6 +141,20 @@ impl RigPose {
}
}
/// This rig is rendered by an **off-world camera** (a portrait booth), so the world pass must
/// not camera-face its billboard bones: [`finalize_rig_worlds`](super::finalize_rig_worlds)
/// takes its basis from the `WorldCamera`, which is not the camera drawing this rig. Dropping
/// the kinds leaves those bones at their composed pose — exactly what the entity-joint booth
/// lane produced (its joints never carried a `BillboardJointRig`), while the booth's own card
/// facer counter-rotates each card's anchor to the booth camera. The `flags & 0x7` parent
/// arms stay: that law is camera-free and runs in [`Self::compose`] regardless.
pub fn without_camera_billboards(mut self) -> Self {
self.kinds.iter_mut().for_each(|k| *k = None);
self.has_billboard = false;
self.has_special = self.arms.iter().any(Option::is_some);
self
}
/// The anchor entity standing in for `bone`, spawned on first demand (decision 1355): a
/// consumer that needs an *entity* on a bone — a held item's parent, an emitter's owner
/// frame, a quest marker's seat — resolves it here, and only bones something actually
+3 -1
View File
@@ -925,7 +925,9 @@ pub fn plugin(app: &mut App) {
/// Engine work that lived in `entities::attach` because the unit spawner was the first lane to
/// want it — and the doodad animator, the portrait booth, the equipment spawner and the spell-fx
/// spawner all reached across for it since. Its only cross-module reference was `RigJoint`, which
/// is here, so this is where it goes.
/// is here, so this is where it goes. The lanes have been leaving one by one for the collapsed
/// `RigPose` buffer (units 0724, doodads 1365, booths 1443); what remains is the **attached
/// model** lanes — equipment and spell-fx, whose joints hang under someone else's bone.
///
/// `holder` is the rig root the palette belongs to; `root` is what an unparented joint attaches
/// to. They differ for an attached model whose joints hang under someone else's bone.
@@ -95,8 +95,13 @@ const RECONSOLIDATE_IDLE_FRAMES: u32 = 180;
/// would flicker the cell off, the exact defect class the 1421–1423 hunt closed.
const RETIRE_FRAMES: u32 = 10;
/// The doodad spatial cell, ¼ of an ADT tile — the bracket's measured 133⅓-yd locality key
/// (`mega_static`'s comment records the round-1 failure without it).
/// The doodad spatial cell, ¼ of an ADT tile — the retired `WOW_MEGA_STATIC` bracket's measured
/// 133⅓-yd locality key. Its round 1 grouped by material alone and LOST (+1.38 cpu_ms at SW,
/// drawn 400 → ~830): one blob per material spans the whole streamed scene, so its Aabb defeats
/// the frustum cull and every blob's full vertex load encodes every frame. The cell restores
/// locality; the distinct-material count (~5.6k at SW) remains the blob-count floor either way —
/// the census finding that per-material merging alone cannot reach the few-hundred-row regime
/// (decision 1413).
const CELL: f32 = 533.333_3 / 4.0;
/// One accumulating blob: shared geometry + placement transforms + per-placement fade
@@ -62,7 +62,8 @@ pub struct SpawnedModel {
/// parts, world-root cards; owner-following cards manage their own lifecycle and stay out).
pub entities: Vec<Entity>,
/// Index-parallel with the `submeshes` slice: the entity each batch spawned, `None` where
/// no entity exists (a missing render form, or the `WOW_MEGA_STATIC` divert). This exists
/// no entity exists (a missing render form; historically also the retired `WOW_MEGA_STATIC`
/// bracket's divert). This exists
/// because POSITION IS A CONTRACT: the WMO path zips per-batch group indices against what
/// was spawned, and a skipped batch must hold its slot rather than shift every later batch
/// onto the wrong group — which is exactly what the bracket's bare `continue` did to the
@@ -133,11 +134,6 @@ pub fn spawn_model_entities(
// parents that list, and a card is a world root the billboard pass writes absolutely).
// `None` for world-static placements (terrain), whose pivots never move.
card_owner: Option<Entity>,
// `Some` on the world-static streamer path when the `WOW_MEGA_STATIC` bracket may divert
// fully static batches into the merge buffer instead of spawning them
// ([`crate::mega_static`] — an EXPERIMENT lever; its module doc owns the caveats). `None`
// on the moving-prop path (a merged blob cannot ride a gameobject).
mut mega: Option<&mut crate::mega_static::MegaStaticPending>,
// `Some` on the world-static streamer paths lane 1 covers (ADT doodads + WMO group
// geometry): the production merge buffer and this placement's merge site (decision 1417,
// `WOW_STATIC_MERGE`). `None` on the WMO-prop and moving-gameobject paths.
@@ -494,34 +490,6 @@ pub fn spawn_model_entities(
continue;
}
}
// The `WOW_MEGA_STATIC` divert (the consolidation bracket): a fully static batch — no
// anim host, not a billboard card, no per-entity material animation — skips its entity
// entirely; the merge flush draws it inside one blob per MATERIAL, which preserves every
// material semantic by construction (the handle is already deduped over blend, shade,
// batch order, fog policy...). Shared-table UV/tint loops would merge fine too (the
// table drives the material, not the entity), excluded anyway to keep the bracket's
// divert predicate trivially auditable.
if let Some(mega) = mega.as_mut() {
if crate::mega_static::enabled()
&& !animated
&& sub.billboard.is_none()
&& sub.alpha_anim.is_none()
&& seq_owner.is_none()
&& sub.uv_anim.is_none()
&& sub.rgb_anim.is_none()
{
mega.parts.push((
cutout.clone(),
crate::mega_static::PendingPart {
geometry: sub.geometry.clone(),
transform,
blend: sub.blend,
kind,
},
));
continue;
}
}
// Register both variants' materials for the per-frame UV scroll (idempotent per material —
// every instance of the batch lands on the same deduped handle).
// A period-0 loop is a constant the material seed already wrote (`sample(0.0)` IS its
@@ -65,7 +65,6 @@ type SpawnTables<'w> = (
Res<'w, crate::terrain_stream::ViewFocus>,
ResMut<'w, ModelForms>,
ResMut<'w, HullWelds>,
ResMut<'w, crate::mega_static::MegaStaticPending>,
ResMut<'w, StaticMerge>,
// `None` only under `WOW_STATIC_GX=0` (the resource exists whenever the retained pass
// is armed — the default since 1434).
@@ -98,8 +97,7 @@ pub(super) fn spawn_loaded_placements(
// model-forms cache (decision 0834).
tables: SpawnTables,
) {
let (mut probes, mut activity, focus, mut forms, mut welds, mut mega, mut merge, mut staticgx) =
tables;
let (mut probes, mut activity, focus, mut forms, mut welds, mut merge, mut staticgx) = tables;
let Some(shared_light) = shared_light else {
return;
};
@@ -205,7 +203,6 @@ pub(super) fn spawn_loaded_placements(
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
Some(&mut *mega),
Some((&mut *merge, MergeSite::Doodad { owner: p.owner })),
// The retained-pass collector (1429/1431) — ADT doodads are its
// lane; uid + label seed the fader exile identity (B2).
@@ -390,7 +387,6 @@ pub(super) fn spawn_loaded_placements(
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
Some(&mut *mega),
Some((
&mut *merge,
MergeSite::Wmo {
@@ -701,7 +697,6 @@ pub(super) fn spawn_loaded_placements(
&mut tint_reg,
&mut anim_table,
None, // world-static placement: cards bake their world pivot
Some(&mut *mega),
// The prop merge site (1418 lane 3): keyed by the rooms that name the prop,
// slot baked per vertex for the interior lane.
Some((
+36 -5
View File
@@ -72,6 +72,42 @@ impl PluginGroup for WorldPlugins {
// thread inside `update_narrow_phase` for 12–41% of their samples; the run that
// prompted this stalled >600 ms three times in five minutes. Decision 1232.
.disable::<BvhBroadPhasePlugin>()
// …and NOT the dynamics pipeline either (decision 1445 — stage 2 of the same diet,
// on the same recorded premise as `SubstepCount(1)` below: the world has ZERO
// `RigidBody::Dynamic` bodies, zero joints, zero velocity/force consumers — verified
// by grep across the workspace, only Static colliders and Kinematic transports that
// write their `Position` directly). With no contacts (broad phase off) and no
// dynamic bodies, the solver bodies, the integrators, the xpbd joint solver, CCD,
// islands/sleeping, the joint graphs, mass-property upkeep, the narrow phase's
// empty-graph iteration and the render-side interpolation (zero
// `TransformInterpolation` users; transports tick in `Update` at render rate) were
// pure fixed-tick schedule overhead — ~60% of a parked frame's physics share on the
// 1445 trace. What stays is exactly what benilla consumes: the collider storage +
// backend, the collider BVH (`ColliderTreePlugin`) and spatial queries the
// controller/pick shape-casts ride, collider transform propagation, and the
// Transform↔Position sync. Revisit the moment a dynamic body enters the world —
// the `SubstepCount` note below is the standing tripwire.
.disable::<ForcePlugin>()
.disable::<MassPropertyPlugin>()
.disable::<NarrowPhasePlugin<Collider>>()
.disable::<JointPlugin>()
.disable::<PhysicsInterpolationPlugin>()
// `SolverSchedulePlugin` deliberately STAYS: it owns the `PhysicsSchedule`'s
// step-set scaffolding (the chain every kept system's `in_set` ordering hangs off —
// removing it left the collider-tree systems floating and tripped the ambiguity
// gate), plus the substep runner, which now runs a near-empty schedule.
.disable::<SolverBodyPlugin>()
.disable::<IntegratorPlugin>()
.disable::<SolverPlugin>()
.disable::<XpbdSolverPlugin>()
.disable::<CcdPlugin>()
.disable::<IslandPlugin>()
.disable::<IslandSleepingPlugin>()
.disable::<avian3d::dynamics::solver::joint_graph::JointGraphPlugin<FixedJoint>>()
.disable::<avian3d::dynamics::solver::joint_graph::JointGraphPlugin<RevoluteJoint>>()
.disable::<avian3d::dynamics::solver::joint_graph::JointGraphPlugin<PrismaticJoint>>()
.disable::<avian3d::dynamics::solver::joint_graph::JointGraphPlugin<DistanceJoint>>()
.disable::<avian3d::dynamics::solver::joint_graph::JointGraphPlugin<SphericalJoint>>()
.add(WorldFoundation)
// The per-frame world-transition ordering (Input → Stream → Present) the loading
// screen relies on to cover a teleport the same frame it happens. See `schedule.rs`.
@@ -251,11 +287,6 @@ impl Plugin for WorldFoundation {
// two scans are its entire price. Pinned, not a knob: no scene benilla renders can be
// on the other side of the threshold.
app.insert_resource(bevy::transform::systems::StaticTransformOptimizations::enabled());
// `WOW_MEGA_STATIC=1` — the consolidation bracket (EXPERIMENT lever; the module doc owns
// the caveats — never a default). The resource + flush register unconditionally: with
// the flag off nothing ever diverts, the buffer stays empty, and the flush early-outs.
app.init_resource::<crate::mega_static::MegaStaticPending>();
app.add_systems(bevy::app::Update, crate::mega_static::flush_mega_static);
// The retained static-world pass — ON by default (1429–1434; `WOW_STATIC_GX=0`
// opts out; the module doc owns the design). Registers NOTHING when opted out —
// the divert sites see `None` for its resource and the render graph never gains