instruments for motion at the glass, and a macro hovers to its name
- the jitter meter measures the palette and the pixels, not just the pose: `pal` probes the rows the vertex stage actually blends (the old bone columns read a matrix the GPU never sees), `SCREEN` carries them to viewport pixels by the shader's own arithmetic, `camrot` watches the camera's rotation where only its translation had been vouching for "bit-stable", and `WOW_JITTER_RAW` dumps one bone's series unaggregated — rotations too, since a translation can be smooth while its frames are not - `WOW_FIXED_DT=<ms>` pins the animation advance so a screenshot burst measures the render rather than its own stall — arming on the wall clock, because during load the app runs ~385 updates a second and a pinned clock reached "90 seconds" in 14 s, photographing an empty world - `benilla-visual flow` estimates the whole window's motion by least squares over every pixel (noise floor 0.0000 px on static regions, and its brightness-constancy limit is measured and stated: invalid on a lit deforming body), `edge` reads a silhouette's sub-pixel crossing renormalised against the lighting with an honest ±0.5 px floor, `WOW_TWIST_GAP` pins a yaw gap so the body counter-twist is exercised at all — every earlier "removing it changed nothing" had measured a pass that never ran — and `WOW_DITHER` adds the dither the frame's 8-bit output has never had, off by default because the reference's framebuffer was undithered - hovering a MACRO action slot shows one white line, its name — the reference's macro leg renders exactly that and no more, in its "normal" white, where ours was a `_ => Ok(())` from before macros shipped; and `WOW_MSAA=8` is refused with a loud warning instead of panicking the render thread, through one parser both world-camera spawners now share Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,10 @@
|
||||
use bevy::camera::Projection;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use bevy::mesh::skinning::SkinnedMeshInverseBindposes;
|
||||
|
||||
use benilla_world::rig_anim::RigPose;
|
||||
use benilla_world::rig_palette::RigSkin;
|
||||
use benilla_world::view::WorldCamera;
|
||||
|
||||
use super::ProbeClock;
|
||||
@@ -88,6 +91,34 @@ struct JitterMeter {
|
||||
/// attachment, same frame, one measured in absolute world space and one in the rig's.
|
||||
prev_rid: Vec<Vec3>,
|
||||
prev2_rid: Vec<Vec3>,
|
||||
/// **The rows the vertex stage actually blends** — the palette, probed at each bone's bind
|
||||
/// position, same two-frame window.
|
||||
///
|
||||
/// The blind spot this closes, and it is the one that let a wrong "fixed" call stand. Every
|
||||
/// other bone column here reads `RigPose::model`, the pose the compose pass produced — and
|
||||
/// the GPU never reads it. It skins from the palette, which `rig_anim::compose::rig_worlds`
|
||||
/// composes a SECOND time from the rig's locals through the root's own frame, folding in the
|
||||
/// `flags & 0x7` arm and the billboard replacement. Anything that goes wrong between those
|
||||
/// two compositions is invisible to `flesh`, so a clean `flesh` column has never been able to
|
||||
/// clear the body — it only ever cleared the pose.
|
||||
prev_pal: Vec<Vec3>,
|
||||
prev2_pal: Vec<Vec3>,
|
||||
/// **The end of the chain: where each probe lands ON SCREEN, in pixels.** Every other column
|
||||
/// stops somewhere upstream — model space, rig space, world space — and each of those can be
|
||||
/// spotless while the thing the eye reads is not, because the camera's ORIENTATION, the
|
||||
/// projection and the viewport all sit between them and the glass. This series is the shader's
|
||||
/// own arithmetic (`view_rot * (p_rig + (origin - cam))`, then clip) carried to viewport
|
||||
/// pixels, so it is the only column that can be held against "I can see it move".
|
||||
prev_scr: Vec<Vec2>,
|
||||
prev2_scr: Vec<Vec2>,
|
||||
/// The camera's ROTATION, same two-frame window. `prev_cam` above is its translation only —
|
||||
/// and a camera that never moves an inch can still swing the whole scene a fraction of a pixel
|
||||
/// by rolling a single ULP, which reads as "everything shimmers" and is invisible to a
|
||||
/// translation probe.
|
||||
prev_camrot: Quat,
|
||||
prev2_camrot: Quat,
|
||||
/// Each bone's BIND position (from the rig's inverse bindposes) — bind data, computed once.
|
||||
binds: Vec<Vec3>,
|
||||
/// Frames of history held (`< 2` means Δ² is not defined yet).
|
||||
depth: u32,
|
||||
}
|
||||
@@ -124,6 +155,13 @@ impl Plugin for JitterMeterPlugin {
|
||||
prev2_anc: Vec::new(),
|
||||
prev_rid: Vec::new(),
|
||||
prev2_rid: Vec::new(),
|
||||
prev_pal: Vec::new(),
|
||||
prev2_pal: Vec::new(),
|
||||
prev_scr: Vec::new(),
|
||||
prev2_scr: Vec::new(),
|
||||
prev_camrot: Quat::IDENTITY,
|
||||
prev2_camrot: Quat::IDENTITY,
|
||||
binds: Vec::new(),
|
||||
depth: 0,
|
||||
})
|
||||
// `Last`, so every writer that can still move the subject this frame — the pose evaluator,
|
||||
@@ -181,8 +219,28 @@ type SubjectQuery = (
|
||||
&'static NetEntity,
|
||||
&'static GlobalTransform,
|
||||
&'static RigPose,
|
||||
Option<&'static RigSkin>,
|
||||
Has<SelfPlayer>,
|
||||
);
|
||||
|
||||
/// **The palette probe** — [`flesh_probes`]' twin on the rows the vertex stage blends. A row is
|
||||
/// `rig_from_joint × inverse_bindpose`, so it maps BIND space, not bone-local space: probing it at
|
||||
/// the bone's bind position plus [`flesh_radii`]'s offset is the same question `flesh_probes` asks
|
||||
/// of `RigPose::model`, put to the numbers that actually reach the GPU. Probing it at the row's own
|
||||
/// translation column instead would read a rotation as up to the model's full height of lever arm,
|
||||
/// ~10x what any real vertex sees.
|
||||
fn palette_probes(rows: &[Mat4], binds: &[Vec3], radii: &[f32]) -> Vec<Vec3> {
|
||||
let mut out = Vec::with_capacity(rows.len() * 3);
|
||||
for (i, m) in rows.iter().enumerate() {
|
||||
let bind = binds.get(i).copied().unwrap_or(Vec3::ZERO);
|
||||
let r = radii.get(i).copied().unwrap_or(LEAF_R);
|
||||
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
|
||||
out.push(m.transform_point3(bind + axis * r));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One `JIT` line per frame for the subject: the three terms' Δ and Δ², in mm and in pixels.
|
||||
#[allow(clippy::too_many_arguments)] // one param per term the reading has to separate
|
||||
fn sample_jitter(
|
||||
@@ -191,13 +249,14 @@ fn sample_jitter(
|
||||
names: Res<NameCache>,
|
||||
cam: Query<(&GlobalTransform, &Projection), With<WorldCamera>>,
|
||||
window: Query<&Window>,
|
||||
subjects: Query<SubjectQuery, Without<SelfPlayer>>,
|
||||
subjects: Query<SubjectQuery>,
|
||||
globals: Query<&GlobalTransform>,
|
||||
kids: Query<&Children>,
|
||||
meshes: Query<(), With<Mesh3d>>,
|
||||
attach: Query<&crate::entities::BoneAttach>,
|
||||
riders: Query<&benilla_world::rig_rider::RigRider>,
|
||||
palettes: Res<benilla_world::rig_palette::RigPalettes>,
|
||||
ibps: Res<Assets<SkinnedMeshInverseBindposes>>,
|
||||
players: Query<&AnimationPlayer>,
|
||||
// The clock the POSE advances on — `ProbeClock` above is `Time<Real>`, the wall clock, and the
|
||||
// gap between the two is the whole defect (`benilla_world::frame_pace`). Logging both side by
|
||||
@@ -214,13 +273,22 @@ fn sample_jitter(
|
||||
let cam_pos = cam_g.translation();
|
||||
// Nearest match to the camera — with no substring, nearest rigged unit outright.
|
||||
let want = meter.want.clone();
|
||||
let Some((entity, guid, net, tf, rig)) = subjects
|
||||
// `WOW_JITTER="self"` aims the meter at the AVATAR — the director's own repro (an undressed
|
||||
// character, watched from a close third person), and the one subject every other spelling of
|
||||
// this filter excluded. Without it the meter could only ever answer for somebody else's body,
|
||||
// which is a different entity, a different equipment lane, and a camera that is seated ON it.
|
||||
let on_self = want == "self";
|
||||
let Some((entity, guid, net, tf, rig, skin, _)) = subjects
|
||||
.iter()
|
||||
.filter(|(_, guid, _, _, _)| {
|
||||
want.is_empty()
|
||||
|| names
|
||||
.peek(guid.0)
|
||||
.is_some_and(|n| n.to_lowercase().contains(&want))
|
||||
.filter(|&(_, guid, _, _, _, _, is_self)| {
|
||||
if on_self {
|
||||
return is_self;
|
||||
}
|
||||
!is_self
|
||||
&& (want.is_empty()
|
||||
|| names
|
||||
.peek(guid.0)
|
||||
.is_some_and(|n| n.to_lowercase().contains(&want)))
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
let d = |g: &GlobalTransform| g.translation().distance_squared(cam_pos);
|
||||
@@ -249,6 +317,80 @@ fn sample_jitter(
|
||||
meter.radii = flesh_radii(&rig.parents, &rig.locals);
|
||||
}
|
||||
let flesh = flesh_probes(&rig.model, &meter.radii);
|
||||
// The rows the vertex stage blends, probed the same way — the body's OWN ground truth. Empty
|
||||
// when the subject holds no palette slot (bind-pose fallback), which reads as all-zero columns
|
||||
// rather than as a clean result.
|
||||
let mut bone_pos: Vec<Vec3> = Vec::new();
|
||||
// The same rows' ROTATION, as the three transformed unit axes. Bone translations can be
|
||||
// smooth while the frames they carry are not — a probe offset from the bone rides the
|
||||
// rotation, and that is what the flesh/vertex actually follows. Differencing a unit axis
|
||||
// measures the rotational motion at unit radius, directly comparable to the file's.
|
||||
let mut bone_axes: Vec<[Vec3; 3]> = Vec::new();
|
||||
let pal: Vec<Vec3> = match skin.and_then(|s| {
|
||||
Some((
|
||||
palettes.rig_rows(s.slot, s.bones() as usize)?,
|
||||
ibps.get(s.ibp())?,
|
||||
))
|
||||
}) {
|
||||
Some((rows, ibp)) => {
|
||||
if meter.binds.len() != rows.len() {
|
||||
meter.binds = ibp
|
||||
.iter()
|
||||
.take(rows.len())
|
||||
.map(|m| m.inverse().transform_point3(Vec3::ZERO))
|
||||
.collect();
|
||||
}
|
||||
let probes = palette_probes(&rows, &meter.binds, &meter.radii);
|
||||
// The bones' OWN rig-relative positions (the row applied to the bind point, no probe
|
||||
// offset) — the raw lane's series, and the one shape an offline compose of the same
|
||||
// M2 can be differenced against point-for-point.
|
||||
bone_pos = rows
|
||||
.iter()
|
||||
.zip(&meter.binds)
|
||||
.map(|(m, &b)| m.transform_point3(b))
|
||||
.collect();
|
||||
bone_axes = rows
|
||||
.iter()
|
||||
.map(|m| {
|
||||
[
|
||||
m.transform_vector3(Vec3::X),
|
||||
m.transform_vector3(Vec3::Y),
|
||||
m.transform_vector3(Vec3::Z),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
probes
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
// **Project them.** The mirror of `wow_model.wgsl`'s vertex stage, operation for operation:
|
||||
// camera-relative first (`p_rig + (origin - cam)`, never the absolute world coordinate), then
|
||||
// the view rotation, then clip, then viewport. What comes out is where the eye finds that bit
|
||||
// of flesh, in pixels, this frame.
|
||||
let vp = Vec2::new(
|
||||
window.physical_width() as f32,
|
||||
window.physical_height() as f32,
|
||||
);
|
||||
let view_rot = Mat3::from_quat(cam_g.rotation()).transpose();
|
||||
let clip_from_view = projection.get_clip_from_view();
|
||||
let rig_origin = skin.and_then(|s| palettes.slot_origin(s.slot));
|
||||
let scr: Vec<Vec2> = match rig_origin {
|
||||
Some(origin) => {
|
||||
let shift = origin - cam_pos;
|
||||
pal.iter()
|
||||
.map(|&p| {
|
||||
let clip = clip_from_view * (view_rot * (p + shift)).extend(1.0);
|
||||
if clip.w.abs() < 1.0e-6 {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let ndc = Vec2::new(clip.x, clip.y) / clip.w;
|
||||
Vec2::new((ndc.x * 0.5 + 0.5) * vp.x, (0.5 - ndc.y * 0.5) * vp.y)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
let camrot = cam_g.rotation();
|
||||
// The anchors are ordinary scene-graph entities in ABSOLUTE world space — the seam 0974
|
||||
// named and did not close. Their translations are read exactly as the renderer reads them.
|
||||
let ancs: Vec<Vec3> = rig
|
||||
@@ -271,13 +413,17 @@ fn sample_jitter(
|
||||
|| meter.prev_flesh.len() != flesh.len()
|
||||
|| meter.prev_anc.len() != ancs.len()
|
||||
|| meter.prev_rid.len() != rids.len()
|
||||
|| meter.prev_pal.len() != pal.len()
|
||||
|| meter.prev_scr.len() != scr.len()
|
||||
{
|
||||
let name = names.peek(guid.0).unwrap_or("?").to_string();
|
||||
println!(
|
||||
"JIT# t=<s> dt=<ms> dist=<yd> pxyd=<px/yd> | camd1 camd2 (mm) | rootd1 rootd2 (mm) \
|
||||
| bone=<max bone> d1 d2 (mm) d1px d2px | flesh=<bone> d1 d2 (mm) d1px d2px v(mm/s) | \
|
||||
anc=<n> d1 d2 (mm) d1px d2px | \
|
||||
ancstep xyz (mm) | rider=<n> d1 d2 (mm) d1px d2px | window={}x{} subject={name:?} \
|
||||
ancstep xyz (mm) | rider=<n> d1 d2 (mm) d1px d2px | \
|
||||
pal=<bone> d1 d2 (mm) d1px d2px nbig | \
|
||||
SCREEN d1med d2med d2max (px) nbig | camrot d1 d2 (urad) | window={}x{} subject={name:?} \
|
||||
guid={:#x} display={:?} bones={} scale={scale:.3}",
|
||||
window.physical_width(),
|
||||
window.physical_height(),
|
||||
@@ -326,6 +472,12 @@ fn sample_jitter(
|
||||
meter.prev2_anc = Vec::new();
|
||||
meter.prev_rid = rids.iter().map(|&(_, t)| t).collect();
|
||||
meter.prev2_rid = Vec::new();
|
||||
meter.prev_pal = pal;
|
||||
meter.prev2_pal = Vec::new();
|
||||
meter.prev_scr = scr;
|
||||
meter.prev2_scr = Vec::new();
|
||||
meter.prev_camrot = camrot;
|
||||
meter.prev2_camrot = camrot;
|
||||
meter.prev2 = Vec::new();
|
||||
meter.prev_root = root;
|
||||
meter.prev2_root = Vec3::ZERO;
|
||||
@@ -343,6 +495,12 @@ fn sample_jitter(
|
||||
meter.prev_anc = ancs;
|
||||
meter.prev2_rid = std::mem::take(&mut meter.prev_rid);
|
||||
meter.prev_rid = rids.iter().map(|&(_, t)| t).collect();
|
||||
meter.prev2_pal = std::mem::take(&mut meter.prev_pal);
|
||||
meter.prev_pal = pal;
|
||||
meter.prev2_scr = std::mem::take(&mut meter.prev_scr);
|
||||
meter.prev_scr = scr;
|
||||
meter.prev2_camrot = meter.prev_camrot;
|
||||
meter.prev_camrot = camrot;
|
||||
meter.prev2_root = meter.prev_root;
|
||||
meter.prev_root = root;
|
||||
meter.prev2_cam = meter.prev_cam;
|
||||
@@ -398,6 +556,48 @@ fn sample_jitter(
|
||||
let fd1med = d1s.get(d1s.len() / 2).copied().unwrap_or(0.0);
|
||||
let big_px = 0.25 / (scale * px_per_yd).max(1.0e-6);
|
||||
let fnbig = per_bone.iter().filter(|&&c| c > big_px).count();
|
||||
// The same pair on the PALETTE probes. No `scale` here, unlike the flesh columns: these rows
|
||||
// are composed through the root's own frame, so the unit's scale is already in them.
|
||||
let (mut pd2, mut pworst) = (0.0f32, 0usize);
|
||||
let mut pal_bone = vec![0.0f32; pal.len() / 3];
|
||||
for (i, &p) in pal.iter().enumerate() {
|
||||
let c = (p - 2.0 * meter.prev_pal[i] + meter.prev2_pal[i]).length();
|
||||
let b = &mut pal_bone[i / 3];
|
||||
*b = b.max(c);
|
||||
if c > pd2 {
|
||||
pd2 = c;
|
||||
pworst = i;
|
||||
}
|
||||
}
|
||||
let pd1 = pal
|
||||
.get(pworst)
|
||||
.map_or(0.0, |&p| (p - meter.prev_pal[pworst]).length());
|
||||
let pnbig = pal_bone
|
||||
.iter()
|
||||
.filter(|&&c| c > 0.25 / px_per_yd.max(1.0e-6))
|
||||
.count();
|
||||
// **In pixels, at the glass.** Per bone, the max over its three probes; then the MEDIAN bone
|
||||
// (the "whole model" number the report actually asks about), the worst bone, and the count
|
||||
// past a quarter pixel.
|
||||
let mut scr_bone = vec![(0.0f32, 0.0f32); scr.len() / 3];
|
||||
for (i, &q) in scr.iter().enumerate() {
|
||||
let c = (q - 2.0 * meter.prev_scr[i] + meter.prev2_scr[i]).length();
|
||||
let d = (q - meter.prev_scr[i]).length();
|
||||
let b = &mut scr_bone[i / 3];
|
||||
*b = (b.0.max(d), b.1.max(c));
|
||||
}
|
||||
let mut s1: Vec<f32> = scr_bone.iter().map(|b| b.0).collect();
|
||||
let mut s2: Vec<f32> = scr_bone.iter().map(|b| b.1).collect();
|
||||
s1.sort_by(f32::total_cmp);
|
||||
s2.sort_by(f32::total_cmp);
|
||||
let sd1med = s1.get(s1.len() / 2).copied().unwrap_or(0.0);
|
||||
let sd2med = s2.get(s2.len() / 2).copied().unwrap_or(0.0);
|
||||
let sd2max = s2.last().copied().unwrap_or(0.0);
|
||||
let snbig = s2.iter().filter(|&&c| c > 0.25).count();
|
||||
// Camera ROTATION, in microradians: Δ and Δ² of the angle between consecutive orientations.
|
||||
let ang = |a: Quat, b: Quat| a.angle_between(b) * 1.0e6;
|
||||
let crd1 = ang(camrot, meter.prev_camrot);
|
||||
let crd2 = (ang(camrot, meter.prev_camrot) - ang(meter.prev_camrot, meter.prev2_camrot)).abs();
|
||||
// The flesh probe's **velocity**, mm/s — `Δ/dt`, not `Δ`. This is the judder discriminator,
|
||||
// and it is the one number the series cannot reconstruct from the others: if the pose is
|
||||
// smooth in time but sampled at uneven `dt`, then `Δ ≈ v·dt` is ragged exactly as `dt` is
|
||||
@@ -473,12 +673,70 @@ fn sample_jitter(
|
||||
// when a smooth pose is merely sampled at uneven times, and blows up when it is not smooth.
|
||||
let dvel = d2 * scale / (dt * dt);
|
||||
let mm = |v: f32| v * scale * 1000.0;
|
||||
// **`WOW_JITTER_RAW=<bone>` — one bone's series, unaggregated.** Every other column here is a
|
||||
// median or a worst-of over ~350 probe points, and both of those hide the shape of a series:
|
||||
// a median cannot show a staircase (the *set* stays busy while each member holds), and a
|
||||
// worst-of is not one series at all — it re-picks its subject every frame, so differencing it
|
||||
// is meaningless. Twice now a reading taken off those columns pointed at the wrong mechanism.
|
||||
// This prints the chosen bone's palette-probe position (rig-relative yards, full f32) and its
|
||||
// projected screen position (pixels), so the series can be differenced honestly downstream.
|
||||
// **The two ABSOLUTE numbers the vertex stage still sums** (`wow_model.wgsl`:
|
||||
// `p_cam = frame_from_local·v + (frame_origin - view.world_position)`). Everything else on the
|
||||
// path is rig-sized and exact; these two are world coordinates, so at Elwynn's ~9.5 k yards
|
||||
// ONE of their axes has an ULP of 2^-10 yd = 0.9766 mm while the other two sit at ~7.6e-6.
|
||||
// That asymmetry is the whole fingerprint: the staircase is one-dimensional, so it is
|
||||
// invisible looking ALONG the big axis and full-amplitude looking across it.
|
||||
if let Some(o) = rig_origin {
|
||||
println!(
|
||||
"ORIGIN\t{now:.6}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}",
|
||||
o.x, o.y, o.z, cam_pos.x, cam_pos.y, cam_pos.z
|
||||
);
|
||||
}
|
||||
if raw_bone() == Some(usize::MAX) {
|
||||
// `WOW_JITTER_RAW=all` — the whole skeleton, one line per bone per frame. The point is
|
||||
// localisation: differenced against an offline compose of the same M2 it says WHICH bones
|
||||
// carry motion the file does not, and a subtree answer names the pass that added it.
|
||||
for (b, p) in bone_pos.iter().enumerate() {
|
||||
let a = bone_axes.get(b).copied().unwrap_or_default();
|
||||
println!(
|
||||
"RAWALL\t{now:.6}\t{b}\t{:.9}\t{:.9}\t{:.9}\t{:.6}\t\
|
||||
{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}",
|
||||
p.x,
|
||||
p.y,
|
||||
p.z,
|
||||
anim.first().map_or(-1.0, |a| a.1),
|
||||
a[0].x,
|
||||
a[0].y,
|
||||
a[0].z,
|
||||
a[1].x,
|
||||
a[1].y,
|
||||
a[1].z,
|
||||
a[2].x,
|
||||
a[2].y,
|
||||
a[2].z
|
||||
);
|
||||
}
|
||||
} else if let Some(b) = raw_bone() {
|
||||
if let (Some(p), Some(q)) = (bone_pos.get(b), scr.get(3 * b)) {
|
||||
println!(
|
||||
"RAW\t{now:.6}\t{b}\t{:.9}\t{:.9}\t{:.9}\t{:.6}\t{:.6}\t{:.6}",
|
||||
p.x,
|
||||
p.y,
|
||||
p.z,
|
||||
q.x,
|
||||
q.y,
|
||||
anim.first().map_or(-1.0, |a| a.1)
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"JIT\t{now:.4}\t{:.3}\t{dist:.3}\t{px_per_yd:.1}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{worst}\t\
|
||||
{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.1}\t\
|
||||
{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.1}\t{:.4}\t{:.4}\t{}\t{:.3}\t{}\t{:.4}\t{:.3}\t\
|
||||
{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t\
|
||||
{:.4}\t{:.4}\t{:.4}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}",
|
||||
{:.4}\t{:.4}\t{:.4}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t\
|
||||
{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{}\t\
|
||||
{:.4}\t{:.4}\t{:.4}\t{}\t{:.2}\t{:.2}",
|
||||
dt * 1000.0,
|
||||
cam_d1 * 1000.0,
|
||||
cam_d2 * 1000.0,
|
||||
@@ -515,10 +773,25 @@ fn sample_jitter(
|
||||
rd2 * 1000.0,
|
||||
rd1 * px_per_yd,
|
||||
rd2 * px_per_yd,
|
||||
pworst / 3,
|
||||
mm(pd1),
|
||||
mm(pd2),
|
||||
pd1 * px_per_yd,
|
||||
pd2 * px_per_yd,
|
||||
pnbig,
|
||||
sd1med,
|
||||
sd2med,
|
||||
sd2max,
|
||||
snbig,
|
||||
crd1,
|
||||
crd2,
|
||||
);
|
||||
|
||||
meter.prev2 = std::mem::replace(&mut meter.prev, bones);
|
||||
meter.prev2_flesh = std::mem::replace(&mut meter.prev_flesh, flesh);
|
||||
meter.prev2_pal = std::mem::replace(&mut meter.prev_pal, pal);
|
||||
meter.prev2_scr = std::mem::replace(&mut meter.prev_scr, scr);
|
||||
meter.prev2_camrot = std::mem::replace(&mut meter.prev_camrot, camrot);
|
||||
meter.prev2_anc = std::mem::replace(&mut meter.prev_anc, ancs);
|
||||
meter.prev2_rid =
|
||||
std::mem::replace(&mut meter.prev_rid, rids.iter().map(|&(_, t)| t).collect());
|
||||
@@ -526,3 +799,17 @@ fn sample_jitter(
|
||||
meter.prev2_cam = std::mem::replace(&mut meter.prev_cam, cam_pos);
|
||||
meter.prev_anim = anim;
|
||||
}
|
||||
|
||||
/// `WOW_JITTER_RAW`'s bone — read once. Absent (or unparseable) leaves the raw lane silent, which
|
||||
/// is the default: it is a debugging firehose, one line per frame on top of the `JIT` line.
|
||||
fn raw_bone() -> Option<usize> {
|
||||
static B: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
|
||||
*B.get_or_init(|| {
|
||||
std::env::var("WOW_JITTER_RAW")
|
||||
.ok()
|
||||
.and_then(|v| match v.trim() {
|
||||
"all" => Some(usize::MAX),
|
||||
n => n.parse().ok(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -130,6 +130,21 @@ pub(super) fn apply_body_twist(
|
||||
locals: Query<&Transform>,
|
||||
stores: Query<&crate::net::ObjectStore>,
|
||||
) {
|
||||
// `WOW_NO_TWIST=1` — stand the whole counter-twist down, and say what the gap WAS. The pass
|
||||
// is inert at `yaw_gap == 0` (both shares are zero and every write is a no-op), so a bisect
|
||||
// that only removes it cannot tell "the twist was innocent" from "the twist never ran"; the
|
||||
// logged gap separates them.
|
||||
if twist_off() {
|
||||
for (_, t) in &units {
|
||||
if t.yaw_gap != 0.0 {
|
||||
info!(
|
||||
"twist: STOOD DOWN (WOW_NO_TWIST) — yaw_gap was {:.5} rad",
|
||||
t.yaw_gap
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (unit, mut twist) in &mut units {
|
||||
let mounted = stores
|
||||
.get(unit)
|
||||
@@ -218,6 +233,12 @@ pub(super) fn plugin(app: &mut App) {
|
||||
);
|
||||
}
|
||||
|
||||
/// `WOW_NO_TWIST`'s switch — read once. A bisect lever, not a shipping mode.
|
||||
fn twist_off() -> bool {
|
||||
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*OFF.get_or_init(|| std::env::var_os("WOW_NO_TWIST").is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{armed_shares, twist_shares};
|
||||
|
||||
@@ -1812,7 +1812,14 @@ fn control(
|
||||
// The counter-twist gap: how far the aim sits from the rendered body — the strafe
|
||||
// offset while it lasts, unwinding to zero as `model_yaw` closes on `face_yaw`.
|
||||
if let Some(mut twist) = twist {
|
||||
twist.yaw_gap = wrap_pi(player.face_yaw - player.model_yaw);
|
||||
// `WOW_TWIST_GAP=<radians>` forces the gap — the counter-twist's A/B lever. The
|
||||
// pass is inert at `yaw_gap == 0` and a scripted probe cannot open a real gap
|
||||
// (`WOW_PROBE_CAM` turns the model with the camera, so the measured gap is float
|
||||
// noise, ~1e-6 rad), which means "removing the twist changed nothing" has never
|
||||
// yet been a measurement of the twist — only of a pass that never ran. This is
|
||||
// what lets it actually be exercised.
|
||||
twist.yaw_gap = twist_gap_override()
|
||||
.unwrap_or_else(|| wrap_pi(player.face_yaw - player.model_yaw));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1984,3 +1991,14 @@ fn control(
|
||||
camera::fly_free(dt, &keys, typing, &mut rig, &mut cam, &mut cam_t);
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_TWIST_GAP=<radians>`: pin the body counter-twist's yaw gap instead of deriving it from
|
||||
/// aim-minus-model. Zero-cost when unset: one env read, once.
|
||||
fn twist_gap_override() -> Option<f32> {
|
||||
static G: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
|
||||
*G.get_or_init(|| {
|
||||
std::env::var("WOW_TWIST_GAP")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<f32>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -603,6 +603,60 @@ fn action_hover_delegates_by_kind() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A MACRO slot's hover is the reference's `0x52b040`: ONE white line, the macro's name (the
|
||||
/// "normal" colour `0xc0cf60` = 0xffffffff, wow-re `tooltip-content-law.md`), and a slot whose
|
||||
/// macro no longer exists shows no plate at all. The director's report after 1636 landed: a macro
|
||||
/// on the bar had no tooltip — the arm was a pre-0983 `_ => Ok(())`.
|
||||
#[test]
|
||||
fn action_hover_on_a_macro_slot_shows_its_name_in_white() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_macros(MacroState {
|
||||
account: vec![MacroView {
|
||||
name: "spawn".into(),
|
||||
texture: Some("Interface\\Icons\\Ability_Racial_Cannibalize".into()),
|
||||
body: ".spawn 16032".into(),
|
||||
local_only: false,
|
||||
}],
|
||||
character: Vec::new(),
|
||||
});
|
||||
s.set_action(
|
||||
24,
|
||||
Some(ActionSlot {
|
||||
kind: 0x40, // MACRO
|
||||
action: 1, // macro index 1
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.set_action(
|
||||
25,
|
||||
Some(ActionSlot {
|
||||
kind: 0x40,
|
||||
action: 7, // no such macro
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "AB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetAction(24)
|
||||
assert(tt:IsShown(), "a macro slot shows a plate")
|
||||
assert(tt:NumLines() == 1, "exactly one line, got " .. tt:NumLines())
|
||||
assert(TTTextLeft1:GetText() == "spawn", "the macro's name")
|
||||
local r, g, b = TTTextLeft1:GetTextColor()
|
||||
assert(r == 1 and g == 1 and b == 1, "white — the normal line colour, not the spell title gold")
|
||||
assert(not TTTextRight1:IsShown(), "no right column")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetAction(25)
|
||||
assert(tt:NumLines() == 0 and not tt:IsShown(), "a missing macro shows no plate")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty(), "{:?}", s.take_errors());
|
||||
}
|
||||
|
||||
/// The tooltip's left-column texts in order.
|
||||
fn left_lines(s: &mut UiScript) -> Vec<String> {
|
||||
let n: i64 = s.eval("return TT:NumLines()").unwrap();
|
||||
|
||||
@@ -604,8 +604,16 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
// GameTooltip:SetAction(slot) — the action-bar hover: pure delegation by payload kind
|
||||
// (byte-verified: SPELL 0x00 → the spell builder, ITEM 0x80 → the item builder, MACRO 0x40 —
|
||||
// no macro system yet, no tooltip).
|
||||
// (`0x5322a0`, byte-verified: SPELL 0x00 → the spell builder `0x52e610`, ITEM 0x80 → the item
|
||||
// builder `0x52b650`, MACRO 0x40 → `0x52b040`; wow-re `ui.md`'s SetAction dispatch).
|
||||
//
|
||||
// The MACRO arm is the reference's whole `0x52b040`: fetch the record (`0x4f0f40`), and either
|
||||
// hide (no record) or render ONE line — the macro's NAME (`rec+0x24`) — through the
|
||||
// single-coloured-line wrapper `0x5303b0` in the "normal" colour `0xc0cf60`, which is WHITE
|
||||
// (`0xffffffff`, VERIFIED never rewritten — wow-re `tooltip-content-law.md` §1's colour
|
||||
// table). Not the spell title's gold, and nothing about the bound spell: 1.12 has no
|
||||
// `#showtooltip`. This arm was a `_ => Ok(())` left from before 0983 shipped macros, which is
|
||||
// why a macro on the bar hovered to nothing (the director, 2026-08-27, after 1636).
|
||||
m.set(
|
||||
"SetAction",
|
||||
lua.create_function(|lua, (this, slot): (Table, u32)| {
|
||||
@@ -631,6 +639,22 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let f: mlua::Function = this.get("SetItemById")?;
|
||||
f.call::<()>((this.clone(), a.action))
|
||||
}
|
||||
0x40 => {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let name = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
clear_content(&mut model, h);
|
||||
// The same 1..36 index lookup `GetActionText` and the bar's icon arm
|
||||
// make, so the three can never disagree about which macro a slot holds.
|
||||
model.macros.get(a.action as usize).map(|m| m.name.clone())
|
||||
};
|
||||
fire_cleared(lua, h);
|
||||
if let Some(name) = name {
|
||||
append_line(lua, &this, (name, WHITE), None, false)?;
|
||||
}
|
||||
super::tooltip::show_or_hide_empty(lua, h);
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
})?,
|
||||
|
||||
@@ -199,6 +199,18 @@ fn main() -> Result<()> {
|
||||
.context("series needs --at \"<x>,<y>[;<x>,<y>…]\"")?;
|
||||
series(Path::new(dir), at)?;
|
||||
}
|
||||
"flow" => {
|
||||
let [dir] = one(rest, "flow")?;
|
||||
flow(Path::new(dir), opts.rect.as_deref())?;
|
||||
}
|
||||
"edge" => {
|
||||
let [dir] = one(rest, "edge")?;
|
||||
let at = opts
|
||||
.at
|
||||
.as_deref()
|
||||
.context("edge needs --at \"<x0>,<y>,<x1>[;…]\"")?;
|
||||
edge(Path::new(dir), at)?;
|
||||
}
|
||||
"compose-dir" => {
|
||||
let [da, db] = two(rest, "compose-dir")?;
|
||||
let out = opts
|
||||
@@ -444,6 +456,174 @@ fn parse_at(spec: &str) -> Result<Vec<(u32, u32)>> {
|
||||
}
|
||||
|
||||
/// Stitch every `*.png` present in both dirs into `left | right` side-by-side images under `out`.
|
||||
/// **The sub-pixel motion ruler** (`flow <burst_dir> [--rect "<x>,<y>,<w>,<h>"]`).
|
||||
///
|
||||
/// `flicker` and `series` answer *how much* a pixel changed. Neither can answer the question a
|
||||
/// "the animation ticks" report actually asks — is the thing in front of the pixel advancing
|
||||
/// **evenly**? — and the obvious way to ask it is a trap: recover one silhouette edge's sub-pixel
|
||||
/// position frame by frame, and the ruler is built out of 8-bit pixels, so it carries its own
|
||||
/// 1/255 quantisation and a 0.2 px step cannot be separated from the tool's noise floor. That
|
||||
/// ambiguity is exactly what left the first staircase reading unattributed.
|
||||
///
|
||||
/// So estimate the whole window's displacement at once, by least squares over every pixel in it
|
||||
/// (Lucas–Kanade, the standard first-order flow solve): thousands of gradients vote on one
|
||||
/// `(dx, dy)`, the 8-bit floor averages down by `√N`, and the estimate lands two orders of
|
||||
/// magnitude under a pixel. A breathing body is not a rigid translation and does not need to be —
|
||||
/// what is read off the series is whether the aggregate advances smoothly, and a staircase in the
|
||||
/// RENDER shows up as a staircase here whatever the body underneath is doing.
|
||||
///
|
||||
/// Columns are the per-frame displacement, its magnitude, and the **second difference** — the
|
||||
/// curvature-per-frame discriminator the world-space jitter meter uses, on this side of the glass.
|
||||
/// The summary counts **stalled** frames (under a fifth of the median step): a smooth pan has
|
||||
/// none, a staircase is mostly them.
|
||||
fn flow(dir: &Path, rect: Option<&str>) -> Result<()> {
|
||||
let names = pngs(dir)?;
|
||||
if names.len() < 3 {
|
||||
bail!(
|
||||
"flow wants at least 3 frames, {} has {}",
|
||||
dir.display(),
|
||||
names.len()
|
||||
);
|
||||
}
|
||||
let first = load(&dir.join(names.iter().next().expect("non-empty")))?;
|
||||
let (w, h) = first.dimensions();
|
||||
// The default window is the whole frame minus the one-pixel border the central differences
|
||||
// need. A named rect is clamped out loud, never silently (the `crop` honesty rule).
|
||||
let win = match rect {
|
||||
Some(spec) => {
|
||||
let want = parse_rect(spec)?;
|
||||
let got = Rect {
|
||||
x0: want.x0.min(w.saturating_sub(1)),
|
||||
y0: want.y0.min(h.saturating_sub(1)),
|
||||
x1: want.x1.min(w),
|
||||
y1: want.y1.min(h),
|
||||
};
|
||||
if got.x0 >= got.x1 || got.y0 >= got.y1 {
|
||||
bail!("--rect {spec:?} lies outside the {w}x{h} frame");
|
||||
}
|
||||
if got != want {
|
||||
println!(
|
||||
"flow: --rect clamped to [{}..{}, {}..{}]",
|
||||
got.x0, got.x1, got.y0, got.y1
|
||||
);
|
||||
}
|
||||
got
|
||||
}
|
||||
None => Rect {
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
x1: w,
|
||||
y1: h,
|
||||
},
|
||||
};
|
||||
let (ww, wh) = ((win.x1 - win.x0) as usize, (win.y1 - win.y0) as usize);
|
||||
if ww < 3 || wh < 3 {
|
||||
bail!("flow wants a window at least 3x3, got {ww}x{wh}");
|
||||
}
|
||||
println!(
|
||||
"flow: {} frames, {w}x{h}, window [{}..{}, {}..{}] ({ww}x{wh} = {} px)",
|
||||
names.len(),
|
||||
win.x0,
|
||||
win.x1,
|
||||
win.y0,
|
||||
win.y1,
|
||||
ww * wh
|
||||
);
|
||||
println!(" frame dx dy |d| |d2|");
|
||||
let mut prev = luma_window(&first, &win);
|
||||
let mut steps: Vec<(f64, f64)> = Vec::new();
|
||||
for (i, name) in names.iter().enumerate().skip(1) {
|
||||
let cur = luma_window(&load(&dir.join(name))?, &win);
|
||||
let Some((dx, dy)) = lucas_kanade(&prev, &cur, ww, wh) else {
|
||||
println!(" {i:5} (no gradient in the window — flow is undefined here)");
|
||||
prev = cur;
|
||||
continue;
|
||||
};
|
||||
let d2 = steps
|
||||
.last()
|
||||
.map(|&(px, py)| (dx - px).hypot(dy - py))
|
||||
.unwrap_or(f64::NAN);
|
||||
println!(
|
||||
" {i:5} {dx:+8.4} {dy:+8.4} {:7.4} {:8.4}",
|
||||
dx.hypot(dy),
|
||||
d2
|
||||
);
|
||||
steps.push((dx, dy));
|
||||
prev = cur;
|
||||
}
|
||||
let mags: Vec<f64> = steps.iter().map(|&(x, y)| x.hypot(y)).collect();
|
||||
let curv: Vec<f64> = steps
|
||||
.windows(2)
|
||||
.map(|p| (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1))
|
||||
.collect();
|
||||
if mags.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let med = |v: &mut Vec<f64>| -> f64 {
|
||||
v.sort_by(f64::total_cmp);
|
||||
v[v.len() / 2]
|
||||
};
|
||||
let med_mag = med(&mut mags.clone());
|
||||
let stalled = mags.iter().filter(|m| **m < 0.2 * med_mag).count();
|
||||
println!(
|
||||
" --- steps: median |d| {med_mag:.4} px max |d| {:.4} stalled (<20% of median) {stalled}/{}",
|
||||
mags.iter().copied().fold(0.0, f64::max),
|
||||
mags.len()
|
||||
);
|
||||
if !curv.is_empty() {
|
||||
let med_c = med(&mut curv.clone());
|
||||
println!(
|
||||
" --- curvature: median |d2| {med_c:.4} px max |d2| {:.4} ratio |d2|/|d| {:.3}",
|
||||
curv.iter().copied().fold(0.0, f64::max),
|
||||
med_c / med_mag.max(f64::MIN_POSITIVE)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One frame's luma over `win`, row-major — the flow solve's input (f32 is exact for 8-bit sums
|
||||
/// and halves the working set against a 3200x1800 f64 buffer).
|
||||
fn luma_window(img: &image::RgbImage, win: &Rect) -> Vec<f32> {
|
||||
let mut out = Vec::with_capacity(((win.x1 - win.x0) * (win.y1 - win.y0)) as usize);
|
||||
for y in win.y0..win.y1 {
|
||||
for x in win.x0..win.x1 {
|
||||
let p = img.get_pixel(x, y).0;
|
||||
out.push(
|
||||
0.2126 * f32::from(p[0]) + 0.7152 * f32::from(p[1]) + 0.0722 * f32::from(p[2]),
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Least-squares first-order optical flow between two luma windows: the `(dx, dy)` that best
|
||||
/// explains `b` as `a` shifted, solved once over every interior pixel. `None` when the window
|
||||
/// carries no usable gradient structure (a flat sky — the normal matrix is singular and any
|
||||
/// answer would be invented).
|
||||
fn lucas_kanade(a: &[f32], b: &[f32], w: usize, h: usize) -> Option<(f64, f64)> {
|
||||
let (mut sxx, mut sxy, mut syy, mut sxt, mut syt) = (0.0f64, 0.0, 0.0, 0.0, 0.0);
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..w - 1 {
|
||||
let i = y * w + x;
|
||||
let ix = 0.5 * f64::from(a[i + 1] - a[i - 1]);
|
||||
let iy = 0.5 * f64::from(a[i + w] - a[i - w]);
|
||||
let it = f64::from(b[i] - a[i]);
|
||||
sxx += ix * ix;
|
||||
sxy += ix * iy;
|
||||
syy += iy * iy;
|
||||
sxt += ix * it;
|
||||
syt += iy * it;
|
||||
}
|
||||
}
|
||||
let det = sxx * syy - sxy * sxy;
|
||||
// Scale-relative: the normal matrix grows with the window, so an absolute epsilon would call
|
||||
// a big flat window solvable and a small textured one singular.
|
||||
if det <= 1e-12 * (sxx * syy).max(f64::MIN_POSITIVE) {
|
||||
return None;
|
||||
}
|
||||
Some(((sxy * syt - syy * sxt) / det, (sxy * sxt - sxx * syt) / det))
|
||||
}
|
||||
|
||||
fn compose_dir(da: &Path, db: &Path, out: &Path) -> Result<()> {
|
||||
let names: BTreeSet<String> = pngs(da)?.intersection(&pngs(db)?).cloned().collect();
|
||||
if names.is_empty() {
|
||||
@@ -736,6 +916,121 @@ fn over_fail(m: &Metrics, fail: Option<f64>) -> bool {
|
||||
}
|
||||
|
||||
/// Load an image as RGB (dropping any alpha — capture windows are opaque).
|
||||
/// One silhouette's sub-pixel position along a scanline, per frame — the shading-independent
|
||||
/// answer to "did the rendered geometry MOVE smoothly?".
|
||||
///
|
||||
/// Per frame and per scanline: take the row's own darkest and brightest samples, put the
|
||||
/// threshold halfway between them, and linearly interpolate the crossing. Renormalising to the
|
||||
/// row's own ends every frame is the whole point — a uniform brightness change (the body turning
|
||||
/// into the light) moves `lo` and `hi` together and leaves the crossing where it was, while a
|
||||
/// geometric shift moves it. That is the property [`flow`] lacks.
|
||||
fn edge(dir: &Path, at: &str) -> Result<()> {
|
||||
let names = pngs(dir)?;
|
||||
if names.len() < 3 {
|
||||
bail!(
|
||||
"edge wants at least 3 frames, {} has {}",
|
||||
dir.display(),
|
||||
names.len()
|
||||
);
|
||||
}
|
||||
let mut lines: Vec<(u32, u32, u32)> = Vec::new();
|
||||
for spec in at.split(';').filter(|s| !s.trim().is_empty()) {
|
||||
let p: Vec<u32> = spec
|
||||
.trim()
|
||||
.split(',')
|
||||
.map(|v| {
|
||||
v.trim()
|
||||
.parse::<u32>()
|
||||
.context("edge --at wants <x0>,<y>,<x1>")
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let [x0, y, x1] = p[..] else {
|
||||
bail!("edge --at wants <x0>,<y>,<x1>, got {spec:?}");
|
||||
};
|
||||
if x1 <= x0 + 1 {
|
||||
bail!("edge scanline {spec:?} needs x1 > x0+1");
|
||||
}
|
||||
lines.push((x0, y, x1));
|
||||
}
|
||||
for &(x0, y, x1) in &lines {
|
||||
println!("scanline y={y} x {x0}..{x1}");
|
||||
let mut pos: Vec<f64> = Vec::new();
|
||||
for name in &names {
|
||||
let img = load(&dir.join(name))?;
|
||||
if y >= img.height() || x1 >= img.width() {
|
||||
bail!(
|
||||
"scanline ({x0},{y},{x1}) outside {}x{}",
|
||||
img.width(),
|
||||
img.height()
|
||||
);
|
||||
}
|
||||
let row: Vec<f64> = (x0..=x1)
|
||||
.map(|x| {
|
||||
let p = img.get_pixel(x, y).0;
|
||||
0.2126 * f64::from(p[0]) + 0.7152 * f64::from(p[1]) + 0.0722 * f64::from(p[2])
|
||||
})
|
||||
.collect();
|
||||
let lo = row.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = row.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
// Too little contrast to call an edge — report it rather than invent a position.
|
||||
if hi - lo < 12.0 {
|
||||
pos.push(f64::NAN);
|
||||
continue;
|
||||
}
|
||||
let mid = 0.5 * (lo + hi);
|
||||
let mut found = f64::NAN;
|
||||
for i in 0..row.len() - 1 {
|
||||
let (a, b) = (row[i] - mid, row[i + 1] - mid);
|
||||
if (a <= 0.0 && b > 0.0) || (a >= 0.0 && b < 0.0) {
|
||||
found = f64::from(x0) + i as f64 + a / (a - b);
|
||||
break;
|
||||
}
|
||||
}
|
||||
pos.push(found);
|
||||
}
|
||||
let good = pos.iter().filter(|p| p.is_finite()).count();
|
||||
for (i, p) in pos.iter().enumerate().take(28) {
|
||||
let d = if i == 0 { f64::NAN } else { p - pos[i - 1] };
|
||||
println!(" {i:4} x={p:10.4} d={d:+8.4}");
|
||||
}
|
||||
let d1: Vec<f64> = pos
|
||||
.windows(2)
|
||||
.map(|w| (w[1] - w[0]).abs())
|
||||
.filter(|v| v.is_finite())
|
||||
.collect();
|
||||
let d2: Vec<f64> = pos
|
||||
.windows(3)
|
||||
.map(|w| (w[2] - 2.0 * w[1] + w[0]).abs())
|
||||
.filter(|v| v.is_finite())
|
||||
.collect();
|
||||
if d1.is_empty() || d2.is_empty() {
|
||||
println!(
|
||||
" no usable edge on {}/{} frames",
|
||||
names.len() - good,
|
||||
names.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let med = |mut v: Vec<f64>| {
|
||||
v.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
|
||||
v[v.len() / 2]
|
||||
};
|
||||
let (m1, m2) = (med(d1.clone()), med(d2.clone()));
|
||||
let mx = d1.iter().copied().fold(0.0, f64::max);
|
||||
println!(
|
||||
" frames with an edge {good}/{} median |d| {m1:.4} px max |d| {mx:.4} px",
|
||||
names.len()
|
||||
);
|
||||
println!(
|
||||
" median |d2| {m2:.4} px roughness |d2|/|d| {:.3} (a smooth curve at 60 Hz is <<1)",
|
||||
m2 / m1.max(1e-9)
|
||||
);
|
||||
let stalled = d1.iter().filter(|&&v| v < 0.2 * m1).count();
|
||||
println!(" stalled steps (<20% of median): {stalled}/{}", d1.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load(path: &Path) -> Result<image::RgbImage> {
|
||||
Ok(image::open(path)
|
||||
.with_context(|| format!("opening {}", path.display()))?
|
||||
@@ -852,6 +1147,8 @@ fn print_usage() {
|
||||
benilla-visual flicker <burst_dir> [--out <envelope.png>] [--fail <mae>] [--amplify <n>] [--toggle-delta <n>]\n \
|
||||
benilla-visual hotspot <burst_dir> --out <strip.png> [--toggle-delta <n>] [--pad <n>]\n \
|
||||
benilla-visual series <burst_dir> --at \"<x>,<y>[;<x>,<y>…]\"\n \
|
||||
benilla-visual flow <burst_dir> [--rect \"<x>,<y>,<w>,<h>\"]\n \
|
||||
benilla-visual edge <burst_dir> --at \"<x0>,<y>,<x1>[;…]\"\n \
|
||||
benilla-visual compose-dir <dir_a> <dir_b> --out <dir> (side-by-side `a | b` per image)\n \
|
||||
benilla-visual stat <img.png> [--rect \"<x>,<y>,<w>,<h>\"]\n\
|
||||
benilla-visual crop <img.png> --rect \"<x>,<y>,<w>,<h>\" --out <crop.png> [--scale <n>] [--at \"<x>,<y>;…\"]\n\
|
||||
@@ -862,6 +1159,21 @@ fn print_usage() {
|
||||
surface blinking, many thin ones are z-fighting — and crops the largest into a contact sheet.\n\
|
||||
series drops the averaging: one named pixel's rgb/luma per frame, to line up against the\n\
|
||||
client's WOW_DEPTH log at the same coordinates.\n\
|
||||
edge NOISE FLOOR: about +/-0.5 px per frame on real content. VALIDATED against a provably
|
||||
smooth 0.5 deg/s camera pan over static geometry (true motion 0.28 px/frame, monotone): edge
|
||||
reported steps of -0.241 +0.212 +0.998 +0.732 -0.749 — NEGATIVE steps on a monotone pan. Its
|
||||
MEAN is right, its per-frame value is not. So it can only resolve discrete events well above
|
||||
~1 px (the attachment staircase's 0.83-1.1 px hop clears it; a 0.1-0.2 px breathing motion does
|
||||
not, and a roughness statistic computed at that scale is measuring this floor, not the render).
|
||||
edge tracks a SILHOUETTE's sub-pixel position along one scanline, per frame. Unlike flow it
|
||||
does not assume brightness constancy — it renormalises to the scanline's own light/dark ends
|
||||
every frame, so a shaded, deforming surface (skin, cloth) whose lighting changes as it moves
|
||||
still reads its GEOMETRY. flow is invalid on exactly that content: on a run where the palette
|
||||
was provably 33x rougher it reported *smoother*, because Lucas-Kanade read the shading change
|
||||
as motion. Use edge for a body, flow only for rigid, uniformly-lit subjects.
|
||||
flow measures MOTION rather than change: one least-squares sub-pixel displacement per frame\n\
|
||||
pair over the whole window, so a render that advances in steps reads as steps and one that\n\
|
||||
advances smoothly reads smooth — the question a per-pixel delta cannot answer.\n\
|
||||
crop cuts a window for close reading (nearest-neighbour zoom only, samples printed in\n\
|
||||
SOURCE coordinates) — use it instead of sips/ffmpeg pipelines, which have minted false\n\
|
||||
findings (sips --cropOffset is (y, x) and silently ignores what it can't parse).\n\
|
||||
|
||||
@@ -389,6 +389,28 @@ fn prepare_textures(
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_DITHER=1` — arm the combine's deband dither (the shader's `glow.w`).
|
||||
///
|
||||
/// The frame's ONE quantization to 8 bits is the surface's sRGB present-encode of the combine's
|
||||
/// gamma-space output, so a smooth surface steps in 1/255. Measured on a lit bare arm the shading
|
||||
/// gradient is ~1.3 levels/px; a breathing idle drifts the body ~0.11 px/frame; so a pixel needs
|
||||
/// ~7 FRAMES to cross one step and the shading updates at ~8 Hz under a 60 Hz frame rate. Motion
|
||||
/// large enough to clear a level every frame hides it entirely — the reported
|
||||
/// small-moves-tick / big-moves-smooth split.
|
||||
///
|
||||
/// **Off by default because it is a divergence.** The reference's framebuffer was 8-bit and
|
||||
/// undithered; this lane is byte-exact against it (0161) and dithering trades that for a smoother
|
||||
/// gradient. Bevy would normally apply its own in the tonemapping pass, but `Tonemapping::None`
|
||||
/// makes that node return immediately, so the `DebandDither::Enabled` our camera inherits from
|
||||
/// `Camera3d` never runs — this is the only place it can live.
|
||||
fn dither_armed() -> f32 {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
match *ON.get_or_init(|| std::env::var_os("WOW_DITHER").is_some()) {
|
||||
true => 1.0,
|
||||
false => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The combine's `(x, y, z, w)` uniform for one view — the whole per-view scaling law in one
|
||||
/// pure function, so it can be tested without a render world.
|
||||
///
|
||||
@@ -398,13 +420,13 @@ fn prepare_textures(
|
||||
/// - **y/z** — the FFXDeath gate and the haze mix, both scaled by [`FfxGlow::state_scale`]: these
|
||||
/// are keyed on the **viewer's own state**, which the reference paints inside the WorldFrame and
|
||||
/// every UI frame composites over, so neither may reach a bake (decision 1481).
|
||||
/// - **w** — reserved.
|
||||
/// - **w** — the deband-dither arm ([`dither_armed`]), 0 or 1.
|
||||
fn combine_uniform(zone_gain: f32, death: f32, haze: f32, glow: &FfxGlow) -> [f32; 4] {
|
||||
[
|
||||
zone_gain * glow.gain_scale,
|
||||
death * glow.state_scale,
|
||||
haze * glow.state_scale,
|
||||
0.0,
|
||||
dither_armed(),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -205,7 +205,62 @@ pub(crate) fn pace_virtual_time(
|
||||
*generic = virt.as_generic();
|
||||
}
|
||||
|
||||
/// `WOW_FIXED_DT`'s pending arm — see [`plugin`].
|
||||
#[derive(Resource)]
|
||||
struct FixedDt {
|
||||
dt: Duration,
|
||||
at: std::time::Instant,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
/// Install the manual strategy once the wall clock passes the arm time, and say so in the log —
|
||||
/// a run whose clock silently failed to pin would read exactly like a run that measured something.
|
||||
fn arm_fixed_dt(mut cfg: ResMut<FixedDt>, mut commands: Commands) {
|
||||
if cfg.armed || std::time::Instant::now() < cfg.at {
|
||||
return;
|
||||
}
|
||||
cfg.armed = true;
|
||||
commands.insert_resource(TimeUpdateStrategy::ManualDuration(cfg.dt));
|
||||
info!(
|
||||
"frame_pace: WOW_FIXED_DT armed — animation clock pinned to {:.4} ms/frame",
|
||||
cfg.dt.as_secs_f64() * 1000.0
|
||||
);
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
// **`WOW_FIXED_DT=<ms>` — the deterministic clock**, and it exists for one question that no
|
||||
// other instrument here can answer: *is the RENDERED motion even?*
|
||||
//
|
||||
// A per-frame screenshot burst is the only way to measure what the eye actually receives, and
|
||||
// saving a 3200x1800 PNG stalls the frame to tens of milliseconds — so the burst perturbs the
|
||||
// very timing it is trying to measure, and its frames sample the animation on a grid as ragged
|
||||
// as the capture. Pinning the advance makes the geometry side perfectly even BY CONSTRUCTION,
|
||||
// so any unevenness left in the captured pixels belongs to the render, not the clock. That is
|
||||
// the whole discrimination: a body whose bones all move sub-pixel per frame can be flawless in
|
||||
// world space and still step on screen, and only this pairing separates the two.
|
||||
//
|
||||
// It composes with the pacer rather than fighting it: `Time<Real>` drives `Time<Virtual>`, and
|
||||
// [`pace_virtual_time`] stands down the moment a manual strategy is set.
|
||||
// Spelled `<ms>[,<arm_after_wall_seconds>]`, default 40 s. **The delay is not a convenience,
|
||||
// it is required**: a manual strategy overrides `Time<Real>`, which is the clock every probe
|
||||
// trigger reads, and during load the app is not vsync-limited — it ran ~385 updates/second, so
|
||||
// a pinned 16.6667 ms clock reached "90 seconds" in 14 s of wall time and the run exited before
|
||||
// the character had finished streaming in. Arming on a real wall-clock `Instant` keeps the
|
||||
// pinning for the part of the run that is being measured and leaves login/teleport on real time.
|
||||
if let Some((ms, after)) = std::env::var("WOW_FIXED_DT").ok().and_then(|v| {
|
||||
let (ms, after) = match v.split_once(',') {
|
||||
Some((a, b)) => (a, b.trim().parse::<f64>().ok()?),
|
||||
None => (v.as_str(), 40.0),
|
||||
};
|
||||
Some((ms.trim().parse::<f64>().ok().filter(|m| *m > 0.0)?, after))
|
||||
}) {
|
||||
app.insert_resource(FixedDt {
|
||||
dt: Duration::from_secs_f64(ms / 1000.0),
|
||||
at: std::time::Instant::now() + Duration::from_secs_f64(after.max(0.0)),
|
||||
armed: false,
|
||||
})
|
||||
.add_systems(First, arm_fixed_dt.before(TimeSystems));
|
||||
}
|
||||
app.init_resource::<FramePacer>()
|
||||
.add_systems(First, pace_virtual_time.after(TimeSystems));
|
||||
}
|
||||
|
||||
@@ -519,13 +519,28 @@ impl RigPalettes {
|
||||
/// rig-relative (decision 0974), so the slot's origin goes back on here — the picker ray is a
|
||||
/// world-space ray, and it is not a precision consumer.
|
||||
pub fn world_palette(&self, slot: u16, bones: usize) -> Option<Vec<Mat4>> {
|
||||
let o = *self.origins.get(slot as usize)?;
|
||||
self.rows_at(slot, bones, [o[0], o[1], o[2]])
|
||||
}
|
||||
|
||||
/// The rig's palette rows **as the vertex stage blends them** — rig-relative (decision 0974),
|
||||
/// the slot origin deliberately NOT added back. [`Self::world_palette`] is the picker's
|
||||
/// world-space read; this is the PRECISION one, for the same reason [`Self::rider_placement`]
|
||||
/// returns its pair unsummed: re-adding the ~9 k-yard origin would stamp the very f32 grid
|
||||
/// the lane exists to avoid onto the measurement, and the instrument would then read its own
|
||||
/// arithmetic as the defect.
|
||||
pub fn rig_rows(&self, slot: u16, bones: usize) -> Option<Vec<Mat4>> {
|
||||
self.rows_at(slot, bones, [0.0; 3])
|
||||
}
|
||||
|
||||
/// Both reads above: the rows of `slot`, with `o` added onto the translation column.
|
||||
fn rows_at(&self, slot: u16, bones: usize, o: [f32; 3]) -> Option<Vec<Mat4>> {
|
||||
let s = slot as usize;
|
||||
let len = (*self.slot_len.get(s)? as usize).min(bones);
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
let base = self.table[s] as usize;
|
||||
let o = self.origins[s];
|
||||
let base = *self.table.get(s)? as usize;
|
||||
Some(
|
||||
(0..len)
|
||||
.map(|b| {
|
||||
@@ -541,6 +556,15 @@ impl RigPalettes {
|
||||
)
|
||||
}
|
||||
|
||||
/// A slot's rig ORIGIN — the world position its rows are measured from, exactly as the vertex
|
||||
/// stage reads it out of `rig_origin[slot]`. The instrument pairs it with [`Self::rig_rows`] to
|
||||
/// reproduce the shader's own `frame_from_local * v + (frame_origin - view.world_position)`
|
||||
/// without ever forming the absolute world coordinate the lane exists to avoid.
|
||||
pub fn slot_origin(&self, slot: u16) -> Option<Vec3> {
|
||||
let o = self.origins.get(slot as usize)?;
|
||||
Some(Vec3::new(o[0], o[1], o[2]))
|
||||
}
|
||||
|
||||
/// A rider slot's placement **as the vertex stage reads it** (decision 1609): its
|
||||
/// `(rig_origin, row-0 translation)` pair, unsummed. The instrument read — `WOW_JITTER`
|
||||
/// measures the row's own motion against the anchor's absolute world translation, and the
|
||||
@@ -619,6 +643,16 @@ impl RigSkin {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// The rig's inverse bindposes — the instrument read. `WOW_JITTER`'s palette term probes the
|
||||
/// rows at each bone's BIND position, which is the only honest way to ask "how far does a
|
||||
/// vertex bound to this bone actually move" of the numbers the GPU blends: a row's own
|
||||
/// translation column is the bind-space ORIGIN's image, a lever up to the model's full height
|
||||
/// away from the bone, and differencing it reads a rotation as ~10x the displacement any real
|
||||
/// vertex sees.
|
||||
pub fn ibp(&self) -> &Handle<SkinnedMeshInverseBindposes> {
|
||||
&self.ibp
|
||||
}
|
||||
|
||||
/// Allocate a palette rig for the collapsed lane (decision 0724): no joint entities — the
|
||||
/// world pass hands composed frames to [`RigPalettes::write_rig_worlds`] instead, so the
|
||||
/// entity-lane change sweep never touches this slot (empty `joints` make its `write_rig` a
|
||||
|
||||
@@ -86,6 +86,41 @@ fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
|
||||
// The CPU pack `0x6cb930` builds primary as `(LightParams.glow·255)<<24 | 0x5393A8`: rgb is the
|
||||
// CONSTANT steel blue-gray 0x53/0x93/0xA8, and primary.w (the blur² weight) is the same zone glow
|
||||
// the glow pass uses — so glow.x serves both combines and glow.y is a pure 0/1 gate.
|
||||
// **Deband dither** (`glow.w`, off by default — see `ffx_glow.rs`). The frame's ONE quantization
|
||||
// to 8 bits happens on `outg` below: it is a gamma-space float, and the surface's sRGB
|
||||
// present-encode rounds it to a byte. A smooth surface therefore steps in 1/255, and at the
|
||||
// gradients a lit character actually has (~1.3 levels/px on a bare arm) a body drifting the
|
||||
// 0.11 px/frame of a breathing idle needs ~7 FRAMES to accumulate one step — so the shading
|
||||
// updates at ~8 Hz on a 60 Hz display and every iso-luma contour crosses its threshold together.
|
||||
// Large motions clear a level per frame and hide it completely, which is exactly the
|
||||
// small-moves-tick / big-moves-smooth split the director reported.
|
||||
//
|
||||
// The hash is Bevy's own `screen_space_dither` (its tonemapping pass applies it and we skip that
|
||||
// pass entirely — `Tonemapping::None` returns before the node does anything, so
|
||||
// `DebandDither::Enabled` on our camera is dead code). It is a pure function of the PIXEL, not of
|
||||
// time: a still frame stays bitwise still, while a moving gradient's contour dissolves across
|
||||
// neighbouring pixels instead of snapping as one line.
|
||||
//
|
||||
// **This is a deliberate divergence.** The reference's framebuffer was 8-bit and undithered, so
|
||||
// byte-exactness and this are mutually exclusive; that is why it is opt-in and why the default
|
||||
// keeps the byte lane intact.
|
||||
fn screen_space_dither(frag_coord: vec2<f32>) -> vec3<f32> {
|
||||
var dither = vec3<f32>(dot(vec2<f32>(171.0, 231.0), frag_coord)).xxx;
|
||||
dither = fract(dither.rgb / vec3<f32>(103.0, 71.0, 97.0));
|
||||
return (dither - vec3<f32>(0.5)) / 255.0;
|
||||
}
|
||||
|
||||
// The combine's one exit: dither (when armed) in GAMMA space — the space the present-encode
|
||||
// rounds in — then the frame's single decode.
|
||||
fn combine_out(outg: vec3<f32>, alpha: f32, frag_coord: vec2<f32>) -> vec4<f32> {
|
||||
let dithered = clamp(
|
||||
outg + screen_space_dither(frag_coord) * step(0.5, glow.w),
|
||||
vec3<f32>(0.0),
|
||||
vec3<f32>(1.0),
|
||||
);
|
||||
return vec4<f32>(srgb_to_linear(dithered), alpha);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
// GAMMA LANE (0161): the whole frame is already gamma bytes — the blur ran on gamma (like
|
||||
@@ -108,7 +143,7 @@ fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let p = clamp(4.0 * luma * (1.0 - luma), 0.0, 1.0);
|
||||
let ghost_tint = vec3<f32>(83.0, 147.0, 168.0) / 255.0; // 0x5393A8
|
||||
let outg = min(vec3<f32>(luma) + ghost_tint * p, vec3<f32>(1.0));
|
||||
return vec4<f32>(srgb_to_linear(outg), scene.a);
|
||||
return combine_out(outg, scene.a, in.position.xy);
|
||||
}
|
||||
// The z lane — the drunk/underwater haze: cross-fade the screen toward the ¼-res blur
|
||||
// BEFORE the glow add (`out = lerp(screen, blur, z) + w·blur²`, the shipped FFXGlow.bls;
|
||||
@@ -117,5 +152,5 @@ fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
// the screen term: it stands for the same byte RT.
|
||||
let hazed = mix(sg, min(bg, vec3<f32>(1.0)), glow.z);
|
||||
let outg = min(hazed + glow.x * bg * bg, vec3<f32>(1.0));
|
||||
return vec4<f32>(srgb_to_linear(outg), scene.a);
|
||||
return combine_out(outg, scene.a, in.position.xy);
|
||||
}
|
||||
|
||||
@@ -399,6 +399,35 @@ pub struct WorldCamera;
|
||||
/// relative precision — and our ULP-relative bias ladder ([`crate::sky_order`]) — independent of
|
||||
/// the near value. The small near is what keeps the whole waterline-crossing band (the corner-min
|
||||
/// submersion probe, `liquid::detect_submersion`) inches tall instead of a yard.
|
||||
/// The `$WOW_MSAA` startup knob → a sample count, shared by both world-camera spawners
|
||||
/// (`benilla_world::worldview` and the app's `player::setup`) so the two can never disagree.
|
||||
///
|
||||
/// **8x is refused rather than passed through.** wgpu validates the sample count against the HDR
|
||||
/// target's format, and the WebGPU spec guarantees only `[1, 4]` for `Rgba16Float` (this Mac's
|
||||
/// adapter reports `[1, 2, 4]`). `WOW_MSAA=8` therefore panicked the render thread inside
|
||||
/// `prepare_view_targets` on frame one — an A/B leg that asked for it died with a wgpu validation
|
||||
/// error instead of returning an answer, which is how it was found. There is no adapter handle at
|
||||
/// camera-spawn time to query, so the honest move is to fall back to 4x and say so loudly: a
|
||||
/// usable knob and a legible failure beat a hard crash.
|
||||
///
|
||||
/// A STARTUP knob, not a live toggle: switching MSAA at runtime leaves the post-process passes
|
||||
/// (glow/egui) MSAA-mismatched and freezes the view, so A/B by restarting.
|
||||
pub fn msaa_from_env() -> bevy::render::view::Msaa {
|
||||
use bevy::render::view::Msaa;
|
||||
match std::env::var("WOW_MSAA").ok().as_deref() {
|
||||
Some("off") | Some("0") | Some("1") => Msaa::Off,
|
||||
Some("2") => Msaa::Sample2,
|
||||
Some("8") => {
|
||||
warn!(
|
||||
"WOW_MSAA=8: Rgba16Float is only guaranteed [1, 4] samples and this adapter \
|
||||
reports [1, 2, 4] — falling back to 4x rather than panicking the render thread"
|
||||
);
|
||||
Msaa::Sample4
|
||||
}
|
||||
_ => Msaa::Sample4,
|
||||
}
|
||||
}
|
||||
|
||||
pub const CAM_NEAR: f32 = 1.0 / 9.0;
|
||||
/// The camera's vertical field of view (radians) — one constant shared by the projection
|
||||
/// (the camera spawn) and every consumer that needs the near rectangle's true shape. 45°, the value the
|
||||
|
||||
Reference in New Issue
Block a user