Files
benilla-octo/crates/benilla-formats/examples/eye_burial_anim.rs
T
SamandClaude Fable 5 90efd4efad the voidwalker's eyes come back, and skinning becomes ours
- 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 <noreply@anthropic.com>
2026-07-27 19:38:40 +07:00

291 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **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<Mat4> {
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<Vec3> = (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 <m2 path> [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<usize> = 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<usize> = 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(())
}