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>
This commit is contained in:
@@ -395,6 +395,8 @@ impl AssetLoader for M2ModelLoader {
|
||||
alpha_anim: sub.alpha_anim.clone().map(std::sync::Arc::new),
|
||||
uv_anim: sub.uv_anim.clone().map(std::sync::Arc::new),
|
||||
uv_seq: sub.uv_seq.clone().map(std::sync::Arc::new),
|
||||
uv_rot_seq: sub.uv_rot_seq.clone().map(std::sync::Arc::new),
|
||||
uv_scale_seq: sub.uv_scale_seq.clone().map(std::sync::Arc::new),
|
||||
rgb_anim: sub.rgb_anim.clone().map(std::sync::Arc::new),
|
||||
rgb_seq: sub.rgb_seq.clone().map(std::sync::Arc::new),
|
||||
wmo_batch: None, // M2 batches have no MOBA section
|
||||
|
||||
@@ -112,13 +112,13 @@ pub struct WowModelKey {
|
||||
far_side: bool,
|
||||
/// The **WMO-skybox lane** (`clutter_fade.z` bit 13 — `model_render::SKY_DEPTH_MARKER`): the
|
||||
/// building-owned painted sky, drawn as the ordinary M2 it is. `specialize` compiles the
|
||||
/// `WOW_SKY_DEPTH` fragment def, which emits `@builtin(frag_depth) = 0.0` — reverse-Z far, the
|
||||
/// sky depth law every other sky shader already obeys (`benilla_world::sky_order`). It is a key
|
||||
/// axis because writing `frag_depth` costs the whole pipeline its early-Z, and the model lane
|
||||
/// draws every doodad and wall in the frame; only this one camera-anchored model may pay it.
|
||||
/// Like [`Self::far_side`] it also zeroes the rasterizer bias constant: the lane's rung is a
|
||||
/// SORT rung (`sky_order::WMO_SKYBOX_BIAS`, ~−6e4), and the depth it writes is a constant the
|
||||
/// rasterizer must not be perturbing behind the shader's back.
|
||||
/// `WOW_SKY_DEPTH` def into BOTH stages: the vertex pins clip z to 0 — reverse-Z far, the sky
|
||||
/// depth law every other sky shader obeys (`benilla_world::sky_order`) — and the fragment
|
||||
/// writes no depth at all (it did until 2016, at the cost of the pipeline's early-Z). It is a
|
||||
/// key axis because the pin is one model's: every other draw on this lane keeps its real
|
||||
/// depth. Like [`Self::far_side`] it also zeroes the rasterizer bias constant: the lane's rung
|
||||
/// is a SORT rung (`sky_order::WMO_SKYBOX_BIAS`, ~−6e4), and the pinned depth is a constant
|
||||
/// the rasterizer must not be perturbing behind the shader's back.
|
||||
sky_depth: bool,
|
||||
// NB: the WMO authored batch order is deliberately NOT a key axis. It used to be (a
|
||||
// per-batch-index `DepthBiasState` constant), which made every batch index its own pipeline —
|
||||
@@ -205,7 +205,12 @@ pub struct WowModelExt {
|
||||
/// sampler; the per-frame samples live in the shared light buffer's `matanim` region, so an
|
||||
/// animating material is never mutated again (no per-frame `Modified`, no bind-group
|
||||
/// rebuild, no whole-population `AssetChanged` walks — B131's chain, severed at the root).
|
||||
/// `zw` free.
|
||||
/// `z` = the **affine** slot (decision 2019): its row is the texture transform's rotation
|
||||
/// and scale as deltas from the identity, `[cos − 1, sin, sx − 1, sy − 1]`
|
||||
/// (`mat_anim_table::affine_row`), composed about the pivot `(½, ½)` after the translation —
|
||||
/// the reference's `uv' = R((uv + t − p) ⊙ s) + p`. Row 0 is the identity, so a material
|
||||
/// with no rotation reads exactly the translated UV it always did. Written by the lanes that
|
||||
/// own a material per instance (the UI model tiles); `w` free.
|
||||
#[uniform(100)]
|
||||
pub anim_slots: Vec4,
|
||||
/// **The shared global light** (`lighting::global_light`): one storage buffer every material reads,
|
||||
@@ -326,11 +331,13 @@ impl MaterialExtension for WowModelExt {
|
||||
ds.bias.constant = 0;
|
||||
}
|
||||
}
|
||||
// The WMO-skybox lane: force the sky's far depth in the fragment, and — like the far-side
|
||||
// twin above — keep its big negative rung sort-only. The rung (`sky_order::WMO_SKYBOX_BIAS`)
|
||||
// exists to sink a camera-anchored backdrop under every world transparent; as a rasterizer
|
||||
// constant it would be perturbing an interpolated depth this pipeline discards anyway.
|
||||
// The WMO-skybox lane: pin the sky's far depth in the vertex stage (the def reaches the
|
||||
// fragment too, for symmetry with the other lane defs — it reads nothing there), and —
|
||||
// like the far-side twin above — keep its big negative rung sort-only. The rung
|
||||
// (`sky_order::WMO_SKYBOX_BIAS`) exists to sink a camera-anchored backdrop under every
|
||||
// world transparent; as a rasterizer constant it would be perturbing the pinned depth.
|
||||
if key.bind_group_data.sky_depth {
|
||||
descriptor.vertex.shader_defs.push("WOW_SKY_DEPTH".into());
|
||||
if let Some(fragment) = descriptor.fragment.as_mut() {
|
||||
fragment.shader_defs.push("WOW_SKY_DEPTH".into());
|
||||
}
|
||||
@@ -633,27 +640,30 @@ impl MaterialExtension for TerrainExtension {
|
||||
mod tests {
|
||||
/// The sky depth law, for the one sky element that draws on the MODEL lane: the WMO skybox
|
||||
/// ([`WowModelKey::sky_depth`]). Every other sky shader is checked the same way, together, in
|
||||
/// `benilla_world::sky_order::every_sky_shader_forces_the_far_depth` — this half lives here
|
||||
/// because the shader does. Without it a skybox silently goes back to being occluded by its own
|
||||
/// 94-yard shell radius instead of by world geometry (the regression decision 0588 fixed).
|
||||
/// `benilla_world::sky_order::the_sky_depth_is_pinned_at_the_vertex_and_nowhere_else` — this
|
||||
/// half lives here because the shader does. Without it a skybox silently goes back to being
|
||||
/// occluded by its own 94-yard shell radius instead of by world geometry (the regression
|
||||
/// decision 0588 fixed) — or, the other way, a `frag_depth` write comes back and every
|
||||
/// doodad, creature and wall in the frame loses its early-Z with it (decision 2016).
|
||||
#[test]
|
||||
fn the_sky_lane_forces_the_far_depth() {
|
||||
fn the_sky_lane_pins_the_far_depth_at_the_vertex() {
|
||||
let src = include_str!("shaders/wow_model.wgsl");
|
||||
// The pin, BEHIND its ifdef — matched with the guard attached, so ungating it (every model
|
||||
// draw at the far plane) fails here rather than as a blank world.
|
||||
let pin = src
|
||||
.find("#ifdef WOW_SKY_DEPTH")
|
||||
.expect("the sky-depth branch is gone");
|
||||
let branch = &src[pin..src[pin..].find("#endif").map_or(src.len(), |e| pin + e)];
|
||||
assert!(
|
||||
src.contains("#ifdef WOW_SKY_DEPTH\n out.depth = 0.0;\n#endif"),
|
||||
"the model lane's sky branch no longer forces the far depth — a WMO skybox's shell \
|
||||
radius is deciding occlusion again (benilla_world::sky_order, \"The depth law\")"
|
||||
branch.contains("out.position.z = 0.0;"),
|
||||
"the model lane's sky branch no longer pins the far depth at the vertex — a WMO \
|
||||
skybox's shell radius is deciding occlusion again (benilla_world::sky_order, \"The \
|
||||
depth law\")"
|
||||
);
|
||||
// The declaration must stay BEHIND the ifdef: writing `frag_depth` unconditionally costs
|
||||
// every doodad, creature and wall in the frame its early-Z, and this lane draws all of
|
||||
// them. Matched with its guard attached, so ungating it fails here rather than in a
|
||||
// frame-time regression nobody attributes to this file. (Counting occurrences instead
|
||||
// would trip over the prose above the struct, which names the builtin too.)
|
||||
assert!(
|
||||
src.contains("#ifdef WOW_SKY_DEPTH\n // Reverse-Z \"infinitely far\"",)
|
||||
&& src.contains(" @builtin(frag_depth) depth: f32,\n#endif"),
|
||||
"the model lane's sky output no longer declares frag_depth behind WOW_SKY_DEPTH — \
|
||||
either the sky writes no depth, or every model draw just lost its early-Z"
|
||||
!src.contains("@builtin(frag_depth)"),
|
||||
"the model lane writes a fragment depth again — the sky pin is the vertex stage's, \
|
||||
and a fragment write costs every draw on this lane its early-Z (decision 2016)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,12 @@ pub struct ModelSubmesh {
|
||||
/// because which loop applies depends on the sequence the *instance* is playing (decision
|
||||
/// 1408). Like [`Self::uv_anim`], the `Arc` doubles as a material-dedup identity.
|
||||
pub uv_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
|
||||
/// The batch's texture-transform **rotation** loop per file sequence slot (decision 2019)
|
||||
/// — the UI model tiles sample it off the pane's play head into the material's affine row;
|
||||
/// no world lane reads it (no placed doodad authors one). `None` for the rest.
|
||||
pub uv_rot_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 4]>>>,
|
||||
/// The scaling twin of [`Self::uv_rot_seq`].
|
||||
pub uv_scale_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
|
||||
/// The batch's **animated RGB tint** (the M2Color colour track, time-varying only — a spell
|
||||
/// effect's white-hot flash cooling to red). When `Some`, the static vertex tint was skipped
|
||||
/// at parse (`benilla-formats`): the render side seeds the material's tint at the first key —
|
||||
|
||||
@@ -51,22 +51,14 @@
|
||||
mesh_functions,
|
||||
}
|
||||
|
||||
// Our own fragment output, so the SKY lane can force the far depth. Bevy's `forward_io::FragmentOutput`
|
||||
// is `@location(0) color` and nothing else (bevy_pbr 0.18.1 `forward_io.wgsl`), so this is that struct
|
||||
// plus one optional builtin — not a divergence from it.
|
||||
//
|
||||
// **`WOW_SKY_DEPTH` is a pipeline-key branch, never an unconditional field.** Declaring
|
||||
// `@builtin(frag_depth)` disables early-Z for the whole pipeline, and the model lane draws every
|
||||
// doodad, creature and WMO wall in the frame. The def is pushed only for the WMO-skybox lane
|
||||
// (`clutter_fade.z` bit 13, `model_render::SKY_DEPTH_MARKER`), which is one camera-anchored model.
|
||||
// Our own fragment output — bevy's `forward_io::FragmentOutput` verbatim (`@location(0) color` and
|
||||
// nothing else, bevy_pbr 0.18.1 `forward_io.wgsl`), kept as a named struct so the lane's output
|
||||
// has one place to grow. The WMO-skybox lane's forced far depth (`WOW_SKY_DEPTH`) used to add a
|
||||
// fragment-depth builtin here; it is a VERTEX-stage pin now (below, decision 2016) — a fragment
|
||||
// depth write costs the pipeline its early-Z, and even on the one camera-anchored model that lane
|
||||
// draws, the same number decided one stage earlier is free.
|
||||
struct WowFragOut {
|
||||
@location(0) color: vec4<f32>,
|
||||
#ifdef WOW_SKY_DEPTH
|
||||
// Reverse-Z "infinitely far", under bevy's `GreaterEqual` test — the sky depth law
|
||||
// (`benilla_world::sky_order`, "The depth law"): the world paints over the sky whatever the
|
||||
// shell's radius, and the sky can never land in front of world geometry.
|
||||
@builtin(frag_depth) depth: f32,
|
||||
#endif
|
||||
}
|
||||
|
||||
// Per-material model uniforms packed at binding 100 (see `WowModelExt` in terrain.rs). Light + fog + the
|
||||
@@ -190,9 +182,10 @@ struct WowLight {
|
||||
rig_origin: array<vec4<f32>, 2048>,
|
||||
// The mat-anim delta table (decision 1381; mat_anim_table.rs mirrors the size): row 0 is the
|
||||
// pinned-zero identity every static material's anim_slots = 0 reads; a live row is the drawn
|
||||
// batch's sampled UV-scroll delta (xy, added to sun_scale.zw) or tint delta (xyz, added to
|
||||
// tint.rgb). Zero region = every batch at its built seed — the studio buffers and
|
||||
// deterministic captures ride that exactly like the tint table's zero-identity.
|
||||
// batch's sampled UV-scroll delta (xy, added to sun_scale.zw), tint delta (xyz, added to
|
||||
// tint.rgb), or texture-transform affine (decision 2019: [cos − 1, sin, sx − 1, sy − 1]).
|
||||
// Zero region = every batch at its built seed — the studio buffers and deterministic
|
||||
// captures ride that exactly like the tint table's zero-identity.
|
||||
matanim: array<vec4<f32>, 2048>,
|
||||
palettes: array<vec4<f32>>,
|
||||
};
|
||||
@@ -203,6 +196,13 @@ struct WowLight {
|
||||
// twin so its silhouette matches the steady cutout exactly.
|
||||
const VANILLA_ALPHA_KEY: f32 = 0.8784314;
|
||||
|
||||
// The detail-doodad (ground clutter) texture LOD bias — the reference sets
|
||||
// `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 for its detail-doodad pass alone (`0x6813f4`,
|
||||
// capture-confirmed on all 29 doodad batches). It is what makes the distance cut resolve per pixel
|
||||
// rather than per leaf; the full derivation is at the use site in `fragment`, and in wow-re
|
||||
// `terrain/scratch/doodad-fade-pixel-granularity.md`.
|
||||
const DETAIL_DOODAD_LOD_BIAS: f32 = 0.25;
|
||||
|
||||
// bevy's `VertexOutput` (same fields, same locations, same defs) + the per-vertex dynamic
|
||||
// point-light term at a free location. One extra interpolant is why this can't BE `VertexOutput`;
|
||||
// the fragment rebuilds one for `pbr_input_from_standard_material`. (Our meshes never carry
|
||||
@@ -515,6 +515,15 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
|
||||
// but as uniform DATA: as pipeline state it made every batch index its own pipeline, and a
|
||||
// first city sight synchronously compiled ~3000 of them on the render thread (decision 0837).
|
||||
out.position.z *= 1.0 + m.sun_scale.y * 1.1920929e-7;
|
||||
#ifdef WOW_SKY_DEPTH
|
||||
// The WMO-skybox lane (`clutter_fade.z` bit 13, `model_render::SKY_DEPTH_MARKER`): clip z
|
||||
// pinned to 0 — reverse-Z "infinitely far" for every fragment, whatever w — under bevy's
|
||||
// `GreaterEqual` test: the sky depth law (`benilla_world::sky_order`, "The depth law"). The
|
||||
// world paints over the painted sky whatever the shell's radius, and the sky can never land
|
||||
// in front of world geometry. A pipeline-key branch, never unconditional: every other model
|
||||
// draw keeps its real depth.
|
||||
out.position.z = 0.0;
|
||||
#endif
|
||||
#ifdef WOW_MERGED_FADE
|
||||
// The merged fader lane (decision 1418). Alpha channel: the faithful curve, per vertex.
|
||||
// Hidden channel: a fully-faded placement collapses its clip position past the far plane —
|
||||
@@ -612,11 +621,14 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
}
|
||||
// Rebuild bevy's `VertexOutput` from our extended interstage struct (WowVsOut carries one extra
|
||||
// interpolant — the per-vertex point term — which the pbr entry point doesn't know about).
|
||||
// M2 UV animation folds in here (decision 0130 phase 3, wow-re m2-texanim-uv): add the batch's
|
||||
// live texture-transform translation to the stage UVs before the base-colour sample — the real
|
||||
// client's composed matrix collapses to exactly this for the translation-only doodad corpus
|
||||
// (translation is un-pivoted; rotation/scaling — pivoted at (0.5, 0.5) — are authored by no
|
||||
// placed world doodad). `sun_scale.zw` is 0 for static batches, so this is a no-op there.
|
||||
// M2 UV animation folds in here (decision 0130 phase 3, wow-re m2-texanim-uv; the full law
|
||||
// since decision 2019, wow-re modelframe-texanim-and-sequence-law §3.4):
|
||||
// uv' = R((uv + t − p) ⊙ s) + p, p = (½, ½)
|
||||
// — the batch's live texture-transform TRANSLATION is added to the stage UVs (`sun_scale.zw`
|
||||
// is the built seed, the mat-anim row its delta), then the scale is applied about the pivot,
|
||||
// then the rotation about the pivot. The affine row (`anim_slots.z`) carries `[cos − 1, sin,
|
||||
// sx − 1, sy − 1]`; row 0 is all zeros, so a static batch and every translation-only doodad
|
||||
// read `uv + t` exactly as before. `benilla_formats::uv_transform` is this fold's twin.
|
||||
var vo: VertexOutput;
|
||||
vo.position = in.position;
|
||||
vo.world_position = in.world_position;
|
||||
@@ -636,7 +648,11 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
if ((u32(m.clutter_fade.z) & 4096u) != 0u) {
|
||||
vo.uv = in.uv;
|
||||
} else {
|
||||
vo.uv = in.uv + m.sun_scale.zw + wow_light.matanim[u32(m.anim_slots.x)].xy;
|
||||
let uv_t = in.uv + m.sun_scale.zw + wow_light.matanim[u32(m.anim_slots.x)].xy;
|
||||
let affine = wow_light.matanim[u32(m.anim_slots.z)];
|
||||
let d = (uv_t - vec2<f32>(0.5, 0.5)) * vec2<f32>(1.0 + affine.z, 1.0 + affine.w);
|
||||
let c = 1.0 + affine.x;
|
||||
vo.uv = vec2<f32>(0.5 + d.x * c - d.y * affine.y, 0.5 + d.x * affine.y + d.y * c);
|
||||
}
|
||||
#endif
|
||||
#ifdef VERTEX_UVS_B
|
||||
@@ -680,6 +696,45 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
// **The detail-doodad LOD BIAS** — the state that decides whether the fade cuts a leaf pixel by
|
||||
// pixel or takes it whole (wow-re `terrain/scratch/doodad-fade-pixel-granularity.md`, 2021).
|
||||
// `0x6813d0` sets `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 at `0x6813f4`, once per frame,
|
||||
// immediately before the pass dispatches `0x6c02d0` — outside `[0x6b1000, 0x6b3200)`, which is
|
||||
// why 2004's state census missed it. Capture-confirmed: all 29 doodad batches bind a sampler
|
||||
// with `GL_TEXTURE_LOD_BIAS = 0.25` where the draws just before them bind an otherwise
|
||||
// byte-identical sampler with bias 0.
|
||||
//
|
||||
// Why a quarter-mip is worth a re-sample: three of the shipped detail atlases (Elwynn's among
|
||||
// them) carry a **binary** alpha pyramid — mip 0 has DXT3's 16 levels, every level below it has
|
||||
// two. A fragment can only reach `a ≈ 1.0`, and so share the terminal death depth, inside a 2×2
|
||||
// all-opaque texel neighbourhood; those neighbourhoods run out between mip 2 and mip 3. Biasing
|
||||
// a quarter-mip blurrier lands the reference past that cliff, so no fragment holds full alpha
|
||||
// and the tuft erodes continuously instead of every leaf crossing together. Measured on the
|
||||
// dominant Elwynn cell, the share of a tuft's pixels leaving in the last half-yard: **2.2 %**
|
||||
// with this bias, **11.6 %** without it.
|
||||
//
|
||||
// wgpu has no sampler LOD bias (WebGPU dropped it), so it goes on the sample. Re-sampling here
|
||||
// rather than pre-biasing the sampler is what keeps it *this lane's* bias: the sampler is shared
|
||||
// with every other model batch, which must keep the reference's 0. Same UV, same implicit
|
||||
// derivatives, so the texture cache makes the second tap nearly free.
|
||||
// `pbr_input_from_standard_material` folds ATTRIBUTE_COLOR and the material tint into what it
|
||||
// sampled; for clutter the material tint is white and the vertex colour is the MCSH lit/shadow
|
||||
// grey, so re-applying `vo.color` reproduces the fold exactly — no divide-back-out, which is the
|
||||
// trap the WMO branch above documents.
|
||||
if (m.clutter_fade.w > 0.5) {
|
||||
#ifdef VERTEX_UVS_A
|
||||
var biased = textureSampleBias(
|
||||
pbr_bindings::base_color_texture,
|
||||
pbr_bindings::base_color_sampler,
|
||||
vo.uv,
|
||||
view.mip_bias + DETAIL_DOODAD_LOD_BIAS,
|
||||
);
|
||||
#ifdef VERTEX_COLORS
|
||||
biased = biased * vo.color;
|
||||
#endif
|
||||
base_color = biased;
|
||||
#endif
|
||||
}
|
||||
// Ground-clutter distance fade — the reference's stage-1 ramp, byte-exact and capture-confirmed
|
||||
// (wow-re `terrain/scratch/detail-doodad-distance-fade.md`, 2004). Three facts shape this:
|
||||
//
|
||||
@@ -1138,9 +1193,6 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
|
||||
|
||||
var out: WowFragOut;
|
||||
#ifdef WOW_SKY_DEPTH
|
||||
out.depth = 0.0;
|
||||
#endif
|
||||
// Raw gamma out (GAMMA LANE, 0161 — the frame's one decode is the FFXGlow combine). Alpha = the
|
||||
// faded cutout alpha (tex × fade) for the blend twin; ignored (blend off) on steady/opaque draws.
|
||||
// OPAQUE-INTENT alpha pin (clutter_fade.z bit 3, set in model_render for steady opaque/alpha-key
|
||||
|
||||
@@ -670,6 +670,8 @@ impl AssetLoader for WmoModelLoader {
|
||||
alpha_anim: None, // WMO batches carry no M2 colour/weight tracks
|
||||
uv_anim: None, // …nor texture transforms
|
||||
uv_seq: None, // …so no per-sequence set either (1408)
|
||||
uv_rot_seq: None,
|
||||
uv_scale_seq: None,
|
||||
rgb_anim: None,
|
||||
rgb_seq: None, // …nor M2Color tints
|
||||
wmo_batch: sub.wmo_batch, // the MOBA section — an interior group's lighting law
|
||||
|
||||
Reference in New Issue
Block a user