performance day two: the resting interface stops rebuilding itself
- the resting player's blinking status glow was rebuilding the whole interface's batches every frame — a run whose bytes didn't move is no longer rewritten, worth 1.17 ms at Stormwind - doodad skeletons collapse into the pose buffer and parked rigs skip the model pass; doodad and prop hulls weld into batched trimeshes - the Update schedule runs on the single-threaded executor by default, measured faster than the multithreaded one for this frame - a parked camera stops claiming to move every frame Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -167,6 +167,10 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
|
||||
"X" => KeyCode::KeyX,
|
||||
"Z" => KeyCode::KeyZ,
|
||||
"Tab" => KeyCode::Tab,
|
||||
// TOGGLEAUTORUN's 1.12 default. The moving-axis protocol (task: grade streaming while
|
||||
// running) engages autorun with one tap and steers with `WOW_PROBE_LOOK` — the whole
|
||||
// scripted drive out of two instruments that already existed.
|
||||
"NumLock" => KeyCode::NumLock,
|
||||
// The nameplate toggles (`vplates`): bare `V` is enemy plates, `Shift`-held `V` is
|
||||
// friendly. A probe that wants plates over a town's *friendly* NPCs — the only way to
|
||||
// measure plate behaviour without standing in aggro range of something — presses this.
|
||||
|
||||
@@ -102,7 +102,15 @@ pub(super) fn gate_rig_animation(
|
||||
// events), `go_anim`'s state machine arms the player regardless of the marker, and the
|
||||
// wake samples the absolute clock. Booth/studio rigs are entity-joint-lane (no
|
||||
// `RigPose`), so this query can never park the character pane.
|
||||
With<RigPose>,
|
||||
//
|
||||
// `Without<DoodadAnimHost>` (decision 1365): placed doodads joined the collapsed lane,
|
||||
// and their draw gate (`doodad_anim::gate_doodad_anim`) owns their `AnimParked` marker —
|
||||
// it parks on the composed draw verdict + fade sphere, the doodad lane's own law, and
|
||||
// two writers to one marker would fight every frame the two policies disagree.
|
||||
(
|
||||
With<RigPose>,
|
||||
Without<benilla_world::doodad_anim::DoodadAnimHost>,
|
||||
),
|
||||
>,
|
||||
self_hosts: Query<Has<Embodied>>,
|
||||
instances: Query<&WmoPortalInstance>,
|
||||
|
||||
@@ -310,15 +310,14 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
if let Some(&root) = ents.first() {
|
||||
let placement = host_world.mul_transform(prop.local);
|
||||
for em in &m.emitters {
|
||||
// The emitter rides its host bone's joint when the prop animates (the same
|
||||
// ride as a terrain doodad — an unanimated chain reproduces the static path
|
||||
// exactly); a boneless prop follows its root. Either owner's propagated
|
||||
// The emitter rides its host bone's anchor when the prop animates (the
|
||||
// same ride as a terrain doodad — an unanimated chain reproduces the static
|
||||
// path exactly); a boneless prop follows its root. Either owner's propagated
|
||||
// global composes the moving hull.
|
||||
let owner = host
|
||||
.as_ref()
|
||||
.map(|h| h.joints.as_slice())
|
||||
.and_then(|js| js.get(em.def.bone as usize))
|
||||
.map_or((root, [0.0; 3]), |&j| (j, em.bone_pivot));
|
||||
.and_then(|h| h.anchor(em.def.bone))
|
||||
.map_or((root, [0.0; 3]), |a| (a, em.bone_pivot));
|
||||
if let Some(e) = particles::spawn_emitter(
|
||||
&mut commands,
|
||||
em,
|
||||
@@ -358,9 +357,8 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
for rb in &m.ribbons {
|
||||
let (owner, use_pivot) = host
|
||||
.as_ref()
|
||||
.map(|h| h.joints.as_slice())
|
||||
.and_then(|js| js.get(rb.def.bone as usize))
|
||||
.map_or((root, false), |&j| (j, true));
|
||||
.and_then(|h| h.anchor(rb.def.bone))
|
||||
.map_or((root, false), |a| (a, true));
|
||||
if benilla_world::ribbons::spawn_ribbon(
|
||||
&mut commands,
|
||||
rb,
|
||||
|
||||
@@ -243,6 +243,20 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
// when MOST rows move in one frame (a load burst into a near-empty world), where
|
||||
// dirty-marking briefly costs more than it saves — a loading-band term, accepted knowingly.
|
||||
app.insert_resource(bevy::transform::systems::StaticTransformOptimizations::enabled());
|
||||
// The Update schedule runs on the SINGLE-THREADED executor (decision 1366). Not a tuning
|
||||
// whim: ~60% of our per-frame Update systems are non-Send (mlua's UiScript, kira's audio
|
||||
// handles) and serialize through the multi-threaded executor's one `local_thread_running`
|
||||
// flag anyway, so the cross-thread dispatch machinery was pure overhead — measured
|
||||
// −1.30 cpu_ms at the LBRS pin under 5-round grading with the wall tail (p95/p99/max)
|
||||
// flat-to-better (the 1364 "fatter tail" reading did not reproduce; 1366 has the tables,
|
||||
// both pins). PostUpdate stays multi-threaded — its wide parallel bands
|
||||
// (propagation, visibility) are real parallelism; re-grading it is a named open probe.
|
||||
// `WOW_MT_UPDATE=1` is the A/B lever back to the multi-threaded executor.
|
||||
if std::env::var_os("WOW_MT_UPDATE").is_none() {
|
||||
app.edit_schedule(Update, |s| {
|
||||
s.set_executor_kind(bevy::ecs::schedule::ExecutorKind::SingleThreaded);
|
||||
});
|
||||
}
|
||||
// …and one startup line says which build produced this log. Registered HERE, beside the stamp
|
||||
// it prints, rather than in `preflight` where it sat until decision 1179: **which build is
|
||||
// this** is the first thing a bug report from someone else's machine has to establish, and a
|
||||
|
||||
@@ -98,6 +98,32 @@ impl Plugin for PerfPlugin {
|
||||
// whether or not anything is tracing, or the counters would accumulate forever.
|
||||
.add_systems(Last, trace_stream)
|
||||
.add_systems(EguiPrimaryContextPass, perf_hud_ui);
|
||||
// `WOW_MESH_EVENTS=1` — who churns Mesh assets per frame? The premise counter behind
|
||||
// the Stormwind trace's `allocate_and_free_meshes` row (0.86 ms/frame at a PARKED pin):
|
||||
// the allocator answers every Modified with a free+realloc, so a steady scene should
|
||||
// show ~zero here. Printed once a second with the top mutated ids' first sighting.
|
||||
if std::env::var_os("WOW_MESH_EVENTS").is_some() {
|
||||
app.add_systems(Update, count_mesh_events);
|
||||
}
|
||||
// `WOW_CAM_CHANGED=1` — is the world camera's transform bit-stable at a parked pin?
|
||||
// The premise counter for gating the per-submesh visibility sweep on frame-stable
|
||||
// camera inputs: if the controller rewrites an equal transform every frame, change
|
||||
// detection fires anyway and the gate can never hold — the controller's no-op write
|
||||
// is then the first fix, not the sweep's.
|
||||
if std::env::var_os("WOW_CAM_CHANGED").is_some() {
|
||||
app.add_systems(bevy::app::PostUpdate, count_camera_changes);
|
||||
}
|
||||
// `WOW_ARCH_CENSUS=<secs>` — one archetype census at t=secs: every non-empty
|
||||
// archetype's entity count beside its component set, largest first. The exact by-lane
|
||||
// entity picture (1354's anchor census generalized to every lane at once), for sizing
|
||||
// which populations are worth collapsing before designing any collapse.
|
||||
if let Some(at) = std::env::var("WOW_ARCH_CENSUS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
{
|
||||
app.insert_resource(ArchCensusAt(at));
|
||||
app.add_systems(Last, arch_census);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
stall_sample::plugin(app);
|
||||
}
|
||||
@@ -1001,3 +1027,107 @@ mod stall_sample {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_MESH_EVENTS=1`: per-second Mesh asset-event counts (see the plugin registration). The
|
||||
/// `sample` list names a few mutated asset ids so the writer can be found by grepping who holds
|
||||
/// that handle.
|
||||
fn count_mesh_events(
|
||||
mut events: MessageReader<bevy::asset::AssetEvent<Mesh>>,
|
||||
time: Res<Time>,
|
||||
mut acc: Local<(f32, u32, u32, u32, Vec<String>)>,
|
||||
) {
|
||||
let (last, added, modified, removed, sample) = &mut *acc;
|
||||
for e in events.read() {
|
||||
match e {
|
||||
bevy::asset::AssetEvent::Added { .. } => *added += 1,
|
||||
bevy::asset::AssetEvent::Modified { id } => {
|
||||
*modified += 1;
|
||||
if sample.len() < 4 {
|
||||
sample.push(format!("{id:?}"));
|
||||
}
|
||||
}
|
||||
bevy::asset::AssetEvent::Removed { .. } | bevy::asset::AssetEvent::Unused { .. } => {
|
||||
*removed += 1;
|
||||
}
|
||||
bevy::asset::AssetEvent::LoadedWithDependencies { .. } => {}
|
||||
}
|
||||
}
|
||||
if time.elapsed_secs() - *last >= 1.0 {
|
||||
eprintln!(
|
||||
"[mesh-events] added={added} modified={modified} removed={removed}/s sample={sample:?}"
|
||||
);
|
||||
(*added, *modified, *removed) = (0, 0, 0);
|
||||
sample.clear();
|
||||
*last = time.elapsed_secs();
|
||||
}
|
||||
}
|
||||
|
||||
/// When the one-shot archetype census fires (seconds of `Time` elapsed; `f32::MAX` = spent).
|
||||
#[derive(Resource)]
|
||||
struct ArchCensusAt(f32);
|
||||
|
||||
/// The census itself (`WOW_ARCH_CENSUS`): exclusive, so it sees every archetype of the live
|
||||
/// world in one stop. Component paths are trimmed to their last two segments — the census reads
|
||||
/// as lanes, not as imports.
|
||||
fn arch_census(world: &mut World) {
|
||||
let due = world.resource::<ArchCensusAt>().0;
|
||||
if world.resource::<bevy::time::Time>().elapsed_secs() < due {
|
||||
return;
|
||||
}
|
||||
world.resource_mut::<ArchCensusAt>().0 = f32::MAX;
|
||||
let short = |full: &str| -> String {
|
||||
let base = full.split('<').next().unwrap_or(full);
|
||||
let segs: Vec<&str> = base.split("::").collect();
|
||||
segs[segs.len().saturating_sub(2)..].join("::")
|
||||
};
|
||||
let mut rows: Vec<(u32, String)> = world
|
||||
.archetypes()
|
||||
.iter()
|
||||
.filter(|a| !a.is_empty())
|
||||
.map(|a| {
|
||||
let mut names: Vec<String> = a
|
||||
.components()
|
||||
.iter()
|
||||
.filter_map(|&c| world.components().get_info(c))
|
||||
.map(|i| short(&i.name().to_string()))
|
||||
.collect();
|
||||
names.sort();
|
||||
(a.len(), names.join("+"))
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by_key(|r| std::cmp::Reverse(r.0));
|
||||
let total: u32 = rows.iter().map(|r| r.0).sum();
|
||||
eprintln!(
|
||||
"[census] {} entities across {} archetypes",
|
||||
total,
|
||||
rows.len()
|
||||
);
|
||||
for (n, sig) in rows.iter().take(60) {
|
||||
eprintln!("[census] {n:>7} {sig}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_CAM_CHANGED=1`: per-second count of frames whose world-camera `Transform` /
|
||||
/// `GlobalTransform` registered as changed (see the plugin registration).
|
||||
fn count_camera_changes(
|
||||
t_changed: Query<(), (With<benilla_world::view::WorldCamera>, Changed<Transform>)>,
|
||||
g_changed: Query<
|
||||
(),
|
||||
(
|
||||
With<benilla_world::view::WorldCamera>,
|
||||
Changed<GlobalTransform>,
|
||||
),
|
||||
>,
|
||||
time: Res<Time>,
|
||||
mut acc: Local<(f32, u32, u32, u32)>,
|
||||
) {
|
||||
let (last, frames, t_n, g_n) = &mut *acc;
|
||||
*frames += 1;
|
||||
*t_n += u32::from(!t_changed.is_empty());
|
||||
*g_n += u32::from(!g_changed.is_empty());
|
||||
if time.elapsed_secs() - *last >= 1.0 {
|
||||
eprintln!("[cam-changed] frames={frames} transform={t_n} global={g_n}/s");
|
||||
(*frames, *t_n, *g_n) = (0, 0, 0);
|
||||
*last = time.elapsed_secs();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +543,7 @@ pub(super) fn seat_camera(
|
||||
cam_pivot_height: f32,
|
||||
rig: &mut CameraControl,
|
||||
cam: &mut FlyCam,
|
||||
cam_t: &mut Transform,
|
||||
cam_t: &mut Mut<Transform>,
|
||||
collide: &benilla_world::collision::WorldCollision<'_, '_>,
|
||||
cam_probe: &Collider,
|
||||
) {
|
||||
@@ -573,8 +573,10 @@ pub(super) fn seat_camera(
|
||||
// instead of overshooting (the old min-distance floor used to force the camera *past* a too-close
|
||||
// hit — gone; collision wins outright). `cast_move` ignores origin penetration, so a head grazing
|
||||
// a surface still casts outward.
|
||||
cam_t.rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, cam.pitch, 0.0);
|
||||
let cam_fwd = *cam_t.forward();
|
||||
let rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, cam.pitch, 0.0);
|
||||
// `Transform::forward()` is exactly `rotation * -Z` (no renormalize), computed here from the
|
||||
// local so the write below can be gated.
|
||||
let cam_fwd = rotation * Vec3::NEG_Z;
|
||||
let pivot = player_pos + Vec3::Y * cam_pivot_height;
|
||||
let seat = pivot - cam_fwd * rig.distance;
|
||||
let boom = seat - head;
|
||||
@@ -593,7 +595,20 @@ pub(super) fn seat_camera(
|
||||
rig.collision_distance + (open - rig.collision_distance) * t
|
||||
};
|
||||
let frac = (rig.collision_distance / boom_len).clamp(0.0, 1.0);
|
||||
cam_t.translation = head + boom * frac;
|
||||
let translation = head + boom * frac;
|
||||
// The no-op write gate (decision 1362 — 1355's clamp lesson, at the camera): a parked
|
||||
// camera's pose is bit-stable once the collision ease settles, but writing it anyway marked
|
||||
// the camera's transform changed every frame — which re-ran its propagation and told every
|
||||
// camera-watching gate in the app that the view moved when it hadn't. Bit equality, not an
|
||||
// epsilon: a real sub-epsilon drift must still land.
|
||||
{
|
||||
let t = cam_t.bypass_change_detection();
|
||||
if t.rotation != rotation || t.translation != translation {
|
||||
t.rotation = rotation;
|
||||
t.translation = translation;
|
||||
cam_t.set_changed();
|
||||
}
|
||||
}
|
||||
// No waterline handling here — deliberately. The reference NEVER moves the eye for liquid
|
||||
// (verified negative, wow-re `water-frame-straddle` §4a: zero liquid-height queries in the
|
||||
// camera TU); the no-straddle experience is the *submersion probe's* — the frame flips
|
||||
@@ -622,23 +637,20 @@ pub(super) fn seat_camera(
|
||||
open,
|
||||
rig.collision_distance,
|
||||
frac,
|
||||
cam_t.translation.x,
|
||||
cam_t.translation.y,
|
||||
cam_t.translation.z,
|
||||
cam_t.translation.x.to_bits(),
|
||||
cam_t.translation.y.to_bits(),
|
||||
cam_t.translation.z.to_bits(),
|
||||
translation.x,
|
||||
translation.y,
|
||||
translation.z,
|
||||
translation.x.to_bits(),
|
||||
translation.y.to_bits(),
|
||||
translation.z.to_bits(),
|
||||
);
|
||||
}
|
||||
|
||||
// Fade the avatar as the camera nears its pivot (zoom-in / a wall pulling the boom in): opaque
|
||||
// in third-person, ramping to invisible in first-person. Keyed off the *realized* camera→pivot
|
||||
// distance (collision-pulled), so backing into a wall also thins you — the faithful behavior.
|
||||
rig.self_fade_alpha = self_model_fade_alpha(
|
||||
(cam_t.translation - pivot).length(),
|
||||
CAM_NEAR,
|
||||
SELF_FADE_WINDOW,
|
||||
);
|
||||
rig.self_fade_alpha =
|
||||
self_model_fade_alpha((translation - pivot).length(), CAM_NEAR, SELF_FADE_WINDOW);
|
||||
}
|
||||
|
||||
/// Apply the self-avatar zoom-in fade ([`CameraControl::self_fade_alpha`], computed in [`control`]) to
|
||||
|
||||
@@ -85,7 +85,7 @@ pub(super) fn setup_player(
|
||||
// radius-derived view range); distance fog itself is added back in the lighting rebuild (step 5).
|
||||
let spawn = wow_to_bevy([SPAWN_XY.0, SPAWN_XY.1, 100.0]);
|
||||
let cam_far = (fog_range(config.tile_radius).1 + 800.0).max(3000.0);
|
||||
commands.spawn((
|
||||
let mut world_cam = commands.spawn((
|
||||
Camera3d::default(),
|
||||
// THE world camera (the portrait booths are further `Camera3d`s — every "where is the viewer"
|
||||
// consumer filters on this marker, never on bare `Camera3d`; see its doc).
|
||||
@@ -125,6 +125,25 @@ pub(super) fn setup_player(
|
||||
speed: 100.0,
|
||||
},
|
||||
));
|
||||
// `WOW_NO_INDIRECT=1` — opt the world camera out of indirect draws + GPU culling. An
|
||||
// EXPERIMENT knob (the full-picture sizing): wgpu 27 dropped MULTI_DRAW_INDIRECT from
|
||||
// bevy's gate, so 0.18 is the first release where Metal reaches `Culling` mode — silently.
|
||||
// This knob restores the pre-0.18 mode for an A/B. It must ride the SPAWN: the phase
|
||||
// cache latches the preprocessing mode the first time it sees the view.
|
||||
if std::env::var_os("WOW_NO_INDIRECT").is_some() {
|
||||
world_cam.insert(bevy::render::view::NoIndirectDrawing);
|
||||
}
|
||||
// `WOW_NO_CLUSTERS=1` — turn off Bevy's clustered-forward light assignment on the world
|
||||
// camera. An EXPERIMENT knob (the 1370 bracket): every world shader takes its point-light
|
||||
// term off OUR storage buffer (`lighting::global_light` — bevy's clusterable buffer is
|
||||
// fragment-only in the view layout and nothing of ours imports `apply_pbr_lighting`), so
|
||||
// the whole assign/extract/prepare cluster lane is suspected dead work proportional to the
|
||||
// resident `PointLight` population (794 at the SW pin). `ClusterConfig::None` short-circuits
|
||||
// `assign_objects_to_clusters` per view and starves the light extract/prepare downstream;
|
||||
// the `Clusters` component stays, so the view bind group still builds.
|
||||
if std::env::var_os("WOW_NO_CLUSTERS").is_some() {
|
||||
world_cam.insert(bevy::light::cluster::ClusterConfig::None);
|
||||
}
|
||||
}
|
||||
|
||||
/// The world camera's demand gate (decision 0540): active in world or under the opaque loading
|
||||
|
||||
@@ -322,11 +322,13 @@ fn build_markers(
|
||||
// EAGER slot allocation — this lane has no draw gate to promote lazily (decision 0863
|
||||
// made laziness the terrain-stream caller's policy, not the host's). A handful of
|
||||
// markers exist at once, so eager is the right spend; slot 0 (table full, warned)
|
||||
// falls back to the static mesh below exactly as before.
|
||||
// falls back to the static mesh below exactly as before. `allocate_bones` — the
|
||||
// collapsed shape (decision 1365): the world pass writes the rows off the host's
|
||||
// `RigPose`, no joint list exists.
|
||||
let marker_slot = host.as_ref().map_or(0, |h| {
|
||||
benilla_world::rig_palette::RigSkin::allocate(
|
||||
benilla_world::rig_palette::RigSkin::allocate_bones(
|
||||
&mut palettes,
|
||||
h.joints.clone(),
|
||||
h.bones(),
|
||||
h.inverse_bindposes.clone(),
|
||||
)
|
||||
.map_or(0, |rig| {
|
||||
@@ -414,6 +416,16 @@ fn build_markers(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Attach the pose buffer, plus the seat-frame cascade marker (decision 1365): the
|
||||
// marker host root is a rig's `joints_root` living inside the UNIT's anchor subtree, so
|
||||
// a patch walk that re-seats the overhead anchor must re-finalize the marker rig the
|
||||
// same frame (`RigFrame` — the mount seat's law, `finalize_rig_worlds`).
|
||||
if let Some(h) = host {
|
||||
commands
|
||||
.entity(h.root)
|
||||
.insert(benilla_world::rig_anim::RigFrame(h.root));
|
||||
h.finish(&mut commands);
|
||||
}
|
||||
marker.seat = Some(seat);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,9 +656,26 @@ impl Run {
|
||||
struct BatchPools {
|
||||
entities: Vec<Entity>,
|
||||
meshes: Vec<Handle<Mesh>>,
|
||||
/// Each slot's last-written content — the per-run skip gate (decision 1361): a slot whose
|
||||
/// run is bit-identical to what its pooled mesh already holds is not rewritten, so one
|
||||
/// animating quad no longer drags every batch through the GPU mesh allocator.
|
||||
stored: Vec<StoredRun>,
|
||||
materials: std::collections::HashMap<MatKey, Handle<UiQuadMaterial>>,
|
||||
}
|
||||
|
||||
/// One pooled batch slot's full identity: the mesh bytes plus everything the entity was last
|
||||
/// written with (material key, z). Equality is exact — see the skip gate's comment for why a
|
||||
/// hash was rejected.
|
||||
#[derive(PartialEq)]
|
||||
struct StoredRun {
|
||||
positions: Vec<[f32; 3]>,
|
||||
uvs: Vec<[f32; 2]>,
|
||||
colors: Vec<[f32; 4]>,
|
||||
indices: Vec<u32>,
|
||||
z_bits: u32,
|
||||
key: MatKey,
|
||||
}
|
||||
|
||||
/// A material's full identity: texture, blend/shape flags, mask texture + mask rect (as bits, so
|
||||
/// NaN/-0.0 can't split cache entries byte-equal materials would share).
|
||||
type MatKey = (
|
||||
@@ -678,6 +695,9 @@ fn retire_batches(pools: &mut BatchPools, commands: &mut Commands) {
|
||||
for entity in pools.entities.drain(..) {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
// The skip gate must forget with them (decision 1361): a retired slot's entity is gone, so
|
||||
// "content unchanged" must not skip the respawn when the UI returns.
|
||||
pools.stored.clear();
|
||||
}
|
||||
|
||||
/// Per frame: when the BASE lane flagged a change ([`UiQuads::dirty`]) or the APPEND lane's
|
||||
@@ -685,6 +705,11 @@ fn retire_batches(pools: &mut BatchPools, commands: &mut Commands) {
|
||||
/// texture-identity runs, and write the runs into the pooled batches ([`BatchPools`]). See the
|
||||
/// module doc for the full approach (painter's order within/across runs) and its batching
|
||||
/// consequence.
|
||||
fn ui_mesh_frozen() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("WOW_FREEZE_UI_MESH").is_some())
|
||||
}
|
||||
|
||||
fn rebuild_ui_mesh(
|
||||
mut quads: ResMut<UiQuads>,
|
||||
mut commands: Commands,
|
||||
@@ -717,6 +742,40 @@ fn rebuild_ui_mesh(
|
||||
if !q.dirty && q.overlays == q.last_overlays {
|
||||
return;
|
||||
}
|
||||
// `WOW_UI_DIFF=1` — WHO re-triggers the rebuild? Names the first differing overlay quad (or
|
||||
// says the BASE lane's dirty flag did it), once a second. The instrument behind the
|
||||
// Stormwind mesh-churn hunt: a widget re-emitting a moving/jittering quad every frame drags
|
||||
// every batch through a full rebuild and the GPU allocator through ~90 frees+reallocs.
|
||||
if std::env::var_os("WOW_UI_DIFF").is_some() {
|
||||
if q.dirty {
|
||||
eprintln!("[ui-diff] BASE lane dirty");
|
||||
} else {
|
||||
let i = q
|
||||
.overlays
|
||||
.iter()
|
||||
.zip(&q.last_overlays)
|
||||
.position(|(a, b)| a != b);
|
||||
match i {
|
||||
Some(i) => {
|
||||
let (a, b) = (&q.overlays[i], &q.last_overlays[i]);
|
||||
eprintln!(
|
||||
"[ui-diff] overlay {i}/{} differs: tex={:?} rect {:?} -> {:?} uv_changed={} color_changed={}",
|
||||
q.overlays.len(),
|
||||
a.texture.as_ref().and_then(|t| t.path()),
|
||||
b.rect,
|
||||
a.rect,
|
||||
a.uv != b.uv,
|
||||
a.color != b.color,
|
||||
);
|
||||
}
|
||||
None => eprintln!(
|
||||
"[ui-diff] overlay COUNT changed: {} -> {}",
|
||||
q.last_overlays.len(),
|
||||
q.overlays.len()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
q.dirty = false;
|
||||
q.last_overlays.clone_from(&q.overlays);
|
||||
|
||||
@@ -849,27 +908,6 @@ fn rebuild_ui_mesh(
|
||||
continue;
|
||||
}
|
||||
let z = -450.0 + (i as f32 / run_count) * 900.0;
|
||||
// MAIN_WORLD too (not RENDER_WORLD-only): the pool rewrites this asset in place next
|
||||
// rebuild, so the main-world copy must survive extraction.
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, run.positions);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, run.uvs);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, run.colors);
|
||||
mesh.insert_indices(Indices::U32(run.indices));
|
||||
let mesh_handle = match pools.meshes.get(used) {
|
||||
Some(handle) => {
|
||||
let _ = meshes.insert(handle.id(), mesh);
|
||||
handle.clone()
|
||||
}
|
||||
None => {
|
||||
let handle = meshes.add(mesh);
|
||||
pools.meshes.push(handle.clone());
|
||||
handle
|
||||
}
|
||||
};
|
||||
// The mask span converts to physical framebuffer px here (the shader compares
|
||||
// `@builtin(position)`); a maskless run gets a degenerate rect, which disables the branch.
|
||||
let scale = window.scale_factor();
|
||||
@@ -900,6 +938,61 @@ fn rebuild_ui_mesh(
|
||||
mask.as_ref().map(bevy::asset::Handle::id),
|
||||
mask_rect.to_array().map(f32::to_bits),
|
||||
);
|
||||
// The per-slot skip gate (decision 1361). A rebuild fires for the WHOLE quad stream the
|
||||
// moment anything differs — and one continuously-animating quad (the resting blink on
|
||||
// the player frame, in every city) fires it every frame. The run that quad lives in is
|
||||
// the only one whose bytes moved; the other ~90 slots are bit-identical to what their
|
||||
// pooled mesh already holds, and rewriting them anyway put every UI batch through the
|
||||
// GPU mesh allocator's free+realloc each frame (0.86 ms/frame at the Stormwind pin).
|
||||
// Full content compare, not a hash — a stale batch drawn over a collision would be a
|
||||
// rendering bug no one could reproduce.
|
||||
let stored = StoredRun {
|
||||
positions: run.positions,
|
||||
uvs: run.uvs,
|
||||
colors: run.colors,
|
||||
indices: run.indices,
|
||||
z_bits: z.to_bits(),
|
||||
key,
|
||||
};
|
||||
if pools.stored.get(used) == Some(&stored) {
|
||||
used += 1;
|
||||
continue;
|
||||
}
|
||||
// MAIN_WORLD too (not RENDER_WORLD-only): the pool rewrites this asset in place next
|
||||
// rebuild, so the main-world copy must survive extraction.
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, stored.positions.clone());
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, stored.uvs.clone());
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, stored.colors.clone());
|
||||
mesh.insert_indices(Indices::U32(stored.indices.clone()));
|
||||
if pools.stored.len() > used {
|
||||
pools.stored[used] = stored;
|
||||
} else {
|
||||
pools.stored.push(stored);
|
||||
}
|
||||
let mesh_handle = match pools.meshes.get(used) {
|
||||
Some(handle) => {
|
||||
// `WOW_FREEZE_UI_MESH=1` — never rewrite a pooled batch mesh in place (the UI
|
||||
// freezes at its first-built frame). An EXPERIMENT knob (the 1370 bracket): the
|
||||
// resting blink's ~2 rewrites/frame are one-asset `Assets<Mesh>` mutations, and
|
||||
// bevy 0.18's `AssetChanged<Mesh3d>` fast path is all-or-nothing — one modified
|
||||
// mesh arms a hash probe over every `Mesh3d` row in the scene (~44k at the SW
|
||||
// pin) in three PostUpdate walks. This lever prices that arming; `WOW_MESH_EVENTS`
|
||||
// confirms it (events/s → 0). First build (the `None` arm) is untouched.
|
||||
if !ui_mesh_frozen() {
|
||||
let _ = meshes.insert(handle.id(), mesh);
|
||||
}
|
||||
handle.clone()
|
||||
}
|
||||
None => {
|
||||
let handle = meshes.add(mesh);
|
||||
pools.meshes.push(handle.clone());
|
||||
handle
|
||||
}
|
||||
};
|
||||
let material_handle = pools
|
||||
.materials
|
||||
.entry(key)
|
||||
@@ -946,6 +1039,7 @@ fn rebuild_ui_mesh(
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
pools.meshes.truncate(used);
|
||||
pools.stored.truncate(used);
|
||||
}
|
||||
|
||||
/// Deliberately-overlapping synthetic content proving the sort: 5 z strata × 40 quads each, offset both
|
||||
|
||||
@@ -787,6 +787,28 @@ pub(super) fn drive_script(
|
||||
let n_quads = out.len();
|
||||
let changed = quads.quads != out;
|
||||
if changed {
|
||||
// `WOW_UI_DIFF=1` — the base-lane half of the rebuild-trigger probe (`ui_pass`' twin):
|
||||
// names the first Lua-UI quad that differs from last frame's extraction.
|
||||
if std::env::var_os("WOW_UI_DIFF").is_some() {
|
||||
match quads.quads.iter().zip(&out).position(|(a, b)| a != b) {
|
||||
Some(i) => {
|
||||
let (b, a) = (&quads.quads[i], &out[i]);
|
||||
eprintln!(
|
||||
"[ui-diff-base] quad {i}/{n_quads}: tex={:?} rect {:?} -> {:?} uv_changed={} color_changed={}",
|
||||
a.texture.as_ref().and_then(|t| t.path()),
|
||||
b.rect,
|
||||
a.rect,
|
||||
a.uv != b.uv,
|
||||
a.color != b.color,
|
||||
);
|
||||
}
|
||||
None => eprintln!(
|
||||
"[ui-diff-base] quad COUNT changed: {} -> {}",
|
||||
quads.quads.len(),
|
||||
out.len()
|
||||
),
|
||||
}
|
||||
}
|
||||
quads.quads = out;
|
||||
quads.dirty = true;
|
||||
}
|
||||
|
||||
@@ -209,6 +209,16 @@ fn is_instrument_consumer(rel: &str) -> bool {
|
||||
/// count. The items were always crossing; the measurement improved. That is the only kind of raise
|
||||
/// that is not a retreat — a raise for a NEW leak is the failure this number exists to catch.
|
||||
///
|
||||
/// And 155 → 156: `doodad_anim::DoodadAnimHost`, a PUBLISH by 1164's test (decision 1365). The
|
||||
/// doodad joint collapse put placed doodads on the collapsed-rig lane (`RigPose`), which made
|
||||
/// them visible to the game's animation-LOD gate — whose park marker the engine's own doodad
|
||||
/// draw gate already owns, on a different law (the composed draw verdict + fade sphere, not the
|
||||
/// unit frustum test). Two writers to one marker silently un-park hidden hosts, so the game's
|
||||
/// gate must be able to say "this population is not mine" — and the engine component that IS
|
||||
/// that population's name is the smallest honest way to say it. The instruments already named
|
||||
/// it; this makes the one gameplay filter explicit rather than inventing a second marker to
|
||||
/// carry the same fact.
|
||||
///
|
||||
/// And 154 → 155: `modkeys::SyntheticHold`, a PUBLISH by 1164's test rather than a leak. The
|
||||
/// engine owns the macOS stuck-modifier reconciler, and that reconciler decides by polling the
|
||||
/// **hardware** flag state — which by construction reads "up" for a key no hand is on. So a
|
||||
@@ -219,7 +229,7 @@ fn is_instrument_consumer(rel: &str) -> bool {
|
||||
/// input, which is game-side; a one-field resource is the smallest honest expression of that. The
|
||||
/// alternative was ordering a game system against an engine system, which crosses this same line
|
||||
/// *and* publishes a schedule point to do it.
|
||||
const CEILING: usize = 155;
|
||||
const CEILING: usize = 156;
|
||||
|
||||
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
|
||||
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
|
||||
|
||||
@@ -1400,5 +1400,37 @@ impl Model {
|
||||
/// loudly, by proving the fingerprint still matches whenever tier 1 claims quiet.
|
||||
pub(crate) fn touch_layout(&mut self) {
|
||||
self.layout_epoch = self.layout_epoch.wrapping_add(1);
|
||||
// `WOW_LAYOUT_TOUCH_TRACE=<n>` — name the epoch's per-frame toucher: print a backtrace
|
||||
// for the first n touches (a mechanism probe, meaningful in a debuginfo build). Built
|
||||
// because the SW meters read `resolve≈240 µs, solves=0` on every steady frame: the
|
||||
// tier-1 gate is being defeated by a toucher whose writes never move a fingerprint,
|
||||
// and 48 call sites is too many to tag by hand.
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
// Spec `<secs>:<n>`: arm after `secs` (past UI load + settle, so the trace names the
|
||||
// STEADY toucher, not the thousands of legitimate load-time touches), then print `n`.
|
||||
static SPEC: OnceLock<Option<(std::time::Instant, f64, AtomicU32)>> = OnceLock::new();
|
||||
let spec = SPEC.get_or_init(|| {
|
||||
let v = std::env::var("WOW_LAYOUT_TOUCH_TRACE").ok()?;
|
||||
let (secs, n) = v.split_once(':')?;
|
||||
Some((
|
||||
std::time::Instant::now(),
|
||||
secs.trim().parse().ok()?,
|
||||
AtomicU32::new(n.trim().parse().ok()?),
|
||||
))
|
||||
});
|
||||
if let Some((t0, delay, left)) = spec {
|
||||
if t0.elapsed().as_secs_f64() >= *delay
|
||||
&& left
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| v.checked_sub(1))
|
||||
.is_ok()
|
||||
{
|
||||
eprintln!(
|
||||
"[layout-touch] epoch={} at:\n{}",
|
||||
self.layout_epoch,
|
||||
std::backtrace::Backtrace::force_capture()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,10 @@ const REAP_MIN_PARKED_SECS: f32 = 2.0;
|
||||
/// drawn. Present ⇔ the placement has skinned parts worth a slot.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct LazyRig {
|
||||
/// The host's joint entities, bone-indexed — [`RigSkin`]'s joint list at promote.
|
||||
pub(crate) joints: Vec<Entity>,
|
||||
/// The skeleton's bone count — the allocation size. No joint list since decision 1365: the
|
||||
/// pose lives in the host's [`crate::rig_anim::RigPose`] buffer and the world pass writes
|
||||
/// the rows.
|
||||
pub(crate) bones: u32,
|
||||
pub(crate) ibp: Handle<SkinnedMeshInverseBindposes>,
|
||||
/// The [`SkinnedTwin`]-carrying part entities the promote/demote swaps.
|
||||
pub(crate) parts: Vec<Entity>,
|
||||
@@ -80,11 +82,13 @@ pub(super) type TwinParts<'w, 's> = Query<
|
||||
),
|
||||
>;
|
||||
|
||||
/// The wake edge: allocate the slot, seed its rows from the joints' CURRENT worlds, and swap the
|
||||
/// parts. Returns whether the host now has a rig (so the gate can keep retrying a denial while
|
||||
/// the host stays drawn). Rows are seeded before the meshes swap: the first skinned frame shows
|
||||
/// the parked pose the static mesh was already showing, and the palette sweep takes over the
|
||||
/// same frame (the wake re-arms the player, whose targets mark the joints changed).
|
||||
/// The wake edge: allocate the slot, seed its rows from the pose buffer's CURRENT composed
|
||||
/// affines, and swap the parts. Returns whether the host now has a rig (so the gate can keep
|
||||
/// retrying a denial while the host stays drawn). Rows are seeded before the meshes swap: the
|
||||
/// first skinned frame shows the parked pose the static mesh was already showing, and the world
|
||||
/// pass takes over the same frame (the wake re-arms the player, whose evaluation re-raises
|
||||
/// `pose_dirty`).
|
||||
#[allow(clippy::too_many_arguments)] // the gate's full promote handoff
|
||||
pub(super) fn promote_lazy_rig(
|
||||
commands: &mut Commands,
|
||||
palettes: &mut RigPalettes,
|
||||
@@ -92,31 +96,18 @@ pub(super) fn promote_lazy_rig(
|
||||
worlds: &Query<&GlobalTransform>,
|
||||
root: Entity,
|
||||
lazy: &LazyRig,
|
||||
pose: Option<&crate::rig_anim::RigPose>,
|
||||
parts: &mut TwinParts,
|
||||
) -> bool {
|
||||
let Some(rig) = RigSkin::allocate(palettes, lazy.joints.clone(), lazy.ibp.clone()) else {
|
||||
let Some(rig) = RigSkin::allocate_bones(palettes, lazy.bones, lazy.ibp.clone()) else {
|
||||
return false; // table full — the warn fired; the gate retries while drawn
|
||||
};
|
||||
let slot = rig.slot;
|
||||
if let Some(ibp) = ibps.get(&lazy.ibp) {
|
||||
// Rig-relative like every other seed (decision 0974) — these joint worlds came off Bevy
|
||||
// propagation, so the rebase only carries the convention here; it cannot un-spend the
|
||||
// precision propagation already spent. The sweep rewrites the rows the same frame anyway.
|
||||
let origin = crate::rig_palette::rebase_origin(
|
||||
worlds
|
||||
.get(root)
|
||||
.map(|g| g.translation())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let joint_worlds: Vec<GlobalTransform> = lazy
|
||||
.joints
|
||||
.iter()
|
||||
.map(|&j| {
|
||||
let g = worlds.get(j).copied().unwrap_or_default();
|
||||
crate::rig_palette::rebase_global(g, origin)
|
||||
})
|
||||
.collect();
|
||||
palettes.write_rig_worlds(&rig, &joint_worlds, ibp, origin);
|
||||
if let (Some(ibp), Some(pose)) = (ibps.get(&lazy.ibp), pose) {
|
||||
// Rig-relative like every other seed (decision 0974): the same compose the world pass
|
||||
// runs, from the root's propagated world.
|
||||
let root_g = worlds.get(root).copied().unwrap_or_default();
|
||||
crate::rig_anim::seed_rig_rows(pose, root_g, &rig, ibp, palettes);
|
||||
}
|
||||
// Queued, liveness-checked at APPLY time: the slot's only free path is the component's
|
||||
// `on_replace` hook, so a plain `insert` racing this frame's tile-unload despawn would drop
|
||||
@@ -225,8 +216,9 @@ mod tests {
|
||||
}
|
||||
|
||||
/// One host: a hidden-by-default part carrying a [`SkinnedTwin`] of two distinct weak mesh
|
||||
/// handles, a one-joint [`LazyRig`], and the mesh-backed draw-gate shape. Returns
|
||||
/// `(host, part, mesh_vis_entity)` — the part IS the visibility carrier, like assemble's.
|
||||
/// handles, a one-bone [`LazyRig`] + [`crate::rig_anim::RigPose`] (the collapsed shape,
|
||||
/// decision 1365), and the mesh-backed draw-gate shape. Returns `(host, part)` — the part IS
|
||||
/// the visibility carrier, like assemble's.
|
||||
fn lazy_host(app: &mut App, visible: bool) -> (Entity, Entity) {
|
||||
let stat = Handle::<Mesh>::default();
|
||||
let skinned = app
|
||||
@@ -249,10 +241,6 @@ mod tests {
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let joint = app
|
||||
.world_mut()
|
||||
.spawn((Transform::default(), GlobalTransform::default()))
|
||||
.id();
|
||||
let ibp = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<SkinnedMeshInverseBindposes>>()
|
||||
@@ -271,12 +259,24 @@ mod tests {
|
||||
parked_at: 0.0,
|
||||
},
|
||||
LazyRig {
|
||||
joints: vec![joint],
|
||||
bones: 1,
|
||||
ibp,
|
||||
parts: vec![part],
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let skeleton = benilla_assets::ModelSkeleton {
|
||||
joints: vec![benilla_assets::ModelJoint {
|
||||
parent: -1,
|
||||
local_translation: Vec3::ZERO,
|
||||
billboard: None,
|
||||
parent_arm: None,
|
||||
}],
|
||||
spine_bone: None,
|
||||
head_bone: None,
|
||||
};
|
||||
let pose = crate::rig_anim::RigPose::new(host, &skeleton);
|
||||
app.world_mut().entity_mut(host).insert(pose);
|
||||
(host, part)
|
||||
}
|
||||
|
||||
@@ -295,8 +295,8 @@ mod tests {
|
||||
/// The lazy law end to end: born hidden, a host holds NO slot; a wake allocates on its
|
||||
/// SECOND consecutive drawn frame (the first is the spawn-default-visibility lie — see the
|
||||
/// gate's promote comment), swaps the part to the skinned twin, writes its tag's rig field,
|
||||
/// and seeds the palette rows (the joint's identity world × identity bindpose = a non-zero
|
||||
/// row — the swapped-in mesh never renders zeroed rows).
|
||||
/// and seeds the palette rows (the pose buffer's identity bind affine × identity bindpose =
|
||||
/// a non-zero row — the swapped-in mesh never renders zeroed rows).
|
||||
#[test]
|
||||
fn a_host_rigs_at_first_wake_not_at_spawn() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
//!
|
||||
//! benilla's translation: the spawn site ([`crate::terrain_stream`]) classifies each placed model by
|
||||
//! [`classify`] — the ~90% with no animated channel stay on today's static path (measured,
|
||||
//! `benilla-extract doodadscan`) — and an animated one spawns the skinned twin + a joint hierarchy
|
||||
//! under an anim-root entity carrying [`DoodadAnimHost`]: an `AnimationPlayer` on the armed clip,
|
||||
//! `benilla-extract doodadscan`) — and an animated one spawns the skinned twin + a collapsed
|
||||
//! [`RigPose`] buffer (decision 1365 — no joint entities; consumers ride on-demand anchors) on an
|
||||
//! anim-root entity carrying [`DoodadAnimHost`]: an `AnimationPlayer` on the armed clip,
|
||||
//! re-rolled every window by [`reroll_doodad_variation`], and/or a [`GlobalSeqDrive`] for the
|
||||
//! free-running channels. [`gate_doodad_anim`] here is the draw-time tick made explicit: animation
|
||||
//! runs iff any of the doodad's submeshes is actually drawn (the `Visibility` verdict the debug-panel
|
||||
@@ -26,14 +27,13 @@
|
||||
//! *linkage* (here: residency) gates the variation cycle.
|
||||
|
||||
use bevy::animation::graph::AnimationNodeIndex;
|
||||
use bevy::animation::AnimatedBy;
|
||||
use bevy::app::AnimationSystems;
|
||||
use bevy::mesh::skinning::SkinnedMeshInverseBindposes;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::{bone_target_id, AnimClip, M2Model, ModelAnimations, ModelSkeleton};
|
||||
use benilla_assets::{AnimClip, M2Model, ModelAnimations, ModelSkeleton};
|
||||
|
||||
use crate::rig_anim::GlobalSeqDrive;
|
||||
use crate::rig_anim::{AnimParked, GlobalSeqDrive, RigPose};
|
||||
|
||||
mod lazy;
|
||||
mod mat_anim;
|
||||
@@ -118,15 +118,19 @@ pub fn classify<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`spawn_anim_host`] set up for one animated placement — the spawn site binds each ordinary
|
||||
/// submesh's skinned twin to `joints`/`inverse_bindposes` and arms the [`DoodadAnimHost`] on `root`
|
||||
/// with the submesh list once it has spawned them.
|
||||
/// What [`spawn_anim_host`] set up for one animated placement — the spawn site arms the
|
||||
/// [`DoodadAnimHost`] on `root` with the submesh list once it has spawned them.
|
||||
///
|
||||
/// Since decision 1365 there are NO joint entities: the pose lives in a [`RigPose`] buffer,
|
||||
/// carried here BY VALUE so the caller can mint consumer anchors ([`Self::anchor`]) for the bones
|
||||
/// something actually rides — billboard cards, emitters, ribbons — and then attach the buffer
|
||||
/// with [`Self::finish`]. Only consumed bones ever get an entity (the 1354 census: 96 % of the
|
||||
/// old eagerly-spawned population hosted nothing).
|
||||
pub struct AnimHostSpawn {
|
||||
pub root: Entity,
|
||||
/// The host's joint entities, bone-indexed. NO palette slot rides along any more (decision
|
||||
/// 0863): the terrain-stream caller stores these in a [`LazyRig`] and the draw gate
|
||||
/// allocates at first wake; a gate-less caller (quest markers) allocates eagerly itself.
|
||||
pub joints: Vec<Entity>,
|
||||
/// The collapsed rig's pose buffer, not yet inserted — [`Self::finish`] attaches it to
|
||||
/// `root` once every consumer anchor is minted.
|
||||
pose: RigPose,
|
||||
pub inverse_bindposes: Handle<SkinnedMeshInverseBindposes>,
|
||||
/// The looping first-sequence node + duration, `None` on the gseq-only tier.
|
||||
pub(crate) clip: Option<(AnimationNodeIndex, f32)>,
|
||||
@@ -146,6 +150,30 @@ pub struct AnimHostSpawn {
|
||||
pub(crate) anim_id: Option<u16>,
|
||||
}
|
||||
|
||||
impl AnimHostSpawn {
|
||||
/// The anchor entity standing in for `bone` — spawned on first demand, shared on repeat asks
|
||||
/// ([`RigPose::anchor_for`]). `None` = the bone is outside this skeleton (the consumer
|
||||
/// misses, exactly as `joints.get(bone)` used to).
|
||||
pub fn anchor(&mut self, commands: &mut Commands, bone: u16) -> Option<Entity> {
|
||||
self.pose.anchor_for(commands, self.root, bone)
|
||||
}
|
||||
|
||||
/// The skeleton's bone count — what a palette allocation sizes itself with now that there is
|
||||
/// no joint list to measure.
|
||||
pub fn bones(&self) -> u32 {
|
||||
self.pose.locals.len() as u32
|
||||
}
|
||||
|
||||
/// Attach the pose buffer to the host root. Called once, after the caller minted its
|
||||
/// anchors; returns the `(bone, anchor)` registry for consumers that resolve after this
|
||||
/// point (the placement's emitter/ribbon spawns).
|
||||
pub fn finish(self, commands: &mut Commands) -> Vec<(u16, Entity)> {
|
||||
let anchors = self.pose.anchors.clone();
|
||||
commands.entity(self.root).insert(self.pose);
|
||||
anchors
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [`spawn_anim_host`] would rig this model — the model-forms requester's twin of the
|
||||
/// spawn gate (decision 0834), so the skinned twins are built exactly for the placed models that
|
||||
/// will draw them. Mirrors both of the host's gates: the tier, and the capture freeze (captures
|
||||
@@ -159,13 +187,14 @@ pub fn wants_rig(m: &M2Model) -> bool {
|
||||
}
|
||||
|
||||
/// Spawn the animation host for one placed M2, if [`classify`] says it animates: an anim-root entity
|
||||
/// at the placement transform, the joint hierarchy as its children (so despawning the root cascades),
|
||||
/// at the placement transform carrying the collapsed [`RigPose`] buffer (decision 1365 — no joint
|
||||
/// entities; consumer anchors are minted on demand and cascade with the root),
|
||||
/// and — per tier — an `AnimationPlayer` looping the **loader-idle seed's** clip (the client's
|
||||
/// one-time load arm at `0x70ebd0`: `0x7121a0(bone 0, animation id 0 resolved through the model's
|
||||
/// own `playableAnimationLookup`, linkFlag=1)` once — wow-re `gameobject-anim-arm.md` §1, which
|
||||
/// CORRECTED `doodad-anim-host.md` §1's prose reading of `animations[0].id`; decision 0637) and/or
|
||||
/// the free-running [`GlobalSeqDrive`]. `None` ⇒ the model is static and
|
||||
/// the caller keeps today's path untouched.
|
||||
/// the free-running [`GlobalSeqDrive`] (bone-index targets, the collapsed lane). `None` ⇒ the
|
||||
/// model is static and the caller keeps today's path untouched.
|
||||
pub fn spawn_anim_host(
|
||||
commands: &mut Commands,
|
||||
m: &M2Model,
|
||||
@@ -183,20 +212,18 @@ pub fn spawn_anim_host(
|
||||
}
|
||||
let anims = m.animations.as_ref().expect("animated tier ⇒ animations");
|
||||
let root = commands.spawn((transform, Visibility::default())).id();
|
||||
let joints = crate::rig_palette::spawn_joints(commands, root, root, &m.skeleton);
|
||||
// NO palette rig here (decision 0863). The host's slot allocation is the CALLER's policy:
|
||||
// the terrain-stream lane goes lazy — parts spawn on the static form and the draw gate
|
||||
// allocates at the first wake ([`LazyRig`]) — because the resident population is thousands
|
||||
// of parked hosts while the 2048-slot table is the scarce axis (the 0863 census: 1300–1750
|
||||
// parked of ~1900 live slots, active peak ~630). A gate-less caller (quest markers)
|
||||
// allocates eagerly itself, or its host would never skin.
|
||||
// The collapsed pose buffer (decision 1365 — 0724 replayed for this lane): no joint
|
||||
// entities, no `AnimatedBy` targets, no `BillboardJointRig`. The 0712 evaluator poses
|
||||
// `locals` off the player, the model pass folds + seats the anchors, and the world pass
|
||||
// handles the billboard/arm bones and the palette rows.
|
||||
//
|
||||
// Billboard bones on an animated doodad (a swinging lamp's glow child): the joint-level
|
||||
// camera facing, children inheriting — independent of the palette slot (its pass rewrites
|
||||
// joint GlobalTransforms, which the cards follow whether or not the parts skin yet).
|
||||
if let Some(bb) = crate::billboard::BillboardJointRig::new(&m.skeleton, &joints, root) {
|
||||
commands.entity(root).insert(bb);
|
||||
}
|
||||
// NO palette rig here either (decision 0863). The host's slot allocation is the CALLER's
|
||||
// policy: the terrain-stream lane goes lazy — parts spawn on the static form and the draw
|
||||
// gate allocates at the first wake ([`LazyRig`]) — because the resident population is
|
||||
// thousands of parked hosts while the 2048-slot table is the scarce axis (the 0863 census:
|
||||
// 1300–1750 parked of ~1900 live slots, active peak ~630). A gate-less caller (quest
|
||||
// markers) allocates eagerly itself, or its host would never skin.
|
||||
let pose = RigPose::new(root, &m.skeleton);
|
||||
let mut clip_info = None;
|
||||
let mut armed_seq = None;
|
||||
let mut arm_id = None;
|
||||
@@ -218,21 +245,16 @@ pub fn spawn_anim_host(
|
||||
// (`playing_seq`): the emitters' rate/enable tracks, the material-alpha sampler.
|
||||
anims.clone(),
|
||||
));
|
||||
for (i, &j) in joints.iter().enumerate() {
|
||||
commands
|
||||
.entity(j)
|
||||
.insert((bone_target_id(i as u16), AnimatedBy(root)));
|
||||
}
|
||||
clip_info = Some((head.node, head.duration));
|
||||
armed_seq = Some(head.seq_index);
|
||||
arm_id = Some(head.anim_id);
|
||||
}
|
||||
if let Some(drive) = GlobalSeqDrive::new(&anims.global_bones, &joints) {
|
||||
if let Some(drive) = GlobalSeqDrive::new_rig(&anims.global_bones, m.skeleton.joints.len()) {
|
||||
commands.entity(root).insert(drive);
|
||||
}
|
||||
Some(AnimHostSpawn {
|
||||
root,
|
||||
joints,
|
||||
pose,
|
||||
inverse_bindposes: m.inverse_bindposes.clone(),
|
||||
clip: clip_info,
|
||||
seq: armed_seq,
|
||||
@@ -347,6 +369,7 @@ fn gate_doodad_anim(
|
||||
Entity,
|
||||
&mut DoodadAnimHost,
|
||||
Option<&lazy::LazyRig>,
|
||||
Option<&RigPose>,
|
||||
Has<crate::rig_palette::RigSkin>,
|
||||
Option<&mut AnimationPlayer>,
|
||||
Option<&mut GlobalSeqDrive>,
|
||||
@@ -389,7 +412,7 @@ fn gate_doodad_anim(
|
||||
world_cam.map(|(tf, _, proj)| (tf, proj)),
|
||||
);
|
||||
let camera_instance = camera_claim.0.map(|c| c.room.instance);
|
||||
for (entity, mut host, lazy, has_rig, player, drive) in &mut hosts {
|
||||
for (entity, mut host, lazy, pose, has_rig, player, drive) in &mut hosts {
|
||||
let drawn = if host.meshes.is_empty() {
|
||||
// Meshless (particles-only) host: the emitters' own draw-set law (see the `fade`
|
||||
// field doc) — the reference ticks animation for any model in the draw set, and a
|
||||
@@ -454,6 +477,7 @@ fn gate_doodad_anim(
|
||||
&worlds,
|
||||
entity,
|
||||
lazy,
|
||||
pose,
|
||||
&mut twin_parts,
|
||||
);
|
||||
}
|
||||
@@ -462,8 +486,16 @@ fn gate_doodad_anim(
|
||||
continue;
|
||||
}
|
||||
host.active = drawn;
|
||||
if !drawn {
|
||||
// The rig-machinery half of the park (decision 1365): the marker gates the 0712
|
||||
// evaluator, the model compose, and the world finalize — so a hidden host's collapsed
|
||||
// rig costs nothing at all, where the joint hierarchy kept paying propagation. The
|
||||
// commands apply before `AnimationSystems` (the gate orders `.before` it), so a resume's
|
||||
// re-arm below evaluates the same frame the marker drops — 0739's wake law.
|
||||
if drawn {
|
||||
commands.entity(entity).remove::<AnimParked>();
|
||||
} else {
|
||||
host.parked_at = now; // the reaper's age key (decision 0863)
|
||||
commands.entity(entity).insert(AnimParked);
|
||||
}
|
||||
if let Some(mut p) = player {
|
||||
if drawn {
|
||||
|
||||
@@ -25,6 +25,7 @@ mod compose;
|
||||
mod global_seq;
|
||||
mod pose;
|
||||
|
||||
pub(crate) use compose::seed_rig_rows;
|
||||
pub use compose::{finalize_rig_worlds, PosePost};
|
||||
pub use global_seq::GlobalSeqDrive;
|
||||
pub use pose::{RigAnchor, RigFrame, RigPose};
|
||||
|
||||
@@ -50,7 +50,18 @@ pub struct PosePost;
|
||||
/// Pre-propagation: fold each pose-dirty rig's locals into model-space affines and re-seat its
|
||||
/// anchors' local `Transform`s, so this frame's propagation places every consumer subtree at
|
||||
/// this frame's pose. `pose_dirty` stays raised — the world pass consumes and clears it.
|
||||
fn compose_rig_models(mut rigs: Query<&mut RigPose>, mut anchors: Query<&mut Transform>) {
|
||||
///
|
||||
/// A PARKED rig skips even this (decision 1365): the doodad lane's hosts are born pose-dirty and
|
||||
/// spend most of their residency parked with the flag still raised (the world pass, which clears
|
||||
/// it, also skips parked rigs), so an unfiltered pass re-composed every parked host's bind pose
|
||||
/// every frame. Nothing observable is lost — a parked rig's anchors were seated by its last
|
||||
/// composed frame (or by `RigPose::new`/`anchor_for` at bind pose), its consumers are out of the
|
||||
/// draw set by the same gate that parked it, and the wake drops the marker before this pass runs,
|
||||
/// so the first unparked frame composes the current pose before anything reads it.
|
||||
fn compose_rig_models(
|
||||
mut rigs: Query<&mut RigPose, Without<AnimParked>>,
|
||||
mut anchors: Query<&mut Transform>,
|
||||
) {
|
||||
for rig in &mut rigs {
|
||||
if !rig.pose_dirty {
|
||||
continue;
|
||||
@@ -134,13 +145,37 @@ fn rig_worlds(
|
||||
(worlds, touched)
|
||||
}
|
||||
|
||||
/// Seed one collapsed rig's palette rows from its CURRENT composed pose — the doodad lane's
|
||||
/// lazy-promote edge (decision 1365, the 0863 wake): the slot is claimed mid-frame and the
|
||||
/// skinned mesh swaps in the same frame, so the rows must be written NOW or the first skinned
|
||||
/// frame renders zeroed (origin-collapsed) rows. No camera basis — a billboard bone seeds at its
|
||||
/// composed pose and the world pass re-faces it this same frame (the promoted host is unparked
|
||||
/// and `has_billboard` puts it in the refresh set).
|
||||
pub(crate) fn seed_rig_rows(
|
||||
rig: &RigPose,
|
||||
root_g: GlobalTransform,
|
||||
skin: &RigSkin,
|
||||
ibp: &[Mat4],
|
||||
palettes: &mut RigPalettes,
|
||||
) {
|
||||
let origin = crate::rig_palette::rebase_origin(root_g.translation());
|
||||
let root_rel = crate::rig_palette::rebase_global(root_g, origin);
|
||||
let (worlds, _) = rig_worlds(rig, root_rel, None);
|
||||
palettes.write_rig_worlds(skin, &worlds, ibp, origin);
|
||||
}
|
||||
|
||||
/// Post-propagation (inside `BillboardPlace`, chained after the entity lane's
|
||||
/// `billboard_joint_palette` and before `face_billboards`): finalize every rig that needs it —
|
||||
/// palette rows + replaced-subtree anchor re-seats, with the seat-frame cascade.
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)] // billboard_joint_palette's shape
|
||||
pub fn finalize_rig_worlds(
|
||||
cam: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
mut rigs: Query<(Entity, &mut RigPose, &RigSkin, Has<AnimParked>)>,
|
||||
// `Option<&RigSkin>`, not `&RigSkin` (decision 1365): the doodad lane's rigs hold their
|
||||
// palette slot lazily (allocated at first wake, reaped under pressure), and a slot-less rig
|
||||
// still needs this pass for two things — clearing `pose_dirty` (or the model pass re-composes
|
||||
// it every frame for ever) and, when it authors billboard/arm bones, the camera-dependent
|
||||
// anchor re-seat its cards and emitters ride. The palette write alone is skipped.
|
||||
mut rigs: Query<(Entity, &mut RigPose, Option<&RigSkin>, Has<AnimParked>)>,
|
||||
// B0001: the `Changed` filter reads `GlobalTransform` ticks, which conflicts with the
|
||||
// mutable frame query — a `ParamSet` sequences them (the refresh set is collected first).
|
||||
mut worlds_params: ParamSet<(
|
||||
@@ -191,10 +226,16 @@ pub fn finalize_rig_worlds(
|
||||
let mut cascade: Vec<Entity> = Vec::new();
|
||||
let mut finalize =
|
||||
|rig: &mut RigPose,
|
||||
skin: &RigSkin,
|
||||
skin: Option<&RigSkin>,
|
||||
globals: &mut Query<(&Transform, &mut GlobalTransform), Without<WorldCamera>>,
|
||||
cascade: &mut Vec<Entity>,
|
||||
root_moved: bool| {
|
||||
// A slot-less rig with no special bones has nothing left for this pass: no rows to
|
||||
// write, and its anchors were placed by the model pass + ordinary propagation. The
|
||||
// caller still clears `pose_dirty` — which is the point of arriving here at all.
|
||||
if skin.is_none() && !rig.has_special && !root_moved {
|
||||
return;
|
||||
}
|
||||
let Ok(root_g) = globals.get(rig.joints_root).map(|(_, g)| *g) else {
|
||||
return;
|
||||
};
|
||||
@@ -210,8 +251,10 @@ pub fn finalize_rig_worlds(
|
||||
let origin = crate::rig_palette::rebase_origin(root_g.translation());
|
||||
let root_rel = crate::rig_palette::rebase_global(root_g, origin);
|
||||
let (worlds, touched) = rig_worlds(rig, root_rel, cam_basis);
|
||||
if let Some(ibp) = ibps.get(&skin.ibp) {
|
||||
palettes.write_rig_worlds(skin, &worlds, ibp, origin);
|
||||
if let Some(skin) = skin {
|
||||
if let Some(ibp) = ibps.get(&skin.ibp) {
|
||||
palettes.write_rig_worlds(skin, &worlds, ibp, origin);
|
||||
}
|
||||
}
|
||||
if !rig.has_special && !root_moved {
|
||||
return;
|
||||
|
||||
@@ -156,6 +156,7 @@ pub fn plugin(app: &mut App) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::rig_anim::RigPose;
|
||||
use benilla_assets::GlobalSeqChannel;
|
||||
|
||||
fn eyelid_bone() -> GlobalBone {
|
||||
@@ -249,4 +250,62 @@ mod tests {
|
||||
"wraps on its period"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two write targets of the one sampler agree (decision 1360's second golden, built
|
||||
/// AHEAD of the doodad collapse): the same channels driven through a joint entity
|
||||
/// ([`GlobalSeqDrive::new`], the doodad/effect lane) and through a `RigPose` local
|
||||
/// ([`GlobalSeqDrive::new_rig`], the collapsed lane) read **bit-identically** frame after
|
||||
/// frame — the collapse changes where a sample lands, never what it is. Both drives spawn
|
||||
/// the same frame, so the per-instance anchors coincide by the attach law.
|
||||
#[test]
|
||||
fn joint_and_rig_targets_write_the_same_pose() {
|
||||
let full = GlobalBone {
|
||||
bone: 0,
|
||||
translation: Some(benilla_assets::GlobalSeqChannel {
|
||||
period: 2.5,
|
||||
keys: vec![(0.0, Vec3::ZERO), (1.2, Vec3::X), (2.5, Vec3::NEG_Z)],
|
||||
}),
|
||||
rotation: Some(benilla_assets::GlobalSeqChannel {
|
||||
period: 1.7,
|
||||
keys: vec![(0.0, Quat::IDENTITY), (1.7, Quat::from_rotation_y(0.9))],
|
||||
}),
|
||||
scale: Some(benilla_assets::GlobalSeqChannel {
|
||||
period: 100.0,
|
||||
keys: vec![(0.0, Vec3::ONE), (100.0, Vec3::splat(3.0))],
|
||||
}),
|
||||
};
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.insert_resource(bevy::time::TimeUpdateStrategy::ManualDuration(
|
||||
std::time::Duration::from_millis(47),
|
||||
));
|
||||
app.add_systems(Update, apply_global_sequences);
|
||||
|
||||
let joint = app.world_mut().spawn(Transform::default()).id();
|
||||
app.world_mut().spawn(
|
||||
GlobalSeqDrive::new(std::slice::from_ref(&full), &[joint]).expect("keyed channels map"),
|
||||
);
|
||||
let skeleton = benilla_assets::ModelSkeleton {
|
||||
joints: vec![benilla_assets::ModelJoint {
|
||||
parent: -1,
|
||||
local_translation: Vec3::ZERO,
|
||||
billboard: None,
|
||||
parent_arm: None,
|
||||
}],
|
||||
spine_bone: None,
|
||||
head_bone: None,
|
||||
};
|
||||
let host = app.world_mut().spawn_empty().id();
|
||||
app.world_mut().entity_mut(host).insert((
|
||||
RigPose::new(host, &skeleton),
|
||||
GlobalSeqDrive::new_rig(&[full], 1).expect("keyed channels map"),
|
||||
));
|
||||
|
||||
for frame in 0..6 {
|
||||
app.update();
|
||||
let jt = *app.world().entity(joint).get::<Transform>().unwrap();
|
||||
let rl = app.world().entity(host).get::<RigPose>().unwrap().locals[0];
|
||||
assert_eq!(jt, rl, "frame {frame}: joint target vs rig target diverged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,4 +816,88 @@ mod tests {
|
||||
step(&mut app, 0.05);
|
||||
assert_twins_equal(&mut app, &oj, ours); // woke to the absolute-clock pose
|
||||
}
|
||||
|
||||
/// The DOODAD lane's drive pattern (decision 1360's golden, built AHEAD of the collapse):
|
||||
/// the three player mutations `doodad_anim` performs — the FirstSeq arm
|
||||
/// (`play(node).repeat()`), the variation re-roll's snap (`stop_all` + replay,
|
||||
/// `reroll_doodad_variation`), and the draw gate's hide/resume (`stop_all`, then re-arm +
|
||||
/// `seek_to` the absolute clock, `gate_doodad_anim`) — each run identically on the oracle
|
||||
/// and the evaluator rig and asserted bit-equal, wraps and the untouched-property law
|
||||
/// included. Green here means the joint-entity collapse only has to move consumers; the
|
||||
/// drive semantics are already proven shared.
|
||||
#[test]
|
||||
fn doodad_drive_pattern_matches_animate_targets() {
|
||||
let mut app = app();
|
||||
// Clip A keys bones 0+1; clip B keys only bone 1 — so the re-roll snap leaves bone 0 on
|
||||
// clip A's last-applied value on BOTH paths (the no-rest-reset law across a re-arm).
|
||||
let spec_a = ClipSpec {
|
||||
bones: vec![
|
||||
(
|
||||
0,
|
||||
BoneSpec {
|
||||
t: vec![(0.0, Vec3::ZERO), (0.4, Vec3::X), (0.8, Vec3::Y)],
|
||||
r: vec![(0.0, Quat::IDENTITY), (0.8, Quat::from_rotation_y(1.1))],
|
||||
s: vec![],
|
||||
},
|
||||
),
|
||||
(
|
||||
1,
|
||||
BoneSpec {
|
||||
t: vec![(0.1, Vec3::Z), (0.7, Vec3::splat(0.3))],
|
||||
r: vec![],
|
||||
s: vec![(0.0, Vec3::ONE), (0.8, Vec3::splat(1.5))],
|
||||
},
|
||||
),
|
||||
],
|
||||
mask: 0,
|
||||
};
|
||||
let spec_b = ClipSpec {
|
||||
bones: vec![(
|
||||
1,
|
||||
BoneSpec {
|
||||
t: vec![(0.0, Vec3::NEG_Y), (0.5, Vec3::NEG_X)],
|
||||
r: vec![(0.0, Quat::from_rotation_z(0.3)), (0.5, Quat::IDENTITY)],
|
||||
s: vec![],
|
||||
},
|
||||
)],
|
||||
mask: 0,
|
||||
};
|
||||
let (graph, nodes, pose) = build(&mut app, &[spec_a, spec_b], vec![0, 0]);
|
||||
let (oracle, oj, ours) = twin_rigs(&mut app, 2, &graph, &pose);
|
||||
let rigs = [oracle, ours];
|
||||
app.update(); // ThreadedAnimationGraphs warm-up (see single_clip's note)
|
||||
|
||||
// 1 · the FirstSeq arm, mid-loop and across the loop wrap.
|
||||
drive(&mut app, &rigs, |p, _| {
|
||||
p.play(nodes[0]).repeat();
|
||||
});
|
||||
step(&mut app, 0.3);
|
||||
assert_twins_equal(&mut app, &oj, ours);
|
||||
step(&mut app, 0.6); // past 0.8 — the repeat wrap
|
||||
assert_twins_equal(&mut app, &oj, ours);
|
||||
|
||||
// 2 · the re-roll snap: stop everything, hard-play the new variation.
|
||||
drive(&mut app, &rigs, |p, _| {
|
||||
p.stop_all();
|
||||
p.play(nodes[1]).repeat();
|
||||
});
|
||||
step(&mut app, 0.2);
|
||||
assert_twins_equal(&mut app, &oj, ours); // bone 0 untouched-by-B on both paths
|
||||
|
||||
// 3 · the draw gate's hide: stop (not pause — a paused animation is still sampled;
|
||||
// stopped, both paths leave every target exactly where it stands).
|
||||
drive(&mut app, &rigs, |p, _| {
|
||||
p.stop_all();
|
||||
});
|
||||
step(&mut app, 0.15);
|
||||
assert_twins_equal(&mut app, &oj, ours);
|
||||
|
||||
// 4 · the resume: re-arm + seek to the shared-clock cursor, the gate's exact calls.
|
||||
drive(&mut app, &rigs, |p, _| {
|
||||
p.stop_all();
|
||||
p.play(nodes[0]).repeat().seek_to(0.37);
|
||||
});
|
||||
step(&mut app, 0.1);
|
||||
assert_twins_equal(&mut app, &oj, ours);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,9 +524,10 @@ impl RigSkin {
|
||||
Self::allocate_inner(palettes, bones, Vec::new(), ibp)
|
||||
}
|
||||
|
||||
/// Allocate a palette rig over live joint entities (the doodad/effect/booth lane — the
|
||||
/// change sweep computes its rows). `None` (with one loud warn per session) when the table
|
||||
/// is full — the caller renders the static bind-pose mesh instead.
|
||||
/// Allocate a palette rig over live joint entities (the effect/booth/equipment lane — the
|
||||
/// change sweep computes its rows; doodads moved to [`Self::allocate_bones`], decision 1365).
|
||||
/// `None` (with one loud warn per session) when the table is full — the caller renders the
|
||||
/// static bind-pose mesh instead.
|
||||
pub fn allocate(
|
||||
palettes: &mut RigPalettes,
|
||||
joints: Vec<Entity>,
|
||||
|
||||
@@ -42,11 +42,13 @@ mod collider;
|
||||
mod furnish;
|
||||
mod queries;
|
||||
mod spawn;
|
||||
mod weld;
|
||||
|
||||
use collider::{finish_colliders, impassable_wall_data, terrain_collider_data};
|
||||
use furnish::furnish_tile_cells;
|
||||
use spawn::prop_light::WmoDoodadInst;
|
||||
use spawn::spawn_loaded_placements;
|
||||
use weld::{flush_hull_welds, HullWelds};
|
||||
// The WMO prop-light machinery lives spawn-side (0830's named carve, executed in 0832); the two
|
||||
// outside consumers — `crate::interior` and `crate::entities`' `wmo_props` — keep their
|
||||
// `terrain_stream::X` paths, same as the `queries` items below.
|
||||
@@ -139,6 +141,9 @@ struct TileState {
|
||||
/// The tile's per-chunk `ClutterChunk` entities (tile-exclusive; their lazily-built clutter meshes
|
||||
/// are children, so despawning these cascades to them).
|
||||
clutter: Vec<Entity>,
|
||||
/// The welded map-doodad hull colliders this tile owns (decision 1369): batches of the hulls
|
||||
/// whose placements registered here first, despawned with the tile like [`Self::wall`].
|
||||
welds: Vec<Entity>,
|
||||
}
|
||||
|
||||
/// Doodad/WMO placements spawned **once** and shared across the tiles that reference them — the client's
|
||||
@@ -176,6 +181,10 @@ struct Placement {
|
||||
portal_instance: Option<Entity>,
|
||||
/// How many loaded tiles reference this placement; despawned when it hits zero.
|
||||
refs: u32,
|
||||
/// The first tile to register this placement — loaded at that moment by construction. For an
|
||||
/// M2 doodad this is its hull weld's owner tile (decision 1369; the lifetime argument is in
|
||||
/// `weld`'s module doc). WMO placements never read it: their prop hulls weld per placement.
|
||||
owner: (i32, i32),
|
||||
}
|
||||
|
||||
/// How much of the world the streamer wants around the view focus is actually there. Written each
|
||||
@@ -438,6 +447,7 @@ impl Plugin for TerrainPlugin {
|
||||
.init_resource::<TerrainStreamer>()
|
||||
.init_resource::<StreamActivity>()
|
||||
.init_resource::<Placements>()
|
||||
.init_resource::<HullWelds>()
|
||||
.init_resource::<crate::model_forms::ModelForms>()
|
||||
.init_resource::<CurrentArea>()
|
||||
// **The streaming chain lives in `WorldStage::Stream`** — the load-bearing half of the
|
||||
@@ -456,6 +466,7 @@ impl Plugin for TerrainPlugin {
|
||||
stream_terrain,
|
||||
furnish_tile_cells,
|
||||
spawn_loaded_placements,
|
||||
flush_hull_welds,
|
||||
sync_interior_volumes,
|
||||
)
|
||||
.chain()
|
||||
@@ -545,6 +556,7 @@ fn stream_terrain(
|
||||
// reason as `asset_stores`.
|
||||
location: (Option<Res<CurrentMap>>, Option<Res<MapCatalogRes>>),
|
||||
mut load_progress: Option<ResMut<WorldLoadProgress>>,
|
||||
mut welds: ResMut<HullWelds>,
|
||||
) {
|
||||
let (mut materials, mut meshes, wdts, _time, mut activity) = asset_stores;
|
||||
let (current_map, map_catalog) = location;
|
||||
@@ -572,7 +584,13 @@ fn stream_terrain(
|
||||
state.map_dir,
|
||||
state.tiles.len()
|
||||
);
|
||||
drop_streamed_world(&mut commands, &mut state, placements, &mut activity);
|
||||
drop_streamed_world(
|
||||
&mut commands,
|
||||
&mut state,
|
||||
placements,
|
||||
&mut welds,
|
||||
&mut activity,
|
||||
);
|
||||
state.map_dir = Some(dir.clone());
|
||||
state.wdt = Some(asset_server.load(format!("mpq://World/Maps/{dir}/{dir}.wdt")));
|
||||
state.wdt_ungated = false;
|
||||
@@ -733,6 +751,7 @@ fn stream_terrain(
|
||||
liquid: Vec::new(),
|
||||
wall: None,
|
||||
clutter: Vec::new(),
|
||||
welds: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -811,7 +830,7 @@ fn stream_terrain(
|
||||
// not contain its origin, so the shade is resolved at spawn via a global lookup (see
|
||||
// `doodad_ground_shade` / `spawn_loaded_placements`) — the reference's own per-frame model.
|
||||
for d in &adt.doodads {
|
||||
register_doodad(placements, &asset_server, d);
|
||||
register_doodad(placements, &asset_server, d, (tx, ty));
|
||||
tile.placements.push(d.unique_id);
|
||||
}
|
||||
for w in &adt.wmos {
|
||||
@@ -943,7 +962,12 @@ fn stream_terrain(
|
||||
|
||||
/// Register one M2 doodad placement: bump the refcount if it's already known, else load its model and
|
||||
/// record it (spawned later by [`spawn_loaded_placements`] once the asset is ready).
|
||||
fn register_doodad(placements: &mut Placements, asset_server: &AssetServer, d: &Doodad) {
|
||||
fn register_doodad(
|
||||
placements: &mut Placements,
|
||||
asset_server: &AssetServer,
|
||||
d: &Doodad,
|
||||
tile: (i32, i32),
|
||||
) {
|
||||
if let Some(p) = placements.by_id.get_mut(&d.unique_id) {
|
||||
p.refs += 1;
|
||||
return;
|
||||
@@ -965,6 +989,7 @@ fn register_doodad(placements: &mut Placements, asset_server: &AssetServer, d: &
|
||||
doodads: Vec::new(),
|
||||
portal_instance: None,
|
||||
refs: 1,
|
||||
owner: tile,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -992,6 +1017,7 @@ fn register_wmo(placements: &mut Placements, asset_server: &AssetServer, w: &Wmo
|
||||
doodads: Vec::new(),
|
||||
portal_instance: None,
|
||||
refs: 1,
|
||||
owner: (0, 0), // unread for WMOs — their prop hulls weld per placement (1369)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1011,6 +1037,7 @@ fn drop_streamed_world(
|
||||
commands: &mut Commands,
|
||||
state: &mut TerrainStreamer,
|
||||
placements: &mut Placements,
|
||||
welds: &mut HullWelds,
|
||||
activity: &mut StreamActivity,
|
||||
) {
|
||||
for ((_tx, _ty), t) in state.tiles.drain() {
|
||||
@@ -1024,6 +1051,11 @@ fn drop_streamed_world(
|
||||
release_placement(commands, placements, GLOBAL_WMO_UID, activity);
|
||||
}
|
||||
placements.materials.clear();
|
||||
// The weld accumulators describe the world just dropped. Cleared HERE and not left to the
|
||||
// flush system's dead-key discard, because tile keys are not unique across maps: the new
|
||||
// map's tiles are requested (and their `TileState`s inserted) on this very frame, so a stale
|
||||
// accumulator could weld the previous map's geometry into a same-numbered fresh tile.
|
||||
welds.clear();
|
||||
}
|
||||
|
||||
/// Expire the placement material dedup by **distance** (decision 0793) — the within-map half of
|
||||
@@ -1050,6 +1082,7 @@ fn release_world(
|
||||
mut forms: ResMut<crate::model_forms::ModelForms>,
|
||||
mut progress: Option<ResMut<WorldLoadProgress>>,
|
||||
mut activity: ResMut<StreamActivity>,
|
||||
mut welds: ResMut<HullWelds>,
|
||||
) {
|
||||
if state.tiles.is_empty() && state.map_dir.is_none() {
|
||||
return;
|
||||
@@ -1062,7 +1095,13 @@ fn release_world(
|
||||
// cache in one deterministic sweep instead — nothing world-scoped may survive a logout, and
|
||||
// an entry whose asset another holder keeps resident would otherwise pin its meshes forever.
|
||||
forms.clear();
|
||||
drop_streamed_world(&mut commands, &mut state, &mut placements, &mut activity);
|
||||
drop_streamed_world(
|
||||
&mut commands,
|
||||
&mut state,
|
||||
&mut placements,
|
||||
&mut welds,
|
||||
&mut activity,
|
||||
);
|
||||
state.map_dir = None;
|
||||
state.wdt = None;
|
||||
state.wdt_ungated = false;
|
||||
@@ -1077,7 +1116,7 @@ fn despawn_tile_owned(commands: &mut Commands, t: &TileState) {
|
||||
for e in t.entity.into_iter().chain(t.wall) {
|
||||
commands.entity(e).try_despawn();
|
||||
}
|
||||
for &e in t.liquid.iter().chain(&t.clutter) {
|
||||
for &e in t.liquid.iter().chain(&t.clutter).chain(&t.welds) {
|
||||
commands.entity(e).try_despawn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,9 +102,15 @@ pub(super) fn finish_colliders(
|
||||
// released onto. This system heads the Stream chain (0738), so the consumers read this
|
||||
// frame's depth; the count is the depth entering the frame (attaches below shrink it next
|
||||
// publish), which can only delay a release, never wrong one.
|
||||
// The weld accumulators count as pending too (decision 1369): each unflushed batch becomes
|
||||
// at least one collider on this queue, and the settle release must not let a body go while
|
||||
// doodad hulls still sit in one. Same conservative semantics as the depth itself.
|
||||
let weld_backlog = world
|
||||
.get_resource::<super::weld::HullWelds>()
|
||||
.map_or(0, |w| w.unflushed());
|
||||
if let Some(mut progress) = world.get_resource_mut::<crate::terrain_stream::WorldLoadProgress>()
|
||||
{
|
||||
progress.colliders_pending = pending;
|
||||
progress.colliders_pending = pending + weld_backlog;
|
||||
}
|
||||
if ready.is_empty() {
|
||||
// `get_`: the unit tests below drive this system on a minimal App without the perf layer.
|
||||
@@ -151,6 +157,26 @@ pub(super) fn finish_colliders(
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_NO_DOODAD_BODIES=1` — spawn NO doodad/prop hulls at all (decision 1367's premise
|
||||
/// bracket): the ceiling of what the whole lane can be worth, measured by deleting it. The
|
||||
/// player loses doodad collision and the doodad pick clamp for the run — a measurement lever,
|
||||
/// never a setting. Terrain, impassable fences and WMO building colliders are untouched.
|
||||
pub(crate) fn doodad_bodies_disabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("WOW_NO_DOODAD_BODIES").is_some())
|
||||
}
|
||||
|
||||
/// `WOW_BARE_DOODAD_COLLIDERS=1` — doodad/prop hulls spawn as body-LESS colliders (decision
|
||||
/// 1367): avian 0.6 files a collider with no `ColliderOf` in its first-class **Standalone**
|
||||
/// tree (`collider_tree/proxy_key.rs`), still spatial-queryable and still a contact target, so
|
||||
/// this is the idiomatic collider-only shape itself, not an approximation of it. What the lever
|
||||
/// removes is the `RigidBody::Static` row weight — the velocities/mass/solver components the
|
||||
/// 1364 census counted 12.2k of at the Stormwind pin.
|
||||
pub(crate) fn doodad_hulls_bare() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("WOW_BARE_DOODAD_COLLIDERS").is_some())
|
||||
}
|
||||
|
||||
/// The world-space `(vertices, triangles)` for a model's static collider — its raw-WoW collision hull
|
||||
/// mapped through `wow_to_bevy` then the placement [`Transform`] (so the collider coincides with the
|
||||
/// drawn model). `None` for a hull-less model or an empty mesh. The (potentially expensive) parry build
|
||||
|
||||
@@ -21,11 +21,14 @@ use crate::model_render::{ModelKind, ModelPart};
|
||||
use benilla_assets::materials::WowModelMaterial;
|
||||
|
||||
/// What one placement's animation host armed, for the consumers that spawn alongside its submeshes.
|
||||
/// Both fields are *per placement*, not per model: the joint set is this instance's, and `arm` is
|
||||
/// All fields are *per placement*, not per model: the anchors are this instance's, and `arm` is
|
||||
/// this instance's own anim root, whose live player names the sequence it is currently playing.
|
||||
pub struct PlacementHost {
|
||||
/// Bone-indexed joint entities — an emitter or ribbon rides its host bone's joint off this.
|
||||
pub joints: Vec<Entity>,
|
||||
/// `(bone, anchor)` — the consumer anchors this placement minted (decision 1365): one entity
|
||||
/// per bone something actually rides, registered in the host's `RigPose` (its compose passes
|
||||
/// re-seat them) and pre-minted here for every emitter and ribbon bone the model authors, so
|
||||
/// the fx spawns that run after the pose buffer is attached only look entities up.
|
||||
anchors: Vec<(u16, Entity)>,
|
||||
/// This placement's anim-root entity. It rides the returned entity list so it despawns with the
|
||||
/// placement, but it is **not geometry** — a caller tagging "everything this placement draws"
|
||||
/// has to be able to leave it out, or it lands in a cull that can only fail open on a boundless
|
||||
@@ -41,6 +44,18 @@ pub struct PlacementHost {
|
||||
pub arm: Option<Entity>,
|
||||
}
|
||||
|
||||
impl PlacementHost {
|
||||
/// The anchor standing in for `bone` — what `joints.get(bone)` used to answer. `None` = the
|
||||
/// bone hosts nothing this placement consumes (or is outside the skeleton): the consumer
|
||||
/// falls back exactly as it did on a missing joint.
|
||||
pub fn anchor(&self, bone: u16) -> Option<Entity> {
|
||||
self.anchors
|
||||
.iter()
|
||||
.find(|&&(b, _)| b == bone)
|
||||
.map(|&(_, e)| e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a model's submeshes at `transform` with the full doodad/WMO component set. Returns the spawned
|
||||
/// entities (for refcounted despawn).
|
||||
///
|
||||
@@ -120,7 +135,7 @@ pub fn spawn_model_entities(
|
||||
// (0 render batches — the InstancePortal swirl, whose two emitters sit on the spinning rotor
|
||||
// bones) still needs its joints driven. Only a model with nothing but billboard cards and no
|
||||
// emitters/ribbons spawns no host.
|
||||
let host = m2
|
||||
let mut host = m2
|
||||
.filter(|(m, _)| {
|
||||
submeshes.iter().any(|s| s.billboard.is_none())
|
||||
|| !m.emitters.is_empty()
|
||||
@@ -129,9 +144,7 @@ pub fn spawn_model_entities(
|
||||
.and_then(|(m, _)| crate::doodad_anim::spawn_anim_host(commands, m, transform));
|
||||
// Captures freeze material-alpha clocks at 0 (deterministic frames) — read the env once.
|
||||
let mat_frozen = crate::dev_state::deterministic_run();
|
||||
let skin = host
|
||||
.as_ref()
|
||||
.map(|h| (h.joints.clone(), h.inverse_bindposes.clone()));
|
||||
let animated = host.is_some();
|
||||
let rig_root = host.as_ref().map(|h| h.root);
|
||||
if let Some(h) = &host {
|
||||
out.push(h.root);
|
||||
@@ -280,14 +293,12 @@ pub fn spawn_model_entities(
|
||||
// lamp does instead of hanging in the air far past it. The mesh is centred at the pivot, so the
|
||||
// fade centre is `ZERO` (→ the entity's world translation = the pivot).
|
||||
let (entity, fade_center) = if let Some(info) = &sub.billboard {
|
||||
// An animated doodad's card rides its billboard bone's live joint (the swinging
|
||||
// lamp's glow follows the swing — 0153 follow-up; the joint frame bakes the pivot,
|
||||
// 0130 rig identity). A static doodad keeps the fixed placement-baked pivot.
|
||||
let card = match skin
|
||||
.as_ref()
|
||||
.and_then(|(joints, _)| joints.get(info.bone as usize))
|
||||
{
|
||||
Some(&joint) => BillboardCard::following_joint(info, joint),
|
||||
// An animated doodad's card rides its billboard bone's live anchor (the swinging
|
||||
// lamp's glow follows the swing — 0153 follow-up; the anchor frame bakes the pivot,
|
||||
// the 0130 rig identity carried into the collapsed lane by 1365). A static doodad
|
||||
// keeps the fixed placement-baked pivot.
|
||||
let card = match host.as_mut().and_then(|h| h.anchor(commands, info.bone)) {
|
||||
Some(anchor) => BillboardCard::following_joint(info, anchor),
|
||||
None => match card_owner {
|
||||
Some(anchor) => BillboardCard::following(info, anchor),
|
||||
None => BillboardCard::new(info, transform),
|
||||
@@ -324,8 +335,7 @@ pub fn spawn_model_entities(
|
||||
// (decision 0863, `doodad_anim::lazy`). The twin comes from the app-built forms
|
||||
// (0834); a lane that didn't request it (or a contract break) simply never promotes,
|
||||
// rather than arm the picker with a joint-less "skinned" mesh.
|
||||
let skinned_mesh = skin
|
||||
.is_some()
|
||||
let skinned_mesh = animated
|
||||
.then(|| forms.skin.and_then(|s| s.get(batch_idx)).cloned())
|
||||
.flatten();
|
||||
let mut part_entity = commands.spawn((
|
||||
@@ -358,10 +368,9 @@ pub fn spawn_model_entities(
|
||||
// second query recomputed the very box 1259 had just widened, and the birds went back
|
||||
// to blinking a second after they streamed in. The bound here is authored, not derived;
|
||||
// this component is how that is said.
|
||||
if let Some(aabb) = cull_bound(
|
||||
stat_aabb.as_ref(),
|
||||
skin.is_some().then_some(anim_bound).flatten(),
|
||||
) {
|
||||
if let Some(aabb) =
|
||||
cull_bound(stat_aabb.as_ref(), animated.then_some(anim_bound).flatten())
|
||||
{
|
||||
part_entity.insert((aabb, NoAutoAabb));
|
||||
}
|
||||
if let Some(sm) = skinned_mesh {
|
||||
@@ -372,12 +381,10 @@ pub fn spawn_model_entities(
|
||||
lazy_parts.push(part_entity.id());
|
||||
}
|
||||
let entity = part_entity.id();
|
||||
if skin.is_some() {
|
||||
if let Some(root) = rig_root {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(crate::rig_palette::RigPart(root));
|
||||
}
|
||||
if let Some(root) = rig_root {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(crate::rig_palette::RigPart(root));
|
||||
// The draw-gate list is about visibility, not skinning — the bind-pose fallback
|
||||
// part still represents the placement.
|
||||
skinned_meshes.push(entity);
|
||||
@@ -420,8 +427,21 @@ pub fn spawn_model_entities(
|
||||
// or, for a meshless (particles-only) host, iff the placement's fade sphere is in the draw
|
||||
// set (the same 0171 law its emitters gate on).
|
||||
let arm = host.as_ref().and_then(|h| h.seq.map(|_| h.root));
|
||||
if let Some(h) = host {
|
||||
let placement_host = host.map(|mut h| {
|
||||
let now = m2.map(|(_, now)| now).unwrap_or_default();
|
||||
// Pre-mint the fx consumers' anchors (decision 1365) while the pose buffer is still in
|
||||
// hand: exactly the bones the model authors emitters/ribbons on, nothing speculative —
|
||||
// the fx spawns that run after this only look the entities up ([`PlacementHost::anchor`]).
|
||||
if let Some((m, _)) = m2 {
|
||||
for bone in m
|
||||
.emitters
|
||||
.iter()
|
||||
.map(|e| e.def.bone)
|
||||
.chain(m.ribbons.iter().map(|r| r.def.bone))
|
||||
{
|
||||
h.anchor(commands, bone);
|
||||
}
|
||||
}
|
||||
commands.entity(h.root).insert(DoodadAnimHost {
|
||||
meshes: skinned_meshes,
|
||||
fade: (radius, transform.transform_point(local_center)),
|
||||
@@ -444,17 +464,16 @@ pub fn spawn_model_entities(
|
||||
// (chimney smoke) never takes a slot, which under the eager design it wasted one on.
|
||||
if !lazy_parts.is_empty() {
|
||||
commands.entity(h.root).insert(crate::doodad_anim::LazyRig {
|
||||
joints: h.joints.clone(),
|
||||
bones: h.bones(),
|
||||
ibp: h.inverse_bindposes.clone(),
|
||||
parts: lazy_parts,
|
||||
});
|
||||
}
|
||||
}
|
||||
(
|
||||
out,
|
||||
skin.zip(rig_root)
|
||||
.map(|((joints, _), root)| PlacementHost { joints, root, arm }),
|
||||
)
|
||||
let root = h.root;
|
||||
let anchors = h.finish(commands);
|
||||
PlacementHost { anchors, root, arm }
|
||||
});
|
||||
(out, placement_host)
|
||||
}
|
||||
|
||||
/// The `Aabb` a placed submesh is culled with: its build-time bind-pose bound, widened by the
|
||||
|
||||
@@ -179,11 +179,13 @@ pub(super) fn emitter_fade(
|
||||
/// `out` so they ride the placement's lifecycle (despawn when it streams out). Emitters with no
|
||||
/// resolved texture are skipped by [`particles::spawn_emitter`].
|
||||
///
|
||||
/// `joints` is the placement's anim-host joint set when it animates (0130 phase 4): each emitter then
|
||||
/// rides its host bone's joint — the torch flame follows the flame-bone jiggle, the inn chandelier's
|
||||
/// candle flames swing with it. Riding is unconditional given a joint set: for a bone whose chain
|
||||
/// never animates the joint holds its telescoped rest pivot, so `joint · (position − pivot)` is
|
||||
/// byte-for-byte the static `placement · position` — no per-emitter classification needed.
|
||||
/// `host` is the placement's [`PlacementHost`] when it animates (0130 phase 4): each emitter then
|
||||
/// rides its host bone's ANCHOR (decision 1365 — the collapsed rig's stand-in for the joint,
|
||||
/// pre-minted by the assembler for exactly these bones) — the torch flame follows the flame-bone
|
||||
/// jiggle, the inn chandelier's candle flames swing with it. Riding is unconditional given a
|
||||
/// host: for a bone whose chain never animates the anchor holds its telescoped rest pivot, so
|
||||
/// `anchor · (position − pivot)` is byte-for-byte the static `placement · position` — no
|
||||
/// per-emitter classification needed.
|
||||
///
|
||||
/// `armed_seq` is the FILE sequence slot this placement's own arm rolled, and the emitters sample
|
||||
/// their rate/gate tracks against **that** slot. This site used to pass `EmitClock::Pinned` — slot 0,
|
||||
@@ -198,18 +200,18 @@ pub(super) fn spawn_emitters_for(
|
||||
commands: &mut Commands,
|
||||
emitters: &[ModelEmitter],
|
||||
transform: Transform,
|
||||
joints: Option<&[Entity]>,
|
||||
arm: Option<Entity>,
|
||||
host: Option<&super::assemble::PlacementHost>,
|
||||
// The shared draw-set gate ([`emitter_fade`]), cloned onto every emitter this model spawns.
|
||||
fade: &particles::EmitterFade,
|
||||
out: &mut Vec<Entity>,
|
||||
) {
|
||||
let arm = host.and_then(|h| h.arm);
|
||||
for em in emitters {
|
||||
// Static placements (no anim host — the ~90% tier, and every capture) have no owner to
|
||||
// follow; they despawn via the placement's entity list.
|
||||
let owner = joints
|
||||
.and_then(|js| js.get(em.def.bone as usize))
|
||||
.map(|&j| (j, em.bone_pivot));
|
||||
let owner = host
|
||||
.and_then(|h| h.anchor(em.def.bone))
|
||||
.map(|a| (a, em.bone_pivot));
|
||||
if let Some(e) = particles::spawn_emitter(
|
||||
commands,
|
||||
em,
|
||||
@@ -242,7 +244,7 @@ pub(super) fn spawn_emitters_for(
|
||||
}
|
||||
|
||||
/// Spawn a ribbon trail for each of a model's ribbon emitters (wisp streamers, the Caverns-of-Time
|
||||
/// energy trails). A trail rides its host bone's joint when the placement animates (same ride as
|
||||
/// energy trails). A trail rides its host bone's anchor when the placement animates (same ride as
|
||||
/// [`spawn_emitters_for`]); a static placement's trail rides the first spawned submesh entity (its
|
||||
/// transform IS the placement). Trails self-despawn when their owner goes, so they don't join
|
||||
/// `out` — the placement's despawn cascades within a frame.
|
||||
@@ -250,8 +252,7 @@ pub(super) fn spawn_ribbons_for(
|
||||
commands: &mut Commands,
|
||||
ribbons: &[benilla_assets::ModelRibbon],
|
||||
transform: Transform,
|
||||
joints: Option<&[Entity]>,
|
||||
arm: Option<Entity>,
|
||||
host: Option<&super::assemble::PlacementHost>,
|
||||
fallback_owner: Option<Entity>,
|
||||
// The SAME draw-set gate the quad clouds one function up are given ([`emitter_fade`]) — a trail
|
||||
// is one of this model's emitters and is admitted by exactly the same five terms. It has to be
|
||||
@@ -259,9 +260,10 @@ pub(super) fn spawn_ribbons_for(
|
||||
// its owner model's fade sphere or its rooms ([`crate::ribbons::RibbonTrail::fade`]).
|
||||
fade: &particles::EmitterFade,
|
||||
) {
|
||||
let arm = host.and_then(|h| h.arm);
|
||||
for rb in ribbons {
|
||||
let (owner, use_pivot) = match joints.and_then(|js| js.get(rb.def.bone as usize)).copied() {
|
||||
Some(j) => (j, true),
|
||||
let (owner, use_pivot) = match host.and_then(|h| h.anchor(rb.def.bone)) {
|
||||
Some(a) => (a, true),
|
||||
None => match fallback_owner {
|
||||
Some(e) => (e, false),
|
||||
None => continue, // nothing to ride (an entity-less placement)
|
||||
|
||||
@@ -35,7 +35,11 @@ use crate::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
|
||||
use benilla_assets::m2_url;
|
||||
use benilla_assets::materials::WowModelMaterial;
|
||||
|
||||
use super::collider::{build_collider_task, placement_collider_data, PendingCollider};
|
||||
use super::collider::{
|
||||
build_collider_task, doodad_bodies_disabled, doodad_hulls_bare, placement_collider_data,
|
||||
PendingCollider,
|
||||
};
|
||||
use super::weld::{hull_weld_disabled, HullWelds};
|
||||
use super::{
|
||||
doodad_ground_shade, ModelHandle, Placements, ShadeResolve, TerrainStreamer, SPAWN_BUDGET,
|
||||
};
|
||||
@@ -59,6 +63,7 @@ type SpawnTables<'w> = (
|
||||
ResMut<'w, crate::terrain_stream::StreamActivity>,
|
||||
Res<'w, crate::terrain_stream::ViewFocus>,
|
||||
ResMut<'w, ModelForms>,
|
||||
ResMut<'w, HullWelds>,
|
||||
);
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -86,7 +91,7 @@ pub(super) fn spawn_loaded_placements(
|
||||
// model-forms cache (decision 0834).
|
||||
tables: SpawnTables,
|
||||
) {
|
||||
let (mut probes, mut activity, focus, mut forms) = tables;
|
||||
let (mut probes, mut activity, focus, mut forms, mut welds) = tables;
|
||||
let Some(shared_light) = shared_light else {
|
||||
return;
|
||||
};
|
||||
@@ -195,26 +200,35 @@ pub(super) fn spawn_loaded_placements(
|
||||
.entity(*e)
|
||||
.insert(crate::exterior_cull::ExteriorScene);
|
||||
}
|
||||
// Doodad collider (avian): a static trimesh hull baked at this placement's transform,
|
||||
// built off-thread, as its own entity so it despawns with the placement. `None` ⇒
|
||||
// the model has no collision hull. A world-static doodad hull also clamps the
|
||||
// mouse pick (`PickOccluder` — a hull-less tree canopy stays pick-through,
|
||||
// matching the reference's collision-flagged-doodads-only world trace).
|
||||
if let Some((verts, tris)) =
|
||||
placement_collider_data(m.collision.as_ref(), &p.transform)
|
||||
// Doodad collider (avian): a static trimesh hull baked at this placement's
|
||||
// transform, WELDED into its owner tile's batch (decision 1369 — the entity
|
||||
// granularity was ~0.8 cpu_ms of avian per-frame cost at Stormwind; see
|
||||
// `super::weld`). `None` ⇒ the model has no collision hull, so a hull-less
|
||||
// tree canopy stays pick-through, matching the reference's
|
||||
// collision-flagged-doodads-only world trace — the weld carries the
|
||||
// `PickOccluder` clamp for the hulls it absorbs.
|
||||
// (`!doodad_bodies_disabled()` / `!doodad_hulls_bare()`: the 1367 premise
|
||||
// levers. `hull_weld_disabled`: the pre-1369 per-placement shape, for A/B.)
|
||||
if let Some((verts, tris)) = (!doodad_bodies_disabled())
|
||||
.then(|| placement_collider_data(m.collision.as_ref(), &p.transform))
|
||||
.flatten()
|
||||
{
|
||||
ents.push(
|
||||
commands
|
||||
.spawn((
|
||||
PendingCollider::new(
|
||||
build_collider_task(verts, tris),
|
||||
None,
|
||||
true,
|
||||
),
|
||||
PickOccluder,
|
||||
))
|
||||
.id(),
|
||||
);
|
||||
if hull_weld_disabled() {
|
||||
ents.push(
|
||||
commands
|
||||
.spawn((
|
||||
PendingCollider::new(
|
||||
build_collider_task(verts, tris),
|
||||
None,
|
||||
!doodad_hulls_bare(),
|
||||
),
|
||||
PickOccluder,
|
||||
))
|
||||
.id(),
|
||||
);
|
||||
} else {
|
||||
welds.add_tile(p.owner, verts, tris);
|
||||
}
|
||||
}
|
||||
// One gate for both emitter families. `None`/empty: an ADT map doodad
|
||||
// belongs to no building, so neither the window exemption nor the room
|
||||
@@ -224,8 +238,7 @@ pub(super) fn spawn_loaded_placements(
|
||||
&mut commands,
|
||||
&m.emitters,
|
||||
p.transform,
|
||||
host.as_ref().map(|h| h.joints.as_slice()),
|
||||
host.as_ref().and_then(|h| h.arm),
|
||||
host.as_ref(),
|
||||
&fade,
|
||||
&mut ents,
|
||||
);
|
||||
@@ -233,8 +246,7 @@ pub(super) fn spawn_loaded_placements(
|
||||
&mut commands,
|
||||
&m.ribbons,
|
||||
p.transform,
|
||||
host.as_ref().map(|h| h.joints.as_slice()),
|
||||
host.as_ref().and_then(|h| h.arm),
|
||||
host.as_ref(),
|
||||
ents.first().copied(),
|
||||
&fade,
|
||||
);
|
||||
@@ -604,19 +616,32 @@ pub(super) fn spawn_loaded_placements(
|
||||
// Prop collider (avian): a static trimesh from the prop's collision hull at its world
|
||||
// transform — collide-iff-hull, exactly like a map doodad, so a weapon rack / crate / cargo
|
||||
// net is solid to both the player and the camera while a hull-less prop (banner, small
|
||||
// candle) isn't. Default collision layer ⇒ both audiences. Joins `ents`, so it despawns with
|
||||
// the placement. (WMO doodad props were previously render-only — no collision at all.)
|
||||
if let Some((verts, tris)) = placement_collider_data(m.collision.as_ref(), &d.transform)
|
||||
// candle) isn't. Default collision layer ⇒ both audiences. WELDED per placement
|
||||
// (decision 1369): every prop of a building despawns with the building, so the batch
|
||||
// has exactly the lifetime the individual hulls had — the weld flushes into
|
||||
// `Placement::entities` one chain-step later. (The 1367 premise levers and the 1369
|
||||
// A/B lever gate here exactly as at the map-doodad site above.)
|
||||
if let Some((verts, tris)) = (!doodad_bodies_disabled())
|
||||
.then(|| placement_collider_data(m.collision.as_ref(), &d.transform))
|
||||
.flatten()
|
||||
{
|
||||
ents.push(
|
||||
commands
|
||||
.spawn((
|
||||
PendingCollider::new(build_collider_task(verts, tris), None, true),
|
||||
// A world-static WMO prop's hull clamps the mouse pick like any doodad.
|
||||
PickOccluder,
|
||||
))
|
||||
.id(),
|
||||
);
|
||||
if hull_weld_disabled() {
|
||||
ents.push(
|
||||
commands
|
||||
.spawn((
|
||||
PendingCollider::new(
|
||||
build_collider_task(verts, tris),
|
||||
None,
|
||||
!doodad_hulls_bare(),
|
||||
),
|
||||
// A world-static WMO prop's hull clamps the mouse pick like any doodad.
|
||||
PickOccluder,
|
||||
))
|
||||
.id(),
|
||||
);
|
||||
} else {
|
||||
welds.add_prop(unique_id, verts, tris);
|
||||
}
|
||||
}
|
||||
// One gate for both emitter families: the prop rides its building's exterior-window
|
||||
// exemption AND the portal PVS of the rooms that name it — the same `WmoGroupVis` the
|
||||
@@ -632,8 +657,7 @@ pub(super) fn spawn_loaded_placements(
|
||||
&mut commands,
|
||||
&m.emitters,
|
||||
d.transform,
|
||||
host.as_ref().map(|h| h.joints.as_slice()),
|
||||
host.as_ref().and_then(|h| h.arm),
|
||||
host.as_ref(),
|
||||
&fade,
|
||||
&mut ents,
|
||||
);
|
||||
@@ -641,8 +665,7 @@ pub(super) fn spawn_loaded_placements(
|
||||
&mut commands,
|
||||
&m.ribbons,
|
||||
d.transform,
|
||||
host.as_ref().map(|h| h.joints.as_slice()),
|
||||
host.as_ref().and_then(|h| h.arm),
|
||||
host.as_ref(),
|
||||
ents.first().copied(),
|
||||
&fade,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Doodad-hull **welding** (decision 1369). The 1367 premise check measured ~0.8 cpu_ms at
|
||||
//! Stormwind riding the doodad-hull *population* — 12.2k collider entities' tree proxies, AABBs
|
||||
//! and per-collider row visits in avian's per-frame passes — and refuted the row-weight redesign
|
||||
//! (a body-less collider costs the same). So the fix is the terrain collider's own pattern
|
||||
//! (`terrain_collider_data` welds 256 chunks into one trimesh for exactly this reason): batch the
|
||||
//! world-static hulls into a few dozen welded trimeshes instead of thousands of individual ones.
|
||||
//! Same triangles, same layers, same [`PickOccluder`] clamp — collision and the mouse-pick
|
||||
//! answer are unchanged; only the entity granularity collapses. Identity was verified free to
|
||||
//! give up before this was built: the pick clamp consumes a *distance* (`update_pick_occlusion`),
|
||||
//! and the inspector/GO hover casts against resident [`PickMesh`] geometry, never these hulls.
|
||||
//!
|
||||
//! **Ownership** is the design's load-bearing choice (1367 named it the question to answer
|
||||
//! first):
|
||||
//!
|
||||
//! - **WMO props** weld per *placement* (keyed by uniqueId, batch-split by the caps below). Every
|
||||
//! prop of a building despawns with the building (`Placement::entities`), so a weld pushed
|
||||
//! there has exactly the lifetime the individual hulls had. No approximation — and on a
|
||||
//! WMO-only map (an instance), the entire prop population welds under the one global placement.
|
||||
//! - **ADT map doodads** weld per the placement's **first-registering tile** (recorded by
|
||||
//! `register_doodad`, when that tile is by construction loaded), the weld entity owned by that
|
||||
//! tile (`TileState::welds`) like its impassable-wall collider. This is the one deliberate
|
||||
//! deviation from per-placement lifetime: a straddler kept alive by a *second* tile keeps its
|
||||
//! model when the owner unloads, but its hull goes with the owner's weld. The mismatch can only
|
||||
//! open past the unload line — `radius + 1` tiles (over 500 yd of hysteresis alone) from the
|
||||
//! focus, far past doodad draw fade — so no reachable or visible doodad ever misses its hull;
|
||||
//! near a mover, every referencing tile is loaded, owner included. The converse (a phantom hull
|
||||
//! outliving every model) cannot happen at all: the owner tile references the placement, so the
|
||||
//! models live at least as long as the weld.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::collider::{build_collider_task, doodad_hulls_bare, PendingCollider};
|
||||
use crate::collision::PickOccluder;
|
||||
use crate::interact::WorldObject;
|
||||
use crate::model_render::ModelKind;
|
||||
|
||||
/// Hull-count cap per weld. 12.2k hulls at the Stormwind pin batch into ~100 colliders at this
|
||||
/// size — the population collapse that IS the win — while keeping any one weld's broadphase AABB
|
||||
/// a neighbourhood, not a zone.
|
||||
const WELD_MAX_HULLS: u32 = 128;
|
||||
|
||||
/// Triangle cap per weld: bounds the off-thread QBVH build and the single attach. At 0610's
|
||||
/// measured attach cost (~0.004 ms + 1.8e-5 ms/triangle) a full batch attaches in ~0.3 ms — well
|
||||
/// under `ATTACH_BUDGET`, so one weld can never eat a frame's attach queue. A single oversized
|
||||
/// hull (a bridge span) may exceed the cap alone; it then closes its batch immediately.
|
||||
const WELD_MAX_TRIS: usize = 16_384;
|
||||
|
||||
/// Quiet frames that close a live batch's tail. The spawner lands a tile's placements over many
|
||||
/// budget-paced frames; a quarter second of silence means the burst is done. Under the loading
|
||||
/// cover the tail closes after ONE quiet frame instead: the settle release waits on the collider
|
||||
/// backlog (0737, via `finish_colliders`' publish), and a fixed quarter-second tail would push
|
||||
/// every world entry longer for nothing.
|
||||
const WELD_IDLE_FRAMES: u32 = 15;
|
||||
|
||||
/// One accumulating weld: a world-space triangle soup, index-rebased as hulls append — the same
|
||||
/// concatenation `terrain_collider_data` does for its 256 chunks.
|
||||
struct WeldAcc {
|
||||
verts: Vec<Vec3>,
|
||||
tris: Vec<[u32; 3]>,
|
||||
hulls: u32,
|
||||
/// [`HullWelds::frame`] at the last append — the idle clock the tail flush reads.
|
||||
last_add: u32,
|
||||
}
|
||||
|
||||
impl WeldAcc {
|
||||
fn append(&mut self, verts: Vec<Vec3>, tris: Vec<[u32; 3]>, frame: u32) {
|
||||
let base = self.verts.len() as u32;
|
||||
self.verts.extend(verts);
|
||||
self.tris
|
||||
.extend(tris.into_iter().map(|t| t.map(|i| base + i)));
|
||||
self.hulls += 1;
|
||||
self.last_add = frame;
|
||||
}
|
||||
|
||||
fn ready(&self, frame: u32, idle_frames: u32) -> bool {
|
||||
self.hulls >= WELD_MAX_HULLS
|
||||
|| self.tris.len() >= WELD_MAX_TRIS
|
||||
|| frame.wrapping_sub(self.last_add) >= idle_frames
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-flight weld accumulators, fed by `spawn_loaded_placements` and drained by
|
||||
/// [`flush_hull_welds`] one chain-step later. Cleared with the world it describes
|
||||
/// (`drop_streamed_world`) — a stale accumulator would otherwise weld the previous map's geometry
|
||||
/// into a new map's same-numbered tile, since tile keys are re-inserted at request time on the
|
||||
/// very frame the old world drops.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct HullWelds {
|
||||
/// Flush-system tick, the idle clock. Wrapping u32 — only ever read as a difference.
|
||||
frame: u32,
|
||||
/// ADT map-doodad hulls by owner tile.
|
||||
tiles: HashMap<(i32, i32), WeldAcc>,
|
||||
/// WMO prop hulls by placement uniqueId.
|
||||
props: HashMap<u32, WeldAcc>,
|
||||
}
|
||||
|
||||
impl HullWelds {
|
||||
pub(super) fn add_tile(&mut self, tile: (i32, i32), verts: Vec<Vec3>, tris: Vec<[u32; 3]>) {
|
||||
let frame = self.frame;
|
||||
self.tiles
|
||||
.entry(tile)
|
||||
.or_insert_with(|| WeldAcc {
|
||||
verts: Vec::new(),
|
||||
tris: Vec::new(),
|
||||
hulls: 0,
|
||||
last_add: frame,
|
||||
})
|
||||
.append(verts, tris, frame);
|
||||
}
|
||||
|
||||
pub(super) fn add_prop(&mut self, uid: u32, verts: Vec<Vec3>, tris: Vec<[u32; 3]>) {
|
||||
let frame = self.frame;
|
||||
self.props
|
||||
.entry(uid)
|
||||
.or_insert_with(|| WeldAcc {
|
||||
verts: Vec::new(),
|
||||
tris: Vec::new(),
|
||||
hulls: 0,
|
||||
last_add: frame,
|
||||
})
|
||||
.append(verts, tris, frame);
|
||||
}
|
||||
|
||||
/// Accumulators not yet flushed into a [`PendingCollider`] — counted into
|
||||
/// `WorldLoadProgress::colliders_pending` by `finish_colliders`, so the settle release never
|
||||
/// lets a body go while doodad hulls still sit in a batch (each accumulator becomes at least
|
||||
/// one pending collider). Like the queue depth itself, an overcount here (an accumulator a
|
||||
/// later flush discards) can only delay a release, never wrong one.
|
||||
pub(super) fn unflushed(&self) -> usize {
|
||||
self.tiles.len() + self.props.len()
|
||||
}
|
||||
|
||||
pub(super) fn clear(&mut self) {
|
||||
self.tiles.clear();
|
||||
self.props.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_NO_HULL_WELD=1` — spawn doodad/prop hulls one entity per placement, the pre-1369 shape:
|
||||
/// the welding A/B lever (the `WOW_NO_*` pattern), never a setting.
|
||||
pub(super) fn hull_weld_disabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("WOW_NO_HULL_WELD").is_some())
|
||||
}
|
||||
|
||||
/// Close ready batches into [`PendingCollider`] entities and hand each to its owner — tile welds
|
||||
/// onto `TileState::welds` (despawned with the tile, like the wall), prop welds into
|
||||
/// `Placement::entities` (despawned with the placement, like the hulls they replace). An
|
||||
/// accumulator whose owner is already gone is discarded: its hulls were only ever reachable past
|
||||
/// the unload line (module doc), and the alternative — spawning a weld nothing owns — is a leak.
|
||||
///
|
||||
/// Runs in the Stream chain right after `spawn_loaded_placements`, so the frame's appends see the
|
||||
/// flush at a deterministic point and the owner lookups race nothing.
|
||||
pub(super) fn flush_hull_welds(
|
||||
mut commands: Commands,
|
||||
welds: ResMut<HullWelds>,
|
||||
mut streamer: ResMut<super::TerrainStreamer>,
|
||||
mut placements: ResMut<super::Placements>,
|
||||
focus: Res<super::ViewFocus>,
|
||||
) {
|
||||
let welds = welds.into_inner();
|
||||
welds.frame = welds.frame.wrapping_add(1);
|
||||
let frame = welds.frame;
|
||||
let idle = if focus.paced { WELD_IDLE_FRAMES } else { 1 };
|
||||
welds.tiles.retain(|&key, acc| {
|
||||
let Some(tile) = streamer.tiles.get_mut(&key) else {
|
||||
return false;
|
||||
};
|
||||
if !acc.ready(frame, idle) {
|
||||
return true;
|
||||
}
|
||||
tile.welds.push(spawn_weld(&mut commands, acc));
|
||||
false
|
||||
});
|
||||
welds.props.retain(|&uid, acc| {
|
||||
let Some(p) = placements.by_id.get_mut(&uid) else {
|
||||
return false;
|
||||
};
|
||||
if !acc.ready(frame, idle) {
|
||||
return true;
|
||||
}
|
||||
p.entities.push(spawn_weld(&mut commands, acc));
|
||||
false
|
||||
});
|
||||
}
|
||||
|
||||
/// One closed batch → one off-thread trimesh build, carrying exactly what an individual hull
|
||||
/// carried: default layers (both audiences), the pick clamp, and — unless the 1367 lever bares
|
||||
/// it — a static body. Tagged for the census/inspector so the row is nameable, but with no
|
||||
/// `PickMesh`/`PickBox` it is not pickable (0929: pick geometry is declared, never inferred).
|
||||
fn spawn_weld(commands: &mut Commands, acc: &mut WeldAcc) -> Entity {
|
||||
let verts = std::mem::take(&mut acc.verts);
|
||||
let tris = std::mem::take(&mut acc.tris);
|
||||
commands
|
||||
.spawn((
|
||||
PendingCollider::new(build_collider_task(verts, tris), None, !doodad_hulls_bare()),
|
||||
PickOccluder,
|
||||
WorldObject {
|
||||
kind: ModelKind::Doodad,
|
||||
label: "hull-weld".into(),
|
||||
id: 0,
|
||||
detail: format!("{} hulls welded", acc.hulls),
|
||||
},
|
||||
))
|
||||
.id()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{ModelHandle, Placement, Placements, TerrainStreamer, TileState, ViewFocus};
|
||||
use super::*;
|
||||
use bevy::app::TaskPoolPlugin;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
fn hull() -> (Vec<Vec3>, Vec<[u32; 3]>) {
|
||||
(vec![Vec3::ZERO, Vec3::X, Vec3::Y], vec![[0, 1, 2]])
|
||||
}
|
||||
|
||||
fn test_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(TaskPoolPlugin::default());
|
||||
app.init_resource::<HullWelds>();
|
||||
app.init_resource::<TerrainStreamer>();
|
||||
app.init_resource::<Placements>();
|
||||
app.init_resource::<ViewFocus>();
|
||||
app
|
||||
}
|
||||
|
||||
fn blank_tile() -> TileState {
|
||||
TileState {
|
||||
handle: Default::default(),
|
||||
entity: None,
|
||||
material: None,
|
||||
next_cell: 0,
|
||||
furnished: false,
|
||||
placements: Vec::new(),
|
||||
liquid: Vec::new(),
|
||||
wall: None,
|
||||
clutter: Vec::new(),
|
||||
welds: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn blank_placement() -> Placement {
|
||||
Placement {
|
||||
model: ModelHandle::M2(Default::default()),
|
||||
transform: Transform::IDENTITY,
|
||||
entities: Vec::new(),
|
||||
spawned: true,
|
||||
doodad_set: 0,
|
||||
name_set: 0,
|
||||
doodads: Vec::new(),
|
||||
portal_instance: None,
|
||||
refs: 1,
|
||||
owner: (0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Appending rebases indices onto the running vertex count — two 3-vert hulls weld into one
|
||||
/// 6-vert soup whose second triangle indexes past the first.
|
||||
#[test]
|
||||
fn append_rebases_indices() {
|
||||
let mut welds = HullWelds::default();
|
||||
let (v, t) = hull();
|
||||
welds.add_tile((0, 0), v.clone(), t.clone());
|
||||
welds.add_tile((0, 0), v, t);
|
||||
let acc = welds.tiles.get(&(0, 0)).unwrap();
|
||||
assert_eq!(acc.hulls, 2);
|
||||
assert_eq!(acc.verts.len(), 6);
|
||||
assert_eq!(acc.tris, vec![[0, 1, 2], [3, 4, 5]]);
|
||||
assert_eq!(welds.unflushed(), 1);
|
||||
}
|
||||
|
||||
/// A batch past the hull cap flushes on the next pass, and the weld entity lands in its
|
||||
/// owner tile's `welds` (the despawn list).
|
||||
#[test]
|
||||
fn cap_flush_ties_weld_to_tile() {
|
||||
let mut app = test_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<TerrainStreamer>()
|
||||
.tiles
|
||||
.insert((3, 4), blank_tile());
|
||||
{
|
||||
let mut welds = app.world_mut().resource_mut::<HullWelds>();
|
||||
for _ in 0..WELD_MAX_HULLS {
|
||||
let (v, t) = hull();
|
||||
welds.add_tile((3, 4), v, t);
|
||||
}
|
||||
}
|
||||
app.world_mut().run_system_once(flush_hull_welds).unwrap();
|
||||
assert_eq!(app.world().resource::<HullWelds>().unflushed(), 0);
|
||||
let streamer = app.world().resource::<TerrainStreamer>();
|
||||
let owned = streamer.tiles.get(&(3, 4)).unwrap().welds.clone();
|
||||
assert_eq!(owned.len(), 1);
|
||||
assert!(app.world().get::<PendingCollider>(owned[0]).is_some());
|
||||
assert!(app.world().get::<PickOccluder>(owned[0]).is_some());
|
||||
}
|
||||
|
||||
/// An under-cap batch holds while appends keep landing, then closes after the idle tail.
|
||||
#[test]
|
||||
fn idle_tail_closes_a_quiet_batch() {
|
||||
let mut app = test_app();
|
||||
app.world_mut().resource_mut::<ViewFocus>().paced = true;
|
||||
app.world_mut()
|
||||
.resource_mut::<TerrainStreamer>()
|
||||
.tiles
|
||||
.insert((0, 0), blank_tile());
|
||||
{
|
||||
let mut welds = app.world_mut().resource_mut::<HullWelds>();
|
||||
let (v, t) = hull();
|
||||
welds.add_tile((0, 0), v, t);
|
||||
}
|
||||
for _ in 0..(WELD_IDLE_FRAMES - 1) {
|
||||
app.world_mut().run_system_once(flush_hull_welds).unwrap();
|
||||
}
|
||||
assert_eq!(app.world().resource::<HullWelds>().unflushed(), 1);
|
||||
app.world_mut().run_system_once(flush_hull_welds).unwrap();
|
||||
assert_eq!(app.world().resource::<HullWelds>().unflushed(), 0);
|
||||
let streamer = app.world().resource::<TerrainStreamer>();
|
||||
assert_eq!(streamer.tiles.get(&(0, 0)).unwrap().welds.len(), 1);
|
||||
}
|
||||
|
||||
/// An accumulator whose owner tile is gone is discarded — no weld entity, no leak.
|
||||
#[test]
|
||||
fn dead_owner_discards_the_batch() {
|
||||
let mut app = test_app();
|
||||
{
|
||||
let mut welds = app.world_mut().resource_mut::<HullWelds>();
|
||||
for _ in 0..WELD_MAX_HULLS {
|
||||
let (v, t) = hull();
|
||||
welds.add_tile((9, 9), v, t);
|
||||
}
|
||||
}
|
||||
let before = app.world().entities().len();
|
||||
app.world_mut().run_system_once(flush_hull_welds).unwrap();
|
||||
assert_eq!(app.world().resource::<HullWelds>().unflushed(), 0);
|
||||
assert_eq!(app.world().entities().len(), before);
|
||||
}
|
||||
|
||||
/// A prop batch flushes into its placement's entity list — the lifetime the individual
|
||||
/// hulls had.
|
||||
#[test]
|
||||
fn prop_weld_lands_in_placement_entities() {
|
||||
let mut app = test_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<Placements>()
|
||||
.by_id
|
||||
.insert(7, blank_placement());
|
||||
{
|
||||
let mut welds = app.world_mut().resource_mut::<HullWelds>();
|
||||
for _ in 0..WELD_MAX_HULLS {
|
||||
let (v, t) = hull();
|
||||
welds.add_prop(7, v, t);
|
||||
}
|
||||
}
|
||||
app.world_mut().run_system_once(flush_hull_welds).unwrap();
|
||||
let placements = app.world().resource::<Placements>();
|
||||
let owned = &placements.by_id.get(&7).unwrap().entities;
|
||||
assert_eq!(owned.len(), 1);
|
||||
assert!(app.world().get::<PendingCollider>(owned[0]).is_some());
|
||||
}
|
||||
}
|
||||
@@ -204,5 +204,21 @@ impl Plugin for WorldFoundation {
|
||||
// read, whose defaults ARE the player behaviour. The debug panel is only its editor
|
||||
// and may not be installed at all.
|
||||
.init_resource::<crate::dev_state::DebugState>();
|
||||
// `WOW_NO_PHYS_PREPROP=1` — skip avian's own copy of Bevy's transform propagation in
|
||||
// FixedPostUpdate. An EXPERIMENT knob (the 1370 bracket): `PhysicsTransformPlugin`
|
||||
// re-registers `mark_dirty_trees`/`propagate_parent_transforms`/`sync_simple_transforms`
|
||||
// to run before each physics step — a second whole-world transform sweep on top of
|
||||
// PostUpdate's own. Our world is static geometry plus kinematic movers that write
|
||||
// `Position` directly, so the pre-step copy is suspected dead work; the gate is a
|
||||
// runtime `run_if` on this resource, so inserting it IS the whole mechanism. Known
|
||||
// semantic edge while on: a collider whose `Transform` moves during Update reaches
|
||||
// physics with last frame's `GlobalTransform` (one-frame lag) — a measurement lever,
|
||||
// never a setting.
|
||||
if std::env::var_os("WOW_NO_PHYS_PREPROP").is_some() {
|
||||
app.insert_resource(avian3d::physics_transform::PhysicsTransformConfig {
|
||||
propagate_before_physics: false,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user