- spell shimmer timing is right: shared clocks tick together, each cast anchors its own - the flickering ground items in Orgrimmar are fixed, a transparent draw-order tie - static scenery answers the mouse again, and the freeze debuff's crackle rings at the target - bug reports moved to Discord, GitHub issues are closed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
591 lines
33 KiB
Rust
591 lines
33 KiB
Rust
//! Shared world-model material build — the `WowModelMaterial` (a `StandardMaterial` base carrying the
|
||
//! texture/alpha/cull + the `WowModelExt` shared-light extension that does the WoW shading) used for
|
||
//! every M2/WMO instance: doodads, WMO buildings, creatures, and GameObjects. Deduped per owner by
|
||
//! `(texture, blend, sidedness, kind, fade-variant)` so instances sharing a look batch into one draw.
|
||
|
||
use benilla_formats::{ModelBlend, WmoBatchClass};
|
||
use bevy::asset::AssetId;
|
||
use bevy::pbr::ExtendedMaterial;
|
||
use bevy::prelude::*;
|
||
use bevy::render::render_resource::{Buffer, Face};
|
||
|
||
use crate::terrain::{WowModelExt, WowModelMaterial};
|
||
|
||
/// Alpha-test reference for `Blend_AlphaKey` (M2 blend mode 1) materials — the value below which a
|
||
/// fragment is discarded (`D3DCMP_GREATEREQUAL`). Per wowdev.wiki M2/Rendering this is **version
|
||
/// dependent**: `224/255 ≈ 0.878` on **≤ WotLK** (our target is 1.12.1 build 5875), vs `128/255 ≈
|
||
/// 0.5` on Cata+. We initially hardcoded the Cata value (0.5), which left foliage too dense and a
|
||
/// white fringe on cutout edges. The spec multiplies this by the element's animated alpha (1.0 for
|
||
/// static doodads), so the bare constant is correct until we add doodad alpha fades.
|
||
/// Source: <https://wowdev.wiki/M2/Rendering> § Alpha Testing.
|
||
pub const VANILLA_ALPHA_KEY_REF: f32 = 224.0 / 255.0;
|
||
|
||
/// Yards of transparent-pass sort bias per authored batch-order step (`MatKey::batch_order` ×
|
||
/// this, on `StandardMaterial::depth_bias` — bevy adds that field to the phase item's sort
|
||
/// distance; positive = drawn later, the `sky_order` sign law). **Why it exists:** one model's
|
||
/// coplanar transparent batches (the Naxx items' Opaque + Mod2x sheen + Blend overlay triples)
|
||
/// share one mesh centre, so their sort distances TIE, and a tie is re-resolved every frame by
|
||
/// whatever order the queue happens to iterate — in a churning scene (a city) the coplanar layers
|
||
/// swap draw order frame to frame and the composite strobes (Mod2x-then-Blend ≠ Blend-then-Mod2x).
|
||
/// The reference cannot express the bug: it ties every draw command of one instance to a single
|
||
/// sort key and keeps the *authored command order* inside the tie (wow-re
|
||
/// `m2-blend-promotion-zfill.md` §4/§6; MOBA file order for WMO). This epsilon reproduces that
|
||
/// order through bevy's sort. Sized well under [`benilla_formats::owner_last_rung`]'s 1-yd floor
|
||
/// (an effect still draws after every batch of its owner) and far over f32 noise on a ~500 yd
|
||
/// sort distance (~4e-5). Not a pipeline axis: `depth_bias` participates in sorting only
|
||
/// (`WowModelKey` deliberately excludes it — decision 0837).
|
||
pub(crate) const BATCH_ORDER_SORT_EPS: f32 = 1e-3;
|
||
|
||
/// Material-dedup key: same texture + blend + sidedness + kind + fade-variant → one shared material.
|
||
#[derive(PartialEq, Eq, Hash)]
|
||
pub(crate) struct MatKey {
|
||
texture: Option<AssetId<Image>>,
|
||
blend: ModelBlend,
|
||
two_sided: bool,
|
||
is_wmo: bool,
|
||
is_interior: bool,
|
||
is_emissive: bool,
|
||
is_additive: bool,
|
||
fade_variant: bool,
|
||
/// M2 render flag 0x10 / 0x08 — disable depth write / depth test (`specialize` honours them).
|
||
no_depth_write: bool,
|
||
no_depth_test: bool,
|
||
/// The batch's fog COLOUR policy ([`benilla_formats::FogPolicy`]) — packed into `clutter_fade.z`
|
||
/// bits 4-6 (`wow_model.wgsl`'s step-5 fog).
|
||
fog_policy: benilla_formats::FogPolicy,
|
||
/// The batch's static terrain-shade selector ([`ShadeSel`]). Static per placement, so it dedups a
|
||
/// lit / matte / shaded material variant.
|
||
shade: ShadeSel,
|
||
/// Authored batch index + 1 (0 = a legacy/unordered caller). Every ordered batch (M2 and WMO)
|
||
/// folds it into the material's transparent SORT bias ([`BATCH_ORDER_SORT_EPS`]); WMO batches
|
||
/// additionally ride it into the vertex-stage clip-z nudge (the byte-verified MOBA draw-order
|
||
/// determinism, wow-5875-re models/scratch/wmo-batch-blend-depth-state.md).
|
||
batch_order: u16,
|
||
/// The batch's UV-animation identity (decision 0130 phase 3): the `Arc<UvAnim>` pointer, so
|
||
/// batches scrolling on different loops never share a material (their `sun_scale.zw` offsets
|
||
/// diverge every frame) while every instance of the same model batch does. Sound because the
|
||
/// `Arc` lives in the loaded model asset — one allocation per model per batch, stable while
|
||
/// loaded. `None` = static UVs (the overwhelming majority).
|
||
uv_anim: Option<usize>,
|
||
/// The batch's animated-RGB-tint identity: the `Arc<RgbAnim>` pointer (same soundness argument
|
||
/// as [`Self::uv_anim`]), so batches tinting on different tracks never share a material while
|
||
/// every instance of the same model batch does. `None` = a static tint (the vertex bake).
|
||
rgb_anim: Option<usize>,
|
||
/// The WMO batch's MOBA section (`None` for M2): an interior group's INT and TRANS batches take
|
||
/// different lighting lanes (`tint.w`), so they must never dedupe onto one material.
|
||
wmo_class: Option<WmoBatchClass>,
|
||
/// The WMO MOMT SIDN night-glow colour (RGB gamma bytes; `None` = no SIDN / M2) — part of the
|
||
/// material identity so glass authored with different glow colours never dedupes together.
|
||
sidn: Option<[u8; 3]>,
|
||
/// The WMO MOMT WINDOW flag — an interior-group batch on the brighter midpoint light.
|
||
window: bool,
|
||
/// This material is a **depth-prime twin** ([`zfill_material`] — the reference's `M2UseZFill`
|
||
/// clone command, wow-re `m2-blend-promotion-zfill.md` §4): colour writes masked off, blend off,
|
||
/// z-write on, drawn before its model's colour batches. Its own key axis so a twin can never
|
||
/// dedupe onto a colour material.
|
||
zfill: bool,
|
||
}
|
||
|
||
/// The per-material static terrain-shade **selector** baked into `sun_scale.x` — NOT an intensity
|
||
/// itself. `wow_model.wgsl` thresholds the selector (≥0.85 / ≥0.5) into the byte-true INTENSITY
|
||
/// family (decision 0354: 2.5 lit / 1.0 day-night / 0.5 MCSH-shadowed — `[node+0xa4]`) scaling the
|
||
/// global SH eval. It is the static half of the verified per-category matrix (wow-re
|
||
/// `m2-interior-doodad-base-light` §6/§8/§9, decision 0173) — dynamic entities
|
||
/// (units/players/GameObjects) are always [`ShadeSel::Lit`] here and mix toward the shaded
|
||
/// intensity per instance via the `MeshTag` shade byte ([`crate::entity_shade`]).
|
||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub(crate) enum ShadeSel {
|
||
/// Lit ground, the boosted intensity family (the binary's 2.5): ADT map doodads on unshadowed
|
||
/// ground, and every entity M2.
|
||
///
|
||
/// Entities still select this, but what it *means* for them changed in 0809. The selector is
|
||
/// only the static half — the per-instance `MeshTag` shade byte carries the rest, and
|
||
/// [`crate::entity_shade`] now pins that byte to the day/night ×1.0 for units and players
|
||
/// (`0x672a20`'s null-node fallback commits with no per-node intensity multiply at all), while
|
||
/// GameObjects keep the real 2.5/0.5 chase. So for an entity this variant reads "on the light-node
|
||
/// path"; the node decides the amplitude.
|
||
Lit,
|
||
/// Lit ground, intensity 1.0: an exterior WMO MODD prop — §8b, byte-verified never to reach
|
||
/// the 2.5 site (a Stormwind street fountain is NOT brightened like an Elwynn tree).
|
||
Matte,
|
||
/// The base sits on MCSH-shadowed terrain: the dim intensity (the binary's 0.5).
|
||
Shaded,
|
||
/// Lit by an **authored M2 light rig** (the glue create booth, decision 0429): the lit value is
|
||
/// the order-2 SH probe in slot 0 of the material's *own* light buffer — the scene's ambient +
|
||
/// directional lights folded by `lighting::prop_probe_coeffs`, the same `Model2.bls` curve the
|
||
/// reference's vertex program runs — plus the per-vertex ≤3-nearest point term (the scene's
|
||
/// authored point lights ride the buffer's point table; a rig material is neither WMO nor
|
||
/// interior, so the vertex stage computes it). The world's sun/intensity machinery never
|
||
/// applies: a glue scene has no day/night, no MCSH, no storm band.
|
||
Rig,
|
||
}
|
||
|
||
impl ShadeSel {
|
||
/// The `sun_scale.x` encoding `wow_model.wgsl` thresholds (≥1.5 ⇒ authored-rig probe;
|
||
/// ≥0.85 ⇒ ADT lit; ≥0.5 ⇒ matte; else shaded).
|
||
pub(crate) fn selector(self) -> f32 {
|
||
match self {
|
||
ShadeSel::Lit => 1.0,
|
||
ShadeSel::Matte => 0.6,
|
||
ShadeSel::Shaded => 0.2,
|
||
ShadeSel::Rig => 2.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A material-dedup cache. Each model-spawning subsystem (terrain doodads/WMOs, streamed entities)
|
||
/// keeps its own so its handles drop with it — and, since decision 0793, so its entries expire by
|
||
/// **distance** ([`crate::art_scope::SpatialCache`]) instead of living until the next map change. A
|
||
/// cache nobody sweeps (the `Local<MaterialCache>`s in the glue booth and the portrait bake) behaves
|
||
/// exactly as it did: a plain dedup map.
|
||
pub(crate) type MaterialCache = crate::art_scope::SpatialCache<MatKey, Handle<WowModelMaterial>>;
|
||
|
||
/// Build (or fetch the deduped) [`WowModelMaterial`] for a model batch: a `StandardMaterial` base
|
||
/// carrying the texture/alpha/cull, plus the `WowModelExt` shared-light extension. `fade_variant`
|
||
/// marks the `AlphaMode::Blend` twin every feather pass rides — the doodad distance fade AND the
|
||
/// entity appear/despawn/aura fades (`entities::display` builds its twins with it too); steady
|
||
/// materials pass `false`. `specialize` keys depth-write-ON on this bit for the twin.
|
||
///
|
||
/// A twin builder passes the **SOURCE batch's blend mode** (never `ModelBlend::Blend`): the twin
|
||
/// always blends — the reference's promotion is transparent-pass membership, not a per-mode state
|
||
/// (`m2-blend-promotion-zfill.md` §1) — but the alpha TEST under promotion keys on the *stored*
|
||
/// blend mode (§2): AlphaKey keeps its 224/255 cutout while blending, Opaque runs with no alpha
|
||
/// test at all. The source blend is what sets [`TWIN_CUTOUT_MARKER`] right (decision 0842: a twin
|
||
/// built as `Blend` cut every texel under 224/255 out of a stealthed Opaque batch, which erased
|
||
/// Gressil's blade body and left only its high-alpha rune pattern).
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn model_material(
|
||
cache: &mut MaterialCache,
|
||
materials: &mut Assets<WowModelMaterial>,
|
||
texture: Option<Handle<Image>>,
|
||
blend: ModelBlend,
|
||
two_sided: bool,
|
||
is_wmo: bool,
|
||
is_interior: bool,
|
||
is_emissive: bool,
|
||
is_additive: bool,
|
||
fade_variant: bool,
|
||
no_depth_write: bool,
|
||
no_depth_test: bool,
|
||
fog_policy: benilla_formats::FogPolicy,
|
||
shade: ShadeSel,
|
||
// Authored batch index + 1 (0 = unordered): the transparent-pass ORDER of this batch among its
|
||
// model's other batches — see [`MatKey::batch_order`] and [`BATCH_ORDER_SORT_EPS`].
|
||
batch_order: u16,
|
||
uv_anim: Option<&std::sync::Arc<benilla_formats::UvAnim>>,
|
||
rgb_anim: Option<&std::sync::Arc<benilla_formats::RgbAnim>>,
|
||
// The WMO batch's MOBA section (`None` for M2 batches) — with `is_interior`, it picks the
|
||
// batch's lighting lane (`tint.w`; see `WowModelExt::tint`).
|
||
wmo_class: Option<WmoBatchClass>,
|
||
// The WMO window/glass law (`None`/`false` for M2): the MOMT SIDN night-glow colour and the
|
||
// WINDOW midpoint-light flag (`WowModelExt::sidn`).
|
||
sidn: Option<[u8; 3]>,
|
||
window: bool,
|
||
light: &Buffer,
|
||
) -> Handle<WowModelMaterial> {
|
||
let key = MatKey {
|
||
texture: texture.as_ref().map(Handle::id),
|
||
blend,
|
||
two_sided,
|
||
is_wmo,
|
||
is_interior,
|
||
is_emissive,
|
||
is_additive,
|
||
fade_variant,
|
||
no_depth_write,
|
||
no_depth_test,
|
||
fog_policy,
|
||
shade,
|
||
batch_order,
|
||
uv_anim: uv_anim.map(|a| std::sync::Arc::as_ptr(a) as usize),
|
||
rgb_anim: rgb_anim.map(|a| std::sync::Arc::as_ptr(a) as usize),
|
||
wmo_class,
|
||
sidn,
|
||
window,
|
||
zfill: false,
|
||
};
|
||
if let Some(h) = cache.fetch(&key) {
|
||
return h;
|
||
}
|
||
// Additive batches (M2 glow cards) ADD their colour to the framebuffer — so the warm glow isn't
|
||
// muted by the (cool, at night) background bleeding through. They go in the transparent pass
|
||
// (`AlphaMode::Blend`); the shader folds the radial alpha into the colour IN GAMMA SPACE
|
||
// (decision 0160) and `specialize` overrides the blend STATE to a pure (ONE, ONE) add. The
|
||
// additive marker is `clutter_fade.z` bit 2 — the shader gates the gamma-premultiply on the
|
||
// SAME bit specialize keys on (a stale "model_flags.w == 2.0" claim here once desynced the
|
||
// two: blend went pure-add while the premultiply never fired — the flat-square regression).
|
||
// `WOW_NO_ALPHATEST=1` draws every cutout batch opaque — the A/B for "is the alpha test
|
||
// itself discarding this surface?", which is otherwise indistinguishable in the pixels
|
||
// from the surface losing a depth test or never being submitted. (B38: the flip
|
||
// survives it unchanged, so the cutout is not what removes the awning.) It suppresses the
|
||
// fade twin's cutout marker too, so the A/B answers the same question mid-fade.
|
||
let source_cutout =
|
||
blend == ModelBlend::AlphaTest && std::env::var("WOW_NO_ALPHATEST").is_err();
|
||
let alpha_mode = if is_additive || fade_variant {
|
||
// A fade twin blends whatever its source blend is — the reference's promotion is
|
||
// transparent-pass membership (`m2-blend-promotion-zfill.md` §1); the source blend the
|
||
// caller passed decides only the cutout marker below.
|
||
AlphaMode::Blend
|
||
} else {
|
||
match blend {
|
||
ModelBlend::Opaque => AlphaMode::Opaque,
|
||
ModelBlend::AlphaTest if source_cutout => AlphaMode::Mask(VANILLA_ALPHA_KEY_REF),
|
||
ModelBlend::AlphaTest => AlphaMode::Opaque,
|
||
// Mod/Mod2x ride the transparent pass (they multiply what's already drawn, so the
|
||
// scene under them must exist); `specialize` swaps the actual blend state to the
|
||
// byte-verified multiply factors via the marker bits below (decision 0528).
|
||
ModelBlend::Blend | ModelBlend::Mod | ModelBlend::Mod2x => AlphaMode::Blend,
|
||
}
|
||
};
|
||
// Single-sided unless the M2's 0x04 flag is set (many canopy planes are one-directional).
|
||
let cull_mode = if two_sided { None } else { Some(Face::Back) };
|
||
// The authored batch order as a transparent sort bias ([`BATCH_ORDER_SORT_EPS`]): one model's
|
||
// coplanar batches draw in file order instead of re-flipping a sort tie every frame.
|
||
let depth_bias = f32::from(batch_order) * BATCH_ORDER_SORT_EPS;
|
||
let base = match texture {
|
||
Some(image) => StandardMaterial {
|
||
base_color_texture: Some(image),
|
||
alpha_mode,
|
||
double_sided: two_sided,
|
||
cull_mode,
|
||
depth_bias,
|
||
..default()
|
||
},
|
||
// No resolved texture → the reference DISABLES the texture stage and draws the batch in
|
||
// its flat vertex/material colour — i.e. WHITE modulate, byte-verified for the no-source
|
||
// runtime-texture case (wow-re m2-runtime-texture-null-bind.md, their 914a1abd: slot 0 →
|
||
// glDisable, no default texture, alpha test passes everywhere — the Westfall lamppost's
|
||
// "bulb on" pane). The old muted-brown debug tint was unfaithful. Blend/cull state still
|
||
// honoured — an untextured fade twin must keep its per-instance alpha path.
|
||
None => StandardMaterial {
|
||
base_color: Color::WHITE,
|
||
alpha_mode,
|
||
double_sided: two_sided,
|
||
cull_mode,
|
||
depth_bias,
|
||
..default()
|
||
},
|
||
};
|
||
let handle = materials.add(ExtendedMaterial {
|
||
base,
|
||
extension: WowModelExt {
|
||
// `clutter_fade.z` is unread by the shader, so it carries the per-batch pipeline markers
|
||
// `specialize` keys on: bit0 = no-depth-write (M2 0x10), bit1 = no-depth-test (0x08),
|
||
// bit2 = additive blend. (`x`/`y`/`w` stay the ground-clutter distance fade — `0` here.)
|
||
clutter_fade: Vec4::new(
|
||
0.0,
|
||
0.0,
|
||
// Bit 3 = OPAQUE-INTENT: an opaque/alpha-key steady batch whose output alpha is
|
||
// semantically meaningless (opaque/mask pipelines ignore it — only blend pipelines
|
||
// read it). The shader pins such batches' output alpha to 1.0: a spec-level no-op
|
||
// that armors against a multi-view pipeline-state mixup observed on macOS/Metal
|
||
// (opaque WMO/M2 draws intermittently bound with blending enabled when an extra
|
||
// camera exists, bleeding the garbage BLP alpha channel — the "pale film on
|
||
// buildings" regression; full measured chain in the fix commit).
|
||
// Bits 4-6 = the per-batch FOG POLICY (`FogPolicy` discriminant, wow-re
|
||
// rf-weather-emission-timeline ROUND 4): 0 = scene so clutter/water materials —
|
||
// which leave the byte's high bits 0 (see `WorldAssets::model_material`,
|
||
// `water_fx`) — keep ordinary scene fog; 1/2/3 = the additive/Mod/Mod2x BLACK/
|
||
// WHITE/GREY fog colours; 4 = fog disabled outright (render flag 0x02).
|
||
// Bits 7/8 = the MULTIPLY blends (decision 0528): `specialize` swaps the pipeline
|
||
// to the byte-verified factors — Mod DST_COLOR/ZERO, Mod2x DST_COLOR/SRC_COLOR
|
||
// (exact on the 0161 gamma lane: the framebuffer holds gamma, like the reference).
|
||
// Bit 10 = TWIN CUTOUT (decision 0842): this fade twin's SOURCE batch alpha-tests,
|
||
// so the shader re-applies the hard 224/255 cutout on the unfaded alpha (the
|
||
// reference's promoted-AlphaKey ALPHAREF = A×224 — the same fixed silhouette). An
|
||
// Opaque source sets no bit: the reference disables its alpha test outright, steady
|
||
// and promoted alike (`m2-blend-promotion-zfill.md` §2 keys ALPHAREF on the STORED
|
||
// blend mode, mode 0 → ref 0).
|
||
f32::from(
|
||
u16::from(no_depth_write)
|
||
| (u16::from(no_depth_test) << 1)
|
||
| (u16::from(is_additive) << 2)
|
||
| (u16::from(
|
||
matches!(blend, ModelBlend::Opaque | ModelBlend::AlphaTest)
|
||
&& !fade_variant
|
||
&& !is_additive,
|
||
) << 3)
|
||
| (u16::from(fog_policy as u8) << 4)
|
||
| (u16::from(blend == ModelBlend::Mod) << 7)
|
||
| (u16::from(blend == ModelBlend::Mod2x) << 8)
|
||
| (u16::from(fade_variant && source_cutout) * TWIN_CUTOUT_MARKER),
|
||
),
|
||
0.0,
|
||
),
|
||
// x = WMO (FFP N·L × MOCV, not the M2 SH probe); y = distance-fade blend variant; z = WMO
|
||
// interior group (sun off, baked MOCV carries the room); w = **unlit fullbright** (>0.5 ⇒
|
||
// bypass lighting in wow_model.wgsl): the M2 UNLIT (0x01) flag, or WMO UNLIT on an
|
||
// exterior-group batch (the interior drawer ignores it — section law, `wmo-lit-selector`).
|
||
// Additive is NOT fullbright: the real client *lights* additive batches unless 0x01 is set
|
||
// (wow-re `m2-no-envmap-texgen`), so an un-flagged additive (e.g. ArmorReflect shine) is lit.
|
||
// M2 Mod/Mod2x ARE fullbright regardless of 0x01 — the lighting table
|
||
// `DAT_00811fa8 = {1,1,1,1,1,0,0}` clears GL_LIGHTING for modes 5/6 (wow-re
|
||
// `m2-depth-blend-state`); WMO lighting stays flag-driven only (decision 0528).
|
||
model_flags: Vec4::new(
|
||
if is_wmo { 1.0 } else { 0.0 },
|
||
if fade_variant { 1.0 } else { 0.0 },
|
||
if is_interior { 1.0 } else { 0.0 },
|
||
if is_emissive || (!is_wmo && matches!(blend, ModelBlend::Mod | ModelBlend::Mod2x))
|
||
{
|
||
1.0
|
||
} else {
|
||
0.0
|
||
},
|
||
),
|
||
// x = the static MCSH terrain-shade SELECTOR ([`ShadeSel`]: 1.0 ADT-lit / 0.6 matte /
|
||
// 0.2 shaded; shader thresholds at 0.85 and 0.5) that chooses which live sun LEVEL scales the
|
||
// FFP matte's diffuse term. Static per material (a doodad doesn't move), so it dedups the
|
||
// variants; moving entities are Lit here and mix per instance via the `MeshTag` shade byte.
|
||
// y = the WMO authored batch order, read by `wow_model.wgsl`'s VERTEX stage: it scales
|
||
// clip z by (1 + y·2⁻²³) so a later coplanar batch wins the reverse-Z depth test in any
|
||
// draw order — the byte-verified MOBA draw-order determinism. Uniform data by design:
|
||
// as a `WowModelKey` axis driving a fixed-function depth bias it made every batch index
|
||
// its own PIPELINE, and a first city sight compiled ~3000 of them synchronously on the
|
||
// render thread (decision 0837). `WOW_WMO_BIAS=0` (B38's A/B diagnostic) zeroes it here.
|
||
// zw = the batch's live **UV-animation offset** (decision 0130 phase 3, wow-re
|
||
// `m2-texanim-uv`: the real client adds the sampled translation to the stage UVs —
|
||
// translation is un-pivoted, and no placed doodad uses rotation/scaling). Seeded at
|
||
// t = 0 here; `doodad_anim::tick_anim_materials` re-samples it per drawn frame on the
|
||
// shared clock (frozen in captures).
|
||
sun_scale: {
|
||
let uv0 = uv_anim.map_or([0.0, 0.0], |a| a.sample(0.0));
|
||
// The clip-z nudge stays WMO-only: M2 coplanar layers pass GreaterEqual at exactly
|
||
// equal depth (same mesh, same transform, same vertex path), and their ORDER is
|
||
// the sort bias above — nudging their depth would be an unverified extra.
|
||
let order =
|
||
if !is_wmo || matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0")) {
|
||
0.0
|
||
} else {
|
||
f32::from(batch_order)
|
||
};
|
||
Vec4::new(shade.selector(), order, uv0[0], uv0[1])
|
||
},
|
||
// The animated M2Color tint's first key (identity white for static batches — their
|
||
// constant tint rides the vertex colours instead). A lane that never re-samples this
|
||
// shows exactly the old static bake; the effect lane clones + ticks it per instance.
|
||
// `w` = the WMO interior batch-class lane: an interior group's INT batches draw UNLIT
|
||
// (pure tex × MOCV) and its TRANS batches lerp lit↔bake by the MOCV alpha (wow-re
|
||
// `trace-forensics-abbey-interior-d3d` §2 — observed on the abbey at close range, the
|
||
// northshire "lit interior batch" datum having been a mis-identified unit). Exterior
|
||
// groups' batches (and every M2) ride 0 = the exterior law.
|
||
tint: {
|
||
let t0 = rgb_anim.map_or([1.0, 1.0, 1.0], |a| a.sample(0.0));
|
||
let class_lane = match (is_interior && is_wmo, wmo_class) {
|
||
(true, Some(WmoBatchClass::Int)) => 1.0,
|
||
(true, Some(WmoBatchClass::Trans)) => 2.0,
|
||
_ => 0.0,
|
||
};
|
||
Vec4::new(t0[0], t0[1], t0[2], class_lane)
|
||
},
|
||
// The WMO window/glass law (`WowModelExt::sidn`): xyz = the authored SIDN emissive
|
||
// (gamma bytes /255 — the shader ramps it by the live night fraction on lit lanes),
|
||
// w = the WINDOW midpoint-light flag.
|
||
sidn: {
|
||
let c = sidn.unwrap_or([0, 0, 0]);
|
||
Vec4::new(
|
||
f32::from(c[0]) / 255.0,
|
||
f32::from(c[1]) / 255.0,
|
||
f32::from(c[2]) / 255.0,
|
||
if window { 1.0 } else { 0.0 },
|
||
)
|
||
},
|
||
light_buf: light.clone(),
|
||
},
|
||
});
|
||
cache.insert(key, handle.clone());
|
||
handle
|
||
}
|
||
|
||
/// `clutter_fade.z` marker bit 9: this material is a depth-prime twin ([`zfill_material`]).
|
||
pub(crate) const ZFILL_MARKER: u16 = 1 << 9;
|
||
|
||
/// `clutter_fade.z` marker bit 10: this twin's SOURCE batch alpha-tests, so the shader re-applies
|
||
/// the hard 224/255 cutout while blending (`wow_model.wgsl`). Set from the source blend mode by
|
||
/// [`model_material`] (fade twins) and [`zfill_material`] (depth-prime twins); never on an
|
||
/// Opaque-source twin — the reference runs mode 0 with the alpha test disabled, steady and
|
||
/// promoted alike (`m2-blend-promotion-zfill.md` §2; decision 0842).
|
||
pub(crate) const TWIN_CUTOUT_MARKER: u16 = 1 << 10;
|
||
|
||
/// The twin's `Transparent3d` sort bias (yards; **negative = drawn earlier** — the sign law is
|
||
/// `sky_order`'s module doc). It must clear the spread of one model's part AABB centres, so **all**
|
||
/// of a fading model's twins draw before **any** of its colour parts — the reference achieves the
|
||
/// same by tying every command of one instance to a single sort key (`cmd+0x14 = model+0x84`) and
|
||
/// putting twins first inside the tie (`m2-blend-promotion-zfill.md` §4/§6). 8 yd covers every
|
||
/// humanoid and mount; the residue (a model taller than 8 yd may keep a little self-overlap
|
||
/// darkening at its extremities, and transparent scene content sorting within 8 yd behind a fading
|
||
/// body draws after the prime and is depth-clipped where the body covers it) is recorded in
|
||
/// decision 0831. The same field doubles as the rasterizer depth bias in relative ULPs — at −8 that
|
||
/// is ~2⁻²⁰ relative, and its direction (twin marginally farther) only makes the colour pass's
|
||
/// GreaterEqual test safer against cross-pipeline ULP noise.
|
||
pub(crate) const ZFILL_SORT_BIAS: f32 = -8.0;
|
||
|
||
/// Build (or fetch) the **depth-prime twin** material for one fadeable entity batch — the
|
||
/// reference's `M2UseZFill` clone (wow-re `m2-blend-promotion-zfill.md` §4, VERIFIED): while a
|
||
/// model draws translucent (`0 < A < 1`), a colour-masked, blend-off, z-writing copy of each of its
|
||
/// depth-writing batches draws first, so every colour fragment behind the model's own nearest
|
||
/// surface fails the depth test — one blended layer everywhere, no self-overlap darkening.
|
||
///
|
||
/// `cutout` = **the source batch alpha-tests** (AlphaKey). It mirrors what the part's COLOUR pass
|
||
/// discards, and must keep mirroring it: a twin that writes depth where the colour pass discards
|
||
/// leaves an invisible depth wall inside the cutout holes. Only an AlphaKey source rides the fade
|
||
/// twin's hard 224/255 cutout ([`TWIN_CUTOUT_MARKER`]); Opaque sources never alpha-test
|
||
/// (`m2-blend-promotion-zfill.md` §2/§4 — the reference's twin keeps mode 1's test, mode 0 has
|
||
/// none; decision 0842) and authored-Blend sources discard nothing either.
|
||
///
|
||
/// Everything that shapes only the *colour* (lighting lane, shade, fog, tint) is canonicalized so
|
||
/// twins dedupe maximally — the `WOW_ZFILL` fragment returns before any of it.
|
||
pub(crate) fn zfill_material(
|
||
cache: &mut MaterialCache,
|
||
materials: &mut Assets<WowModelMaterial>,
|
||
texture: Option<Handle<Image>>,
|
||
two_sided: bool,
|
||
cutout: bool,
|
||
light: &Buffer,
|
||
) -> Handle<WowModelMaterial> {
|
||
let key = MatKey {
|
||
texture: texture.as_ref().map(Handle::id),
|
||
blend: if cutout {
|
||
ModelBlend::AlphaTest
|
||
} else {
|
||
ModelBlend::Blend
|
||
},
|
||
two_sided,
|
||
is_wmo: false,
|
||
is_interior: false,
|
||
is_emissive: false,
|
||
is_additive: false,
|
||
fade_variant: cutout,
|
||
no_depth_write: false,
|
||
no_depth_test: false,
|
||
fog_policy: benilla_formats::FogPolicy::Scene,
|
||
shade: ShadeSel::Lit,
|
||
batch_order: 0,
|
||
uv_anim: None,
|
||
rgb_anim: None,
|
||
wmo_class: None,
|
||
sidn: None,
|
||
window: false,
|
||
zfill: true,
|
||
};
|
||
if let Some(h) = cache.fetch(&key) {
|
||
return h;
|
||
}
|
||
let cull_mode = if two_sided { None } else { Some(Face::Back) };
|
||
let base = StandardMaterial {
|
||
base_color_texture: texture,
|
||
// Blend keeps the twin in the transparent pass, where the sort bias can place it before
|
||
// the colour parts; `specialize` masks the colour writes off and turns blend off.
|
||
alpha_mode: AlphaMode::Blend,
|
||
depth_bias: ZFILL_SORT_BIAS,
|
||
double_sided: two_sided,
|
||
cull_mode,
|
||
..default()
|
||
};
|
||
let handle = materials.add(ExtendedMaterial {
|
||
base,
|
||
extension: WowModelExt {
|
||
// Bit 10 (the twin-cutout marker) drives the shader's hard 224/255 cutout — set
|
||
// exactly when the colour pass discards, so depth and colour coverage agree.
|
||
clutter_fade: Vec4::new(
|
||
0.0,
|
||
0.0,
|
||
f32::from(ZFILL_MARKER | if cutout { TWIN_CUTOUT_MARKER } else { 0 }),
|
||
0.0,
|
||
),
|
||
// Not a fade twin (`model_flags.y` = 0): the zfill pipeline branch forces its own
|
||
// depth-write, and the cutout rides bit 10 above, so the bit has no work here.
|
||
model_flags: Vec4::ZERO,
|
||
sun_scale: Vec4::new(ShadeSel::Lit.selector(), 0.0, 0.0, 0.0),
|
||
tint: Vec4::new(1.0, 1.0, 1.0, 0.0),
|
||
sidn: Vec4::ZERO,
|
||
light_buf: light.clone(),
|
||
},
|
||
});
|
||
cache.insert(key, handle.clone());
|
||
handle
|
||
}
|
||
|
||
/// Replace the fog-policy bits (4-6) inside a packed `clutter_fade.z` marker word, preserving
|
||
/// every other pipeline marker — bits 0-3 AND the Mod/Mod2x multiply markers in bits 7-8
|
||
/// (decision 0528). The mask lives here, beside the packer above: the portrait booth's rig twin
|
||
/// once truncated the word to `u8` and hand-masked `& 0x0f`, silently dropping the multiply
|
||
/// markers — the char-select white-blade regression.
|
||
pub(crate) fn replace_fog_policy(z: f32, policy: benilla_formats::FogPolicy) -> f32 {
|
||
f32::from((z as u16 & !(7 << 4)) | ((policy as u16) << 4))
|
||
}
|
||
|
||
/// A model reference path (`.mdx`/`.mdl`, mixed case, backslashes) → its `mpq://…m2` load URL.
|
||
/// Lowercased so case variants share one `AssetServer` handle; the physical archive file is `.m2`.
|
||
pub(crate) fn m2_url(raw: &str) -> String {
|
||
let p = raw.to_ascii_lowercase().replace('\\', "/");
|
||
let stem = p
|
||
.strip_suffix(".mdx")
|
||
.or_else(|| p.strip_suffix(".mdl"))
|
||
.or_else(|| p.strip_suffix(".m2"))
|
||
.unwrap_or(&p);
|
||
format!("mpq://{stem}.m2")
|
||
}
|
||
|
||
/// A WMO root path → its `mpq://…wmo` load URL (already `.wmo`; lowercased for handle dedup).
|
||
pub(crate) fn wmo_url(raw: &str) -> String {
|
||
format!("mpq://{}", raw.to_ascii_lowercase().replace('\\', "/"))
|
||
}
|
||
|
||
/// A creature skin variation → its `mpq://…blp` URL: `<model-dir>\<name>.blp`. `model_dir` is the
|
||
/// directory of the creature's model path (where its `Monster1/2/3` skins live).
|
||
pub(crate) fn skin_url(model_dir: &str, name: &str) -> String {
|
||
let dir = model_dir.replace('\\', "/").to_ascii_lowercase();
|
||
let name = name.to_ascii_lowercase();
|
||
if dir.is_empty() {
|
||
format!("mpq://{name}.blp")
|
||
} else {
|
||
format!("mpq://{dir}/{name}.blp")
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::replace_fog_policy;
|
||
use benilla_formats::FogPolicy;
|
||
|
||
/// Swapping the fog policy must preserve EVERY pipeline marker bit — 0-3 and the 0528
|
||
/// multiply markers in 7-8. The portrait booth's old hand-rolled `as u8 & 0x0f` dropped
|
||
/// bits 7-8 (the char-select white-blade regression this pins against).
|
||
#[test]
|
||
fn replace_fog_policy_preserves_pipeline_markers() {
|
||
// All marker bits set (0-3, 7-8) + fog policy Grey (3) in bits 4-6.
|
||
let z = f32::from(0b1_1000_1111u16 | (3 << 4));
|
||
let out = replace_fog_policy(z, FogPolicy::Off) as u16;
|
||
assert_eq!(out & 0b1_1000_1111, 0b1_1000_1111, "markers preserved");
|
||
assert_eq!((out >> 4) & 7, FogPolicy::Off as u16, "fog swapped");
|
||
// And the reverse direction: no stray bits invented.
|
||
let out2 = replace_fog_policy(f32::from(0u16), FogPolicy::Grey) as u16;
|
||
assert_eq!(out2, (FogPolicy::Grey as u16) << 4);
|
||
}
|
||
|
||
/// The authored-batch-order sort epsilon sits in its working band: any realistic model's
|
||
/// last batch still biases UNDER the effect lane's owner-last rung floor (an item's glow
|
||
/// particles must draw after every batch of the item — 0719/0721), and one step is far above
|
||
/// f32 noise on a far-scene sort distance (~4e-5 at 500 yd), so the tie actually breaks.
|
||
/// Pins the constant against a careless resize in either direction — either end failing is
|
||
/// the Naxx-item strobe (coplanar layers re-flipping a sort tie every frame) or its dual
|
||
/// (effects interleaving into their owner's batches).
|
||
#[test]
|
||
fn batch_order_sort_eps_sits_between_f32_noise_and_the_effect_rung() {
|
||
// 512 batches is comfortably past any shipped model's batch table.
|
||
let deepest = super::BATCH_ORDER_SORT_EPS * 512.0;
|
||
assert!(
|
||
deepest < benilla_formats::owner_last_rung(0.0),
|
||
"a model's last batch must still sort before its own effects' rung"
|
||
);
|
||
let far_noise = 500.0_f32 * f32::EPSILON;
|
||
assert!(
|
||
super::BATCH_ORDER_SORT_EPS > 8.0 * far_noise,
|
||
"one order step must dominate f32 noise on a far sort distance"
|
||
);
|
||
// And bevy's pipeline key truncates it to 0 — the bias may never mint pipelines (0837):
|
||
// sub-1.0 biases all cast to the same i32 constant.
|
||
assert_eq!(deepest as i32, 0);
|
||
}
|
||
}
|