Files
benilla-octo/crates/benilla-formats/examples/clutter_size.rs
T
SamandClaude Fable 5.1 c3555b4eea the cooldown goes stock, early-Z comes back, and lists reach the end
- a size-less `<Model>` pane's rect is its file's box in layout units: the reference's geometry overrides (`0x76d080`/`0x76d0d0`) answer the bbox extent whenever no size is authored, `bboxExtent · 768·√(a²+1) · layoutScale` FrameXML units (`1280 · extent` at 4:3), written into the pane's layout input when the file's facts land, re-derived on an aspect change, and ended for good by any authored size; `SetPosition` is the same unit, which is how `0x4a7b20` centres the arrow at `½·GetWidth, ½·GetHeight` through the override — so the map arrow's sprite arm and its hand-sized 33.6 px footprint go (its rect is `0.0262 × 0.0263` layout units, 33.5 × 33.7 FrameXML units at 4:3, 41.0 × 41.2 at 16:9), and the stock `ItemAnim` pane plays the item push unadapted (`ReplaceIconTexture` on the type-14 batch, the 1000 ms clamp, the stock `OnAnimFinished`), `ContainerFrameAdapters.xml` losing 0887's Lua card and its hand-measured 42.41 × 124.72 rect — the implicit one at 16:9 — and keeping the keyring fork and the three bag verbs (2015); and the cooldown is the reference's own model: the texture-transform law `uv' = R_q((uv + t − p) ⊙ s) + p`, `p = (½, ½)`, the raw quaternion component-lerped and never normalised, is one function (`uv_transform`) with the shader's UV fold as its twin — rotation and scale tracks bake per file sequence slot beside the translation, the mat-anim table gains an affine row `[cos − 1, sin, sx − 1, sy − 1]` on `anim_slots.z` whose identity is the zero row so every static batch reads exactly what it did, and a UI tile clones the twin material per animated batch and writes both rows every frame off the pane's play head, never the world clock; the stock `Cooldown.xml` runs off the chain on 2007's two handlers, the file's four quadrant quads turned by their own rotation tracks (`0 → −90°` over each quadrant's 250 ms window, clockwise from 12 o'clock) and its alpha tracks revealing the star for the flash; retired: the native `Cooldown` widget and `SetCooldown` (an Era API 1.12 never had — `CreateFrame("Cooldown")` is the reference's unknown-type refusal now), the loader's `Model → Cooldown` substitution, the app's hand-built pie and star, and our `Cooldown.xml`; `assets/ui` is eight files (2019)
- `UpdateMapHighlight` gets its zone-level exits (B360): at zone zoom ours returned the nil tail for every cursor position, so hovering Deadwind Pass from Duskwood showed no label where 1.12 shows "Deadwind Pass"; the reference's `0x4a7fa0` runs two lookups at zone level and answers a NAME only — first the current zone's exploration-gated overlay list (the builder at `0x4a6ad9` gating on the explored byte), each row's HitRect scaled by 1/1002 and 1/668 against the normalized cursor, edges inclusive, the first hit naming its Area1, then the continent grid re-windowed through the displayed zone's own rect, naming the neighbouring zone or city and nothing for the displayed zone itself — and only the name-only tail is reachable from there, fileName nil and the six coords zero, so the stock frame hides the highlight quad, as the director's own 1.12 shot shows; and `SetVerticalScroll` stores the offset as given (B370, B363): the reference's `0x786db0` compares the new offset against the old and writes it to `[+0x328]` verbatim, `0x787100` re-anchors the scroll child off it, and the range at `[+0x320]` is never read on that path — every clamp the reference exhibits is FrameXML's, through the scroll bar's `[min, max]`; ours clamped into `[0, GetVerticalScrollRange()]`, the child's overflow past the FRAME, which is 40 px short of where `FauxScrollFrame_Update` sends the bar on the 220-tall skills list (three rows lost) and 15 px short on the 287-tall who list (one); the setter fires `OnVerticalScroll` only on a change, as the bytes do, `BenillaScroll_Step` bounds its wheel in Lua where the reference's handler does, and the ScrollFrame kind's prose stops calling its offset unpinned (2017)
- overdraw's cheap half, the sky first: since 0588 every sky element wrote `frag_depth = 0.0`, which costs a pipeline its early-Z, so on an outdoor frame every full-screen dome fragment under the ground and the buildings was shaded in full (the gradient's `asin`, `atan2` and eight table lookups at dawn and dusk) and thrown away; one shared `sky_vertex.wgsl` — bevy's mesh vertex verbatim plus `out.position.z = 0.0` — is now the vertex stage of the gradient dome, the cloud dome, the star patches and the celestial discs and glares, and the model lane's `WOW_SKY_DEPTH` branch moves from its fragment to its vertex, so no sky fragment declares `frag_depth` and the rasterizer rejects the covered screen before the shader runs; every sky `specialize` zeroes the rasterizer bias constant the `sky_order` rung had been leaking into `DepthBiasState::constant`, harmless only while the fragment overwrote its depth, and the prose beside the pin follows it; then the retained `static_gx` pass draws BEFORE bevy's opaque pass (`StartMainPass → static_gx → MainOpaquePass`) — buildings and doodads are the frame's best occluders and a terrain fragment its dearest, six samples a pixel — so walls reject the terrain behind them instead of the reverse, the pass taking the first-use clear with it and the dev depth probe ordered after it; measured on 16 capture scenarios: 14 bit-identical, `northshire-dusk` 1 px and `water-night` 11 px, attributed to exact depth ties on static/terrain silhouettes and last-bit interpolation noise; a depth prepass and near-to-far bins are declined until a journal says where the time goes (2016)
- the grass cut goes per pixel because of a quarter-mip: `0x6813d0` sets `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 (`0x6813f4`) once per frame before the detail-doodad pass — capture-confirmed on all 29 doodad batches, and outside the `[0x6b1000, 0x6b3200)` scan 2004 had called complete; it decides the whole look because the `clutter_cut` probe finds 6 of 31 shipped detail atlases carry a BINARY alpha pyramid (mip 0 has DXT3's 16 levels, every level below exactly two, both starting zones among them), so `a × ramp` degenerates to a global on/off and every texel of a leaf crosses at the same depth — Elwynn's survivor coverage is flat at 47.6 % across the whole 52.5–61.1 yd band, then 0.0 %, while the probe's box-filtered control erodes 47.7 → 20.9 over the same band, so the art has the gradient the shipped pyramid threw away, and `clutter_size` puts the median tuft at 0.92 yd tall, 1.03 yd deep, a full yard for the boundary plane to sweep; the escape is bilinear: full alpha needs a 2×2 all-opaque texel neighbourhood, and those run out between mip 2 and 3 (179/56/10/0/0/0 on the dominant Elwynn cell), so the reference's λ ≈ 2.9–3.3 sits past the cliff and the tuft erodes continuously — 2.2 % of a tuft's pixels leave in the last half-yard with the bias, 11.6 % without, 24.2 % one mip sharper; wgpu has no sampler LOD bias, so the clutter lane re-samples base colour at `view.mip_bias + 0.25`, keeping the sampler every other model batch shares at the reference's 0; ruled out on the way: our BLP decoder (all nine uploaded levels byte-identical against the archive's 52 793 BLP2s), mip regeneration, the `has_mipmaps` byte's value 2 (a plain boolean, an authoring marker), anisotropy and geometry; what it does not fix is resolution-dependent, and so is the reference's own look — λ with the bias is 3.22 at the capture's 648p but 2.48 at 1080p and 1.48 at 2160p, so at a modern resolution the reference itself pops leaf by leaf, and scaling the bias with the viewport is one reversible line left for a look call (2021)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:35:31 +07:00

140 lines
6.1 KiB
Rust

//! **How big is one tuft, and how much DEPTH does it span?** — the geometry half of the
//! "why does the fade cut leaf-by-leaf instead of pixel-by-pixel" question.
//!
//! `cargo run -p benilla-formats --example clutter_size`
//!
//! The fade boundary is a **plane** at a fixed view depth (the reference texgens camera-space Z).
//! Whether that plane *slices* a tuft — some of it drawn, the rest gone, a per-pixel cut — or
//! passes it by in a single frame depends on how much view depth one tuft occupies. A tuft that
//! spans 1 yd of depth is sliced for as long as it takes the player to walk 1 yd. A tuft that
//! spans 5 cm is, for practical purposes, either wholly in or wholly out: it pops.
//!
//! So this prints, per detail model: the world-space bounding box, the tuft's **depth span at the
//! worst yaw** (its horizontal diagonal — the widest it can present to the camera), and the
//! screen-space size that implies at the 61.11 yd cutout crossing.
use std::collections::BTreeSet;
/// Vertical FOV benilla's camera runs at (Bevy's `PerspectiveProjection` default, ≈ the
/// reference's 44.1°) over a 1080-tall viewport — the same basis `clutter_state` uses.
fn px_per_yard(distance: f32) -> f32 {
let fov: f32 = std::f32::consts::PI / 4.0;
(1080.0 / 2.0) / ((fov / 2.0).tan() * distance)
}
/// Where the `detailDoodadAlpha` = 128 cutout erases even a fully-opaque texel.
const CROSSING: f32 = 61.113;
fn main() -> anyhow::Result<()> {
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let catalog = benilla_formats::load_ground_effect_catalog(&mut chain, true)?;
let mut models: BTreeSet<String> = BTreeSet::new();
for id in 0..4096u32 {
if let Some(e) = catalog.effect(id) {
models.extend(e.models().into_iter().map(str::to_string));
}
}
println!(
"{} detail models. WoW model space: +X north, +Y west, +Z up — so Z is the tuft's HEIGHT \
and the XY diagonal is the most depth it can present to the camera.",
models.len()
);
println!(
"\n{:<44} {:>6} {:>6} {:>7} {:>8} {:>8}",
"model", "w(yd)", "h(yd)", "depth", "px tall", "px deep"
);
let mut heights: Vec<f32> = Vec::new();
let mut depths: Vec<f32> = Vec::new();
let mut shown = 0;
for path in &models {
let Ok(bytes) = chain.read_file(path) else {
continue;
};
let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[]) else {
continue;
};
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for s in &subs {
for p in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
}
if lo[0] > hi[0] {
continue;
}
let (dx, dy, dz) = (hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]);
// The widest footprint the tuft can turn toward the camera: its horizontal diagonal.
let depth = (dx * dx + dy * dy).sqrt();
let width = dx.max(dy);
heights.push(dz);
depths.push(depth);
if shown < 14 {
let name = path.rsplit('\\').next().unwrap_or(path);
println!(
"{name:<44} {width:6.2} {dz:6.2} {depth:7.2} {:8.1} {:8.1}",
dz * px_per_yard(CROSSING),
depth * px_per_yard(CROSSING),
);
shown += 1;
}
}
let stat = |v: &mut Vec<f32>| {
v.sort_by(f32::total_cmp);
(v[0], v[v.len() / 2], v[v.len() - 1])
};
let (hlo, hmed, hhi) = stat(&mut heights);
let (dlo, dmed, dhi) = stat(&mut depths);
println!(
"\nheight min {hlo:.2} median {hmed:.2} max {hhi:.2} yd \
({:.0} px tall at the {CROSSING:.1} yd crossing)",
hmed * px_per_yard(CROSSING)
);
println!(
"depth min {dlo:.2} median {dmed:.2} max {dhi:.2} yd \
({:.0} px deep — the width of the slice the cutout plane can show)",
dmed * px_per_yard(CROSSING)
);
println!(
"\nA median tuft occupies {dmed:.2} yd of view depth. The cutout plane sweeps that in the \
time the player walks {dmed:.2} yd, and while it does, the tuft is drawn PARTLY — that is \
the per-pixel slice. Below roughly a tenth of a yard it is a single-frame pop instead."
);
// **Which mip a tuft is sampled at, and why the answer moves with the window.** This is the
// number the whole look turns on: the shipped Elwynn/Westfall/AeriePeaks atlases carry a binary
// alpha pyramid, and a fragment can only hold full alpha — and so die at the same depth as
// every other fragment of its leaf — inside a 2x2 all-opaque texel neighbourhood. Those run out
// between mip 2 and mip 3 (wow-re counts 179/56/10/0/0/0 for levels 0-5 on the dominant Elwynn
// cell), so sampling past ~3 erodes continuously and sampling below it takes leaves whole.
//
// The reference's `+0.25` stage-0 LOD bias buys a quarter of a mip toward that cliff — but the
// sample LOD falls as the viewport grows, so the SAME bias lands differently on a bigger
// window. wow-re measured the reference itself at lambda 2.9-3.3 in a 1152x648 capture and
// lambda ~2.45 at 1920x1080, i.e. the reference pops leaf-by-leaf at a modern resolution too.
const DENSITY: f32 = 100.0; // texels per yard, the median from `clutter_state`
println!("\nsampled mip at the {CROSSING:.1} yd crossing (median {DENSITY:.0} texels/yd art):");
for height in [648.0f32, 1080.0, 1440.0, 2160.0] {
let fov: f32 = std::f32::consts::PI / 4.0;
let px = (height / 2.0) / ((fov / 2.0).tan() * CROSSING);
let lambda = (DENSITY / px).log2();
println!(
" {height:5.0}p lambda {lambda:.2} unbiased {:.2} with the reference's +0.25",
lambda + 0.25
);
}
println!(
" (past ~3.0 a leaf's texels no longer share a death depth and the cut goes per pixel; \
below it, whole leaves cross together)"
);
Ok(())
}