From d00912edd05ebc1f5e61c6d6d8c08e15e84b3815 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 27 Jul 2026 19:38:22 +0700 Subject: [PATCH] crowded dungeons back to 60 fps: two per-NPC costs collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The dungeon framerate reports were right about the mechanism: the cost is CPU-side and it scales with NPC count, not with what's drawn. Two per-unit lanes were each 30–100x more expensive than the work they do. Measured at the reported Lower Blackrock Spire spot: 29.7 ms/frame, 72 CPU ms, before. - "Which room is this NPC in" scanned whole dungeon groups triangle by triangle — a spire stacks several 15k-face groups into any vertical column, so 37 moving NPCs cost 1.2 million triangle tests per frame. Each group's faces now sit in a column index built at load; same verdicts (pinned by exactness tests), 88k tests, the lane drops from 11.3 ms to 1.1 ms. - Animating one creature cost ~190 microseconds per frame in generic per-bone animation machinery — hash lookups and boxed curves per bone, mostly to produce constants. A direct pose evaluator now does the same math into the same joints for ~16 microseconds per rig, checked bone-for-bone against the old output across crossfades, overlays and masks. The reference animates every in-range unit with no view cull, and now so can we: a raid's worth of animating rigs (~450) costs ~4 CPU ms where it used to cost ~85. - Net at the reported spot: a flat 60 fps. The performance probes also learned to report CPU work (ms and %) instead of wall-clock fps, which is what makes these numbers repeatable on a busy machine — and comparable to the Mac report's own units. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + crates/benilla-assets/src/column_grid.rs | 328 +++++++++ crates/benilla-assets/src/lib.rs | 8 +- crates/benilla-assets/src/m2.rs | 46 +- crates/benilla-assets/src/model.rs | 32 +- crates/benilla-assets/src/model/anims.rs | 6 + crates/benilla-assets/src/model/pose.rs | 269 ++++++++ crates/benilla-assets/src/wmo.rs | 62 ++ .../benilla-formats/examples/eye_quad_pass.rs | 201 ++++++ crates/benilla/Cargo.toml | 6 + crates/benilla/src/capture/probes.rs | 35 +- crates/benilla/src/creature_anim.rs | 3 + .../benilla/src/creature_anim/driver/tests.rs | 12 + crates/benilla/src/creature_anim/lod.rs | 164 ++--- crates/benilla/src/creature_anim/pose.rs | 647 ++++++++++++++++++ crates/benilla/src/doodad_anim.rs | 1 + crates/benilla/src/entities/attach/mod.rs | 19 +- crates/benilla/src/entities/equipment.rs | 1 + crates/benilla/src/entities/live_display.rs | 1 + crates/benilla/src/entities/mount.rs | 1 + crates/benilla/src/go_anim.rs | 1 + crates/benilla/src/interior.rs | 26 +- crates/benilla/src/minimap/interior.rs | 4 + crates/benilla/src/particles/depthdump.rs | 79 ++- crates/benilla/src/particles/sim.rs | 7 +- crates/benilla/src/perf.rs | 31 + .../src/wmo_portal/audit/light_probe.rs | 5 + crates/benilla/src/wmo_portal/audit/mod.rs | 2 + crates/benilla/src/wmo_portal/interior.rs | 92 ++- crates/benilla/src/wmo_portal/mod.rs | 2 + crates/benilla/src/wmo_portal/seed.rs | 171 ++++- 31 files changed, 2094 insertions(+), 169 deletions(-) create mode 100644 crates/benilla-assets/src/column_grid.rs create mode 100644 crates/benilla-assets/src/model/pose.rs create mode 100644 crates/benilla-formats/examples/eye_quad_pass.rs create mode 100644 crates/benilla/src/creature_anim/pose.rs diff --git a/Cargo.lock b/Cargo.lock index 9ba4799c..9b97b725 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,6 +586,7 @@ dependencies = [ "fontdb", "image", "kira", + "libc", "mint", "objc2 0.5.2", "objc2-app-kit 0.2.2", diff --git a/crates/benilla-assets/src/column_grid.rs b/crates/benilla-assets/src/column_grid.rs new file mode 100644 index 00000000..9c3e6e98 --- /dev/null +++ b/crates/benilla-assets/src/column_grid.rs @@ -0,0 +1,328 @@ +//! The **column index**: which of a face set's triangles can own a vertical (x, y) column. +//! +//! Every position question this client asks a WMO is a column query — which room is the camera in +//! (`wmo_portal::seed`), which room is a unit in (`track_unit_interiors`), which floor's baked MOCV +//! lights an entity (`interior::classify_entity_interior`), what the zone text should say. All of +//! them cast straight down and want "the triangles whose XY projection contains this point". +//! +//! Until now the narrow phase for that was **a linear scan of every triangle in every group whose +//! AABB contains the column** (decisions 0330/0364 built the per-group bounds; the group is where +//! the culling stopped). That is fine for a cottage and catastrophic for a dungeon: Blackrock +//! Spire's groups hold ~11–16k faces each, and a vertically stacked spire puts several of them in +//! any given column. Measured in LBRS on 2026-07-27: **~1.2 M triangle tests per frame** to light +//! **37 moving NPCs** — ~32k per unit per frame, 11 ms of a 29 ms frame, the second-largest term in +//! the NPC-population collapse (B31/B06 + the BWL/LBRS reports). +//! +//! So each group's faces get a uniform XY grid, built once at load: a column tests one cell's +//! worth of triangles instead of the group's whole face list. +//! +//! **The index never changes a verdict.** It is a pure accelerator with two properties the callers +//! depend on, both pinned by tests: +//! +//! - **Superset** — every triangle whose XY projection contains the column is a candidate. A +//! triangle is inserted into every cell its XY AABB touches (inclusive both ends), a query point +//! outside the grid clamps into the edge cell rather than missing, and a triangle whose AABB +//! spans more cells than [`MAX_SPAN_CELLS`] goes in [`ColumnGrid::spanning`], tested always. +//! - **Order** — candidates come back in **ascending triangle index**, the order a linear scan +//! visits them, so every tie-break downstream (`down_ray_claim`'s first-wins, `footprint_sample`'s +//! later-wins) resolves exactly as it did before the index existed. + +/// A triangle whose XY AABB touches more cells than this is not binned — it goes to +/// [`ColumnGrid::spanning`] and is a candidate for every query. Big floor slabs are the case: at a +/// ~1-triangle-per-cell sizing a 100-yd slab would otherwise be copied into hundreds of cells. +const MAX_SPAN_CELLS: u32 = 32; + +/// Never build cells finer than this (yd) — a degenerate face set (all triangles stacked in one +/// spot) would otherwise ask for an unbounded grid. +const MIN_CELL: f32 = 0.25; + +/// Cell budget per group: the grid is at most this many cells regardless of face count, so a +/// 500-yd dungeon group costs tens of KB of index, not tens of MB. +const MAX_CELLS: usize = 1 << 16; + +/// A face set's uniform XY grid — CSR (`starts`/`items`), so one allocation pair per group rather +/// than a `Vec` per cell. +#[derive(Debug, Clone)] +pub struct ColumnGrid { + /// Grid origin (the face set's XY minimum). + min: [f32; 2], + /// 1 / cell size, in cells per yard. + inv_cell: f32, + nx: u32, + ny: u32, + /// CSR row offsets into [`Self::items`], length `nx * ny + 1`. + starts: Vec, + /// Triangle indices, ascending within each cell. + items: Vec, + /// Triangles too large to bin (see [`MAX_SPAN_CELLS`]), ascending — candidates for every query. + spanning: Vec, +} + +impl ColumnGrid { + /// Build the index for `count` triangles, given each one's XY AABB by index. + /// + /// Returns `None` when there is nothing to accelerate — an empty face set, or one small enough + /// that a linear scan beats the indirection (the caller then keeps its plain scan). + pub fn build(count: usize, xy_aabb: impl Fn(usize) -> ([f32; 2], [f32; 2])) -> Option { + // Below this a linear scan is the cheaper answer and the grid is pure overhead. + const MIN_TRIS: usize = 64; + if count < MIN_TRIS { + return None; + } + let (mut min, mut max) = ([f32::MAX; 2], [f32::MIN; 2]); + for i in 0..count { + let (lo, hi) = xy_aabb(i); + for a in 0..2 { + min[a] = min[a].min(lo[a]); + max[a] = max[a].max(hi[a]); + } + } + let (w, h) = ((max[0] - min[0]).max(0.0), (max[1] - min[1]).max(0.0)); + if !w.is_finite() || !h.is_finite() || (w <= 0.0 && h <= 0.0) { + return None; + } + // Aim for ~one triangle per cell, then clamp both ways: never finer than MIN_CELL, never + // more than MAX_CELLS cells. + let target_cells = count.clamp(1, MAX_CELLS) as f32; + let area = (w * h).max(f32::MIN_POSITIVE); + let mut cell = (area / target_cells).sqrt().max(MIN_CELL); + let (mut nx, mut ny) = dims(w, h, cell); + while nx as usize * ny as usize > MAX_CELLS { + cell *= 2.0; + (nx, ny) = dims(w, h, cell); + } + let inv_cell = 1.0 / cell; + let cells = nx as usize * ny as usize; + + // Pass 1 — count per cell, and set the oversized triangles aside. + let mut counts = vec![0u32; cells + 1]; + let mut spanning = Vec::new(); + let span_of = |i: usize| -> Option<(u32, u32, u32, u32)> { + let (lo, hi) = xy_aabb(i); + let x0 = cell_of(lo[0], min[0], inv_cell, nx); + let x1 = cell_of(hi[0], min[0], inv_cell, nx); + let y0 = cell_of(lo[1], min[1], inv_cell, ny); + let y1 = cell_of(hi[1], min[1], inv_cell, ny); + let touched = (x1 - x0 + 1) * (y1 - y0 + 1); + (touched <= MAX_SPAN_CELLS).then_some((x0, x1, y0, y1)) + }; + for i in 0..count { + match span_of(i) { + Some((x0, x1, y0, y1)) => { + for y in y0..=y1 { + for x in x0..=x1 { + counts[(y * nx + x) as usize] += 1; + } + } + } + None => spanning.push(i as u32), + } + } + // Prefix sum → CSR starts. + let mut starts = vec![0u32; cells + 1]; + let mut acc = 0u32; + for c in 0..cells { + starts[c] = acc; + acc += counts[c]; + } + starts[cells] = acc; + + // Pass 2 — fill. Ascending `i` fills each cell in ascending triangle order, which is the + // order guarantee the tie-breaks downstream rest on. + let mut items = vec![0u32; acc as usize]; + let mut cursor = starts.clone(); + for i in 0..count { + if let Some((x0, x1, y0, y1)) = span_of(i) { + for y in y0..=y1 { + for x in x0..=x1 { + let c = (y * nx + x) as usize; + items[cursor[c] as usize] = i as u32; + cursor[c] += 1; + } + } + } + } + Some(Self { + min, + inv_cell, + nx, + ny, + starts, + items, + spanning, + }) + } + + /// The triangles that can own the column at `(x, y)` — a superset, in ascending index order. + pub fn candidates(&self, x: f32, y: f32) -> ColumnCandidates<'_> { + let cx = cell_of(x, self.min[0], self.inv_cell, self.nx); + let cy = cell_of(y, self.min[1], self.inv_cell, self.ny); + let c = (cy * self.nx + cx) as usize; + let (lo, hi) = (self.starts[c] as usize, self.starts[c + 1] as usize); + ColumnCandidates { + binned: &self.items[lo..hi], + spanning: &self.spanning, + } + } + + /// Index size in triangle slots — the memory the acceleration costs, for the load-time log. + pub fn slots(&self) -> usize { + self.items.len() + self.spanning.len() + } +} + +/// Ascending merge of one cell's triangles with the always-tested oversized ones. Both inputs are +/// ascending, so this is a two-pointer walk and the output order matches a linear scan exactly. +pub struct ColumnCandidates<'a> { + binned: &'a [u32], + spanning: &'a [u32], +} + +impl Iterator for ColumnCandidates<'_> { + type Item = usize; + + fn next(&mut self) -> Option { + let take_binned = match (self.binned.first(), self.spanning.first()) { + (Some(b), Some(s)) => b <= s, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => return None, + }; + let src = if take_binned { + &mut self.binned + } else { + &mut self.spanning + }; + let (first, rest) = src.split_first()?; + *src = rest; + Some(*first as usize) + } +} + +/// Grid dimensions for an extent at a cell size (at least one cell each way). +fn dims(w: f32, h: f32, cell: f32) -> (u32, u32) { + let n = |extent: f32| ((extent / cell).ceil() as u32).max(1); + (n(w), n(h)) +} + +/// Cell coordinate of a world value, clamped into the grid — a column outside the face set's +/// bounds lands in the edge cell rather than missing, which keeps the candidate set a superset +/// under float error at the boundary. +fn cell_of(v: f32, min: f32, inv_cell: f32, n: u32) -> u32 { + let i = ((v - min) * inv_cell).floor(); + if i < 0.0 { + 0 + } else { + (i as u32).min(n - 1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A triangle's XY AABB, the shape the callers hand `build`. + fn aabb(tri: [[f32; 3]; 3]) -> ([f32; 2], [f32; 2]) { + let xs = [tri[0][0], tri[1][0], tri[2][0]]; + let ys = [tri[0][1], tri[1][1], tri[2][1]]; + ( + [ + xs.iter().copied().fold(f32::MAX, f32::min), + ys.iter().copied().fold(f32::MAX, f32::min), + ], + [ + xs.iter().copied().fold(f32::MIN, f32::max), + ys.iter().copied().fold(f32::MIN, f32::max), + ], + ) + } + + /// A deterministic pseudo-random field of small triangles plus a few slabs — the dungeon shape + /// (many small faces, a handful of huge floors) the index has to survive. + fn field(n: usize) -> Vec<[[f32; 3]; 3]> { + let mut out = Vec::with_capacity(n); + let mut s = 12345u32; + let mut rnd = || { + s = s.wrapping_mul(1664525).wrapping_add(1013904223); + (s >> 8) as f32 / (1 << 24) as f32 + }; + for i in 0..n { + let (x, y) = (rnd() * 200.0 - 100.0, rnd() * 200.0 - 100.0); + if i % 97 == 0 { + // A slab spanning a large area — the MAX_SPAN_CELLS path. + out.push([[x, y, 0.0], [x + 80.0, y, 0.0], [x, y + 80.0, 0.0]]); + } else { + let (dx, dy) = (rnd() * 3.0, rnd() * 3.0); + out.push([[x, y, 0.0], [x + dx, y, 0.0], [x, y + dy, 0.0]]); + } + } + out + } + + /// The load-bearing property: for any column, the index's candidates are a SUPERSET of the + /// triangles a linear scan would find containing it — an index that can miss a face silently + /// moves a unit's room, its light, and the zone name. + #[test] + fn candidates_are_a_superset_of_the_linear_scan() { + let tris = field(4000); + let grid = ColumnGrid::build(tris.len(), |i| aabb(tris[i])).expect("field indexes"); + let inside = |t: &[[f32; 3]; 3], x: f32, y: f32| { + let (lo, hi) = aabb(*t); + x >= lo[0] && x <= hi[0] && y >= lo[1] && y <= hi[1] + }; + let mut probes = 0; + for gx in -12..=12 { + for gy in -12..=12 { + let (x, y) = (gx as f32 * 9.7, gy as f32 * 9.3); + let got: Vec = grid.candidates(x, y).collect(); + for (i, t) in tris.iter().enumerate() { + if inside(t, x, y) { + assert!( + got.contains(&i), + "column ({x}, {y}) missed triangle {i} — the index is not a superset" + ); + } + } + probes += 1; + } + } + assert!(probes > 500, "the sweep must actually probe"); + } + + /// The other half of "never changes a verdict": candidates arrive in the order a linear scan + /// visits them, so first-wins / later-wins tie-breaks downstream are unaffected. + #[test] + fn candidates_are_ascending() { + let tris = field(2000); + let grid = ColumnGrid::build(tris.len(), |i| aabb(tris[i])).expect("field indexes"); + for gx in -8..=8 { + for gy in -8..=8 { + let got: Vec = grid + .candidates(gx as f32 * 12.0, gy as f32 * 12.0) + .collect(); + assert!( + got.windows(2).all(|w| w[0] < w[1]), + "candidates must be strictly ascending, got {got:?}" + ); + } + } + } + + /// A column far outside the face set still returns a valid (edge-cell) candidate list rather + /// than panicking or indexing out of range. + #[test] + fn columns_outside_the_bounds_are_safe() { + let tris = field(200); + let grid = ColumnGrid::build(tris.len(), |i| aabb(tris[i])).expect("field indexes"); + for (x, y) in [(-1e6, -1e6), (1e6, 1e6), (0.0, 1e6), (f32::MIN, f32::MAX)] { + let _: Vec = grid.candidates(x, y).collect(); + } + } + + /// Small face sets opt out — the caller keeps its linear scan rather than paying indirection. + #[test] + fn tiny_face_sets_are_not_indexed() { + let tris = field(8); + assert!(ColumnGrid::build(tris.len(), |i| aabb(tris[i])).is_none()); + } +} diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index d4f450f2..9326e714 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -20,13 +20,15 @@ use bevy::asset::io::{ use bevy::asset::AssetApp; use bevy::prelude::*; +pub mod column_grid; pub mod coords; pub mod minimap_grid; mod model; pub use model::{ bone_target_id, AnimClip, BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations, - ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, + ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip, + PoseNode, PoseSource, PoseTrack, }; mod adt; mod terrain; @@ -40,8 +42,8 @@ pub use m2::{M2Model, M2ModelLoader, ModelEmitter, ModelLight, ModelRibbon, Port mod wmo; pub use benilla_formats::{WmoPortalInfo, WmoPortalRef}; pub use wmo::{ - cap96, collision_tri_bounds, floor168, footprint_tri_bounds, DoodadBase, WmoGroupNav, WmoModel, - WmoModelLoader, + cap96, collision_tri_bounds, collision_tri_grids, floor168, footprint_tri_bounds, + footprint_tri_grids, DoodadBase, WmoGroupNav, WmoModel, WmoModelLoader, }; /// The asset-source id for MPQ-backed assets: load paths look like `mpq://World/Azeroth/foo.adt`. diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index b6d241a1..95d98fa9 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -402,6 +402,13 @@ impl AssetLoader for M2ModelLoader { let animations = { let mut graph = AnimationGraph::new(); let root = graph.root; + // The direct pose evaluator's bake (decision 0712) — filled beside every graph + // mutation below so the table mirrors the graph by construction: `bone_masks` beside + // each `add_target_to_mask_group`, `set_node` beside each `add_clip*`. + let mut pose = crate::model::PoseSource { + bone_masks: vec![0u64; skeleton_raw.bones.len()], + ..Default::default() + }; // Per-arm mask groups for the client's per-slot one-shots (the draw/stow plays each // hand's ceremony on its own arm subtree, over the gait). Each arm's subtree root is the // **shoulder-keybone ancestor of the hand attachment's bone** (attachment id 1 = right @@ -414,9 +421,11 @@ impl AssetLoader for M2ModelLoader { let target = bone_target_id(i as u16); if !in_subtree(&skeleton_raw, i, right_root) { graph.add_target_to_mask_group(target, 0); + pose.bone_masks[i] |= 1 << 0; } if !in_subtree(&skeleton_raw, i, left_root) { graph.add_target_to_mask_group(target, 1); + pose.bone_masks[i] |= 1 << 1; } } } @@ -428,6 +437,7 @@ impl AssetLoader for M2ModelLoader { for i in 0..skeleton_raw.bones.len() { if !in_subtree(&skeleton_raw, i, upper_root) { graph.add_target_to_mask_group(bone_target_id(i as u16), 2); + pose.bone_masks[i] |= 1 << 2; } } } @@ -444,6 +454,7 @@ impl AssetLoader for M2ModelLoader { .any(|&r| in_subtree(&skeleton_raw, i, r)) { graph.add_target_to_mask_group(target, 3); + pose.bone_masks[i] |= 1 << 3; } if !finger_roots[1].is_empty() && !finger_roots[1] @@ -451,6 +462,7 @@ impl AssetLoader for M2ModelLoader { .any(|&r| in_subtree(&skeleton_raw, i, r)) { graph.add_target_to_mask_group(target, 4); + pose.bone_masks[i] |= 1 << 4; } } // The model's baked PlayableAnimationLookup (decision 0082): parsed straight off the M2 @@ -477,26 +489,35 @@ impl AssetLoader for M2ModelLoader { .map_or(0, |p: &benilla_formats::PlayableAnim| p.resolved_id); let mut first_seq = None; for (i, anim) in sequences.iter().enumerate() { - if let Some(clip) = build_animation_clip(anim, &skeleton) { + if let Some((clip, pose_clip)) = build_animation_clip(anim, &skeleton) { if first_seq.is_none() && anim.anim_id == idle_id && idle_pose_differs(anim) { first_seq = Some(clips.len()); } + let pose_idx = pose.clips.len() as u32; + pose.clips.push(pose_clip); let clip = ctx.add_labeled_asset(format!("clip{i}"), clip); let node = graph.add_clip(clip.clone(), 1.0, root); + pose.set_node(node, pose_idx, 0); // The sheath family gets per-arm masked variants ([`AnimClip::arm_nodes`]). let arm_nodes = (matches!(anim.anim_id, 89 | 90) && arm_roots.is_some()).then(|| { - ( + let nodes = ( graph.add_clip_with_mask(clip.clone(), 1 << 0, 1.0, root), graph.add_clip_with_mask(clip.clone(), 1 << 1, 1.0, root), - ) + ); + pose.set_node(nodes.0, pose_idx, 1 << 0); + pose.set_node(nodes.1, pose_idx, 1 << 1); + nodes }); // The upper-body masked variant ([`AnimClip::upper_node`]): the masked destination // for a one-shot routed over a live base (swing/emote — the route is chosen per play // in `creature_anim`). Built for every clip when the model has a split key-bone, so // any one-shot id can take the masked route; `None` on the −1-sentinel models. - let upper_node = upper_root - .map(|_| graph.add_clip_with_mask(clip.clone(), 1 << 2, 1.0, root)); + let upper_node = upper_root.map(|_| { + let n = graph.add_clip_with_mask(clip.clone(), 1 << 2, 1.0, root); + pose.set_node(n, pose_idx, 1 << 2); + n + }); clips.push(AnimClip { anim_id: anim.anim_id, seq_index: anim.seq_index, @@ -540,13 +561,19 @@ impl AssetLoader for M2ModelLoader { .collect(); let grip_poses = hand_grip_finger_poses(&bytes, &finger_bones); if !grip_poses.is_empty() { - let grip = - ctx.add_labeled_asset("grip_clip".to_string(), build_grip_clip(&grip_poses)); + let (grip_clip, grip_pose) = build_grip_clip(&grip_poses); + let grip_idx = pose.clips.len() as u32; + pose.clips.push(grip_pose); + let grip = ctx.add_labeled_asset("grip_clip".to_string(), grip_clip); if !finger_roots[0].is_empty() { - hand_close[0] = Some(graph.add_clip_with_mask(grip.clone(), 1 << 3, 1.0, root)); + let n = graph.add_clip_with_mask(grip.clone(), 1 << 3, 1.0, root); + pose.set_node(n, grip_idx, 1 << 3); + hand_close[0] = Some(n); } if !finger_roots[1].is_empty() { - hand_close[1] = Some(graph.add_clip_with_mask(grip.clone(), 1 << 4, 1.0, root)); + let n = graph.add_clip_with_mask(grip.clone(), 1 << 4, 1.0, root); + pose.set_node(n, grip_idx, 1 << 4); + hand_close[1] = Some(n); } } // Global-sequence channels alone are enough to carry `ModelAnimations`: a model whose @@ -565,6 +592,7 @@ impl AssetLoader for M2ModelLoader { hand_close, global_bones, first_seq, + pose: std::sync::Arc::new(pose), } }) }; diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index cc1cf315..a3516ab4 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -18,11 +18,13 @@ use bevy::prelude::*; use crate::coords::wow_to_bevy; mod anims; +mod pose; // `PlayableAnim`/`ResolvedAnim` aren't re-exported further by `lib.rs` (nothing outside this crate // names them yet), so rustc can't see this re-export escape — allow silences the resulting // unused-import false positive on an otherwise-live facade re-export. #[allow(unused_imports)] pub use anims::{AnimClip, ModelAnimations, PlayableAnim, ResolvedAnim}; +pub use pose::{PoseBone, PoseClip, PoseNode, PoseSource, PoseTrack}; /// A billboarded submesh's render data (Bevy space): the bone pivot the card rotates about (model-local /// — compose with the instance transform), the card's resting plane normal, and the billboard kind. @@ -463,8 +465,9 @@ fn keyframe_curve( } } -/// Build one sequence's [`AnimationClip`] from parsed raw-WoW keyframes (decision 0019), transforming -/// each channel into the joints' Bevy-space `Transform`: +/// Build one sequence's [`AnimationClip`] — **and its [`PoseClip`] twin** (decision 0712, one walk +/// so the two cannot drift) — from parsed raw-WoW keyframes (decision 0019), transforming each +/// channel into the joints' Bevy-space `Transform`: /// - **translation** = the bone's rest local (`pivot − pivot_parent`) **plus** `wow_to_bevy(track)` — /// the M2 translation track is a delta on the pivot offset; /// - **rotation** = the WoW quat conjugated into Bevy space (`r·q·r⁻¹`); @@ -474,9 +477,10 @@ fn keyframe_curve( pub(crate) fn build_animation_clip( anim: &ModelAnimation, skeleton: &ModelSkeleton, -) -> Option { +) -> Option<(AnimationClip, PoseClip)> { let r = wow_to_bevy_quat(); let mut clip = AnimationClip::default(); + let mut pose = PoseClip::default(); let mut any = false; for bk in &anim.bones { let target = bone_target_id(bk.bone); @@ -489,6 +493,7 @@ pub(crate) fn build_animation_clip( .iter() .map(|(t, v)| (*t, rest + wow_to_bevy(*v))) .collect(); + let pose_trans = PoseTrack::new(&trans); if let Some(c) = keyframe_curve(anim.duration, trans) { clip.add_curve_to_target( target, @@ -506,6 +511,7 @@ pub(crate) fn build_animation_clip( ) }) .collect(); + let pose_rot = PoseTrack::new(&rot); if let Some(c) = keyframe_curve(anim.duration, rot) { clip.add_curve_to_target( target, @@ -518,6 +524,7 @@ pub(crate) fn build_animation_clip( .iter() .map(|(t, s)| (*t, Vec3::new(s[1], s[2], s[0]))) .collect(); + let pose_scale = PoseTrack::new(&scale); if let Some(c) = keyframe_curve(anim.duration, scale) { clip.add_curve_to_target( target, @@ -525,8 +532,14 @@ pub(crate) fn build_animation_clip( ); any = true; } + pose.push(PoseBone { + bone: bk.bone, + translation: pose_trans, + rotation: pose_rot, + scale: pose_scale, + }); } - any.then_some(clip) + any.then_some((clip, pose)) } /// Build the weapon-grip **overlay clip** from clamped finger poses ([`benilla_formats::hand_grip_finger_poses`]): @@ -534,9 +547,10 @@ pub(crate) fn build_animation_clip( /// as [`build_animation_clip`]. Played masked to one hand's finger subtrees, it holds those fingers curled /// over the gait (rotation only — the fingers don't translate). Empty `poses` → an empty clip (the caller /// builds no grip node). -pub(crate) fn build_grip_clip(poses: &[(u16, [f32; 4])]) -> AnimationClip { +pub(crate) fn build_grip_clip(poses: &[(u16, [f32; 4])]) -> (AnimationClip, PoseClip) { let r = wow_to_bevy_quat(); let mut clip = AnimationClip::default(); + let mut pose = PoseClip::default(); for &(bone, q) in poses { let rot = r * Quat::from_xyzw(q[0], q[1], q[2], q[3]) * r.inverse(); if let Some(c) = keyframe_curve(0.033, vec![(0.0, rot)]) { @@ -544,9 +558,15 @@ pub(crate) fn build_grip_clip(poses: &[(u16, [f32; 4])]) -> AnimationClip { bone_target_id(bone), AnimatableCurve::new(animated_field!(Transform::rotation), c), ); + pose.push(PoseBone { + bone, + translation: PoseTrack::default(), + rotation: PoseTrack::new(&[(0.0, rot)]), + scale: PoseTrack::default(), + }); } } - clip + (clip, pose) } /// The [`BillboardInfo`] for a submesh that rides a billboard bone (Bevy space): the pivot to rotate diff --git a/crates/benilla-assets/src/model/anims.rs b/crates/benilla-assets/src/model/anims.rs index 81cd2899..75fbad1f 100644 --- a/crates/benilla-assets/src/model/anims.rs +++ b/crates/benilla-assets/src/model/anims.rs @@ -121,6 +121,11 @@ pub struct ModelAnimations { /// content gate, decision 0130; the ~90% of placed doodads `doodadscan` measured). A *constant /// but non-rest* pose does NOT qualify for that skip — see `m2::idle_pose_differs`. pub first_seq: Option, + /// The direct pose evaluator's baked data (decision 0712): the pose twin of every clip above, + /// the node → (clip, mask) table, and the per-bone mask bits — filled by the same code that + /// builds [`Self::graph`], so it mirrors the graph by construction. `Arc`: the component is + /// cloned per instance; the bake is shared. + pub pose: std::sync::Arc, } /// A requested `AnimationData.dbc` id resolved to what a specific model actually plays (decision @@ -286,6 +291,7 @@ mod tests { hand_close: [None, None], global_bones: Vec::new(), first_seq: None, + pose: Default::default(), } } diff --git a/crates/benilla-assets/src/model/pose.rs b/crates/benilla-assets/src/model/pose.rs new file mode 100644 index 00000000..ea64c736 --- /dev/null +++ b/crates/benilla-assets/src/model/pose.rs @@ -0,0 +1,269 @@ +//! The pose source — the direct M2 pose evaluator's baked data (decision 0712). +//! +//! Bevy's `animate_targets` prices every bone at graph-walk + hash-lookup + boxed-curve rates +//! (~1.9 µs/bone — 0711's second lane). These types hold the *same* Bevy-space keyframes as the +//! `AnimationClip`s — emitted by the same bake walk, so they cannot drift — in the flat shape a +//! direct sampler wants: per clip, bone-sorted channel tracks; per graph node, the (clip, mask) +//! pair; per bone, its mask-group bits. The runtime evaluator (`benilla::creature_anim::pose`) +//! folds these with Bevy's own `Animatable::interpolate`, reproducing `animate_targets`' output +//! exactly (goldens there assert it bone-for-bone). + +use bevy::animation::animatable::Animatable; +use bevy::animation::graph::AnimationNodeIndex; +use bevy::prelude::*; + +/// One channel's keyframes: times ascending and strictly deduplicated — the exact normalization +/// `UnevenCore::new` applies to the twin `AnimatableKeyframeCurve` (stable sort by time, dedup +/// keeping the FIRST sample at each time), so [`Self::sample`] and `sample_clamped` agree +/// key-for-key. Empty ⇒ the channel is absent (the clip never keys it — Bevy leaves the joint +/// component untouched, and so does the evaluator). +#[derive(Clone)] +pub struct PoseTrack { + times: Vec, + values: Vec, +} + +// Manual: the derive would bound `T: Default`, which `Quat` channels don't need — an empty track +// has no values of `T` at all. +impl Default for PoseTrack { + fn default() -> Self { + Self { + times: Vec::new(), + values: Vec::new(), + } + } +} + +impl PoseTrack { + /// Bake a channel from the clip walk's key list, mirroring `keyframe_curve`'s presence rules + /// exactly: no keys ⇒ absent; one key ⇒ a constant (Bevy builds a flat 2-key curve — same + /// sampled value everywhere); ≥2 keys ⇒ `UnevenCore`'s sort + dedup, and absent if fewer than + /// 2 distinct times remain (Bevy's constructor errors there and the channel is dropped). + pub fn new(keys: &[(f32, T)]) -> Self { + match keys.len() { + 0 => Self::default(), + 1 => Self { + times: vec![keys[0].0], + values: vec![keys[0].1.clone()], + }, + _ => { + let mut sorted: Vec<(f32, T)> = keys.to_vec(); + sorted.sort_by(|(t0, _), (t1, _)| t0.total_cmp(t1)); + sorted.dedup_by_key(|(t, _)| *t); + if sorted.len() < 2 { + return Self::default(); + } + let (times, values) = sorted.into_iter().unzip(); + Self { times, values } + } + } + } + + /// Sample at `t`, clamped into the key span — `uneven_interp`'s law verbatim (binary search; + /// exact hit returns that key; before/after the span clamps to the end keys; between keys + /// interpolates with `Animatable::interpolate` — `Vec3::lerp` / `Quat::slerp`, the same calls + /// the Bevy curve makes). `None` iff the channel is absent. + #[inline] + pub fn sample(&self, t: f32) -> Option { + let times = &self.times; + match times.len() { + 0 => None, + 1 => Some(self.values[0].clone()), + _ => Some( + match times.binary_search_by(|pt| pt.partial_cmp(&t).unwrap()) { + Ok(i) => self.values[i].clone(), + Err(0) => self.values[0].clone(), + Err(i) if i >= times.len() => self.values[times.len() - 1].clone(), + Err(i) => { + let s = (t - times[i - 1]) / (times[i] - times[i - 1]); + T::interpolate(&self.values[i - 1], &self.values[i], s) + } + }, + ), + } + } + + pub fn is_empty(&self) -> bool { + self.times.is_empty() + } +} + +/// One bone's channels in one clip — only bones with at least one present channel appear. +#[derive(Clone)] +pub struct PoseBone { + pub bone: u16, + pub translation: PoseTrack, + pub rotation: PoseTrack, + pub scale: PoseTrack, +} + +/// One clip's keyed bones, sorted ascending by bone index (the evaluator's merge walk relies on +/// it). The same channels, values, and presence as the twin `AnimationClip`. +#[derive(Clone, Default)] +pub struct PoseClip { + pub bones: Vec, +} + +impl PoseClip { + /// Push a bone's channels, keeping [`Self::bones`] sorted (the bake walks file bone order, + /// which is ascending for every retail M2 — the insertion sort is a no-op there and a + /// correctness backstop elsewhere). Bones with no present channel are skipped. + pub fn push(&mut self, bone: PoseBone) { + if bone.translation.is_empty() && bone.rotation.is_empty() && bone.scale.is_empty() { + return; + } + let at = self.bones.partition_point(|b| b.bone <= bone.bone); + self.bones.insert(at, bone); + } +} + +/// One graph node the evaluator understands: which [`PoseClip`] it plays and its mask bits (the +/// `add_clip_with_mask` argument; a node whose mask bit intersects a bone's mask groups skips +/// that bone — Bevy's `computed_masks` law with a flat graph, where ancestors contribute 0). +/// Node weights are all 1.0 by construction (`add_clip(_, 1.0, root)`), so none is stored. +#[derive(Clone, Copy)] +pub struct PoseNode { + pub clip: u32, + pub mask: u64, +} + +/// A model's baked pose source (decision 0712): everything the runtime evaluator needs, filled by +/// the same code that builds the `AnimationGraph` so the two cannot drift. +#[derive(Clone, Default)] +pub struct PoseSource { + /// The pose twin of each built clip (sequence clips in build order, then the grip clip). + pub clips: Vec, + /// Dense by `AnimationNodeIndex::index()`; `None` for the root/unused slots. + pub nodes: Vec>, + /// Per bone: the mask-group bits it belongs to (the `add_target_to_mask_group` calls). + pub bone_masks: Vec, +} + +impl PoseSource { + /// Record `node` as playing `clip` under `mask` — called right after the corresponding + /// `graph.add_clip*` so the table mirrors the graph by construction. + pub fn set_node(&mut self, node: AnimationNodeIndex, clip: u32, mask: u64) { + let i = node.index(); + if self.nodes.len() <= i { + self.nodes.resize(i + 1, None); + } + self.nodes[i] = Some(PoseNode { clip, mask }); + } + + /// The [`PoseNode`] for a graph node, if the evaluator knows it. + #[inline] + pub fn node(&self, node: AnimationNodeIndex) -> Option { + self.nodes.get(node.index()).copied().flatten() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy::animation::animation_curves::AnimatableKeyframeCurve; + use bevy::math::curve::Curve; + + /// The channel golden (decision 0712): [`PoseTrack::sample`] must equal + /// `AnimatableKeyframeCurve::sample_clamped` — the exact curve the twin `AnimationClip` + /// carries — over a dense time sweep spanning before-first, between-keys, exact-hit, and + /// past-last, for both Vec3 (lerp) and Quat (slerp) channels. + #[test] + fn track_matches_the_bevy_keyframe_curve() { + let vkeys = [ + (0.1, Vec3::new(1.0, 2.0, 3.0)), + (0.35, Vec3::new(-2.0, 0.5, 4.0)), + (0.4, Vec3::new(0.0, 1.0, 0.0)), + (1.2, Vec3::new(5.0, -1.0, 2.0)), + ]; + let track = PoseTrack::new(&vkeys); + let curve = AnimatableKeyframeCurve::new(vkeys).unwrap(); + for i in 0..=1400 { + let t = -0.1 + i as f32 * 0.001; + assert_eq!( + track.sample(t).unwrap(), + curve.sample_clamped(t), + "Vec3 diverges at t={t}" + ); + } + + let qkeys = [ + (0.0, Quat::from_rotation_y(0.3)), + (0.25, Quat::from_rotation_x(1.4)), + // A >90° step, exercising slerp's shortest-path sign handling. + (0.6, Quat::from_axis_angle(Vec3::new(0.6, 0.8, 0.0), 2.9)), + ]; + let track = PoseTrack::new(&qkeys); + let curve = AnimatableKeyframeCurve::new(qkeys).unwrap(); + for i in 0..=800 { + let t = -0.05 + i as f32 * 0.001; + assert_eq!( + track.sample(t).unwrap(), + curve.sample_clamped(t), + "Quat diverges at t={t}" + ); + } + } + + /// The presence rules mirror `keyframe_curve`: a single key is a constant everywhere (Bevy's + /// flat 2-key curve), duplicate-time keys dedup keeping the FIRST (`UnevenCore::new`), and a + /// list that dedups below 2 keys is absent (Bevy's constructor errors → the channel drops). + #[test] + fn presence_and_dedup_mirror_the_curve_constructor() { + assert!(PoseTrack::::new(&[]).sample(0.5).is_none()); + + let single = PoseTrack::new(&[(0.7, Vec3::X)]); + for t in [-1.0, 0.0, 0.7, 3.0] { + assert_eq!(single.sample(t), Some(Vec3::X)); + } + + let dup = PoseTrack::new(&[ + (0.0, Vec3::X), + (0.5, Vec3::Y), + (0.5, Vec3::Z), + (1.0, Vec3::X), + ]); + let curve = AnimatableKeyframeCurve::new([ + (0.0, Vec3::X), + (0.5, Vec3::Y), + (0.5, Vec3::Z), + (1.0, Vec3::X), + ]) + .unwrap(); + for i in 0..=1000 { + let t = i as f32 * 0.001; + assert_eq!(dup.sample(t).unwrap(), curve.sample_clamped(t), "t={t}"); + } + + // Two keys at one time: Bevy's constructor would fail — the channel must be absent. + assert!(PoseTrack::new(&[(0.5, Vec3::X), (0.5, Vec3::Y)]) + .sample(0.5) + .is_none()); + } + + /// `PoseClip::push` keeps bones ascending and drops all-absent bones. + #[test] + fn clip_bones_stay_sorted() { + let bone = |i: u16| PoseBone { + bone: i, + translation: PoseTrack::new(&[(0.0, Vec3::X)]), + rotation: PoseTrack::default(), + scale: PoseTrack::default(), + }; + let mut clip = PoseClip::default(); + for i in [3u16, 1, 2, 7, 0] { + clip.push(bone(i)); + } + clip.push(PoseBone { + bone: 5, + translation: PoseTrack::default(), + rotation: PoseTrack::default(), + scale: PoseTrack::default(), + }); + let order: Vec = clip.bones.iter().map(|b| b.bone).collect(); + assert_eq!( + order, + vec![0, 1, 2, 3, 7], + "sorted; the empty bone 5 dropped" + ); + } +} diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index eb0b7fa1..d5b4ab2a 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -9,6 +9,7 @@ //! Same app-independent shape as the M2 loader (meshes + texture handles + metadata; materials at //! spawn). WMOs don't size-fade, so there are no bounds. +use crate::column_grid::ColumnGrid; use std::sync::Arc; use benilla_formats::{ @@ -90,6 +91,12 @@ pub struct WmoModel { /// floats 1.5 yd above its own floor polys — the 2026-07-12 indoor flap), so culling on them /// is exact: a group is skipped only when no face of it could own the probe column. pub group_collision_bounds: Vec>, + /// Per-group column index over [`Self::group_collision_tris`] (parallel; `None` = a face set + /// too small to be worth indexing, and the caller keeps its linear scan). The narrow phase the + /// per-group bounds above never had: a dungeon group holds ~11–16k faces, so bounds alone + /// still left every down-ray scanning tens of thousands of triangles per unit per frame + /// (LBRS, 2026-07-27). See [`crate::column_grid`]. + pub group_collision_grids: Vec>, /// The union of [`Self::group_collision_bounds`] — the whole-building broad phase: a probe /// column outside it cannot hit any collision face, so the per-frame indoor trackers skip the /// placed instance outright (a camera in open country pays one AABB test per building, not a @@ -154,6 +161,10 @@ pub struct WmoModel { /// entity scanned EVERY interior group of the whole model — at Stormwind (one WMO, all /// districts) that was the live-session frame (decision 0364). pub group_footprint_bounds: Vec>, + /// Per-group column index over [`Self::group_footprints`] (parallel; `None` = not indexed). + /// The footprint sample is the heavier of the two column rays — it walks render faces, not + /// collision ones. See [`crate::column_grid`]. + pub group_footprint_grids: Vec>, /// Per-group MOLR light refs (indices into [`Self::lights`]), indexed by absolute group index — /// the point lights that fold into an interior GameObject's committed light are gated by the /// footprint-hit group's list, same as a MODD prop's by its owning group. @@ -219,6 +230,53 @@ pub fn footprint_tri_bounds( .collect() } +/// Per-group column indexes over the footprint faces — [`WmoModel::group_footprint_grids`]. +/// Shared with the fixture builders for the same reason the bounds are: a test model and a loaded +/// one must be indexed by identical code, or an exactness test proves nothing about the real path. +pub fn footprint_tri_grids(footprints: &[Option]) -> Vec> { + footprints + .iter() + .map(|fp| { + let fp = fp.as_ref()?; + let tris: Vec<&[u16]> = fp.indices.chunks_exact(3).collect(); + ColumnGrid::build(tris.len(), |i| { + let mut lo = [f32::MAX; 2]; + let mut hi = [f32::MIN; 2]; + for &vi in tris[i] { + if let Some(v) = fp.positions.get(vi as usize) { + for a in 0..2 { + lo[a] = lo[a].min(v[a]); + hi[a] = hi[a].max(v[a]); + } + } + } + (lo, hi) + }) + }) + .collect() +} + +/// Per-group column indexes over a per-group triangle set — [`WmoModel::group_collision_grids`]. +pub fn collision_tri_grids(tris: &[Vec<[[f32; 3]; 3]>]) -> Vec> { + tris.iter() + .map(|group| { + ColumnGrid::build(group.len(), |i| { + let t = &group[i]; + ( + [ + t[0][0].min(t[1][0]).min(t[2][0]), + t[0][1].min(t[1][1]).min(t[2][1]), + ], + [ + t[0][0].max(t[1][0]).max(t[2][0]), + t[0][1].max(t[1][1]).max(t[2][1]), + ], + ) + }) + }) + .collect() +} + /// The per-group AABBs of a per-group triangle set + their union — [`WmoModel::group_collision_bounds`] /// and [`WmoModel::collision_bounds`], derived from the faces at load (shared with the down-ray tests, /// so fixtures and the loader can never disagree on what the bounds mean). @@ -596,6 +654,8 @@ impl AssetLoader for WmoModelLoader { let (group_collision_bounds, collision_bounds) = collision_tri_bounds(&group_collision_tris); let group_footprint_bounds = footprint_tri_bounds(&group_footprints); + let group_collision_grids = collision_tri_grids(&group_collision_tris); + let group_footprint_grids = footprint_tri_grids(&group_footprints); Ok(WmoModel { wmo_id: benilla_formats::wmo_root_id(&bytes), submeshes, @@ -608,6 +668,7 @@ impl AssetLoader for WmoModelLoader { group_collision_tris, group_camera_only_tris, group_collision_bounds, + group_collision_grids, collision_bounds, collision, collision_camera, @@ -620,6 +681,7 @@ impl AssetLoader for WmoModelLoader { doodad_groups, group_footprints, group_footprint_bounds, + group_footprint_grids, group_light_refs, group_liquids, }) diff --git a/crates/benilla-formats/examples/eye_quad_pass.rs b/crates/benilla-formats/examples/eye_quad_pass.rs new file mode 100644 index 00000000..21d09a8a --- /dev/null +++ b/crates/benilla-formats/examples/eye_quad_pass.rs @@ -0,0 +1,201 @@ +//! **The flush-emitter depth contest, computed offline** — what fraction of a particle emitter's +//! constant-depth billboard survives `LEQUAL` against its own model's z-writing batches, from a +//! given camera direction. No client, no camera, no capture: pure geometry over *our* parse. +//! +//! This exists to hold benilla's asset side against wow-re's measured table +//! (`system/models/scratch/part-flush-emitter-depth.md` §4 — Voidwalker bone 60, half 0.0833: +//! front 43.4 %, 30° above 38.8 %, 60° left 43.3 %). Agreement means our mesh, our bone pivots and +//! our quad model are faithful and any missing glow is the *renderer's*; disagreement localises the +//! bug to the asset side, where it can be fixed without ever launching the game. +//! +//! `cargo run -p benilla-formats --example eye_quad_pass -- 'Creature\Voidwalker\Voidwalker.m2'` +//! +//! Model space, rest pose, WoW axes (X forward, Y left, Z up) — the emitter record `position` is +//! the quad centre (it is bit-identical to its bone's pivot, and the palette is identity at rest). +//! The occluder is every batch that is **not** `no_depth_write`, exactly the set the depth buffer +//! holds when the particle pass runs. + +use benilla_formats::RenderSubmesh; + +/// Möller–Trumbore. Returns the ray parameter `t` of the hit, if the ray meets the triangle ahead +/// of its origin. +fn ray_tri(orig: [f32; 3], dir: [f32; 3], a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> Option { + let sub = |p: [f32; 3], q: [f32; 3]| [p[0] - q[0], p[1] - q[1], p[2] - q[2]]; + let cross = |p: [f32; 3], q: [f32; 3]| { + [ + p[1] * q[2] - p[2] * q[1], + p[2] * q[0] - p[0] * q[2], + p[0] * q[1] - p[1] * q[0], + ] + }; + let dot = |p: [f32; 3], q: [f32; 3]| p[0] * q[0] + p[1] * q[1] + p[2] * q[2]; + + let (e1, e2) = (sub(b, a), sub(c, a)); + let h = cross(dir, e2); + let det = dot(e1, h); + if det.abs() < 1e-9 { + return None; // ray parallel to the triangle plane + } + // Two-sided on purpose: the shell's inward-facing triangles occlude too — the depth buffer + // holds whatever rasterized, and these batches are authored two-sided or seen from inside. + let inv = 1.0 / det; + let s = sub(orig, a); + let u = inv * dot(s, h); + if !(0.0..=1.0).contains(&u) { + return None; + } + let q = cross(s, e1); + let v = inv * dot(dir, q); + if v < 0.0 || u + v > 1.0 { + return None; + } + let t = inv * dot(e2, q); + (t > 1e-6).then_some(t) +} + +/// The camera-facing orthonormal pair for a view direction (the two screen axes the quad's corners +/// are offset along — the third axis carries the shared depth). +fn screen_axes(view: [f32; 3]) -> ([f32; 3], [f32; 3]) { + // `view` points from the camera toward the model. Any up-ish reference not parallel to it. + let up = if view[2].abs() > 0.9 { + [1.0, 0.0, 0.0] + } else { + [0.0, 0.0, 1.0] + }; + let cross = |p: [f32; 3], q: [f32; 3]| { + [ + p[1] * q[2] - p[2] * q[1], + p[2] * q[0] - p[0] * q[2], + p[0] * q[1] - p[1] * q[0], + ] + }; + let norm = |v: [f32; 3]| { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / l, v[1] / l, v[2] / l] + }; + let right = norm(cross(view, up)); + let real_up = norm(cross(right, view)); + (right, real_up) +} + +/// Fraction of the quad's area that passes `LEQUAL` — i.e. whose sample point is not occluded by +/// any z-writing triangle between it and the camera. Constant depth: every sample sits on the plane +/// through `center` perpendicular to `view`, so "occluded" is simply "a triangle lies in front". +fn pass_fraction( + tris: &[([f32; 3], [f32; 3], [f32; 3])], + center: [f32; 3], + half: f32, + view: [f32; 3], + n: usize, +) -> f32 { + let (right, up) = screen_axes(view); + // Cast *toward* the camera: anything hit is in front of the sample point. + let back = [-view[0], -view[1], -view[2]]; + let mut passed = 0usize; + let mut total = 0usize; + for iy in 0..n { + for ix in 0..n { + // Sample cell centres so the grid is unbiased at the quad's edges. + let fx = (ix as f32 + 0.5) / n as f32 * 2.0 - 1.0; + let fy = (iy as f32 + 0.5) / n as f32 * 2.0 - 1.0; + let p = [ + center[0] + right[0] * fx * half + up[0] * fy * half, + center[1] + right[1] * fx * half + up[1] * fy * half, + center[2] + right[2] * fx * half + up[2] * fy * half, + ]; + total += 1; + if !tris + .iter() + .any(|&(a, b, c)| ray_tri(p, back, a, b, c).is_some()) + { + passed += 1; + } + } + } + passed as f32 / total as f32 +} + +/// Every triangle of every batch that writes depth — the occluder set, in model space. +/// +/// `all` ignores the per-material `no_depth_write` bit, i.e. models a renderer that writes depth for +/// the whole model. That is the counterfactual worth pricing: it is what benilla's distance-fade +/// blend twin does (it forces depth-write ON regardless of the flag), so the two columns bracket +/// "did we leak the model's own transparent cards into the depth buffer?". +fn occluders(subs: &[RenderSubmesh], all: bool) -> Vec<([f32; 3], [f32; 3], [f32; 3])> { + let mut tris = Vec::new(); + for s in subs { + if !all && (s.no_depth_write || s.no_depth_test) { + continue; + } + for t in s.indices.chunks_exact(3) { + tris.push(( + s.positions[t[0] as usize], + s.positions[t[1] as usize], + s.positions[t[2] as usize], + )); + } + } + tris +} + +fn main() -> anyhow::Result<()> { + let virt = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: eye_quad_pass "))?; + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + let bytes = chain.read_file(&virt)?; + + let subs = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[])?; + let tris = occluders(&subs, false); + let tris_all = occluders(&subs, true); + let writing: Vec = subs + .iter() + .enumerate() + .filter(|(_, s)| !s.no_depth_write && !s.no_depth_test) + .map(|(i, _)| i) + .collect(); + println!( + "{} batches, {} z-writing ({:?}), {} occluding triangles", + subs.len(), + writing.len(), + writing, + tris.len() + ); + + // Camera directions, as (label, azimuth° about +Z from +X, elevation° above the horizontal). + // `view` points camera → model, so the camera sits at -view. + let views: &[(&str, f32, f32)] = &[ + ("front (+X)", 0.0, 0.0), + ("15 above", 0.0, 15.0), + ("30 above", 0.0, 30.0), + ("45 above", 0.0, 45.0), + ("60 above", 0.0, 60.0), + ("30 left", 30.0, 0.0), + ("60 left", 60.0, 0.0), + ("30 left + 15 above", 30.0, 15.0), + ("30 left + 30 above", 30.0, 30.0), + ]; + + for e in benilla_formats::parse_m2_particle_emitters(&bytes)?.iter() { + // Only the plane/quad emitters we can model this way, and only those actually mounted in + // the mesh — the point of the exercise. + println!( + "\nemitter bone {} pos ({:.4}, {:.4}, {:.4}) blend {:?}", + e.bone, e.position[0], e.position[1], e.position[2], e.blend + ); + for (set, occ) in [("flagged", &tris), ("ALL-write", &tris_all)] { + let half = 0.0833f32; + print!(" {set:9} half {half:.4}:"); + for &(label, az, el) in views { + let (a, l) = (az.to_radians(), el.to_radians()); + // Camera at azimuth `az`, elevation `el`, looking at the model: view = -(camera dir) + let view = [-(a.cos() * l.cos()), -(a.sin() * l.cos()), -(l.sin())]; + let f = pass_fraction(occ, e.position, half, view, 64); + print!(" {label} {:.1}%", f * 100.0); + } + println!(); + } + } + Ok(()) +} diff --git a/crates/benilla/Cargo.toml b/crates/benilla/Cargo.toml index b09cf2cc..6c077f8f 100644 --- a/crates/benilla/Cargo.toml +++ b/crates/benilla/Cargo.toml @@ -24,6 +24,12 @@ bevy_egui = "0.39" # Zero-copy POD→bytes for the shared light storage buffer (one per-frame write_buffer). Already in # the tree transitively; pinned to the same version as the dev-dep below. bytemuck = { version = "1", features = ["derive"] } +# `getrusage` — whole-process CPU time (all threads), the perf probes' `cpu_ms`/`cpu_pct` metric +# (`perf::process_cpu_secs`). Wall-clock fps is unusable as a regression instrument on a machine +# running parallel sessions' builds; CPU-per-frame is, and it is also the number the Mac report +# is about ("250 % CPU at 59 fps"). Already in the tree transitively; declared so the call site is +# an honest dependency. +libc = "0.2" # Resample varying-size layer BLPs to a uniform size for the terrain texture array; `png` so we can # encode the cursor image into data an NSImage can load (macOS hardware cursor). image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/benilla/src/capture/probes.rs b/crates/benilla/src/capture/probes.rs index e612e047..308dc3bd 100644 --- a/crates/benilla/src/capture/probes.rs +++ b/crates/benilla/src/capture/probes.rs @@ -415,6 +415,7 @@ impl Plugin for LiveFpsPlugin { run: std::env::var("WOW_LIVE_FPS_MOVE").as_deref() == Ok("1"), phase: LiveFpsPhase::Waiting, samples: Vec::new(), + cpu_at_start: None, }) .add_systems(Update, drive_live_fps); } @@ -437,6 +438,9 @@ struct LiveFps { run: bool, phase: LiveFpsPhase, samples: Vec, + /// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the + /// baseline for the window's `cpu_ms`/`cpu_pct`. + cpu_at_start: Option, } /// Wait for in-world + the delay, uncap, warm, sample, print, exit — the live twin of the @@ -454,6 +458,11 @@ fn drive_live_fps( // streamed rigs sat parked at sample end. parked: Query<(), With>, entities: Query<()>, + // Where the sample was actually taken (0705's prove-the-run law): a probe number is evidence + // only once the body is known to be at the pin, and `WOW_PROBE_CHAT`'s `.go` can silently + // fail (a bad map id, a refused command) leaving the run measuring the login spot. + map: Option>, + body: Option>, mut keys: ResMut>, mut exit: MessageWriter, ) { @@ -485,6 +494,9 @@ fn drive_live_fps( }; } LiveFpsPhase::Sampling => { + if probe.samples.is_empty() { + probe.cpu_at_start = crate::perf::process_cpu_secs(); + } let ms = time.delta_secs() * 1000.0; probe.samples.push(ms); if probe.samples.len() < probe.frames { @@ -506,8 +518,29 @@ fn drive_live_fps( .single() .map(|w| (w.physical_width(), w.physical_height())) .unwrap_or((0, 0)); + // CPU cost per frame across every thread — the load-robust half of the measurement + // (`perf::process_cpu_secs`), and directly comparable with a reporter's CPU %. + let cpu = match (probe.cpu_at_start, crate::perf::process_cpu_secs()) { + (Some(t0), Some(t1)) => { + let per_frame_ms = (t1 - t0) * 1000.0 / v.len() as f64; + format!( + " cpu_ms={per_frame_ms:.2} cpu_pct={:.0}", + per_frame_ms / mean as f64 * 100.0 + ) + } + _ => String::new(), + }; + // The pin the number belongs to, in the `.go xyz` order, so a probe line can be + // matched against the report's coordinates without a second instrument. + let at_pin = match (map.as_ref(), body.as_ref().filter(|b| b.active)) { + (Some(m), Some(b)) => { + let [x, y, z] = benilla_assets::coords::bevy_to_wow(b.pos); + format!(" map={} pos={x:.1},{y:.1},{z:.1}", m.0) + } + _ => String::new(), + }; println!( - "FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={} px={}x{}", + "FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={} px={}x{}{cpu}{at_pin}", v.len(), at(0.50), at(0.95), diff --git a/crates/benilla/src/creature_anim.rs b/crates/benilla/src/creature_anim.rs index 2eab86b0..388f7bef 100644 --- a/crates/benilla/src/creature_anim.rs +++ b/crates/benilla/src/creature_anim.rs @@ -66,6 +66,8 @@ pub(crate) use global_seq::GlobalSeqDrive; /// `unit-anim-visibility-gate.md`). mod lod; pub(crate) use lod::AnimParked; +mod pose; +pub(crate) use pose::PosedRig; /// The unit's wielded weapon classes — `(item class, item subclass)` per hand, `None` for an empty /// (or non-item) hand. Written by the held-item resolution ([`crate::entities`], decision 0072) from @@ -662,6 +664,7 @@ impl Plugin for CreatureAnimPlugin { twist::plugin(app); global_seq::plugin(app); lod::plugin(app); + pose::plugin(app); app.add_message::() .add_message::() .add_message::() diff --git a/crates/benilla/src/creature_anim/driver/tests.rs b/crates/benilla/src/creature_anim/driver/tests.rs index 6ddbc2dc..6ec7e556 100644 --- a/crates/benilla/src/creature_anim/driver/tests.rs +++ b/crates/benilla/src/creature_anim/driver/tests.rs @@ -59,6 +59,7 @@ fn caster_model() -> ModelAnimations { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), } } @@ -255,6 +256,7 @@ fn fidget_model() -> ModelAnimations { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), } } @@ -364,6 +366,7 @@ fn cast_hold_stows_even_when_the_model_lacks_the_spell_anims() { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), }; let unit = app .world_mut() @@ -431,6 +434,7 @@ fn spell_impact_wound_rides_the_secondary_slot() { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), }; let unit = app .world_mut() @@ -484,6 +488,7 @@ fn whiff_slowdown_spares_a_non_swing_oneshot() { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), }; let spinner = app .world_mut() @@ -569,6 +574,7 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), }; let mut unit = || { app.world_mut() @@ -664,6 +670,7 @@ fn deferred_combat_clip_plays_once_the_body_frees() { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), }; let unit = app .world_mut() @@ -720,6 +727,7 @@ fn a_movement_flag_change_cuts_a_full_body_oneshot_immediately() { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), }; let unit = app .world_mut() @@ -874,6 +882,7 @@ fn the_swim_relatch_holds_the_kick_but_a_ground_cut_freezes_it() { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), }; let unit = app .world_mut() @@ -967,6 +976,7 @@ fn unit_flag_looting_kneels_stationary_units_only() { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), }; let unit = app .world_mut() @@ -1023,6 +1033,7 @@ fn the_self_kneel_rides_the_loot_latch_not_the_flag() { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), }; // A SELF unit whose descriptor carries UNIT_FLAG_LOOTING but whose latch is empty: no kneel — // the flag is the REMOTE trigger only. @@ -1111,6 +1122,7 @@ fn a_looping_arm_advances_through_its_variations_at_window_end() { animation_lookup: Vec::new(), global_bones: Vec::new(), first_seq: None, + pose: Default::default(), }; let unit = app .world_mut() diff --git a/crates/benilla/src/creature_anim/lod.rs b/crates/benilla/src/creature_anim/lod.rs index 41ee2aa6..ca2908e1 100644 --- a/crates/benilla/src/creature_anim/lod.rs +++ b/crates/benilla/src/creature_anim/lod.rs @@ -15,46 +15,33 @@ //! state machine keeps arming clips for parked rigs; `$CSS`/`$CAH`/`$HIT` (0075), footsteps, //! `$BWP`/`$BWR`, and the `$CSL`-keyed missile release (0430) all fire regardless of the camera. //! -//! The park mechanism: repoint each joint's [`AnimatedBy`] from the rig root to [`RigPark`] — a -//! persistent empty entity with no `AnimationPlayer` — so Bevy's `animate_targets` early-outs per -//! bone at the failed player lookup (the documented runtime-repoint use of `AnimatedBy`; a plain -//! component write, no archetype churn). Bone `Transform`s then stop changing, which quiets -//! transform propagation and `extract_skins`' changed-joint upload by change detection. The pose -//! post-passes (body twist, global-sequence writes, billboard joint palette) gate on the same -//! [`AnimParked`] marker. Contrast the DOODAD gate ([`crate::doodad_anim`]), which faithfully +//! The park mechanism is the [`AnimParked`] marker alone (decision 0712 — the evaluator took over +//! from `animate_targets`, and the old per-joint `AnimatedBy` repoint died with the targets): the +//! pose evaluator ([`super::pose`]) skips a parked rig, so its bone `Transform`s stop changing, +//! which quiets transform propagation and `extract_skins`' changed-joint upload by change +//! detection. The pose post-passes (body twist, global-sequence writes, billboard joint palette) +//! gate on the same marker. Contrast the DOODAD gate ([`crate::doodad_anim`]), which faithfully //! stops the player and re-arms on the shared clock — correct there (no one consumes a doodad's //! events off-screen), the exact 0075 trap here. -use bevy::animation::AnimatedBy; use bevy::app::AnimationSystems; use bevy::camera::primitives::{Frustum, Sphere as CullSphere}; use bevy::ecs::entity::EntityHashMap; use bevy::prelude::*; -use crate::entities::{mount::MountBody, BoneAttach}; +use crate::entities::mount::MountBody; use crate::net::SelfPlayer; use crate::player::WorldCamera; use crate::target::SelectionRadius; use super::AnimDriver; -/// This rig's per-bone pose evaluation is parked (decision 0448): its joints' [`AnimatedBy`] -/// point at [`RigPark`], and the pose post-passes skip it. The sequence clocks, the driver state -/// machine, and the event scanner all keep running — parking turns *sampling* off, nothing else. +/// This rig's per-bone pose evaluation is parked (decision 0448): the pose evaluator and the pose +/// post-passes skip it. The sequence clocks, the driver state machine, and the event scanner all +/// keep running — parking turns *sampling* off, nothing else. #[derive(Component)] pub(crate) struct AnimParked; -/// The parking target: a persistent empty entity carrying no [`AnimationPlayer`], so a parked -/// joint's `animate_targets` lookup fails and the bone is skipped that frame. -#[derive(Resource)] -pub(crate) struct RigPark(Entity); - -impl FromWorld for RigPark { - fn from_world(world: &mut World) -> Self { - Self(world.spawn(Name::new("rig park (anim LOD, 0448)")).id()) - } -} - /// Park only after the rig has been continuously out of frustum this long — a camera swing /// across the pack doesn't churn bone repoints. Waking is always instant. const PARK_AFTER_SECS: f32 = 0.5; @@ -69,22 +56,20 @@ const RADIUS_PAD: f32 = 4.0; const FALLBACK_RADIUS: f32 = 6.0; /// Park/wake streamed rigs by the padded sphere-vs-frustum test (decision 0448). PostUpdate, -/// before [`AnimationSystems`] — a wake repoints the joints in time for the same frame's -/// `animate_targets`, so the re-appearing unit samples the absolute-clock pose with no stale +/// before [`AnimationSystems`] — a wake drops the marker in time for the same frame's pose +/// evaluation, so the re-appearing unit samples the absolute-clock pose with no stale /// frame. Exempt: the self-avatar (the camera rides its attachment-17 pivot, and it must keep /// animating faded-out in first person) and its mount child. `WOW_NO_ANIM_LOD=1` disables /// parking — the live-probe A/B lever. #[allow(clippy::type_complexity, clippy::too_many_arguments)] // one Bevy system's full input set pub(super) fn gate_rig_animation( time: Res