From 90efd4efad1f8425f490153e40a0e30e30b6faa1 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 27 Jul 2026 19:38:40 +0700 Subject: [PATCH] the voidwalker's eyes come back, and skinning becomes ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The voidwalker's eye glow vanished whenever the camera rose above eye level. Three investigations said depth; measuring the depth buffer inside the live quads said otherwise — the fragments reached the framebuffer every frame, and the body's own transparent batches painted over them. The real client draws a model's particles after that model's batches, always. Ours now does: every effect carries a draw-order rung sized to clear exactly its own model. - Widened to every effect family after asking "what else does this fix": ribbon trails (a wisp's streamers wove six batches deep into its own body and flickered as the head whipped) and 3-D model particles (Cone of Cold's shards). A census over all 9,691 models found 967 whose own body could paint over their effects; all take the rung now. - The skinning lane is ours end to end: every rig's joint matrices live in our shared GPU buffer, written only when a rig actually moved, and our shader reads them. The engine's lane rewrote the entire joint buffer every frame and swept every entity in the world for changed transforms — that machinery now runs against empty sets. Another 6.7 CPU ms off the worst reported dungeon spot, which now holds a flat 60 fps with p99 under 19 ms. - Getting around while developing: Ctrl+Cmd+G lands your character where the free-fly camera is, and Alt-clicking the world map teleports you there (both ride the server's own teleport, GM-gated). The frozen-body coordinates the panel used to copy while flying now follow the camera. - A stuck main thread now diagnoses itself: a watchdog samples our own stacks and prints where the time went, and a hung teardown names its last system instead of hanging the window. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + crates/benilla-assets/src/m2.rs | 20 +- crates/benilla-formats/Cargo.toml | 3 + .../benilla-formats/examples/batch_alpha.rs | 49 +++ crates/benilla-formats/examples/emdump.rs | 49 +++ .../examples/eye_burial_anim.rs | 290 ++++++++++++++++ .../src/bin/benilla-extract/main.rs | 13 + .../src/bin/benilla-extract/scan.rs | 94 ++++++ crates/benilla-formats/src/lib.rs | 29 +- .../benilla-formats/src/models/m2_batches.rs | 71 ++++ crates/benilla-ui/src/script/worldmap.rs | 22 ++ crates/benilla/src/capture/depth_probe.rs | 254 +++++++++++++- crates/benilla/src/capture/mod.rs | 93 +++++- crates/benilla/src/capture/phase_probe.rs | 111 +++++- crates/benilla/src/capture/probes.rs | 114 ++++++- crates/benilla/src/debug_panel/world.rs | 67 +++- crates/benilla/src/entities/attach/mod.rs | 1 + crates/benilla/src/entities/equipment.rs | 22 +- crates/benilla/src/entities/spell_fx.rs | 55 ++- crates/benilla/src/entities/wmo_props.rs | 1 + crates/benilla/src/main.rs | 9 +- crates/benilla/src/map_proj.rs | 34 +- crates/benilla/src/particles.rs | 94 +++++- crates/benilla/src/particles/material.rs | 11 +- crates/benilla/src/particles/model.rs | 13 + crates/benilla/src/particles/sim.rs | 15 +- crates/benilla/src/perf.rs | 315 +++++++++++++++++- crates/benilla/src/player.rs | 16 +- crates/benilla/src/player/land.rs | 106 ++++++ crates/benilla/src/ribbons.rs | 24 +- crates/benilla/src/terrain_stream/spawn/fx.rs | 3 + .../benilla/src/terrain_stream/spawn/mod.rs | 2 + crates/benilla/src/ui_world_map.rs | 81 +++++ 33 files changed, 1996 insertions(+), 86 deletions(-) create mode 100644 crates/benilla-formats/examples/batch_alpha.rs create mode 100644 crates/benilla-formats/examples/emdump.rs create mode 100644 crates/benilla-formats/examples/eye_burial_anim.rs create mode 100644 crates/benilla/src/player/land.rs diff --git a/Cargo.lock b/Cargo.lock index 9b97b725..6f39573a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -647,6 +647,7 @@ dependencies = [ "benilla-wdt", "benilla-wmo", "clap", + "glam 0.31.1", "image", ] diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index 95d98fa9..390e868f 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -148,6 +148,11 @@ pub struct ModelEmitter { /// particles render as 3-D instances of it instead of billboard quads (wow-re /// `part-model-particles.md`). `None` when unauthored. pub geometry: Option>, + /// How far the OWNER model's own transparent-pass batches sort from its origin, model-local + /// yards — the bound the draw-order rung is sized from (`particles::owner_last_bias`, + /// decisions 0719/0721). Computed by [`benilla_formats::m2_owner_reach`], which is where the + /// reasoning lives. + pub owner_reach: f32, } /// Whether looping `anim` would look like anything other than the **static mesh** — the doodad @@ -204,6 +209,12 @@ pub struct ModelRibbon { pub def: benilla_formats::RibbonEmitterDef, pub texture: Option>, pub bone_pivot: [f32; 3], + /// The owner model's reach — same field, same bound, same reason as + /// [`ModelEmitter::owner_reach`]. A trail is one of its model's emitters and takes the same + /// owner-last draw-order rung: measured on the wisp (14 blend batches, 3 streamers), its + /// trails otherwise interleave with its own body, 6 batches deep, and which batches are over + /// which streamer *changes frame to frame* as they whip (decision 0721). + pub owner_reach: f32, } /// One M2 light block of an [`M2Model`]: the raw [`M2Light`] record plus — like @@ -240,6 +251,10 @@ impl AssetLoader for M2ModelLoader { // skins are applied at the spawn site from CreatureDisplayInfo), so an empty dir is correct. let subs = parse_m2_render_submeshes(&bytes, "", &[]).map_err(to_io)?; let bounds = parse_m2_bounds(&bytes).ok(); + // The owner-last draw-order bound, once for the whole model: every effect this model + // authors (quad emitters, ribbon trails, model-particle instances) takes the rung it sizes, + // because the reference draws them all in the one post-batch bracket. + let owner_reach = benilla_formats::m2_owner_reach(&subs); // The collision hull (collide-iff-hull): keep only a non-empty mesh so the spawn site can skip // hull-less props cheaply. let collision = parse_m2_collision_hull(&bytes) @@ -336,11 +351,13 @@ impl AssetLoader for M2ModelLoader { bone_pivot, recursion, geometry, + owner_reach, } }) .collect(); - // Ribbon emitters (trails): same texture resolution + bone-pivot bake as the particles. + // Ribbon emitters (trails): same texture resolution, bone-pivot bake and owner-reach bake + // as the particles — a trail is one of the model's emitters and draws under the same law. let ribbons = benilla_formats::parse_m2_ribbon_emitters(&bytes) .unwrap_or_default() .into_iter() @@ -359,6 +376,7 @@ impl AssetLoader for M2ModelLoader { def, texture, bone_pivot, + owner_reach, } }) .collect(); diff --git a/crates/benilla-formats/Cargo.toml b/crates/benilla-formats/Cargo.toml index 893813df..a3f8a6c4 100644 --- a/crates/benilla-formats/Cargo.toml +++ b/crates/benilla-formats/Cargo.toml @@ -17,3 +17,6 @@ benilla-blp = { path = "../benilla-blp" } image = "0.25" benilla-wdt = { path = "../benilla-wdt" } benilla-wmo = { path = "../benilla-wmo" } + +[dev-dependencies] +glam = "0.31" diff --git a/crates/benilla-formats/examples/batch_alpha.rs b/crates/benilla-formats/examples/batch_alpha.rs new file mode 100644 index 00000000..957d3e5b --- /dev/null +++ b/crates/benilla-formats/examples/batch_alpha.rs @@ -0,0 +1,49 @@ +//! Print every render batch's **animated material alpha** across a named sequence — the factor the +//! renderer multiplies into the instance's render alpha (`A = instanceAlpha × colourAlpha × weight`). +//! +//! `cargo run -p benilla-formats --example batch_alpha -- 'Creature\Voidwalker\Voidwalker.m2' 0` +//! +//! The number that matters is whether a batch ever reads **exactly 1.0**: benilla swaps a faded +//! entity onto its `AlphaMode::Blend` twin whenever `alpha < 1.0`, and that twin forces depth-write +//! ON. A batch whose authored weight never reaches 1.0 therefore never returns to the steady +//! material — so a `no_depth_write` batch (M2 flag 0x10) starts writing depth it must never write. + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let virt = args + .next() + .ok_or_else(|| anyhow::anyhow!("usage: batch_alpha [sequence slot]"))?; + let slot: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(0); + 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, "", &[])?; + println!("sequence slot {slot} — {} batches", subs.len()); + for (i, s) in subs.iter().enumerate() { + let Some(a) = s.alpha_anim.as_ref() else { + println!( + " batch {i:2}: no alpha_anim (static 1.0) depth_write={}", + !s.no_depth_write + ); + continue; + }; + let samples: Vec = (0..24u16) + .map(|k| a.sample(Some(slot), 3.0 * f32::from(k) / 24.0)) + .collect(); + let lo = samples.iter().copied().fold(f32::MAX, f32::min); + let hi = samples.iter().copied().fold(f32::MIN, f32::max); + let ever_one = samples.iter().any(|&v| v >= 1.0); + println!( + " batch {i:2}: alpha {lo:.4}..{hi:.4} ever==1.0: {ever_one:<5} \ + depth_write={:<5} additive={:<5} {}", + !s.no_depth_write, + s.additive, + if ever_one { + "" + } else { + "<-- STUCK on the blend twin" + } + ); + } + Ok(()) +} diff --git a/crates/benilla-formats/examples/emdump.rs b/crates/benilla-formats/examples/emdump.rs new file mode 100644 index 00000000..ef19b7b3 --- /dev/null +++ b/crates/benilla-formats/examples/emdump.rs @@ -0,0 +1,49 @@ +//! `emdump ` — every particle emitter one model authors, one line each. +//! +//! The per-emitter counterpart to `benilla-extract partcensus`, which answers "which models in the +//! corpus carry emitters matching this flag mask?" but never what an individual emitter *is*. B16 +//! needed the other direction — the voidwalker's eye glow is emitters 1 and 2 of four, bone 60/61, +//! flags `0x0001` (plain anchored), `Add`/`GLOWBALL.BLP` — and reading that off a hex dump by hand +//! is exactly the kind of asset fact that should be one command. +//! +//! The header line carries the model's **reach** (how far its own transparent-pass batches sort +//! from its origin — `m2_owner_reach`) and the `Transparent3d` draw-order rung it produces +//! (`owner_last_rung`): the rung is what keeps a model's effects drawing after its own transparent +//! batches, and it is sized from that number, so the two belong on screen together. + +fn main() -> anyhow::Result<()> { + let virt = std::env::args().nth(1).unwrap(); + 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 dir = virt.rsplit_once('\\').map_or("", |(d, _)| d); + let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); + // The renderer's own bound and rung, not a re-derivation of them: a survey tool has to print + // what the renderer will actually do. + let reach = benilla_formats::m2_owner_reach(&subs); + let rung = benilla_formats::owner_last_rung(reach); + println!("{virt}: reach {reach:.3} yd -> draw-order rung {rung:.0}"); + for (i, e) in benilla_formats::parse_m2_particle_emitters(&bytes)? + .iter() + .enumerate() + { + println!( + "emitter {i}: bone {:>3} flags {:#06x} model_space={} pos ({:.4},{:.4},{:.4}) blend {:?} \ + shape {:?} speed {:.4}±{:.4} grav {:.4} life {:.4} tex {:?}", + e.bone, + e.flags, + e.model_space(), + e.position[0], + e.position[1], + e.position[2], + e.blend, + e.shape, + e.emission_speed, + e.speed_variation, + e.gravity, + e.lifespan, + e.texture, + ); + } + Ok(()) +} diff --git a/crates/benilla-formats/examples/eye_burial_anim.rs b/crates/benilla-formats/examples/eye_burial_anim.rs new file mode 100644 index 00000000..2c50f099 --- /dev/null +++ b/crates/benilla-formats/examples/eye_burial_anim.rs @@ -0,0 +1,290 @@ +//! **Is the eye emitter still proud of the face once the model ANIMATES?** +//! +//! `eye_quad_pass` answers the depth contest at *bind* pose, where benilla already matches the +//! reference. Decision 0707 measured the failing case and found the split: benilla's freshest eye +//! particle — born this frame at the current bone pose — sits **1.6–4.7 cm behind the face our +//! renderer draws**, against **2.5–34 mm** at bind. "Faithful at rest" and "faithful in motion" are +//! different claims, and this is the instrument for the second one. +//! +//! It builds the bone palette ourselves, from our own parse, at sampled times across a sequence: +//! `palette[b] = parentPalette · T(pivot) · R(rot) · S(scale) · T(-pivot)`, the M2 skinning matrix. +//! Then, per sample, it reports the two numbers that decide the contest: +//! +//! - `gap` — the distance from the animated eye-bone pivot (where our particles are born, verified +//! `birth == joint`) to the **nearest skinned vertex** of the depth-writing batches. At bind this +//! is 0.0162 for bone 60 (wow-re `part-flush-emitter-depth.md` §0). If our animation swings the +//! skin centimetres past the bone, this grows — and that growth *is* the bug. +//! - `pass%` — the constant-depth quad's surviving area against the animated shell, front view. +//! +//! `cargo run -p benilla-formats --example eye_burial_anim -- 'Creature\Voidwalker\Voidwalker.m2' 0` +//! +//! A `gap` that stays near its bind value across the cycle says our pose is self-consistent and 0707's +//! centimetres came from somewhere else in the renderer. A `gap` that blows out says our skinning or +//! our animation moved the face off the bone, and the fix is there. + +use benilla_formats::{ModelAnimation, RenderSubmesh, Skeleton}; +use glam::{Mat4, Quat, Vec3}; + +/// The M2 skinning palette at time `t` (seconds into `anim`), in file-bone order. +/// +/// The pivot dance is the whole point: a bone's animated rotation/scale act **about its own pivot**, +/// so the matrix is `T(pivot) · R · S · T(-pivot)` composed under the parent. At rest (no keys) every +/// factor is identity and the palette is identity — which is why an emitter record `position` that +/// equals its bone pivot maps to itself at bind. +fn palette(skel: &Skeleton, anim: &ModelAnimation, t: f32) -> Vec { + let sample_v = |keys: &[(f32, [f32; 3])], t: f32, dflt: Vec3| -> Vec3 { + if keys.is_empty() { + return dflt; + } + match keys.iter().position(|&(kt, _)| kt >= t) { + None => Vec3::from(keys[keys.len() - 1].1), + Some(0) => Vec3::from(keys[0].1), + Some(i) => { + let (t0, a) = keys[i - 1]; + let (t1, b) = keys[i]; + let f = if t1 > t0 { (t - t0) / (t1 - t0) } else { 0.0 }; + Vec3::from(a).lerp(Vec3::from(b), f) + } + } + }; + let sample_q = |keys: &[(f32, [f32; 4])], t: f32| -> Quat { + if keys.is_empty() { + return Quat::IDENTITY; + } + let q = |v: [f32; 4]| Quat::from_xyzw(v[0], v[1], v[2], v[3]).normalize(); + match keys.iter().position(|&(kt, _)| kt >= t) { + None => q(keys[keys.len() - 1].1), + Some(0) => q(keys[0].1), + Some(i) => { + let (t0, a) = keys[i - 1]; + let (t1, b) = keys[i]; + let f = if t1 > t0 { (t - t0) / (t1 - t0) } else { 0.0 }; + q(a).slerp(q(b), f) + } + } + }; + + let mut out = vec![Mat4::IDENTITY; skel.bones.len()]; + for (i, bone) in skel.bones.iter().enumerate() { + let keys = anim.bones.iter().find(|k| k.bone as usize == i); + let (tr, rot, sc) = match keys { + Some(k) => ( + sample_v(&k.translation, t, Vec3::ZERO), + sample_q(&k.rotation, t), + sample_v(&k.scale, t, Vec3::ONE), + ), + None => (Vec3::ZERO, Quat::IDENTITY, Vec3::ONE), + }; + let pivot = Vec3::from(bone.pivot); + let local = Mat4::from_translation(pivot + tr) + * Mat4::from_quat(rot) + * Mat4::from_scale(sc) + * Mat4::from_translation(-pivot); + out[i] = match bone.parent { + p if p >= 0 && (p as usize) < i => out[p as usize] * local, + _ => local, + }; + } + out +} + +/// Skin one vertex by its 4 bone/weight pairs (weights already normalised by the parser). +fn skin(p: [f32; 3], j: [u16; 4], w: [f32; 4], pal: &[Mat4]) -> Vec3 { + let v = Vec3::from(p); + let mut acc = Vec3::ZERO; + let mut total = 0.0; + for k in 0..4 { + if w[k] <= 0.0 { + continue; + } + let Some(m) = pal.get(j[k] as usize) else { + continue; + }; + acc += m.transform_point3(v) * w[k]; + total += w[k]; + } + if total > 0.0 { + acc / total + } else { + v + } +} + +/// Every skinned triangle of the depth-writing batches, plus the nearest-vertex distance to `probe`. +fn shell(subs: &[RenderSubmesh], pal: &[Mat4], probe: Vec3) -> (Vec<[Vec3; 3]>, f32) { + let mut tris = Vec::new(); + let mut near = f32::MAX; + for s in subs { + if s.no_depth_write || s.no_depth_test || s.joints.is_empty() { + continue; + } + let pos: Vec = (0..s.positions.len()) + .map(|i| skin(s.positions[i], s.joints[i], s.weights[i], pal)) + .collect(); + for &p in &pos { + near = near.min(p.distance(probe)); + } + for t in s.indices.chunks_exact(3) { + tris.push([pos[t[0] as usize], pos[t[1] as usize], pos[t[2] as usize]]); + } + } + (tris, near) +} + +fn ray_hits(tris: &[[Vec3; 3]], orig: Vec3, dir: Vec3) -> bool { + tris.iter().any(|t| { + let (e1, e2) = (t[1] - t[0], t[2] - t[0]); + let h = dir.cross(e2); + let det = e1.dot(h); + if det.abs() < 1e-9 { + return false; + } + let inv = 1.0 / det; + let s = orig - t[0]; + let u = inv * s.dot(h); + if !(0.0..=1.0).contains(&u) { + return false; + } + let q = s.cross(e1); + let v = inv * dir.dot(q); + if v < 0.0 || u + v > 1.0 { + return false; + } + inv * e2.dot(q) > 1e-6 + }) +} + +/// Surviving area fraction of the constant-depth quad at `center`, viewed along `view`. +fn pass_fraction(tris: &[[Vec3; 3]], center: Vec3, half: f32, view: Vec3, n: usize) -> f32 { + let up = if view.z.abs() > 0.9 { Vec3::X } else { Vec3::Z }; + let right = view.cross(up).normalize(); + let real_up = right.cross(view).normalize(); + let mut passed = 0usize; + for iy in 0..n { + for ix in 0..n { + 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 + right * (fx * half) + real_up * (fy * half); + if !ray_hits(tris, p, -view) { + passed += 1; + } + } + } + passed as f32 / (n * n) as f32 +} + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let virt = args + .next() + .ok_or_else(|| anyhow::anyhow!("usage: eye_burial_anim [seq slot]"))?; + let slot: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(0); + 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 skel = benilla_formats::parse_m2_skeleton(&bytes)?; + let anims = benilla_formats::parse_m2_animations(&bytes); + let anim = anims + .iter() + .find(|a| a.seq_index == slot) + .ok_or_else(|| anyhow::anyhow!("no sequence in file slot {slot}"))?; + let emitters = benilla_formats::parse_m2_particle_emitters(&bytes)?; + + println!( + "{virt} seq slot {slot} (anim id {}, {:.3}s, {} bones keyed)", + anim.anim_id, + anim.duration, + anim.bones.len() + ); + + // Which bones does the depth-writing shell actually skin to, and does any of them sit under a + // BILLBOARD bone? A billboard joint is re-aimed at the camera every frame and its children + // inherit that rotation, so shell geometry under one moves with the *camera* — which is exactly + // the signature of a burial that appears from one camera elevation and not another. + { + let mut shell_bones: Vec = Vec::new(); + for s in &subs { + if s.no_depth_write || s.no_depth_test { + continue; + } + for (j, w) in s.joints.iter().zip(&s.weights) { + for k in 0..4 { + if w[k] > 0.0 && !shell_bones.contains(&(j[k] as usize)) { + shell_bones.push(j[k] as usize); + } + } + } + } + shell_bones.sort_unstable(); + let billboard: Vec = skel + .bones + .iter() + .enumerate() + .filter(|(_, b)| b.billboard.is_some()) + .map(|(i, _)| i) + .collect(); + let mut tainted = Vec::new(); + for &b in &shell_bones { + let (mut cur, mut hops) = (b, 0); + while cur < skel.bones.len() && hops < 64 { + if skel.bones[cur].billboard.is_some() { + tainted.push((b, cur)); + break; + } + match skel.bones[cur].parent { + p if p >= 0 => cur = p as usize, + _ => break, + } + hops += 1; + } + } + println!( + "shell skins to {} bones {:?}\nbillboard bones: {billboard:?}\nshell bones under a billboard: {tainted:?}", + shell_bones.len(), + shell_bones + ); + } + + for e in &emitters { + let b = e.bone as usize; + if b >= skel.bones.len() { + continue; + } + // Only the additive quad emitters mounted flush in the shell are the B16 subject; the + // others are printed anyway so the contrast is on the same page. + println!( + "\nemitter bone {b} pos ({:.4},{:.4},{:.4}) blend {:?}", + e.position[0], e.position[1], e.position[2], e.blend + ); + println!(" t gap(m) vs bind pass%(front) pass%(30 above)"); + let mut bind_gap = f32::NAN; + for step in 0..=8 { + let t = anim.duration * step as f32 / 8.0; + let pal = palette(&skel, anim, if step == 0 { 0.0 } else { t }); + // Where our particles are born: the emitter record position through the same palette. + let center = pal[b].transform_point3(Vec3::from(e.position)); + let (tris, near) = shell(&subs, &pal, center); + if step == 0 { + bind_gap = near; + } + let front = pass_fraction(&tris, center, 0.0833, Vec3::new(-1.0, 0.0, 0.0), 48); + let el = 30f32.to_radians(); + let above = pass_fraction( + &tris, + center, + 0.0833, + Vec3::new(-el.cos(), 0.0, -el.sin()), + 48, + ); + println!( + " {t:5.3} {near:7.4} {:+7.4} {:5.1} {:5.1}", + near - bind_gap, + front * 100.0, + above * 100.0 + ); + } + } + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 4b841c85..6885fd8a 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -228,6 +228,18 @@ enum Command { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, + /// Sweep every `.m2` (optionally under a path prefix) and count the two halves of the + /// **owner-last draw-order** law per model: the EFFECTS it authors (particle emitters + + /// ribbon trails) and the TRANSPARENT-pass batches of its own body those effects must draw + /// after (decisions 0719/0721), plus the model's reach and the draw-order rung that reach + /// produces. The population instrument for "how much does this fix, besides the one creature + /// it was found on": a model with effects AND transparent batches of its own is one whose + /// effects our distance sort could interleave with its own body; one without never had the + /// defect at all. + Fxordercensus { + /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. + prefix: Option, + }, /// Sweep every `.m2` (optionally under a path prefix) and list the models whose PARTICLE /// emitters carry any of the given file-flag bits (`M2ParticleEmitter+0x04`) — the /// population instrument for particle-flag mechanisms (which content actually authors @@ -490,6 +502,7 @@ fn main() -> Result<()> { Command::Goanimscan => scan::goanimscan(&mut chain)?, Command::Bonescan { prefix } => scan::bonescan(&mut chain, prefix.as_deref())?, Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?, + Command::Fxordercensus { prefix } => scan::fxordercensus(&mut chain, prefix.as_deref())?, Command::Partscan { mask, prefix } => { let mask = parse_u32_maybe_hex(&mask) .with_context(|| format!("parsing flag mask '{mask}'"))?; diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 87a65e31..9abc9278 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -2378,3 +2378,97 @@ pub fn cellscan(chain: &mut Chain) -> Result<()> { ); Ok(()) } + +/// Sweep every `.m2` (optionally under a path prefix) and count, per model, the two halves of the +/// **owner-last draw-order** law: the EFFECTS a model authors (particle emitters + ribbon trails) +/// and the TRANSPARENT-pass batches of its own body those effects must draw after (decisions +/// 0719/0721). A model with both is one the rung actually changes; a model with effects and no +/// transparent batch of its own never had the defect and is listed only in the totals. +/// +/// This is the population instrument the two decisions were argued from. "Does this fix anything +/// besides the voidwalker's eyes?" is not a question to answer by naming plausible creatures — +/// it is a count, and the count is what says whether the mechanism closes a class or a case. +pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) + }) + .collect(); + // Per top-level content family (Creature / Item / Spells / World / …), so the totals say + // WHERE the class lives rather than only how big it is. + let mut family: BTreeMap = BTreeMap::new(); + let (mut scanned, mut with_fx, mut at_risk) = (0u32, 0u32, 0u32); + let mut rungs: BTreeMap = BTreeMap::new(); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let emitters = benilla_formats::parse_m2_particle_emitters(&bytes) + .map(|e| e.len()) + .unwrap_or(0); + let trails = benilla_formats::m2_ribbon_emitter_count(&bytes); + if emitters + trails == 0 { + continue; + } + with_fx += 1; + // The occluders: batches the renderer puts in the one distance-sorted transparent list. + // `additive` forces that pass whatever the authored blend says (`model_material`), so the + // test mirrors the renderer's rather than reading the blend word alone. + let dir = name.rsplit_once('\\').map_or("", |(d, _)| d); + let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); + let occluders: Vec<&benilla_formats::RenderSubmesh> = subs + .iter() + .filter(|s| { + s.additive + || matches!( + s.blend, + benilla_formats::ModelBlend::Blend + | benilla_formats::ModelBlend::Mod + | benilla_formats::ModelBlend::Mod2x + ) + }) + .collect(); + let transparent = occluders.len(); + // The renderer's own bound and the renderer's own rung — not a re-derivation of them. + let reach = benilla_formats::m2_owner_reach(&subs); + let fam = name.split('\\').next().unwrap_or("?").to_string(); + family.entry(fam).or_default().0 += 1; + if transparent == 0 { + continue; // effects, but nothing of its own that could paint over them + } + at_risk += 1; + family + .entry(name.split('\\').next().unwrap_or("?").to_string()) + .or_default() + .1 += 1; + // At placement scale 1 — the survey number; a scaled placement multiplies the reach. + let rung = benilla_formats::owner_last_rung(reach); + *rungs.entry(rung as u32).or_default() += 1; + println!( + "{transparent:>3} transp {emitters:>2} emit {trails:>2} ribb reach {reach:>8.2} \ + rung {rung:>2.0} {name}" + ); + } + eprintln!( + "{scanned} models scanned\n \ + {with_fx} author effects (emitters and/or ribbons)\n \ + {at_risk} of those ALSO author transparent batches of their own — the population the \ + owner-last rung changes" + ); + eprintln!(" by family (with-effects / at-risk):"); + for (fam, (n, risk)) in &family { + eprintln!(" {fam:<24} {n:>5} / {risk:>5}"); + } + eprintln!(" rung distribution (at-risk models, placement scale 1):"); + for (rung, n) in &rungs { + eprintln!(" rung {rung:>2} {n:>5}"); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 354f8c4c..09f510f4 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -145,20 +145,21 @@ pub use models::{ accumulate_wmo_group_camera_collision, accumulate_wmo_group_camera_only_collision, accumulate_wmo_group_collision, hand_grip_finger_poses, load_m2_animation_summary, load_m2_bounds, load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned, load_object_model, - load_wmo, load_wmo_collision_tris, m2_ribbon_emitter_count, m2_texture_transform_count, - parse_m2_animation_lookup, parse_m2_animation_summary, parse_m2_animations, - parse_m2_attachments, parse_m2_bounds, parse_m2_camera, parse_m2_collision_hull, - parse_m2_event_markers, parse_m2_global_sequence_bones, parse_m2_lights, - parse_m2_playable_animation_lookup, parse_m2_portrait_camera, parse_m2_render_submeshes, - parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, - parse_wmo_portals, parse_wmo_root, wmo_group_doodad_refs, wmo_group_footprint_tris, - wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_submeshes, - wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, Billboard, BillboardKind, BoneKeys, BoneScaleAnim, - CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, - GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light, - M2PortraitCamera, ModelAnimation, ModelBlend, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, - Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, - WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, + load_wmo, load_wmo_collision_tris, m2_owner_reach, m2_ribbon_emitter_count, + m2_texture_transform_count, owner_last_rung, parse_m2_animation_lookup, + parse_m2_animation_summary, parse_m2_animations, parse_m2_attachments, parse_m2_bounds, + parse_m2_camera, parse_m2_collision_hull, parse_m2_event_markers, + parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup, + parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton, + parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root, + wmo_group_doodad_refs, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, + wmo_group_liquid_mesh, wmo_group_submeshes, wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, + Billboard, BillboardKind, BoneKeys, BoneScaleAnim, CharSkinSlot, CollisionMesh, + EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel, + GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light, M2PortraitCamera, ModelAnimation, + ModelBlend, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, Skeleton, SkeletonBone, + StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, + WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, }; mod terrain; pub use terrain::{ diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index fb1c6495..c6f2cc8e 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -592,6 +592,77 @@ fn is_white1_placeholder(bbox_min: [f32; 3], bbox_max: [f32; 3], subs: &[RenderS }) } +/// How far a model's own **transparent-pass batches** sort from its origin, model-local yards — +/// the bound the renderer's owner-last draw-order rung is sized from (decisions 0719/0721). +/// +/// The reference draws a model's emitters in their own bracket *after* that model's batches; we put +/// both in one distance-sorted transparent list, so an effect has to be biased past every batch of +/// its owner — and no further, or it also jumps other models' transparents. What it must clear is +/// therefore the farthest point any of those batches SORTS at, and a batch sorts at its own +/// bind-pose AABB **centre**, not at its farthest vertex. +/// +/// That distinction is the whole reason this function exists rather than reading +/// [`super::M2Bounds::vert_max_from_origin`]. Measured over the 1.12.1 corpus's 967 at-risk models, +/// swapping the vertex bound for this one drops 83 models past the rung ceiling to 46 and moves 144 +/// models down to rung 1 — a waterfall's single long batch drags the vertex bound to hundreds of +/// yards while every batch centre sits a few yards from the origin. +/// +/// Only transparent-pass batches count: an opaque or alpha-tested batch is binned in `Opaque3d` / +/// `AlphaMask3d` and settles against the effect by depth, never by list order. The blend test +/// mirrors the renderer's own (`benilla::model_render::model_material`), where `additive` forces the +/// transparent pass whatever the authored blend word says. A model with no transparent batch at all +/// returns `0.0` — nothing of its own can paint over its effects, and rung 1 still holds the law. +pub fn m2_owner_reach(subs: &[RenderSubmesh]) -> f32 { + subs.iter() + .filter(|s| { + s.additive + || matches!( + s.blend, + ModelBlend::Blend | ModelBlend::Mod | ModelBlend::Mod2x + ) + }) + .filter(|s| !s.positions.is_empty()) + .map(|s| { + let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); + for p in &s.positions { + for k in 0..3 { + lo[k] = lo[k].min(p[k]); + hi[k] = hi[k].max(p[k]); + } + } + // Distance from the origin, so the raw-WoW/Bevy axis question doesn't arise: the two + // frames differ by a signed axis permutation, which preserves length. + let c: [f32; 3] = std::array::from_fn(|k| 0.5 * (lo[k] + hi[k])); + (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt() + }) + .fold(0.0f32, f32::max) +} + +/// The **owner-last** draw-order rung for an effect whose owner reaches `reach_world` world yards: +/// the `Transparent3d` depth bias that puts it after every transparent batch of its own model and +/// no further (`benilla::particles::owner_last_bias` is the one caller that applies it; `emdump` +/// and `benilla-extract fxordercensus` print it). +/// +/// It lives here, beside [`m2_owner_reach`], because a rung computed three times in three crates +/// drifts three ways — and this is the number the survey tools have to print to be worth reading. +/// +/// Snapped to the next whole yard **above** the reach, for two reasons. Bevy folds +/// `depth_bias as i32` into the material's *pipeline key*, so a free-floating float would specialize +/// a fresh pipeline per distinct model radius; and `≥` has to become `>`, or a batch centred exactly +/// at the reach ties with the effect and the order falls back to whatever the queue emitted. +/// `floor + 1` does both. Over-biasing costs only ordering against OTHER models' transparents within +/// the rung, so the rounding goes up; under-biasing reinstates the defect. +pub fn owner_last_rung(reach_world: f32) -> f32 { + // The ceiling keeps this rung far below every rung in `benilla::sky_order`'s ladder (the + // nearest, the ground-fx one, is four orders of magnitude up). 46 corpus models reach past it + // — Maraudon's waterfalls, the Dire Maul vortex, the glue screens, whose transparent batches + // spread 30–780 yd from their origin. Those keep the interleave: biasing an effect past a + // 200-yard owner would put it in front of every transparent surface in the zone, which is the + // worse of the two errors (decision 0721). + const MAX_RUNG: f32 = 32.0; + (reach_world.max(0.0).floor() + 1.0).min(MAX_RUNG) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-ui/src/script/worldmap.rs b/crates/benilla-ui/src/script/worldmap.rs index 38f19060..8d031f9c 100644 --- a/crates/benilla-ui/src/script/worldmap.rs +++ b/crates/benilla-ui/src/script/worldmap.rs @@ -277,6 +277,28 @@ impl super::UiScript { pub fn world_map_selection(&self) -> (u32, u32) { self.model_ref().worldmap.selection } + + /// The **normalized position within the map art** at UI-space `(x, y)`, or `None` when the + /// point isn't over it. Reproduces the reference's own click normalization verbatim — + /// `WorldMapFrame.xml`'s `WorldMapButton_OnClick`: `u = (x − left)/width`, + /// `v = (top − y)/height` over `WorldMapButton`'s rect — so the UV handed back is the same one + /// [`ProcessMapClick`](install) reads and the same one the blips are placed in. The Lua's + /// effective-scale division cancels here: point and rect are both already in screen units. + /// + /// A pure query (fires nothing, mutates nothing). Its consumer is the app-side dev map-jump, + /// which needs a click's UV *without* going through the faithful click path — that path drills + /// into a zone, and the reference's law for it must not grow a second meaning. + pub fn world_map_uv_at(&self, x: f32, y: f32) -> Option<(f32, f32)> { + let id = self.hit_test(x, y)?; + let model = self.model_ref(); + let fh = model.id_to_frame.get(&id).copied()?; + if model.arena.frame(fh)?.name.as_deref() != Some("WorldMapButton") { + return None; + } + let r = model.resolved.get(&fh)?; + let (w, h) = (r.right - r.left, r.top - r.bottom); + (w > 0.0 && h > 0.0).then(|| ((x - r.left) / w, (r.top - y) / h)) + } } /// Clamp + store a selection and queue the repaint event. The shared tail of diff --git a/crates/benilla/src/capture/depth_probe.rs b/crates/benilla/src/capture/depth_probe.rs index a0e37a78..f47112fc 100644 --- a/crates/benilla/src/capture/depth_probe.rs +++ b/crates/benilla/src/capture/depth_probe.rs @@ -28,6 +28,24 @@ //! physical pixels. (`WOW_PICK` has to divide by the window scale factor; its ray cast works in //! logical units. Same input space, different reason.) //! +//! ## `WOW_DEPTH_QUADS` — the same reading, taken at a particle quad's OWN pixels +//! +//! A hand-written pixel list is the wrong instrument for a *moving* subject. B16's eye glow is two +//! additive quads a few dozen pixels across, riding an animated bone: name a pixel and by the frame +//! the readback lands the quad is somewhere else, and a grid coarse enough to catch it samples the +//! head, the ground and the neighbouring unit instead (that mis-measurement is retracted). +//! +//! `WOW_DEPTH_QUADS=[,…]` (empty value = every quad emitter) reads the emitter's **live +//! vertex buffer** — the four corners `expand_quads` actually wrote, the ones the GPU is about to +//! rasterize — projects them with the frame's own matrices, and samples the depth buffer on a +//! bilinear grid *inside that quad*. What comes back is the number the offline model predicts and +//! the reference measures: the **surviving area fraction** of the quad under `GreaterEqual`, plus +//! how deep the winning surface sits in front of it, in yards. No camera hunting, no pixel guessing, +//! and no way to accidentally measure a different surface. +//! +//! Frames with no live quad are skipped entirely (no copy, no frame counted), so the burst lands on +//! the frames where the subject exists rather than on a wall clock. +//! //! **MSAA must be off** (`WOW_MSAA=off`). A multisampled depth texture cannot be copied to a buffer //! at all, and there is no single "the" depth at a pixel to report if it could — there are four. The //! probe refuses rather than reporting one of them, because a number that looks like the measurement @@ -50,6 +68,7 @@ use bevy::render::renderer::{RenderContext, RenderDevice}; use bevy::render::view::{ExtractedView, ViewDepthTexture}; use bevy::render::{Render, RenderApp, RenderSystems}; +use crate::particles::ParticleEmitter; use crate::player::WorldCamera; pub(crate) struct DepthProbePlugin; @@ -60,14 +79,21 @@ impl Plugin for DepthProbePlugin { .ok() .map(|s| parse_pixels(&s)) .unwrap_or_default(); - if pixels.is_empty() { - warn!("depth: WOW_DEPTH wants \",[;,…]\" screenshot pixels — inert"); + let quad_bones = parse_bones(std::env::var("WOW_DEPTH_QUADS").ok().as_deref()); + if pixels.is_empty() && quad_bones.is_none() { + warn!( + "depth: WOW_DEPTH wants \",[;,…]\" screenshot pixels (or set \ + WOW_DEPTH_QUADS) — inert" + ); return; } + // Quad mode arms at once: the subject is a particle pool that does not exist until its + // model spawns, and the frame gate below (report only frames that HAVE quads) is a + // sharper window than any wall clock the caller could guess. let at = std::env::var("WOW_DEPTH_AT") .ok() .and_then(|v| v.parse().ok()) - .unwrap_or(20.0); + .unwrap_or(if quad_bones.is_some() { 0.0 } else { 20.0 }); let count = std::env::var("WOW_DEPTH_COUNT") .ok() .and_then(|v| v.parse().ok()) @@ -79,9 +105,16 @@ impl Plugin for DepthProbePlugin { count, armed: false, }) + .insert_resource(QuadWatch(quad_bones)) + .init_resource::() .add_systems(Update, arm) + .add_systems( + PostUpdate, + collect_quads.after(crate::billboard::BillboardPlace), + ) .add_plugins(( ExtractResourcePlugin::::default(), + ExtractResourcePlugin::::default(), ExtractComponentPlugin::::default(), )); let Some(render_app) = app.get_sub_app_mut(RenderApp) else { @@ -148,6 +181,125 @@ impl ExtractResource for DepthWatch { } } +/// `$WOW_DEPTH_QUADS`'s bone scope: `None` = the mode is off, `Some([])` = every quad emitter. +#[derive(Resource)] +struct QuadWatch(Option>); + +/// One live particle quad, carried to the render world in the space the depth buffer is read in. +#[derive(Clone, Copy)] +struct QuadProbe { + bone: u16, + /// Index within the emitter's mesh — quads are written oldest-first, so 0 is the smallest, + /// dimmest particle and the last is the one born this frame (the big, bright one that decides + /// whether the glow reads at all). + index: u32, + /// The four corners in physical pixels, in `expand_quads`' own vertex order. + corners: [Vec2; 4], + /// The NDC depth the corners carry. Read from the projected corners, not assumed constant — + /// the *spread* is reported, because a billboard that is not a constant-depth plane is a + /// different bug wearing the same symptom (wow-re `part-flush-emitter-depth.md` §4). + dquad: f32, + dspread: f32, + /// The quad centre's distance to the camera plane, yards — the unit the burial is reported in. + viewz: f32, +} + +/// This frame's live quads, in `$WOW_DEPTH_QUADS` scope. Empty on every frame in every run that +/// did not ask for the mode. +#[derive(Resource, Clone, Default)] +struct QuadProbes(Vec); + +impl ExtractResource for QuadProbes { + type Source = QuadProbes; + fn extract_resource(source: &Self::Source) -> Self { + source.clone() + } +} + +/// Project every in-scope emitter's live quads into pixels, once a frame. +/// +/// Deliberately reads the **mesh asset**, not the simulation: these are the vertices the draw will +/// consume, so a quad that was mis-built (collapsed, mis-billboarded, left at last frame's anchor) +/// is measured as itself rather than as what the sim intended. Runs after `BillboardPlace`, the set +/// that rebuilds the meshes, so the buffer read is this frame's. +fn collect_quads( + watch: Res, + mut probes: ResMut, + cam: Query<(&Camera, &GlobalTransform, &Projection), With>, + emitters: Query<(&ParticleEmitter, &GlobalTransform, &Mesh3d)>, + meshes: Res>, +) { + let Some(bones) = watch.0.as_deref() else { + return; + }; + probes.0.clear(); + let (Ok((camera, cam_tf, projection)),) = (cam.single(),) else { + return; + }; + let Some(vp) = camera.physical_viewport_size() else { + return; + }; + // The frame's own matrices, the same pair `depthdump` projects with — so a quad's `dquad` here + // and its `dquad` there are the same number, and the two logs can be read against each other. + let clip_from_world = projection.get_clip_from_view() * cam_tf.to_matrix().inverse(); + for (emitter, gt, mesh) in &emitters { + if !bones.is_empty() && !bones.contains(&emitter.bone()) { + continue; + } + let Some(bevy::mesh::VertexAttributeValues::Float32x3(pos)) = meshes + .get(&mesh.0) + .and_then(|m| m.attribute(Mesh::ATTRIBUTE_POSITION)) + else { + continue; + }; + for (index, quad) in pos.chunks_exact(4).enumerate() { + let mut corners = [Vec2::ZERO; 4]; + let (mut dmin, mut dmax, mut center) = (f32::MAX, f32::MIN, Vec3::ZERO); + let mut behind = false; + for (i, v) in quad.iter().enumerate() { + // Verts are anchor-relative (the transparent-pass sort key); the entity transform + // is the anchor. + let world = gt.transform_point(Vec3::from(*v)); + center += world / 4.0; + let clip = clip_from_world * world.extend(1.0); + if clip.w <= 0.0 { + behind = true; + break; + } + let ndc = clip.truncate() / clip.w; + corners[i] = Vec2::new( + (ndc.x + 1.0) * 0.5 * vp.x as f32, + (1.0 - ndc.y) * 0.5 * vp.y as f32, + ); + dmin = dmin.min(ndc.z); + dmax = dmax.max(ndc.z); + } + if behind { + continue; + } + probes.0.push(QuadProbe { + bone: emitter.bone(), + index: index as u32, + corners, + dquad: (dmin + dmax) * 0.5, + dspread: dmax - dmin, + viewz: -(cam_tf.to_matrix().inverse() * center.extend(1.0)).z, + }); + } + } +} + +/// `"60,61"` → the bone scope; an empty or absent value means every quad emitter. `None` when the +/// variable is unset at all, which is what turns the whole mode off. +fn parse_bones(spec: Option<&str>) -> Option> { + Some( + spec? + .split(',') + .filter_map(|b| b.trim().parse().ok()) + .collect(), + ) +} + /// Marks the one view whose depth to read. The depth texture is cached per *render target*, so the /// world camera and the UI camera on the same window share it — but picking a view by viewport size /// would be picking by coincidence, and this bug is made of numbers taken slightly wrong. @@ -267,6 +419,15 @@ impl ViewNode for DepthReadbackNode { if !watch.armed || world.resource::().0 >= watch.count { return Ok(()); } + // Quad mode with nothing live this frame: no copy, and `read_depth` will not count it — + // the burst spends its frames on the ones that have the subject in them. + if watch.pixels.is_empty() + && world + .get_resource::() + .is_none_or(|q| q.0.is_empty()) + { + return Ok(()); + } let size = depth.texture.size(); // `COPY_SRC` only lands on the texture allocated *after* `arm` patched the camera, so the // first armed frame still has the old one. Skip it rather than trip wgpu validation. @@ -308,6 +469,7 @@ struct DepthFramesRead(u32); /// Map back what the node copied and log the named pixels. fn read_depth( watch: Option>, + quads: Option>, view: Query<&ExtractedView, With>, device: Res, staging: Option>, @@ -320,6 +482,12 @@ fn read_depth( if !watch.armed || read.0 >= watch.count { return; } + // Mirrors the node's quad-mode skip: no copy was encoded, so there is nothing to read and this + // frame does not spend one of the burst's slots. + let quads = quads.map(|q| q.0.clone()).unwrap_or_default(); + if watch.pixels.is_empty() && quads.is_empty() { + return; + } let (Ok(view), Ok(depth)) = (view.single(), depth.single()) else { return; }; @@ -379,10 +547,90 @@ fn read_depth( None => info!("depth#{frame} ({x}, {y}): {d:.9} = nothing drew (cleared)"), } } + for q in &quads { + report_quad(frame, q, &data, &staging, size.width, size.height); + } } staging.buffer.unmap(); } +/// Samples in each direction across a quad's own area — 16×16 = 256 tests, fine enough that the +/// fraction is stable to well under a percent against the offline model's 48×48 and 64×64 grids, +/// cheap enough to run on every quad of a pool. +const QUAD_GRID: usize = 16; + +/// Run one quad's depth contest and log it. +/// +/// Reverse-Z, `GreaterEqual` (the particle material takes Bevy's default compare): the fragment +/// survives iff `dquad >= dbuffer`. Sampling walks the quad's own `(u, v)` by bilinear interpolation +/// of its four projected corners, so every sample is inside the quad by construction — including a +/// spun quad, whose screen AABB is up to 41 % larger than the quad itself. +fn report_quad( + frame: u32, + q: &QuadProbe, + data: &[u8], + staging: &DepthStaging, + width: u32, + height: u32, +) { + let (mut passed, mut total) = (0usize, 0usize); + // Reverse-Z: the LARGEST buffer value is the NEAREST surface, so `dmax` is the closest thing + // standing in the quad's way and `dmin` the furthest. + let (mut dmin, mut dmax) = (f32::MAX, f32::MIN); + let mut cleared = 0usize; + for iy in 0..QUAD_GRID { + for ix in 0..QUAD_GRID { + let u = (ix as f32 + 0.5) / QUAD_GRID as f32; + let v = (iy as f32 + 0.5) / QUAD_GRID as f32; + let p = q.corners[0] + .lerp(q.corners[1], u) + .lerp(q.corners[3].lerp(q.corners[2], u), v); + let (x, y) = (p.x.floor(), p.y.floor()); + if x < 0.0 || y < 0.0 || x >= width as f32 || y >= height as f32 { + continue; + } + let at = (y as u32 * staging.bytes_per_row + x as u32 * 4) as usize; + let d = f32::from_le_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]); + total += 1; + if q.dquad >= d { + passed += 1; + } + if d <= 0.0 { + cleared += 1; + } else { + dmin = dmin.min(d); + dmax = dmax.max(d); + } + } + } + if total == 0 { + info!( + "depth#{frame} quad bone={} i={}: entirely off screen", + q.bone, q.index + ); + return; + } + // `dquad` and the buffer values live on the same reverse-Z curve, so the ratio converts either + // to yards without re-deriving the projection: view z scales as 1/d. + let yd = |d: f32| q.viewz * q.dquad / d; + let (near, far) = (yd(dmax), yd(dmin)); + info!( + "depth#{frame} quad bone={} i={} px=({:.0},{:.0}) dquad={:.9} spread={:.9} \ + viewz={:.4} pass={:.1}% ({passed}/{total}) occluder {near:.4}..{far:.4} yd \ + burial {:.4}..{:.4} yd cleared={cleared}", + q.bone, + q.index, + q.corners[0].lerp(q.corners[2], 0.5).x, + q.corners[0].lerp(q.corners[2], 0.5).y, + q.dquad, + q.dspread, + q.viewz, + passed as f32 / total as f32 * 100.0, + q.viewz - far, + q.viewz - near, + ); +} + /// A physical pixel's centre in NDC. Framebuffer rows run down, NDC y runs up. fn ndc_of(x: u32, y: u32, width: u32, height: u32) -> Vec2 { Vec2::new( diff --git a/crates/benilla/src/capture/mod.rs b/crates/benilla/src/capture/mod.rs index 037df9a2..8e158e52 100644 --- a/crates/benilla/src/capture/mod.rs +++ b/crates/benilla/src/capture/mod.rs @@ -74,8 +74,8 @@ pub(crate) use probe_partner::ProbePartnerPlugin; pub(crate) use probe_rig::{rig_char_name_from_env, ProbeRigPlugin}; pub(crate) use probe_taxi::ProbeTaxiPlugin; pub(crate) use probes::{ - LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeExitPlugin, - ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, + EntityCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, + ProbeExitPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, }; use scenarios::{Scenario, UiFixture, GROUND_EYE, SCENARIOS}; @@ -96,6 +96,17 @@ pub(crate) fn scenario_active() -> bool { /// WOW_FX_AGE=1.2 WOW_FX_AZ=60 WOW_FX_EL=15 WOW_CAPTURE_OUT=/tmp/fx.png cargo run -q -p benilla /// ``` /// +/// **`WOW_FX_DISPLAY=` swaps the subject onto the UNIT path** — the same +/// component set a streamed creature gets, seated on the terrain, with everything that hangs off +/// being a unit (tag alpha, the distance-fade gate, anim LOD, the emitter's sequence host). Model +/// lane vs unit lane at one knob is what turns "does this creature look wrong?" into a headless +/// A/B instead of a director round-trip: +/// +/// ```text +/// WOW_DATA= WOW_CAPTURE=fxview WOW_FX_DISPLAY=1132 WOW_FX_AGE=6 WOW_FX_EL=20 \ +/// WOW_FX_DIST=12 WOW_CAPTURE_OUT=/tmp/vw.png cargo run -q -p benilla +/// ``` +/// /// Knobs: `WOW_FX_MODEL` (required, internal path), `WOW_FX_AGE` (seconds after attach, default /// 1.0), `WOW_FX_AZ`/`WOW_FX_EL` (camera orbit degrees, default 0/10), `WOW_FX_DIST` (yards, /// default 5), `WOW_FX_FLY` (yd/s along the model's facing — a missile only trails in motion; @@ -109,6 +120,15 @@ pub(crate) fn scenario_active() -> bool { #[derive(Resource)] pub(crate) struct FxViewRequest { pub(crate) model_path: String, + /// `WOW_FX_DISPLAY=` — spawn the subject as a real **unit** (the live + /// `NetEntity` component set, the same path a streamed creature takes) instead of attaching it + /// as an effect. The two lanes differ in everything that hangs off being a unit — material tag + /// alpha, the distance-fade gate, anim LOD, the emitter's sequence host — so switching this one + /// knob is the A/B that says whether a defect belongs to the model or to the unit path. + /// `WOW_FX_MODEL` is then optional (the display id names the model). + pub(crate) display: Option, + /// `WOW_FX_SCALE` — the unit lane's `NetEntity::scale` (the wire scale a creature carries). + pub(crate) scale: f32, pub(crate) age: f32, pub(crate) az_deg: f32, pub(crate) el_deg: f32, @@ -222,6 +242,25 @@ struct CaptureCtx { probe_frames: u32, /// Probe samples (frame ms). probe_samples: Vec, + /// Process CPU seconds at the first sampled frame — the window baseline for the probe line's + /// `cpu_ms`/`cpu_pct` (the load-robust metric, decision 0711; the scenario probe lacked it). + probe_cpu_start: Option, +} + +/// The present mode a perf probe uncaps to (also the live probe's — see `probes.rs`). +/// +/// `AutoNoVsync`, measured, not assumed: explicit `Immediate` on macOS/Metal is a trap — A/B'd +/// 2026-07-27 (overlook-noon, release, twice each), it *rails* near 16.6 ms AND takes 1.0–1.5 s +/// stalls (the `-[CAMetalLayer nextDrawable]` timeout — drawable starvation), while `AutoNoVsync` +/// genuinely uncaps when macOS grants it (p50 12.7 ms on the same scene). 0362's "AutoNoVsync +/// doesn't uncap" was the *power state* withholding the grant, not the mode — no present mode +/// escapes that; `cpu_ms` on the probe line is the rail-proof metric. `WOW_PROBE_UNCAP=immediate` +/// re-runs the losing arm when macOS/wgpu move. +pub(crate) fn probe_uncap_mode() -> bevy::window::PresentMode { + match std::env::var("WOW_PROBE_UNCAP").as_deref() { + Ok("immediate") => bevy::window::PresentMode::Immediate, + _ => bevy::window::PresentMode::AutoNoVsync, + } } pub(crate) struct CapturePlugin; @@ -233,9 +272,19 @@ impl Plugin for CapturePlugin { // request from env. Not in SCENARIOS — `scripts/visual.sh`'s golden sweep must never // run it (its output depends on the model/age/angle knobs, not just the name). let scenario = if name == "fxview" { - let Ok(model_path) = std::env::var("WOW_FX_MODEL") else { - eprintln!("WOW_CAPTURE=fxview needs WOW_FX_MODEL="); - std::process::exit(2); + let display: Option = std::env::var("WOW_FX_DISPLAY") + .ok() + .and_then(|v| v.trim().parse().ok()); + let model_path = match (std::env::var("WOW_FX_MODEL"), display) { + (Ok(p), _) => p, + (Err(_), Some(_)) => String::new(), // the unit lane names its model by display id + (Err(_), None) => { + eprintln!( + "WOW_CAPTURE=fxview needs WOW_FX_MODEL= or \ + WOW_FX_DISPLAY=" + ); + std::process::exit(2); + } }; let knob = |k: &str, d: f32| { std::env::var(k) @@ -245,6 +294,8 @@ impl Plugin for CapturePlugin { }; app.insert_resource(FxViewRequest { model_path, + display, + scale: knob("WOW_FX_SCALE", 1.0), age: knob("WOW_FX_AGE", 1.0), az_deg: knob("WOW_FX_AZ", 0.0), el_deg: knob("WOW_FX_EL", 10.0), @@ -391,6 +442,7 @@ impl Plugin for CapturePlugin { .and_then(|v| v.parse().ok()) .unwrap_or(0), probe_samples: Vec::new(), + probe_cpu_start: None, }) .add_systems(Update, pin_scene.in_set(WorldStage::Present)) // Before the UnitFeed pass: the seed stands in for wire data that in live play @@ -540,7 +592,7 @@ fn drive_capture( let keep_vsync = std::env::var("WOW_PROBE_VSYNC").as_deref() == Ok("1"); if !keep_vsync { if let Ok(mut w) = windows.single_mut() { - w.present_mode = bevy::window::PresentMode::AutoNoVsync; + w.present_mode = probe_uncap_mode(); } } info!( @@ -564,6 +616,7 @@ fn drive_capture( } Phase::ProbeWarmup(n) => { if n + 1 >= PROBE_WARMUP_FRAMES { + ctx.probe_cpu_start = crate::perf::process_cpu_secs(); Phase::Probing(0) } else { Phase::ProbeWarmup(n + 1) @@ -572,7 +625,9 @@ fn drive_capture( Phase::Probing(n) => { let ms = time.delta_secs() * 1000.0; ctx.probe_samples.push(ms); - if n + 1 >= ctx.probe_frames { + // `==`, not `>=`: `AppExit` takes a frame or two to drain, and the re-entered finish + // branch used to print a second (301-frame) line in the gap. + if n + 1 == ctx.probe_frames { let mut v = ctx.probe_samples.clone(); v.sort_by(f32::total_cmp); let at = |q: f32| v[(((v.len() - 1) as f32) * q).round() as usize]; @@ -593,10 +648,28 @@ fn drive_capture( (n + 1, d + usize::from(v.get())) }); let entity_count = entities.iter().len(); + // CPU cost per frame across every thread — the load-robust half of the measurement + // (`perf::process_cpu_secs`), same fields as the live probe's line. + let cpu = match (ctx.probe_cpu_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 present mode the window actually measured under — an uncap that silently + // rails (0362) is only diagnosable if the line says what was asked for. + let present = windows + .single() + .map(|w| format!(" present={:?}", w.present_mode)) + .unwrap_or_default(); // Machine-greppable one-liner + a human block. stdout, not the log, so a script can // capture it without log-filter noise. println!( - "FPS_PROBE scenario={} 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} entities={entity_count} px={}x{}", + "FPS_PROBE scenario={} 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} entities={entity_count} px={}x{}{cpu}{present}", ctx.scenario.name, v.len(), at(0.50), @@ -608,10 +681,8 @@ fn drive_capture( px.1, ); exit.write(AppExit::Success); - Phase::Probing(n) - } else { - Phase::Probing(n + 1) } + Phase::Probing(n + 1) } Phase::Saving { frames, seen } => { let busy = !capturing.is_empty(); diff --git a/crates/benilla/src/capture/phase_probe.rs b/crates/benilla/src/capture/phase_probe.rs index ab526f55..e416a916 100644 --- a/crates/benilla/src/capture/phase_probe.rs +++ b/crates/benilla/src/capture/phase_probe.rs @@ -44,11 +44,27 @@ impl Plugin for PhaseProbePlugin { // mesh instead of one placement's batches (B16: a pool that is emitted, meshed, visible // and textured, whose pixels never change — "was its mesh ever SUBMITTED to a phase?" is // exactly the gap between the sim-side depth dump and the framebuffer). - let particles = raw.as_deref().is_some_and(|v| v.trim() == "particles"); - let object = raw.as_deref().and_then(|v| v.trim().parse::().ok()); + // `WOW_PHASE=particles[:,…]` — the bone list is the ARMING key, not a filter: the + // watch list is collected once and then held, and a world scene is full of other emitters + // (torches, braziers) that go live long before a fixture subject spawns. Naming the bones + // the question is about (`60,61` = the voidwalker's eyes; `44,45,46` = the wisp's three + // streamers) makes the probe wait for THAT effect instead of latching onto the first live + // one it sees. Ribbon trails arm and report on the same bone key as emitters. + let spec = raw.as_deref().map(str::trim); + let particles = spec.is_some_and(|v| v == "particles" || v.starts_with("particles:")); + let bones: Vec = spec + .and_then(|v| v.strip_prefix("particles:")) + .map(|list| { + list.split(',') + .filter_map(|b| b.trim().parse().ok()) + .collect() + }) + .unwrap_or_default(); + let object = spec.and_then(|v| v.parse::().ok()); if !particles && object.is_none() { warn!( - "phase: WOW_PHASE wants a placement uniqueId (e.g. 235256) or `particles` — inert" + "phase: WOW_PHASE wants a placement uniqueId (e.g. 235256) or \ + `particles[:,…]` — inert" ); return; } @@ -65,6 +81,7 @@ impl Plugin for PhaseProbePlugin { app.insert_resource(PhaseWatch { object, particles, + bones, at, count, batches: Vec::new(), @@ -90,6 +107,8 @@ struct PhaseWatch { object: u32, /// `WOW_PHASE=particles`: follow every live particle emitter's quad mesh instead. particles: bool, + /// `WOW_PHASE=particles:,…`: don't arm until every one of these emitter bones is live. + bones: Vec, at: f32, count: u32, /// The watched batches: main-world entity, its WMO batch order, how it draws, and (particles @@ -159,18 +178,38 @@ fn collect_batches( watch.armed = true; } -/// The `particles` mode's collector: every LIVE particle emitter's own quad-mesh entity, labeled -/// by its emitter bone + blend (the same identity the sim-side depth dump prints, so a phase line -/// and a dump line name the same pool). One-shot at the window like [`collect_batches`]. +/// The `particles` mode's collector: every LIVE **effect** mesh — particle-emitter quad clouds and +/// ribbon trails alike — labeled by its emitter bone + blend (the same identity the sim-side depth +/// dump prints, so a phase line and a dump line name the same pool) — **and every model batch in +/// the scene alongside them**. +/// +/// Ribbons are here because they are the same kind of thing under the same law: a trail is one of +/// its owner model's emitters, drawn in that model's own post-batch bracket, and in our renderer it +/// is another `NoFrustumCulling` mesh in the one distance-sorted transparent list. A probe that saw +/// only quads could say "the eye glow is ordered right" while a wisp's streamers were still +/// interleaved with the wisp. +/// +/// The emitters alone answer "was it submitted?". They cannot answer B16's actual question, which +/// is *relative*: a particle quad and its own model's blend batches share one `Transparent3d` list +/// sorted back-to-front, so which of the two draws last is what decides whether the eyes survive. +/// That is only readable if both sides are in the same report, on the same frame, with the sort +/// distances that produced the order — so this list holds both, and the batch column says which. +/// +/// One-shot like [`collect_batches`], but gated on a live emitter rather than the clock alone: in +/// a fixture capture the subject spawns after streaming settles, so a fixed `WOW_PHASE_AT` either +/// fires before it exists or after the shot. Retrying until the pool is live pins the window to +/// the subject instead of to a wall-clock guess. fn collect_emitters( mut watch: ResMut, time: Res