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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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((
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user