crowded dungeons back to 60 fps: two per-NPC costs collapse

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-27 19:38:22 +07:00
co-authored by Claude Fable 5
parent fd54ea9197
commit d00912edd0
31 changed files with 2094 additions and 169 deletions
Generated
+1
View File
@@ -586,6 +586,7 @@ dependencies = [
"fontdb",
"image",
"kira",
"libc",
"mint",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
+328
View File
@@ -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<u32>,
/// Triangle indices, ascending within each cell.
items: Vec<u32>,
/// Triangles too large to bin (see [`MAX_SPAN_CELLS`]), ascending — candidates for every query.
spanning: Vec<u32>,
}
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<Self> {
// 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<usize> {
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<usize> = 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<usize> = 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<usize> = 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());
}
}
+5 -3
View File
@@ -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`.
+37 -9
View File
@@ -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),
}
})
};
+26 -6
View File
@@ -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<T: Animatable + Clone>(
}
}
/// 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<T: Animatable + Clone>(
pub(crate) fn build_animation_clip(
anim: &ModelAnimation,
skeleton: &ModelSkeleton,
) -> Option<AnimationClip> {
) -> 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
+6
View File
@@ -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<usize>,
/// 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<super::PoseSource>,
}
/// 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(),
}
}
+269
View File
@@ -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<T> {
times: Vec<f32>,
values: Vec<T>,
}
// Manual: the derive would bound `T: Default`, which `Quat` channels don't need — an empty track
// has no values of `T` at all.
impl<T> Default for PoseTrack<T> {
fn default() -> Self {
Self {
times: Vec::new(),
values: Vec::new(),
}
}
}
impl<T: Animatable + Clone> PoseTrack<T> {
/// 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<T> {
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<Vec3>,
pub rotation: PoseTrack<Quat>,
pub scale: PoseTrack<Vec3>,
}
/// 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<PoseBone>,
}
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<PoseClip>,
/// Dense by `AnimationNodeIndex::index()`; `None` for the root/unused slots.
pub nodes: Vec<Option<PoseNode>>,
/// Per bone: the mask-group bits it belongs to (the `add_target_to_mask_group` calls).
pub bone_masks: Vec<u64>,
}
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<PoseNode> {
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::<Vec3>::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<u16> = clip.bones.iter().map(|b| b.bone).collect();
assert_eq!(
order,
vec![0, 1, 2, 3, 7],
"sorted; the empty bone 5 dropped"
);
}
}
+62
View File
@@ -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<Option<([f32; 3], [f32; 3])>>,
/// 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<Option<ColumnGrid>>,
/// 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<Option<([f32; 3], [f32; 3])>>,
/// 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<Option<ColumnGrid>>,
/// 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<FootprintTris>]) -> Vec<Option<ColumnGrid>> {
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<Option<ColumnGrid>> {
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,
})
@@ -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<f32> {
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 <m2 path>"))?;
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<usize> = 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(())
}
+6
View File
@@ -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"] }
+34 -1
View File
@@ -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<f32>,
/// 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<f64>,
}
/// 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<crate::creature_anim::AnimParked>>,
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<Res<crate::world_map::CurrentMap>>,
body: Option<Res<crate::player::Player>>,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut exit: MessageWriter<AppExit>,
) {
@@ -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),
+3
View File
@@ -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::<AnimSoundEvent>()
.add_message::<SwingMessage>()
.add_message::<SwingImpact>()
@@ -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()
+65 -99
View File
@@ -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<Time>,
park: Res<RigPark>,
cam: Query<&Frustum, With<WorldCamera>>,
rigs: Query<
(
Entity,
&GlobalTransform,
Option<&SelectionRadius>,
&BoneAttach,
Has<AnimParked>,
Has<SelfPlayer>,
Option<&MountBody>,
@@ -92,42 +77,47 @@ pub(super) fn gate_rig_animation(
With<AnimDriver>,
>,
self_hosts: Query<Has<SelfPlayer>>,
mut animated_by: Query<&mut AnimatedBy>,
mut out_since: Local<EntityHashMap<f32>>,
mut disabled: Local<Option<bool>>,
mut park_all: Local<Option<bool>>,
mut commands: Commands,
) {
let disabled = *disabled.get_or_insert_with(|| std::env::var_os("WOW_NO_ANIM_LOD").is_some());
// The measurement twin of `WOW_NO_ANIM_LOD`: park EVERY streamed rig regardless of view. The
// two levers bracket the pose-evaluation lane's cost at any pin — "never park" is its ceiling,
// "always park" its floor — which is the only way to attribute a frame budget without a
// profiler build (the chrome-trace feature costs a 5-minute rebuild and a GB-scale file).
// Not a shipping mode: it freezes visible rigs on purpose.
let park_all = *park_all.get_or_insert_with(|| std::env::var_os("WOW_ANIM_PARK_ALL").is_some());
let Ok(frustum) = cam.single() else {
return;
};
let now = time.elapsed_secs();
for (entity, tf, radius, bones, parked, is_self, mount) in &rigs {
for (entity, tf, radius, parked, is_self, mount) in &rigs {
let exempt =
disabled || is_self || mount.is_some_and(|m| self_hosts.get(m.host).unwrap_or(false));
let visible = exempt || {
let scale = tf.to_scale_rotation_translation().0.max_element();
let r = radius.map_or(FALLBACK_RADIUS, |r| r.0 * scale * RADIUS_SCALE + RADIUS_PAD);
frustum.intersects_sphere(
&CullSphere {
center: tf.translation().into(),
radius: r,
},
false,
)
};
let visible = exempt
|| !park_all && {
let scale = tf.to_scale_rotation_translation().0.max_element();
let r = radius.map_or(FALLBACK_RADIUS, |r| r.0 * scale * RADIUS_SCALE + RADIUS_PAD);
frustum.intersects_sphere(
&CullSphere {
center: tf.translation().into(),
radius: r,
},
false,
)
};
if visible {
out_since.remove(&entity);
if parked {
commands.entity(entity).remove::<AnimParked>();
repoint(&mut animated_by, &bones.joints, entity);
}
} else if !parked {
let since = *out_since.entry(entity).or_insert(now);
if now - since >= PARK_AFTER_SECS {
out_since.remove(&entity);
commands.entity(entity).insert(AnimParked);
repoint(&mut animated_by, &bones.joints, park.0);
}
}
}
@@ -135,20 +125,9 @@ pub(super) fn gate_rig_animation(
out_since.retain(|e, _| rigs.contains(*e));
}
/// Repoint every joint's [`AnimatedBy`] — to the park entity (park) or back to the rig root
/// (wake). A plain component write per joint, edges only.
fn repoint(animated_by: &mut Query<&mut AnimatedBy>, joints: &[Entity], target: Entity) {
for &j in joints {
if let Ok(mut ab) = animated_by.get_mut(j) {
ab.0 = target;
}
}
}
/// Register the gate: [`RigPark`] + [`gate_rig_animation`] before the frame's pose evaluation.
/// Register the gate: [`gate_rig_animation`] before the frame's pose evaluation.
pub(super) fn plugin(app: &mut App) {
app.init_resource::<RigPark>()
.add_systems(PostUpdate, gate_rig_animation.before(AnimationSystems));
app.add_systems(PostUpdate, gate_rig_animation.before(AnimationSystems));
}
#[cfg(test)]
@@ -157,7 +136,9 @@ mod tests {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::{animated_field, AnimationClip};
use benilla_assets::{bone_target_id, AnimClip, ModelAnimations};
use benilla_assets::{
bone_target_id, AnimClip, ModelAnimations, PoseBone, PoseClip, PoseSource, PoseTrack,
};
use super::super::{events::fire_anim_events, AnimSoundEvent};
use super::*;
@@ -169,6 +150,18 @@ mod tests {
/// `$TST` event keyframe mid-loop: enough to observe all three gate laws — clocks, bones,
/// events. Returns `(root, joint)`.
fn spawn_rig(app: &mut App, at: Vec3) -> (Entity, Entity) {
let mut pose = PoseSource {
bone_masks: vec![0],
..Default::default()
};
let mut pose_clip = PoseClip::default();
pose_clip.push(PoseBone {
bone: 0,
translation: PoseTrack::new(&[(0.0, Vec3::ZERO), (DUR, Vec3::X)]),
rotation: PoseTrack::default(),
scale: PoseTrack::default(),
});
pose.clips.push(pose_clip);
let mut clip = AnimationClip::default();
clip.add_curve_to_target(
bone_target_id(0),
@@ -183,6 +176,7 @@ mod tests {
.resource_mut::<Assets<AnimationClip>>()
.add(clip);
let (graph, node) = AnimationGraph::from_clip(clip_handle);
pose.set_node(node, 0, 0);
let graph_handle = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
@@ -220,6 +214,7 @@ mod tests {
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: std::sync::Arc::new(pose),
};
// The driver's requested base = the gait slot: point it at the clip so the event
// scanner's `resolved_anim` finds the advancing timeline (its live writer is
@@ -241,18 +236,14 @@ mod tests {
.id();
let joint = app
.world_mut()
.spawn((
Transform::default(),
ChildOf(root),
bone_target_id(0),
AnimatedBy(root),
))
.spawn((Transform::default(), ChildOf(root)))
.id();
app.world_mut().entity_mut(root).insert(BoneAttach {
joints: vec![joint],
points: Default::default(),
markers: Default::default(),
});
// The evaluator's rig handle (0712); target ids no longer ride the joints.
app.world_mut()
.entity_mut(root)
.insert(super::super::PosedRig {
joints: vec![joint],
});
(root, joint)
}
@@ -271,11 +262,11 @@ mod tests {
AssetPlugin::default(),
bevy::animation::AnimationPlugin,
));
app.init_resource::<RigPark>();
app.add_message::<AnimSoundEvent>();
// The live schedule's shape: the gate ahead of Bevy's pose evaluation; the event scanner
// reading the advanced seek after it.
// The live schedule's shape: the gate ahead of the frame's pose evaluation; the event
// scanner reading the advanced seek after it.
app.add_systems(PostUpdate, gate_rig_animation.before(AnimationSystems));
super::super::pose::plugin(&mut app);
app.add_systems(PostUpdate, fire_anim_events.after(AnimationSystems));
app
}
@@ -327,17 +318,6 @@ mod tests {
!app.world().entity(twin).contains::<AnimParked>(),
"the in-frame twin stays live"
);
let park = app.world().resource::<RigPark>().0;
assert_eq!(
app.world()
.entity(behind_joint)
.get::<AnimatedBy>()
.unwrap()
.0,
park,
"parked joints point at the park entity"
);
// While parked: the bone holds, the clock runs, the mid-loop event keyframe still fires.
let frozen = bone(&app, behind_joint);
let seek_at_park = seek(&app, behind);
@@ -379,15 +359,6 @@ mod tests {
!app.world().entity(behind).contains::<AnimParked>(),
"in-frustum ⇒ instant wake"
);
assert_eq!(
app.world()
.entity(behind_joint)
.get::<AnimatedBy>()
.unwrap()
.0,
behind,
"woken joints point back at their rig"
);
assert_eq!(
seek(&app, behind),
seek(&app, twin),
@@ -413,14 +384,9 @@ mod tests {
!app.world().entity(behind).contains::<AnimParked>(),
"SelfPlayer never parks, wherever the camera points"
);
assert_eq!(
app.world()
.entity(behind_joint)
.get::<AnimatedBy>()
.unwrap()
.0,
behind,
"its joints stay live"
);
// And its bones keep animating — the exemption is live evaluation, not just the marker.
let before = bone(&app, behind_joint);
advance(&mut app, DUR * 0.35, 2);
assert_ne!(before, bone(&app, behind_joint), "its joints stay live");
}
}
+647
View File
@@ -0,0 +1,647 @@
//! The direct M2 pose evaluator (decision 0712): turn each rig's `AnimationPlayer` state into
//! bone `Transform`s ourselves, off the baked [`PoseSource`], instead of routing per-bone entities
//! through Bevy's `animate_targets` (0711's second lane — ~1.9 µs *per bone* of graph-walk +
//! hash-lookup + boxed-curve machinery to produce, for most bones, a constant).
//!
//! The player stays the playback state machine — the driver's arms, cross-fades, weights, seeks,
//! and completions are untouched — and this system reproduces `animate_targets`' *output* exactly
//! (the goldens below run both side by side and assert bone-for-bone equality). The law, read from
//! `bevy_animation-0.18.1` and pinned in 0712: per (bone, property), active nodes contribute in
//! **ascending node-index order**, folded by `total += w; acc = interpolate(acc, v, w/total)`
//! (`Vec3::lerp` / `Quat::slerp` — the same `Animatable` calls, so the math is bit-identical); a
//! node contributes iff its weight ≠ 0 and `bone_masks[bone] & node_mask == 0` and its clip keys
//! the channel; sampling clamps into the key span; a property no active clip keys is left alone
//! (there is no rest-pose reset — the joint keeps what it has). Paused animations still evaluate
//! at their frozen seek, exactly as Bevy does.
//!
//! Parking (decision 0448) is the [`AnimParked`] marker: a parked rig is skipped here — clocks,
//! driver, and events all keep running — which preserves 0448's two observables (absolute-clock
//! snap on wake, off-screen combat audibility) with no `AnimatedBy` repoint machinery.
use benilla_assets::{ModelAnimations, PoseSource};
use bevy::animation::animatable::Animatable;
use bevy::app::AnimationSystems;
use bevy::prelude::*;
use bevy::transform::TransformSystems;
use super::AnimParked;
/// The evaluator's rig handle, on the entity carrying the `AnimationPlayer`: bone index → joint
/// entity, in skeleton order (decision 0712). Rigs spawn this **instead of** per-joint
/// `AnimationTargetId`/`AnimatedBy` pairs — with no targets, Bevy's `animate_targets` never
/// touches these bones.
#[derive(Component)]
pub(crate) struct PosedRig {
pub(crate) joints: Vec<Entity>,
}
/// One active animation's evaluation inputs, resolved against the rig's [`PoseSource`].
struct Active {
/// `AnimationNodeIndex::index()` — the sort key that reproduces Bevy's blend order.
node: usize,
clip: usize,
mask: u64,
weight: f32,
seek: f32,
/// The merge walk's position in the clip's bone list.
cursor: usize,
}
/// Evaluate every live rig's pose. Runs where `animate_targets` runs — inside [`AnimationSystems`]
/// after `advance_animations` ticked the seek clocks — so the pose post-passes (body twist,
/// global-sequence writes) and transform propagation see it at the same point in the frame.
fn evaluate_rig_poses(
rigs: Query<(&AnimationPlayer, &ModelAnimations, &PosedRig), Without<AnimParked>>,
mut joints: Query<&mut Transform>,
) {
for (player, anims, rig) in &rigs {
let src = &anims.pose;
// This rig's contributing animations, ascending by node index (Bevy's effective blend
// order — sorted children folded through a LIFO stack, see the module doc).
let mut active: Vec<Active> = Vec::new();
for (&node, anim) in player.playing_animations() {
// Bevy's exact gate: a weight of literal 0.0 skips the node (paused does not).
if anim.weight() == 0.0 {
continue;
}
let Some(pn) = src.node(node) else { continue };
if src.clips.get(pn.clip as usize).is_none() {
continue;
}
active.push(Active {
node: node.index(),
clip: pn.clip as usize,
mask: pn.mask,
weight: anim.weight(),
seek: anim.seek_time(),
cursor: 0,
});
}
active.sort_unstable_by_key(|a| a.node);
match active.as_mut_slice() {
[] => {}
[one] => {
// The steady state: one looping gait. A single contribution commits at full
// value whatever its weight (Bevy's register initializes unnormalized).
for pb in &src.clips[one.clip].bones {
if src.bone_masks.get(pb.bone as usize).copied().unwrap_or(0) & one.mask != 0 {
continue;
}
let Some(&joint) = rig.joints.get(pb.bone as usize) else {
continue;
};
let Ok(mut tf) = joints.get_mut(joint) else {
continue;
};
if let Some(v) = pb.translation.sample(one.seek) {
tf.translation = v;
}
if let Some(v) = pb.rotation.sample(one.seek) {
tf.rotation = v;
}
if let Some(v) = pb.scale.sample(one.seek) {
tf.scale = v;
}
}
}
many => blend_rig(src, many, rig, &mut joints),
}
}
}
/// The multi-animation path (a cross-fade, a masked overlay, the grip): merge-walk the
/// bone-sorted clips and fold each (bone, property)'s contributions in node order.
fn blend_rig(
src: &PoseSource,
active: &mut [Active],
rig: &PosedRig,
joints: &mut Query<&mut Transform>,
) {
// Walk the next keyed bone across all clips until every cursor is spent.
while let Some(bone) = active
.iter()
.filter_map(|a| src.clips[a.clip].bones.get(a.cursor).map(|b| b.bone))
.min()
{
let bone_mask = src.bone_masks.get(bone as usize).copied().unwrap_or(0);
// Fold each property over the clips keying this bone, in node order (`active` is sorted).
let mut translation: Option<(Vec3, f32)> = None;
let mut rotation: Option<(Quat, f32)> = None;
let mut scale: Option<(Vec3, f32)> = None;
for a in active.iter_mut() {
let Some(pb) = src.clips[a.clip]
.bones
.get(a.cursor)
.filter(|b| b.bone == bone)
else {
continue;
};
a.cursor += 1;
if bone_mask & a.mask != 0 {
continue; // masked out for this node — no contribution, cursor still advances
}
fold(&mut translation, pb.translation.sample(a.seek), a.weight);
fold(&mut rotation, pb.rotation.sample(a.seek), a.weight);
fold(&mut scale, pb.scale.sample(a.seek), a.weight);
}
if translation.is_none() && rotation.is_none() && scale.is_none() {
continue;
}
let Some(&joint) = rig.joints.get(bone as usize) else {
continue;
};
let Ok(mut tf) = joints.get_mut(joint) else {
continue;
};
if let Some((v, _)) = translation {
tf.translation = v;
}
if let Some((v, _)) = rotation {
tf.rotation = v;
}
if let Some((v, _)) = scale {
tf.scale = v;
}
}
}
/// One property's running fold — Bevy's blend register verbatim: the first contribution lands at
/// full value (carrying its weight), each later one at `w / total`.
#[inline]
fn fold<T: Animatable>(register: &mut Option<(T, f32)>, sample: Option<T>, weight: f32) {
let Some(v) = sample else { return };
*register = Some(match register.take() {
None => (v, weight),
Some((acc, total)) => {
let total = total + weight;
(T::interpolate(&acc, &v, weight / total), total)
}
});
}
/// Register the evaluator in `animate_targets`' window: after the seek clocks tick, before the
/// pose post-passes (which order `.after(AnimationSystems)`) and transform propagation.
pub(super) fn plugin(app: &mut App) {
app.add_systems(
PostUpdate,
evaluate_rig_poses
.in_set(AnimationSystems)
.after(bevy::animation::advance_animations)
.before(TransformSystems::Propagate),
);
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use benilla_assets::{bone_target_id, PoseBone, PoseClip, PoseTrack};
use bevy::animation::animation_curves::{AnimatableCurve, AnimatableKeyframeCurve};
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle, AnimationNodeIndex};
use bevy::animation::transition::AnimationTransitions;
use bevy::animation::{animated_field, AnimatedBy, AnimationClip};
use super::*;
/// One test clip's authored channels: per bone, optional key lists for T/R/S.
#[derive(Clone, Default)]
struct ClipSpec {
bones: Vec<(u16, BoneSpec)>,
/// The `add_clip_with_mask` mask (0 = unmasked).
mask: u64,
}
#[derive(Clone, Default)]
struct BoneSpec {
t: Vec<(f32, Vec3)>,
r: Vec<(f32, Quat)>,
s: Vec<(f32, Vec3)>,
}
/// Build the graph + clips + pose source from specs, exactly as `m2.rs` does: the Bevy clip
/// through `AnimatableCurve`/`AnimatableKeyframeCurve`, the pose twin through
/// `PoseTrack`/`PoseClip`, node table beside each graph add.
fn build(
app: &mut App,
specs: &[ClipSpec],
bone_masks: Vec<u64>,
) -> (Handle<AnimationGraph>, Vec<AnimationNodeIndex>, PoseSource) {
let mut graph = AnimationGraph::new();
let root = graph.root;
let mut pose = PoseSource {
bone_masks,
..Default::default()
};
// Mirror the graph's mask groups from the baked bone bits (group g ⇔ bit g).
for (i, &bits) in pose.bone_masks.iter().enumerate() {
for g in 0..8 {
if bits & (1 << g) != 0 {
graph.add_target_to_mask_group(bone_target_id(i as u16), g);
}
}
}
let mut nodes = Vec::new();
for spec in specs {
let mut clip = AnimationClip::default();
let mut pclip = PoseClip::default();
for (bone, bs) in &spec.bones {
let target = bone_target_id(*bone);
if bs.t.len() >= 2 {
clip.add_curve_to_target(
target,
AnimatableCurve::new(
animated_field!(Transform::translation),
AnimatableKeyframeCurve::new(bs.t.iter().copied()).unwrap(),
),
);
}
if bs.r.len() >= 2 {
clip.add_curve_to_target(
target,
AnimatableCurve::new(
animated_field!(Transform::rotation),
AnimatableKeyframeCurve::new(bs.r.iter().copied()).unwrap(),
),
);
}
if bs.s.len() >= 2 {
clip.add_curve_to_target(
target,
AnimatableCurve::new(
animated_field!(Transform::scale),
AnimatableKeyframeCurve::new(bs.s.iter().copied()).unwrap(),
),
);
}
pclip.push(PoseBone {
bone: *bone,
translation: if bs.t.len() >= 2 {
PoseTrack::new(&bs.t)
} else {
PoseTrack::default()
},
rotation: if bs.r.len() >= 2 {
PoseTrack::new(&bs.r)
} else {
PoseTrack::default()
},
scale: if bs.s.len() >= 2 {
PoseTrack::new(&bs.s)
} else {
PoseTrack::default()
},
});
}
let handle = app
.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(clip);
let clip_idx = pose.clips.len() as u32;
pose.clips.push(pclip);
let node = if spec.mask == 0 {
graph.add_clip(handle, 1.0, root)
} else {
graph.add_clip_with_mask(handle, spec.mask, 1.0, root)
};
pose.set_node(node, clip_idx, spec.mask);
nodes.push(node);
}
let graph = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
.add(graph);
(graph, nodes, pose)
}
/// A `ModelAnimations` carrying only what the evaluator reads (graph + pose).
fn model_anims(graph: Handle<AnimationGraph>, pose: PoseSource) -> ModelAnimations {
ModelAnimations {
graph,
clips: Vec::new(),
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
hand_close: [None, None],
global_bones: Vec::new(),
first_seq: None,
pose: std::sync::Arc::new(pose),
}
}
/// Spawn the two rigs: the Bevy-path oracle (per-joint targets, `animate_targets` evaluates
/// it) and the evaluator rig (`PosedRig`). Returns `(oracle_root, oracle_joints, ours_root,
/// our_joints)`.
fn twin_rigs(
app: &mut App,
nbones: u16,
graph: &Handle<AnimationGraph>,
pose: &PoseSource,
) -> (Entity, Vec<Entity>, Entity, Vec<Entity>) {
let spawn_root = |app: &mut App| {
app.world_mut()
.spawn((
Transform::default(),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimationGraphHandle(graph.clone()),
))
.id()
};
let oracle = spawn_root(app);
let oracle_joints: Vec<Entity> = (0..nbones)
.map(|i| {
app.world_mut()
.spawn((
Transform::default(),
ChildOf(oracle),
bone_target_id(i),
AnimatedBy(oracle),
))
.id()
})
.collect();
let ours = spawn_root(app);
let our_joints: Vec<Entity> = (0..nbones)
.map(|_| {
app.world_mut()
.spawn((Transform::default(), ChildOf(ours)))
.id()
})
.collect();
app.world_mut().entity_mut(ours).insert((
model_anims(graph.clone(), pose.clone()),
PosedRig {
joints: our_joints.clone(),
},
));
(oracle, oracle_joints, ours, our_joints)
}
fn app() -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
AssetPlugin::default(),
bevy::animation::AnimationPlugin,
));
plugin(&mut app);
app
}
/// Run `f` identically on both players, then step and assert every joint's `Transform` is
/// **exactly** equal between the oracle and the evaluator — same inputs, same `Animatable`
/// calls, bit-identical outputs.
#[track_caller]
fn assert_twins_equal(app: &mut App, oracle_joints: &[Entity], our_joints: &[Entity]) {
for (i, (&a, &b)) in oracle_joints.iter().zip(our_joints).enumerate() {
let ta = *app.world().entity(a).get::<Transform>().unwrap();
let tb = *app.world().entity(b).get::<Transform>().unwrap();
assert_eq!(ta, tb, "bone {i} diverged (oracle vs evaluator)");
}
}
fn step(app: &mut App, secs: f32) {
std::thread::sleep(Duration::from_secs_f32(secs));
app.update();
}
/// Run the same player mutation on each rig — identical inputs is the whole point of the
/// twin-rig golden.
fn drive(
app: &mut App,
rigs: &[Entity],
f: impl Fn(&mut AnimationPlayer, &mut AnimationTransitions),
) {
for &rig in rigs {
let mut e = app.world_mut().entity_mut(rig);
// Take transitions out to appease the borrow checker, run, put back.
let mut tr = e.take::<AnimationTransitions>().unwrap();
{
let player = e.get_mut::<AnimationPlayer>().unwrap();
f(player.into_inner(), &mut tr);
}
e.insert(tr);
}
}
/// The steady state (one looping clip): channels present and absent, exact equality mid-loop —
/// including a bone the clip never keys (both paths must leave it at its spawn value).
#[test]
fn single_clip_matches_animate_targets() {
let mut app = app();
let spec = 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(2.0))],
s: vec![],
},
),
(
1,
BoneSpec {
t: vec![],
r: vec![
(0.1, Quat::from_rotation_x(0.4)),
(0.5, Quat::from_rotation_z(-1.2)),
],
s: vec![(0.0, Vec3::ONE), (0.8, Vec3::splat(2.0))],
},
),
// bone 2 never keyed — the untouched-property law.
],
mask: 0,
};
let (graph, nodes, pose) = build(&mut app, &[spec], vec![0, 0, 0]);
let (oracle, oj, ours, uj) = twin_rigs(&mut app, 3, &graph, &pose);
// One warm-up frame: Bevy's ThreadedAnimationGraphs builds off the graph asset's Added
// event, so `animate_targets` bails on the spawn frame (our evaluator doesn't need it and
// evaluates immediately — strictly earlier, not different). Compare from frame 2 on.
app.update();
drive(&mut app, &[oracle, ours], |p, _| {
p.play(nodes[0]).repeat();
});
for _ in 0..5 {
step(&mut app, 0.05);
assert_twins_equal(&mut app, &oj, &uj);
}
}
/// A cross-fade (`AnimationTransitions::play` mid-flight): main at weight 1 plus the fading
/// node's decaying weight — the normalized fold must match Bevy's frame for frame, and the
/// past-the-end clamp holds once the one-shot finishes.
#[test]
fn crossfade_matches_animate_targets() {
let mut app = app();
let walk = ClipSpec {
bones: vec![(
0,
BoneSpec {
t: vec![(0.0, Vec3::ZERO), (0.6, Vec3::X * 3.0)],
r: vec![(0.0, Quat::IDENTITY), (0.6, Quat::from_rotation_z(1.0))],
s: vec![],
},
)],
mask: 0,
};
let run = ClipSpec {
bones: vec![(
0,
BoneSpec {
t: vec![(0.0, Vec3::Y), (0.3, Vec3::NEG_Y)],
r: vec![
(0.0, Quat::from_rotation_x(0.5)),
(0.3, Quat::from_rotation_x(-0.5)),
],
s: vec![],
},
)],
mask: 0,
};
let (graph, nodes, pose) = build(&mut app, &[walk, run], vec![0]);
let (oracle, oj, ours, uj) = twin_rigs(&mut app, 1, &graph, &pose);
app.update(); // warm-up: see single_clip_matches_animate_targets
drive(&mut app, &[oracle, ours], |p, tr| {
tr.play(p, nodes[0], Duration::ZERO).repeat();
});
step(&mut app, 0.1);
// Cross-fade into the run over 0.25 s; sample mid-fade several times.
drive(&mut app, &[oracle, ours], |p, tr| {
tr.play(p, nodes[1], Duration::from_secs_f32(0.25)).repeat();
});
for _ in 0..6 {
step(&mut app, 0.06);
assert_twins_equal(&mut app, &oj, &uj);
}
}
/// The masked upper-body overlay at weight 8 over a full-body base (the driver's one-shot
/// route), plus a weight-0 node that must contribute nothing: bones in the mask group blend
/// 8:1, bones outside it take the base alone.
#[test]
fn masked_overlay_matches_animate_targets() {
let mut app = app();
let base = ClipSpec {
bones: vec![
(
0, // "legs" — in mask group 2, the overlay must not touch it
BoneSpec {
t: vec![(0.0, Vec3::ZERO), (0.5, Vec3::X)],
r: vec![(0.0, Quat::IDENTITY), (0.5, Quat::from_rotation_y(1.0))],
s: vec![],
},
),
(
1, // "torso" — both drive it, the 8:1 blend
BoneSpec {
t: vec![(0.0, Vec3::ZERO), (0.5, Vec3::Z)],
r: vec![(0.0, Quat::IDENTITY), (0.5, Quat::from_rotation_z(0.7))],
s: vec![],
},
),
],
mask: 0,
};
let overlay = ClipSpec {
bones: vec![
(
0,
BoneSpec {
t: vec![(0.0, Vec3::splat(9.0)), (0.4, Vec3::splat(9.0))],
r: vec![],
s: vec![],
},
),
(
1,
BoneSpec {
t: vec![(0.0, Vec3::NEG_Z), (0.4, Vec3::NEG_X)],
r: vec![
(0.0, Quat::from_rotation_x(1.2)),
(0.4, Quat::from_rotation_x(0.2)),
],
s: vec![],
},
),
],
mask: 1 << 2,
};
let idle = ClipSpec {
bones: vec![(
1,
BoneSpec {
t: vec![(0.0, Vec3::splat(50.0)), (0.4, Vec3::splat(50.0))],
r: vec![],
s: vec![],
},
)],
mask: 0,
};
// bone 0 is in mask group 2 (the "outside the upper subtree" group).
let (graph, nodes, pose) = build(&mut app, &[base, overlay, idle], vec![1 << 2, 0]);
let (oracle, oj, ours, uj) = twin_rigs(&mut app, 2, &graph, &pose);
app.update(); // warm-up: see single_clip_matches_animate_targets
drive(&mut app, &[oracle, ours], |p, _| {
p.play(nodes[0]).repeat();
p.play(nodes[1]).repeat().set_weight(8.0);
p.play(nodes[2]).repeat().set_weight(0.0); // must be skipped entirely
});
for _ in 0..4 {
step(&mut app, 0.07);
assert_twins_equal(&mut app, &oj, &uj);
}
// And the masked bone really is base-only: it moved off spawn (the base wrote it), yet
// never took the overlay's parked 9.0 constant.
let t0 = app.world().entity(uj[0]).get::<Transform>().unwrap();
assert_ne!(
t0.translation,
Vec3::splat(9.0),
"mask kept the overlay off bone 0"
);
}
/// A parked rig is skipped (the 0448 gate's new mechanism): bones freeze while the clock
/// advances; unparking resumes sampling at the absolute clock.
#[test]
fn parked_rig_freezes_bones_only() {
let mut app = app();
let spec = ClipSpec {
bones: vec![(
0,
BoneSpec {
t: vec![(0.0, Vec3::ZERO), (0.4, Vec3::X)],
r: vec![],
s: vec![],
},
)],
mask: 0,
};
let (graph, nodes, pose) = build(&mut app, &[spec], vec![0]);
let (oracle, oj, ours, uj) = twin_rigs(&mut app, 1, &graph, &pose);
// The oracle twin runs unparked throughout — the absolute-clock witness.
drive(&mut app, &[oracle, ours], |p, _| {
p.play(nodes[0]).repeat();
});
step(&mut app, 0.05);
app.world_mut().entity_mut(ours).insert(AnimParked);
let frozen = app
.world()
.entity(uj[0])
.get::<Transform>()
.unwrap()
.translation;
step(&mut app, 0.1);
assert_eq!(
app.world()
.entity(uj[0])
.get::<Transform>()
.unwrap()
.translation,
frozen,
"parked bones hold"
);
app.world_mut().entity_mut(ours).remove::<AnimParked>();
step(&mut app, 0.05);
assert_twins_equal(&mut app, &oj, &uj); // woke to the absolute-clock pose
}
}
+1
View File
@@ -557,6 +557,7 @@ mod tests {
Vec::new()
},
first_seq,
pose: Default::default(),
}
}
+11 -8
View File
@@ -8,10 +8,9 @@
//! reaches the shared types + caches in the parent via `super::`.
use avian3d::prelude::RigidBody;
use benilla_assets::{bone_target_id, ModelSkeleton};
use benilla_assets::ModelSkeleton;
use benilla_formats::CharSkinSlot;
use benilla_protocol::EntityKind;
use bevy::animation::AnimatedBy;
use bevy::camera::primitives::MeshAabb;
use bevy::camera::visibility::NoFrustumCulling;
use bevy::mesh::skinning::{SkinnedMesh, SkinnedMeshInverseBindposes};
@@ -85,7 +84,8 @@ fn setup_skinned_instance(
let ibp = d.inverse_bindposes.as_ref()?;
// `joints_root` — where the skeleton's root bones parent — is normally `entity` itself; a
// MOUNTED rider's joints root under the seat anchor instead (decision 0441), while the
// `AnimationPlayer`/driver components stay on `entity` (animation targets bind by entity).
// `AnimationPlayer`/driver components stay on `entity` (the pose evaluator binds joints
// through the root's `PosedRig`, decision 0712 — parentage never enters it).
// Skinned parts render purely from joint world transforms, so their own parentage is free.
let joints = spawn_joints(commands, joints_root, &d.skeleton);
// Billboard bones (glow riders on billboard children): palette-level camera facing, children inherit.
@@ -147,11 +147,14 @@ fn setup_skinned_instance(
.remove::<crate::creature_anim::AnimParked>();
}
}
for (i, &j) in joints.iter().enumerate() {
commands
.entity(j)
.insert((bone_target_id(i as u16), AnimatedBy(entity)));
}
// The direct pose evaluator's rig handle (decision 0712) — instead of per-joint
// `AnimationTargetId`/`AnimatedBy` pairs: with no targets, Bevy's `animate_targets` never
// touches these bones, and `creature_anim::pose` samples the player state itself.
commands
.entity(entity)
.insert(crate::creature_anim::PosedRig {
joints: joints.clone(),
});
// Global-sequence bone channels (the eye-blink eyelid scale, resting fidget pulses; a GO's
// free-running flicker): free-clock loops the per-sequence reader drops, driven on their own clock.
if let Some(drive) = crate::creature_anim::GlobalSeqDrive::new(&anims.global_bones, &joints)
+1
View File
@@ -168,6 +168,7 @@ pub(super) fn refresh_player_looks(
AnimationGraphHandle,
benilla_assets::ModelAnimations,
crate::creature_anim::AnimDriver,
crate::creature_anim::PosedRig,
BoneAttach,
HeldAttached,
)>()
@@ -119,6 +119,7 @@ pub(super) fn refresh_live_display(
AnimationGraphHandle,
benilla_assets::ModelAnimations,
crate::creature_anim::AnimDriver,
crate::creature_anim::PosedRig,
super::BoneAttach,
super::equipment::HeldAttached,
)>()
+1
View File
@@ -65,6 +65,7 @@ pub(super) fn refresh_mounts(
AnimationGraphHandle,
benilla_assets::ModelAnimations,
crate::creature_anim::AnimDriver,
crate::creature_anim::PosedRig,
super::BoneAttach,
super::equipment::HeldAttached,
)>()
+1
View File
@@ -480,6 +480,7 @@ mod tests {
animation_lookup: lookup,
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
}
}
+24 -2
View File
@@ -246,7 +246,11 @@ pub(crate) fn classify_entity_interior(
// One down-ray (and at most one probe fold) per unit per run: parts share their anchor's
// resolved law, so an 8-part body re-testing after a step costs one ray, not eight.
let mut verdicts: HashMap<Entity, AppliedLaw> = HashMap::new();
let _t0 = std::time::Instant::now();
let (mut n_parts, mut n_resolved) = (0usize, 0usize);
let mut resolve_us = 0.0f32;
for (mut lit, mut material, mut tag) in &mut parts {
n_parts += 1;
let Ok(anchor_t) = anchors.get(lit.anchor) else {
continue; // anchor despawning this frame — the parts go with it
};
@@ -277,7 +281,9 @@ pub(crate) fn classify_entity_interior(
}
}
let law = *verdicts.entry(lit.anchor).or_insert_with(|| {
resolve_anchor_law(
n_resolved += 1;
let _r = std::time::Instant::now();
let out = resolve_anchor_law(
&mut commands,
&mut probes,
&wmos,
@@ -293,7 +299,9 @@ pub(crate) fn classify_entity_interior(
lit.applied,
seated,
moved,
)
);
resolve_us += _r.elapsed().as_secs_f32() * 1e6;
out
});
if moved {
lit.last_pos = pos;
@@ -350,6 +358,20 @@ pub(crate) fn classify_entity_interior(
};
lit.applied = Some(law);
}
// `WOW_INTERIOR_COST=1`: this lane's per-frame cost, in the terms that diagnose it — how many
// PARTS the walk touches vs how many ANCHORS actually re-resolve, and what the resolves cost.
// The split is the whole diagnosis: on 2026-07-27 the lane read 11.3 ms/frame at the LBRS pin
// and the walk was 0.1 ms of it — 35 moving units paying ~300 us each in `resolve_anchor_law`,
// which is what sent the hunt into the WMO column rays (decision 0711) rather than into this
// loop. Cheap enough to leave in: two counters and one `Instant` per frame.
static COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if *COST.get_or_init(|| std::env::var_os("WOW_INTERIOR_COST").is_some()) {
eprintln!(
"[interior-cost] parts={n_parts} anchors_resolved={n_resolved} resolve_ms={:.2} total_ms={:.2}",
resolve_us / 1000.0,
_t0.elapsed().as_secs_f32() * 1000.0
);
}
}
/// Resolve one anchor's indoor law this run: the down-ray verdict (or, `!moved`, a ramp-only
+4
View File
@@ -225,6 +225,7 @@ mod tests {
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
group_collision_grids: Vec::new(),
collision_bounds: None,
collision: None,
collision_camera: None,
@@ -234,6 +235,7 @@ mod tests {
group_bounds: Vec::new(),
group_footprints: Vec::new(),
group_footprint_bounds: Vec::new(),
group_footprint_grids: Vec::new(),
group_light_refs: Vec::new(),
group_liquids: Vec::new(),
doodad_base: Default::default(),
@@ -307,6 +309,7 @@ mod tests {
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
group_collision_grids: Vec::new(),
collision_bounds: None,
collision: None,
collision_camera: None,
@@ -316,6 +319,7 @@ mod tests {
group_bounds: Vec::new(),
group_footprints: Vec::new(),
group_footprint_bounds: Vec::new(),
group_footprint_grids: Vec::new(),
group_light_refs: Vec::new(),
group_liquids: Vec::new(),
doodad_base: Default::default(),
+59 -20
View File
@@ -35,6 +35,23 @@ static WINDOW: std::sync::LazyLock<Option<(f32, u32)>> = std::sync::LazyLock::ne
Some((at.trim().parse().ok()?, n.trim().parse().unwrap_or(8)))
});
/// Parsed `$WOW_PARTICLE_DEPTHDUMP_BONES` — dump ONLY emitters mounted on these bones (empty = all).
/// A live world scene has dozens of drawing emitters; unfiltered, the dump floods the log and costs
/// the framerate of the very session it is measuring. Scoping to the subject's bones (the
/// voidwalker's eyes are 60/61) makes the dump free when the subject is off screen, so the window
/// can stay open for a whole director-driven session instead of a timed guess.
static BONES: std::sync::LazyLock<Vec<u32>> = std::sync::LazyLock::new(|| {
std::env::var("WOW_PARTICLE_DEPTHDUMP_BONES")
.ok()
.map(|v| v.split(',').filter_map(|b| b.trim().parse().ok()).collect())
.unwrap_or_default()
});
/// Is this emitter's bone in the `$WOW_PARTICLE_DEPTHDUMP_BONES` scope (always true when unset)?
pub(super) fn bone_selected(bone: u32) -> bool {
BONES.is_empty() || BONES.contains(&bone)
}
/// This frame's dump index if the window is open (advances the counter), else `None`. Called once
/// per `simulate_particles` run.
pub(super) fn frame(elapsed: f32, count: &mut u32) -> Option<u32> {
@@ -55,6 +72,12 @@ pub(super) fn dump_emitter(
particles: &[Particle],
dframe: &DrawFrame,
placement: &Transform,
// The world point births are folded through THIS frame (the sim's own `emitter_world`, passed
// rather than recomputed so it cannot drift from the formula births actually use). Tracked
// across a dump window it measures our **eye-bone sway** — the number wow-re's rig puts at
// 0.128 units (11.7 cm) over the Stand cycle, and the discriminator for "do our births sample
// the current animated palette or a rest pose" (`part-anchoring-live-bone.md` §5.2).
birth_world: Vec3,
basis: &CamBasis,
cam_tf: &GlobalTransform,
camera: &Camera,
@@ -73,29 +96,38 @@ pub(super) fn dump_emitter(
};
// The mesh's OWN first quad, exactly as last expansion wrote it (anchor-relative) — read back
// from the asset rather than recomputed, so vertex-data corruption is visible as itself.
// An EMPTY buffer is a real state, not an impossible one: an emitter's pool goes live one
// frame before that pool is first expanded into the mesh, so a live-world dump catches
// `pos == []` and indexing `pos[0]` panics the sim (it did, mid-session, on the director's
// client). Report the empty mesh as itself — it is exactly the "drawable numbers, nothing in
// the vertex buffer" state this dump exists to make visible.
if let Some(bevy::mesh::VertexAttributeValues::Float32x3(pos)) =
mesh_positions.and_then(|m| m.attribute(Mesh::ATTRIBUTE_POSITION))
{
// Per-quad diagonal |v2−v0| over the WHOLE buffer — the young (big) quads live at the
// tail, and only reading quad 0 (the oldest, smallest) once mis-called this mesh "sane".
let diag = |q: &[[f32; 3]]| (Vec3::from(q[2]) - Vec3::from(q[0])).length();
let quads: Vec<f32> = pos.chunks_exact(4).map(diag).collect();
let (mut dmin, mut dmax) = (f32::MAX, f32::MIN);
for &d in &quads {
dmin = dmin.min(d);
dmax = dmax.max(d);
}
info!(
"PARTICLE_DEPTHDUMP f={fidx} mesh quads={} diag_min={dmin:.4} diag_max={dmax:.4} \
if pos.is_empty() {
info!("PARTICLE_DEPTHDUMP f={fidx} mesh EMPTY (0 verts — pool live, not yet expanded)");
} else {
// Per-quad diagonal |v2−v0| over the WHOLE buffer — the young (big) quads live at the
// tail, and only reading quad 0 (the oldest, smallest) once mis-called this mesh "sane".
let diag = |q: &[[f32; 3]]| (Vec3::from(q[2]) - Vec3::from(q[0])).length();
let quads: Vec<f32> = pos.chunks_exact(4).map(diag).collect();
let (mut dmin, mut dmax) = (f32::MAX, f32::MIN);
for &d in &quads {
dmin = dmin.min(d);
dmax = dmax.max(d);
}
info!(
"PARTICLE_DEPTHDUMP f={fidx} mesh quads={} diag_min={dmin:.4} diag_max={dmax:.4} \
first=({:.4},{:.4},{:.4}) last=({:.4},{:.4},{:.4})",
quads.len(),
pos[0][0],
pos[0][1],
pos[0][2],
pos[pos.len() - 1][0],
pos[pos.len() - 1][1],
pos[pos.len() - 1][2],
);
quads.len(),
pos[0][0],
pos[0][1],
pos[0][2],
pos[pos.len() - 1][0],
pos[pos.len() - 1][1],
pos[pos.len() - 1][2],
);
}
}
let view_from_world = cam_tf.to_matrix().inverse();
let clip_from_view = projection.get_clip_from_view();
@@ -114,7 +146,8 @@ pub(super) fn dump_emitter(
};
info!(
"PARTICLE_DEPTHDUMP f={fidx} emitter bone={} pos=({:.3},{:.3},{:.3}) blend={:?} pool={} \
tex_resident={texture_resident} mesh_verts={} vis={vis:?}",
tex_resident={texture_resident} mesh_verts={} vis={vis:?} \
birth=({:.4},{:.4},{:.4}) joint=({:.4},{:.4},{:.4})",
def.bone,
def.position[0],
def.position[1],
@@ -122,6 +155,12 @@ pub(super) fn dump_emitter(
def.blend,
particles.len(),
mesh_positions.map_or(0, Mesh::count_vertices),
birth_world.x,
birth_world.y,
birth_world.z,
placement.translation.x,
placement.translation.y,
placement.translation.z,
);
// Sample the pool EVENLY by index (retain order = age order), not the first four: the pool's
// head is its oldest, near-contemporary particles, and a cap there hides both the young end
+6 -1
View File
@@ -759,13 +759,18 @@ pub(super) fn simulate_particles(
// Booth emitters are skipped — their pixels belong to a booth camera's target, not the
// world depth buffer `WOW_DEPTH` reads.
if let Some(fidx) = dump_frame {
if !is_booth && def.geometry_model.is_none() && !particles.is_empty() {
if !is_booth
&& def.geometry_model.is_none()
&& !particles.is_empty()
&& super::depthdump::bone_selected(u32::from(def.bone))
{
super::depthdump::dump_emitter(
fidx,
def,
particles,
&frame,
placement,
emitter_world,
&cam,
cam_tf,
camera,
+31
View File
@@ -406,3 +406,34 @@ fn journal_fps(
let _ = f.write_all(line.as_bytes());
}
}
/// Whole-process CPU seconds consumed so far — **user + system, summed across every thread**
/// (`getrusage(RUSAGE_SELF)`).
///
/// The perf probes report wall-clock frame time, which on this machine is not a usable regression
/// instrument: parallel session worktrees build on the same 14 cores, and two identical probe runs
/// of the same pin came back 49.6 ms and 28.6 ms apart purely on machine load. CPU-per-frame moves
/// with the work we actually do, not with who else is compiling — and it is the metric the Mac
/// report is written in ("250 % CPU at 59 fps" against 1.12.1's "100 % at 160"), so a probe that
/// prints it can be compared against a reporter's number directly.
///
/// Non-unix returns `None`: the probes print the field only where the platform answers.
pub(crate) fn process_cpu_secs() -> Option<f64> {
#[cfg(unix)]
{
// SAFETY: `getrusage` writes a fully-initialized `rusage` into the out-param and reads
// nothing from it; zeroed is a valid starting value for a C struct of plain integers.
unsafe {
let mut ru: libc::rusage = std::mem::zeroed();
if libc::getrusage(libc::RUSAGE_SELF, &mut ru) != 0 {
return None;
}
let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 * 1e-6;
Some(secs(ru.ru_utime) + secs(ru.ru_stime))
}
}
#[cfg(not(unix))]
{
None
}
}
@@ -128,6 +128,7 @@ fn forge_floor_lane_map() {
let verdict = area_down_ray(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_collision_grids,
&model.group_nav,
probe,
subject.terrain_z(probe),
@@ -230,6 +231,7 @@ fn inn_corridor_light_probe() {
let verdict = area_down_ray(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_collision_grids,
&model.group_nav,
probe,
terrain,
@@ -289,6 +291,7 @@ fn inn_corridor_light_probe() {
let verdict = area_down_ray(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_collision_grids,
&model.group_nav,
probe,
terrain,
@@ -466,6 +469,7 @@ fn world_point_light_probe() {
let zone = area_down_ray(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_collision_grids,
&model.group_nav,
probe_local,
terrain_local,
@@ -474,6 +478,7 @@ fn world_point_light_probe() {
let light = down_ray_claim(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_collision_grids,
&model.group_nav,
probe_local,
terrain_local,
@@ -233,6 +233,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
portal_refs: portals.refs.clone(),
group_nav,
fogs: root.fogs().to_vec(),
group_collision_grids: benilla_assets::collision_tri_grids(&group_collision_tris),
group_collision_tris,
group_camera_only_tris,
group_collision_bounds,
@@ -244,6 +245,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
lights: Vec::new(),
group_bounds: root.group_infos().to_vec(),
group_footprint_bounds: benilla_assets::footprint_tri_bounds(&group_footprints),
group_footprint_grids: benilla_assets::footprint_tri_grids(&group_footprints),
group_footprints,
group_light_refs: Vec::new(),
group_liquids: Vec::new(),
+86 -6
View File
@@ -143,6 +143,7 @@ pub(super) fn track_area_interior(
if let Some(gi) = area_down_ray(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_collision_grids,
&model.group_nav,
eye_local,
terrain_local,
@@ -321,6 +322,7 @@ fn room_at(
if let Some(gi) = area_down_ray(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_collision_grids,
&model.group_nav,
probe_local,
terrain_local,
@@ -424,6 +426,7 @@ pub(crate) fn indoor_verdict_at<'a>(
let Some(claim) = down_ray_claim(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_collision_grids,
&model.group_nav,
probe_local,
terrain_local,
@@ -494,35 +497,41 @@ pub(super) fn footprint_sample(
continue;
}
}
for (ti, tri) in fp.indices.chunks_exact(3).enumerate() {
// The narrow phase: the group's column index when it has one, else every face. The index
// yields a superset in ascending face order (`column_grid`), so the exact-z tie below —
// "the LATER face wins" — resolves exactly as the linear scan resolved it.
let mut consider = |ti: usize| {
let Some(tri) = fp.indices.get(ti * 3..ti * 3 + 3) else {
return;
};
let (Some(&a), Some(&b), Some(&c)) = (
fp.positions.get(tri[0] as usize),
fp.positions.get(tri[1] as usize),
fp.positions.get(tri[2] as usize),
) else {
continue;
return;
};
// 2D barycentric in the down-ray's projection (x/y), inclusive edges.
let det = (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]);
if det.abs() < 1e-9 {
continue;
return;
}
let wb = ((px - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (py - a[1])) / det;
let wc = ((b[0] - a[0]) * (py - a[1]) - (px - a[0]) * (b[1] - a[1])) / det;
let wa = 1.0 - wb - wc;
if wa < 0.0 || wb < 0.0 || wc < 0.0 {
continue;
return;
}
let z = wa * a[2] + wb * b[2] + wc * c[2];
if z > pz || best.is_some_and(|(bz, ..)| z < bz) {
continue;
return;
}
let (Some(ca), Some(cb), Some(cc)) = (
fp.mocv.get(tri[0] as usize),
fp.mocv.get(tri[1] as usize),
fp.mocv.get(tri[2] as usize),
) else {
continue;
return;
};
let mocv = [
wa * f32::from(ca[0]) + wb * f32::from(cb[0]) + wc * f32::from(cc[0]),
@@ -531,6 +540,10 @@ pub(super) fn footprint_sample(
];
let mopy = fp.mopy_flags.get(ti).is_some_and(|f| f & 0x1 != 0);
best = Some((z, gi, mocv, mopy));
};
match model.group_footprint_grids.get(gi).and_then(Option::as_ref) {
Some(grid) => grid.candidates(px, py).for_each(&mut consider),
None => (0..fp.indices.len() / 3).for_each(&mut consider),
}
}
best.map(|(_, gi, mocv, mopy)| (gi, mocv.map(|v| v.round().clamp(0.0, 255.0) as u8), mopy))
@@ -587,6 +600,7 @@ mod tests {
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
group_collision_grids: Vec::new(),
collision_bounds: None,
collision: None,
collision_camera: None,
@@ -596,6 +610,7 @@ mod tests {
group_bounds: Vec::new(),
group_footprints: Vec::new(),
group_footprint_bounds: Vec::new(),
group_footprint_grids: Vec::new(),
group_light_refs: Vec::new(),
group_liquids: Vec::new(),
doodad_base: Default::default(),
@@ -624,6 +639,71 @@ mod tests {
]
}
/// A dungeon-scale footprint group: a `n × n` grid of floor quads at a sawtooth height, so a
/// column's answer depends on WHICH face wins, not merely on hitting something. Big enough
/// (>= 64 faces) that [`ColumnGrid::build`] actually indexes it.
fn slab_field(n: usize) -> FootprintTris {
let (mut positions, mut indices, mut mocv, mut mopy_flags) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
for iy in 0..n {
for ix in 0..n {
let (x, y) = (ix as f32 * 4.0, iy as f32 * 4.0);
// Sawtooth z so neighbouring quads differ — a mis-picked face changes the verdict.
let z = ((ix + iy) % 3) as f32 * 0.5;
let base = positions.len() as u16;
positions.extend_from_slice(&[
[x, y, z],
[x + 4.0, y, z],
[x + 4.0, y + 4.0, z],
[x, y + 4.0, z],
]);
let shade = ((ix * 7 + iy * 13) % 200) as u8;
mocv.extend_from_slice(&[[shade, shade / 2, shade / 3]; 4]);
indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
mopy_flags.extend_from_slice(&[0, 0]);
}
}
FootprintTris {
positions,
indices,
mocv,
mopy_flags,
}
}
/// **The column index never changes a verdict.** Same model, same columns, indexed vs not:
/// identical `(group, MOCV, MOPY)` every time — including the exact-z ties the sawtooth field
/// manufactures, where the answer depends on the "later face wins" rule the index's ascending
/// order preserves. This is what lets the index be a pure accelerator for the lighting lane
/// (the 0330/0364 exactness contract, one rung deeper: inside the group, not just at it).
#[test]
fn footprint_column_index_is_exact() {
let mut model = bare_model();
model.group_footprints = vec![Some(slab_field(12))];
model.group_footprint_bounds = footprint_tri_bounds(&model.group_footprints);
let grids = benilla_assets::footprint_tri_grids(&model.group_footprints);
assert!(grids[0].is_some(), "the field must actually be indexed");
let mut probed_hits = 0;
for gx in -2..=50 {
for gy in -2..=50 {
let probe = [gx as f32 * 1.1, gy as f32 * 0.9, 3.0];
model.group_footprint_grids = Vec::new();
let linear = footprint_sample(&model, probe);
model.group_footprint_grids = grids.clone();
let indexed = footprint_sample(&model, probe);
assert_eq!(
linear, indexed,
"column {probe:?}: the index changed the footprint verdict"
);
probed_hits += usize::from(linear.is_some());
}
}
assert!(
probed_hits > 500,
"the sweep must land on the field, got {probed_hits} hits"
);
}
/// The broad phase is a pure skip, never a re-ranking: the verdict with load-derived face
/// bounds equals the verdict with NO bounds at all (the fixtures' `Vec::new()` — every group
/// scanned). Pins the 0364 footprint cull's exactness contract, the 0330 argument one lane
+2
View File
@@ -826,6 +826,7 @@ mod tests {
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
group_collision_grids: Vec::new(),
collision_bounds: None,
collision: None,
collision_camera: None,
@@ -835,6 +836,7 @@ mod tests {
group_bounds: Vec::new(),
group_footprints: Vec::new(),
group_footprint_bounds: Vec::new(),
group_footprint_grids: Vec::new(),
group_light_refs: Vec::new(),
group_liquids: Vec::new(),
doodad_base: Default::default(),
+157 -14
View File
@@ -41,6 +41,7 @@
//! of every place the client's verdict is *right*: any terrain at/below the eye (doorsteps, streets,
//! open country) or any Leg A/B result leaves the faithful answer untouched.
use benilla_assets::column_grid::ColumnGrid;
use benilla_assets::{WmoGroupNav, WmoModel, WmoPortalInfo, WmoPortalRef};
pub(crate) use benilla_formats::triangle_z_at as floor_z_at;
@@ -68,6 +69,7 @@ pub(crate) fn down_ray_seeds(
) -> DownRaySeeds {
down_ray_pick(
&model.group_collision_tris,
&model.group_collision_grids,
&model.group_camera_only_tris,
&model.group_nav,
&model.portal_vertices,
@@ -87,6 +89,7 @@ pub(crate) fn down_ray_seeds(
#[allow(clippy::too_many_arguments)] // the model's stored pieces, passed as parallel slices
pub(crate) fn down_ray_pick(
tris: &[Vec<[[f32; 3]; 3]>],
grids: &[Option<ColumnGrid>],
camera_only_tris: &[Vec<[[f32; 3]; 3]>],
nav: &[WmoGroupNav],
portal_vertices: &[[f32; 3]],
@@ -112,13 +115,26 @@ pub(crate) fn down_ray_pick(
if !in_column(g) {
continue;
}
for tri in tris.get(gi).into_iter().flatten() {
// Narrowed by the group's column index where it has one — same faces, same order (see
// `down_ray_claim`).
let mut consider = |tri: &[[f32; 3]; 3]| {
if let Some(z) = floor_z_at(tri, eye[0], eye[1]) {
if z <= eye[2] && z > best_z {
best_z = z;
best = Some(gi);
}
}
};
let group_tris = tris.get(gi).map(Vec::as_slice).unwrap_or_default();
match grids.get(gi).and_then(Option::as_ref) {
Some(grid) => {
for i in grid.candidates(eye[0], eye[1]) {
if let Some(tri) = group_tris.get(i) {
consider(tri);
}
}
}
None => group_tris.iter().for_each(&mut consider),
}
}
@@ -257,12 +273,13 @@ const ZONE_RAY_LEN: f32 = 1000.0;
pub(crate) fn area_down_ray(
tris: &[Vec<[[f32; 3]; 3]>],
bounds: &[Option<([f32; 3], [f32; 3])>],
grids: &[Option<ColumnGrid>],
nav: &[WmoGroupNav],
eye: [f32; 3],
terrain_z: Option<f32>,
outdoor_mask: u32,
) -> Option<usize> {
down_ray_claim(tris, bounds, nav, eye, terrain_z, outdoor_mask)
down_ray_claim(tris, bounds, grids, nav, eye, terrain_z, outdoor_mask)
.and_then(|c| (!c.outdoor).then_some(c.group))
}
@@ -288,6 +305,7 @@ pub(crate) struct DownRayClaim {
pub(crate) fn down_ray_claim(
tris: &[Vec<[[f32; 3]; 3]>],
bounds: &[Option<([f32; 3], [f32; 3])>],
grids: &[Option<ColumnGrid>],
nav: &[WmoGroupNav],
eye: [f32; 3],
terrain_z: Option<f32>,
@@ -320,13 +338,26 @@ pub(crate) fn down_ray_claim(
if !column_owned(gi) {
continue;
}
for tri in group_tris {
// The narrow phase: the group's column index when it has one, else its whole face list.
// The index yields a superset in ascending order (`column_grid`), so the first-wins tie on
// an exact `z` match below resolves exactly as the linear scan resolved it.
let mut consider = |tri: &[[f32; 3]; 3]| {
if let Some(z) = floor_z_at(tri, eye[0], eye[1]) {
if z <= eye[2] && z > best_z {
best_z = z;
best = Some(gi);
}
}
};
match grids.get(gi).and_then(Option::as_ref) {
Some(grid) => {
for i in grid.candidates(eye[0], eye[1]) {
if let Some(tri) = group_tris.get(i) {
consider(tri);
}
}
}
None => group_tris.iter().for_each(&mut consider),
}
}
let in_group = best?;
@@ -407,7 +438,18 @@ mod tests {
nav: &[WmoGroupNav],
eye: [f32; 3],
) -> Option<usize> {
down_ray_pick(tris, &[], nav, &[], &[], &[], eye, None).in_group
down_ray_pick(
tris,
&benilla_assets::collision_tri_grids(tris),
&[],
nav,
&[],
&[],
&[],
eye,
None,
)
.in_group
}
/// `down_ray_pick` with a portal graph, no terrain — the pre-terrain-race fixtures.
@@ -421,6 +463,7 @@ mod tests {
) -> DownRaySeeds {
down_ray_pick(
tris,
&benilla_assets::collision_tri_grids(tris),
&[],
nav,
portal_vertices,
@@ -673,7 +716,70 @@ mod tests {
terrain_z: Option<f32>,
) -> Option<usize> {
let (bounds, _) = benilla_assets::collision_tri_bounds(tris);
area_down_ray(tris, &bounds, nav, eye, terrain_z, EXTERIOR)
let grids = benilla_assets::collision_tri_grids(tris);
area_down_ray(tris, &bounds, &grids, nav, eye, terrain_z, EXTERIOR)
}
/// **The column index never changes a down-ray verdict.** A dungeon-shaped face set (a big
/// stack of small floor quads at varying heights, spread over several groups) answered with
/// the per-group column index must equal the answer from the plain linear scan, column for
/// column — group, depth and outdoor class. The index is a narrow phase, and a narrow phase
/// that can re-rank moves a unit's room, its light and the zone name.
#[test]
fn down_ray_column_index_is_exact() {
// Three stacked storeys of floor tiles in the same XY column range — the Blackrock shape
// that defeats the per-group bounds broad phase (every group owns the column).
let storey = |z: f32| -> Vec<[[f32; 3]; 3]> {
let mut out = Vec::new();
for ix in 0..10 {
for iy in 0..10 {
let (x, y) = (ix as f32 * 3.0, iy as f32 * 3.0);
// Vary z within the storey so which tile wins is observable.
let tz = z + ((ix + iy) % 4) as f32 * 0.25;
out.push([[x, y, tz], [x + 3.0, y, tz], [x + 3.0, y + 3.0, tz]]);
out.push([[x, y, tz], [x + 3.0, y + 3.0, tz], [x, y + 3.0, tz]]);
}
}
out
};
let tris = vec![storey(0.0), storey(8.0), storey(16.0)];
let navs: Vec<WmoGroupNav> = (0..3)
.map(|i| {
nav(
0,
[0.0, 0.0, i as f32 * 8.0],
[30.0, 30.0, i as f32 * 8.0 + 8.0],
0,
0,
)
})
.collect();
let (bounds, _) = benilla_assets::collision_tri_bounds(&tris);
let grids = benilla_assets::collision_tri_grids(&tris);
assert!(
grids.iter().all(Option::is_some),
"each storey must actually be indexed"
);
let mut hits = 0;
for gx in -1..=32 {
for gy in -1..=32 {
for eye_z in [1.0f32, 9.5, 17.0, 25.0] {
let eye = [gx as f32 * 0.97, gy as f32 * 1.03, eye_z];
let linear = down_ray_claim(&tris, &bounds, &[], &navs, eye, None, EXTERIOR);
let indexed =
down_ray_claim(&tris, &bounds, &grids, &navs, eye, None, EXTERIOR);
assert_eq!(
linear, indexed,
"column {eye:?}: the index changed the down-ray claim"
);
hits += usize::from(linear.is_some());
}
}
}
assert!(
hits > 1000,
"the sweep must land on the storeys, got {hits}"
);
}
#[test]
@@ -746,7 +852,7 @@ mod tests {
let (bounds, _) = benilla_assets::collision_tri_bounds(&tris);
let eye = [0.0, 0.0, 1.0];
assert_eq!(
area_down_ray(&tris, &bounds, &[street], eye, None, EXTERIOR),
area_down_ray(&tris, &bounds, &[], &[street], eye, None, EXTERIOR),
Some(0),
"zone-text law: indoors"
);
@@ -754,6 +860,7 @@ mod tests {
area_down_ray(
&tris,
&bounds,
&[],
&[street],
eye,
None,
@@ -781,18 +888,34 @@ mod tests {
let (bounds, _) = benilla_assets::collision_tri_bounds(&tris);
let mask = EXTERIOR | EXTERIOR_LIT;
// Standing on the deck (terrain far below): an OUTDOOR claim, depth = probe − face.
let c = down_ray_claim(&tris, &bounds, &[deck], [0.0, 0.0, 5.5], Some(-25.0), mask)
.expect("the deck face claims the column");
let c = down_ray_claim(
&tris,
&bounds,
&[],
&[deck],
[0.0, 0.0, 5.5],
Some(-25.0),
mask,
)
.expect("the deck face claims the column");
assert!(c.outdoor && c.group == 0);
assert!((c.depth - 0.5).abs() < 1e-6);
// Terrain strictly nearer (probe over open ground above a buried deck): no claim at all.
assert_eq!(
down_ray_claim(&tris, &bounds, &[deck], [0.0, 0.0, 8.0], Some(7.0), mask),
down_ray_claim(
&tris,
&bounds,
&[],
&[deck],
[0.0, 0.0, 8.0],
Some(7.0),
mask
),
None
);
// An interior group's face under the same probe: a non-outdoor claim.
let room = nav(0, [-10.0, -10.0, 0.0], [10.0, 10.0, 10.0], 0, 0);
let c = down_ray_claim(&tris, &bounds, &[room], [0.0, 0.0, 5.5], None, mask)
let c = down_ray_claim(&tris, &bounds, &[], &[room], [0.0, 0.0, 5.5], None, mask)
.expect("the room face claims the column");
assert!(!c.outdoor);
}
@@ -815,7 +938,18 @@ mod tests {
let tris = [quad(0.0, 20.0)];
// A camera 2 yd over the tunnel floor. With no terrain leg (or terrain far below), it is inside.
assert_eq!(
down_ray_pick(&tris, &[], &[mine], &[], &[], &[], [0.0, 0.0, 2.0], None).in_group,
down_ray_pick(
&tris,
&[],
&[],
&[mine],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
None
)
.in_group,
Some(0)
);
// The hillside surface sits at z=8, between the eye (z=2) and... no: the eye is BELOW the
@@ -825,6 +959,7 @@ mod tests {
down_ray_pick(
&tris,
&[],
&[],
&[mine],
&[],
&[],
@@ -841,6 +976,7 @@ mod tests {
down_ray_pick(
&tris,
&[],
&[],
&[mine],
&[],
&[],
@@ -857,6 +993,7 @@ mod tests {
down_ray_pick(
&tris,
&[],
&[],
&[mine],
&[],
&[],
@@ -872,6 +1009,7 @@ mod tests {
down_ray_pick(
&tris,
&[],
&[],
&[mine],
&[],
&[],
@@ -894,6 +1032,7 @@ mod tests {
// Both faithful legs miss, no terrain below ⇒ Leg C names the room, single root.
let s = down_ray_pick(
&walk,
&[],
&detail,
&[pocket],
&[],
@@ -908,6 +1047,7 @@ mod tests {
assert_eq!(
down_ray_pick(
&walk,
&[],
&detail,
&[pocket],
&[],
@@ -930,13 +1070,14 @@ mod tests {
assert_eq!(
down_ray_pick(
&[Vec::new()],
&[],
&detail,
&[pocket],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
Some(1.9),
Some(1.9)
)
.in_group,
None
@@ -949,13 +1090,14 @@ mod tests {
assert_eq!(
down_ray_pick(
&walk,
&[],
&det2,
&[pocket, hall],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
None,
None
)
.in_group,
Some(1)
@@ -965,13 +1107,14 @@ mod tests {
assert_eq!(
down_ray_pick(
&[Vec::new()],
&[],
&detail,
&[ext],
&[],
&[],
&[],
[0.0, 0.0, 2.0],
None,
None
)
.in_group,
None