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:
Sam
2026-08-16 17:52:00 +07:00
co-authored by Claude Fable 5
parent d99435beb8
commit 11a889bc56
26 changed files with 1293 additions and 229 deletions
@@ -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.
+9 -1
View File
@@ -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>,
+7 -9
View File
@@ -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,
+14
View File
@@ -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
+130
View File
@@ -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();
}
}
+27 -15
View File
@@ -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
+20 -1
View File
@@ -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
+15 -3
View File
@@ -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);
}
}
+115 -21
View File
@@ -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;
}
+11 -1
View File
@@ -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
+32
View File
@@ -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()
);
}
}
}
}
+36 -36
View File
@@ -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();
+69 -37
View File
@@ -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 {
+1
View File
@@ -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};
+48 -5
View File
@@ -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");
}
}
}
+84
View File
@@ -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);
}
}
+4 -3
View File
@@ -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>,
+44 -5
View File
@@ -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());
}
}
+16
View File
@@ -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()
});
}
}
}