the midnight fence hunt begins, and a spell's ground glow comes back
- Sun-lit fences rendered noon-bright at midnight. The hunt starts here: the model sun response switches to the curve read out of the real client's own shipped vertex program, the terrain-shadow bit under any doodad became a one-command query, and a new golden frame holds a fence lying across the shadow boundary — both states of the sun term in a single image (with a night twin). The full resolution lands in the next snapshot. - Arcane Explosion's ground decal drew as a black tile: the additive blend was lost when a decal hopped render lanes. A regression, bisected with a human grading each build, and fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -381,6 +381,22 @@ enum Command {
|
||||
/// Case-insensitive substring of the model path (e.g. `instanceportal`).
|
||||
filter: String,
|
||||
},
|
||||
/// Print the terrain MCSH baked-shadow bit at a world position — the per-instance
|
||||
/// terrain-shade selector an exterior M2 doodad samples once at its base (sun intensity
|
||||
/// 2.5 lit / 0.5 shadowed, `0x69e4ad`; decisions 0063/0747) — plus an ASCII texel
|
||||
/// neighborhood. A doodad base near a shadow edge sits one ~0.52 yd texel from the OTHER
|
||||
/// intensity family, so two adjacent fence pieces can land on opposite sides — the
|
||||
/// "why is ONE of these blown out" instrument.
|
||||
Shadeat {
|
||||
/// Map directory name (e.g. `Azeroth`).
|
||||
map: String,
|
||||
/// World-space X (raw WoW yards; may be negative).
|
||||
#[arg(allow_hyphen_values = true)]
|
||||
x: f32,
|
||||
/// World-space Y.
|
||||
#[arg(allow_hyphen_values = true)]
|
||||
y: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// MPQ internal paths are backslash-separated; accept `/` for convenience.
|
||||
@@ -561,6 +577,7 @@ fn main() -> Result<()> {
|
||||
center_y,
|
||||
tile_radius,
|
||||
} => scan::doodadscan(&mut chain, &map, center_x, center_y, tile_radius)?,
|
||||
Command::Shadeat { map, x, y } => scan::shadeat(&mut chain, &map, x, y)?,
|
||||
Command::Spellvis { spell_id } => spellvis::run(&mut chain, spell_id)?,
|
||||
}
|
||||
|
||||
|
||||
@@ -2472,3 +2472,45 @@ pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The terrain MCSH shadow bit at a world position + an ASCII texel neighborhood (`#` shadowed,
|
||||
/// `.` lit, `?` off-tile/no-chunk). One MCSH texel is `TILE_SIZE/1024` ≈ 0.52 yd; the grid spans
|
||||
/// ±8 texels so a doodad base sitting one texel from a shadow edge — the 2.5-vs-0.5 intensity
|
||||
/// cliff — is visible at a glance.
|
||||
pub fn shadeat(chain: &mut Chain, map: &str, x: f32, y: f32) -> Result<()> {
|
||||
let tiles = benilla_formats::load_tiles_around(chain, map, x, y, 0)
|
||||
.with_context(|| format!("loading the tile under ({x}, {y}) on {map}"))?;
|
||||
let Some((_, tile)) = tiles.first() else {
|
||||
anyhow::bail!("no tile exists under ({x}, {y}) on {map}");
|
||||
};
|
||||
let texel = benilla_formats::TILE_SIZE / 1024.0;
|
||||
let word = |s: Option<bool>| match s {
|
||||
Some(true) => "SHADOWED (doodad sun intensity 0.5)",
|
||||
Some(false) => "lit (doodad sun intensity 2.5)",
|
||||
None => "off-tile / no chunk",
|
||||
};
|
||||
println!(
|
||||
"MCSH at ({x:.2}, {y:.2}): {}",
|
||||
word(benilla_formats::mcsh_shadowed_at(&tile.chunks, [x, y, 0.0]))
|
||||
);
|
||||
println!(
|
||||
"neighborhood, texel {texel:.3} yd — rows +X (north) up, cols +Y (west) left; center marked:"
|
||||
);
|
||||
for dx in (-8i32..=8).rev() {
|
||||
let mut row = String::new();
|
||||
for dy in (-8i32..=8).rev() {
|
||||
let p = [x + dx as f32 * texel, y + dy as f32 * texel, 0.0];
|
||||
let mut c = match benilla_formats::mcsh_shadowed_at(&tile.chunks, p) {
|
||||
Some(true) => '#',
|
||||
Some(false) => '.',
|
||||
None => '?',
|
||||
};
|
||||
if dx == 0 && dy == 0 {
|
||||
c = if c == '#' { 'S' } else { 'O' };
|
||||
}
|
||||
row.push(c);
|
||||
}
|
||||
println!("{row}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -503,10 +503,10 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
|
||||
// `dot(L, that normal)` × a per-vertex colour, MODULATE × texture — so a tuft darkens with the
|
||||
// ground it stands on (shaded/sloped tufts go darker, like the dirt beneath). We bake that normal
|
||||
// in clutter.rs (`terrain_normal_at`). World-up was an earlier flat-ground approximation, removed.
|
||||
// **Exterior M2 doodads use the same FFP `ambient + diffuse·max(N·L,0)`** as clutter/WMO, but with the
|
||||
// diffuse/sun term scaled by the terrain-shade at the doodad's base (lit vs MCSH-shadowed ground). No
|
||||
// separate lobe — the same directional matte, just a per-instance sun multiplier. Clutter keeps its own
|
||||
// FFP N·L path lit by the ground normal (a different reference program).
|
||||
// **Exterior M2 doodads take the verified `Model2.bls` sun curve** (0747, below) with the
|
||||
// diffuse/sun term scaled by the terrain-shade at the doodad's base (lit vs MCSH-shadowed ground);
|
||||
// clutter and WMO keep the plain FFP `ambient + diffuse·max(N·L,0)` — clutter lit by the ground
|
||||
// normal, WMO by its own (both genuinely fixed-function reference programs).
|
||||
let is_clutter = m.clutter_fade.w > 0.5;
|
||||
let L = -normalize(wow_light.light_sun.xyz);
|
||||
let n_m2 = normalize(pbr_input.world_normal);
|
||||
@@ -527,13 +527,15 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
|
||||
let quad = vec4<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
|
||||
let n1 = vec4<f32>(n_lit, 1.0);
|
||||
let x2y2 = n_lit.x * n_lit.x - n_lit.y * n_lit.y;
|
||||
// Exterior world doodad/entity: a hard-cutoff directional matte, scaled by the per-instance
|
||||
// INTENSITY, the byte-verified `[node+0xa4]`: 2.5 on lit ground / 0.5 on MCSH-shadowed
|
||||
// ground / 1.0 day-night (`0x69e4ad`, `0x69e280`). Lane history: the `Model2.bls` SH lobe
|
||||
// (0354/0358, the mainstream VS-path curve) → the FFP matte with a hard cutoff (the SH wrap
|
||||
// fed warm sun onto the anti-sun side; director's call: a sunlit character's shadow side
|
||||
// must read the SAME as the skin standing in shade, so both sit exactly at the ambient
|
||||
// level) → the same hard cutoff on an UNCLAMPED source (0706, below).
|
||||
// Exterior world doodad/entity: the VERIFIED `Model2.bls` sun response, scaled by the
|
||||
// per-instance INTENSITY, the byte-verified `[node+0xa4]`: 2.5 on lit ground / 0.5 on
|
||||
// MCSH-shadowed ground / 1.0 on the interior/WMO-prop leg (`0x69e4ad`, `0x69e280`; the leg
|
||||
// that force-writes 1.0 is `0x69e36b`). Lane history: the `Model2.bls` SH lobe (0354/0358)
|
||||
// → the FFP matte with a hard cutoff (the SH wrap fed warm sun onto the anti-sun side;
|
||||
// director's call: a sunlit character's shadow side must read the SAME as the skin standing
|
||||
// in shade, so both sit exactly at the ambient level) → the same hard cutoff on an
|
||||
// UNCLAMPED source (0706) → the verified closed-form curve on the sun side, cutoff kept
|
||||
// (0747, below).
|
||||
//
|
||||
// The per-material `sun_scale.x` selector has THREE states (model_render::ShadeSel): ≥0.85 =
|
||||
// the lit-ground family (ADT doodads and every entity M2 — intensity 2.5, mixed toward 0.5 by
|
||||
@@ -546,18 +548,36 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
|
||||
let shade_t = max(mat_shade, inst_shade);
|
||||
let mid_band = m.sun_scale.x >= 0.5 && m.sun_scale.x < 0.85;
|
||||
let intensity = select(mix(2.5, 0.5, shade_t), 1.0, mid_band);
|
||||
// Hard sun cutoff on an UNCLAMPED source: `clamp01(ambient + I·D·max(N·L,0))` — ONE
|
||||
// saturating clamp, at the sum (decision 0706, which keeps the earlier director ruling:
|
||||
// the anti-sun side sits exactly at ambient, no SH wrap). The old per-channel source
|
||||
// pre-clamp `clamp01(I·D)` was faithful to NEITHER reference leg — the FFP leg
|
||||
// peak-NORMALIZES (`(I·D)/max(1,maxch)`, hue-preserving, glue-model-lighting §4) and the
|
||||
// mainstream Model2.bls SH path consumes the moments unclamped — and it bleached the sun:
|
||||
// the EK dawn diffuse (254,123,51) at I=2.5 pre-clamped to (1,1,.5), which is bug B33/B97
|
||||
// (bone-white fences, pale-olive beams, where the reference renders both dark-warm). The
|
||||
// unclamped sum matches the SH fidelity target's colour response to ≤10% on the sun side
|
||||
// (equal at the pole and at μ≈0.2) while the hard cutoff stands in for the rejected wrap.
|
||||
// The VERIFIED sun response (decision 0747): `clamp01(ambient + I·D·max(0, f(μ)))` with
|
||||
// `f(μ) = (3 + 16μ + 15μ²)/34` — the closed form of the shipped `Model2.bls` vertex program
|
||||
// (wow-re model2-bls-vertex-sh.md §4: three independent decodes convergent to ≤2.4e-8; the
|
||||
// moments are consumed UNCLAMPED, so 0706's one saturating clamp at the sum stands). Peak
|
||||
// f(1) = 1 (= the FFP `N·L` peak, the 16/17 accumulate scale exists for exactly that), and
|
||||
// vs the previous linear `max(N·L,0)` the curve sits up to ~15% lower through the mid
|
||||
// angles (f(0.5) = 0.434), which is where a near-saturating sun (any I·D channel > 1 — all
|
||||
// three at night, R at dawn) stopped clipping ~15% too early. `max(0, ·)` keeps the
|
||||
// director's anti-sun ruling (the hard-cutoff history above): f crosses zero at μ = −0.243,
|
||||
// so the deep shadow hemisphere sits exactly at ambient — the reference's back-side wrap
|
||||
// (f(−1) = 2/34) and its small negative dip are deliberately NOT taken, only the sun-side
|
||||
// curve is. μ is the RAW dot (not `ndotl`): clamping first would pin the terminator band
|
||||
// μ ∈ (−0.243, 0) at f(0) = 3/34 instead of letting it fade out.
|
||||
let mu = dot(n_lit, L);
|
||||
let sun_curve = max((3.0 + 16.0 * mu + 15.0 * mu * mu) / 34.0, 0.0);
|
||||
// PEAK-NORMALIZE the committed sun (decision 0750, provisional — the byte verdict on which
|
||||
// representation the reference's moment accumulate reads is a dispatched wow-re question):
|
||||
// `C = I·D / max(1, max_ch(I·D))` — hue-preserving, capped at white. The raw over-gamut
|
||||
// product (night: 2.5·[97,130,162]/255 = (0.95, 1.27, 1.59)) clips every moon-facing doodad
|
||||
// face to flat white — the director's "day-lit fence at midnight" / "one Silverpine fence
|
||||
// blown out at every hour" reports; the normalized form keeps the night's blue hue and
|
||||
// softens the ×5 MCSH lit/shadowed cliff to the smooth blend the reference shows. Gain-1
|
||||
// lanes (WMO, clutter, mid-band, shaded 0.5) never exceed white, so this touches ONLY the
|
||||
// lit-intensity family. The client's own light encoder (`0x71ca80`) demonstrably produces
|
||||
// exactly this peak-normalized form; whether the raw peak is re-applied on the sun's path
|
||||
// is the open byte question — if the verdict says raw, this reverts on the record.
|
||||
let sun_c = wow_light.light_diffuse.rgb * intensity;
|
||||
let sun_peak = max(max(sun_c.r, sun_c.g), max(sun_c.b, 1.0));
|
||||
let lit_doodad = clamp(
|
||||
wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * intensity * ndotl,
|
||||
wow_light.light_ambient.rgb + (sun_c / sun_peak) * sun_curve,
|
||||
vec3<f32>(0.0),
|
||||
vec3<f32>(1.0),
|
||||
);
|
||||
|
||||
@@ -140,12 +140,14 @@ pub(super) const HOUSE_EYE: [f32; 3] = [-9439.1, 71.2, 68.0];
|
||||
pub(super) const MAP_AZEROTH: u32 = 0;
|
||||
pub(super) const MAP_KALIMDOR: u32 = 1;
|
||||
|
||||
/// THE golden baseline: five spots FRAMED BY THE DIRECTOR in the live client ([`/shot`], the
|
||||
/// THE golden baseline: six spots FRAMED BY THE DIRECTOR in the live client ([`/shot`], the
|
||||
/// numbers below verbatim from their `~/.benilla/shots.txt`) — the Northshire overlook toward the
|
||||
/// Abbey, a Stormwind canal view, Elwynn water, the Lion's Pride Inn common room, and a Felwood
|
||||
/// hollow — each at noon and at night, except the inn (which the clock barely reaches; see below).
|
||||
/// Abbey, a Stormwind canal view, Elwynn water, the Lion's Pride Inn common room, a Felwood hollow,
|
||||
/// and an Elwynn fence lying across the sun's shadow line — each at noon and at night, except the
|
||||
/// inn (which the clock barely reaches) and the fence, whose DAY cell keeps the director's own
|
||||
/// 10:24 because its subject is where the shadow line falls; see both below.
|
||||
///
|
||||
/// Nine shots of maximal mutual difference. The old set was thirty, and the director judged the
|
||||
/// Ten landscape shots of maximal mutual difference. The old set was thirty, and the director judged the
|
||||
/// design bad: low-value repetition (four compass looks at one farmhouse, five frames from one
|
||||
/// Northshire camera, sixteen UI windows over the same backdrop), each one a real window popping
|
||||
/// open on their screen while they worked. The law is **a few spots chosen with the director, times
|
||||
@@ -238,6 +240,31 @@ pub(super) const SCENARIOS: &[Scenario] = &[
|
||||
minute: 0,
|
||||
ui: None,
|
||||
},
|
||||
// The day cell keeps the director's own minute (10:24) rather than the sweep's noon: the subject
|
||||
// IS the shadow boundary, and where it falls is a function of the sun's angle, so at noon it
|
||||
// slides off the rails and the shot degrades into an ordinary fence.
|
||||
Scenario {
|
||||
name: "fence-shadowline-day",
|
||||
map: MAP_AZEROTH,
|
||||
eye: FENCE_EYE,
|
||||
look: FENCE_LOOK,
|
||||
minute: 624,
|
||||
ui: None,
|
||||
},
|
||||
// The night twin, at the director's call — this record's own first draft argued against it on
|
||||
// the grounds that there is no shadow line without a sun, which was reasoning from the mechanism
|
||||
// and never measured. The bit it photographs is BAKED, present at every hour; what the night
|
||||
// frame pins is the other half of the mechanism — how much of the shade term survives once the
|
||||
// sun's contribution is gone and the ambient/moon palette carries the frame. That is a question
|
||||
// about the light curves, and a baseline is the right place to hold the answer (0749 addendum).
|
||||
Scenario {
|
||||
name: "fence-shadowline-night",
|
||||
map: MAP_AZEROTH,
|
||||
eye: FENCE_EYE,
|
||||
look: FENCE_LOOK,
|
||||
minute: 0,
|
||||
ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "creature-sun-front",
|
||||
map: MAP_AZEROTH,
|
||||
@@ -380,6 +407,23 @@ pub(super) const WATER_LOOK: [f32; 3] = [-9499.4, -351.3, 61.4];
|
||||
pub(super) const INN_EYE: [f32; 3] = [-9471.4, 39.4, 59.9];
|
||||
pub(super) const INN_LOOK: [f32; 3] = [-9458.8, -7.5, 48.2];
|
||||
|
||||
/// Director shot 6 (decision 0749) — an Elwynn rail fence lying ACROSS the sun's shadow boundary,
|
||||
/// at 10:24: the right-hand span is in full sun, the left-hand span in shade, and the same edge
|
||||
/// runs on across the road behind it. One frame holding **both** states of the MCSH sun term, on
|
||||
/// terrain and on a doodad at once, with the ramp visible as the hard line between them.
|
||||
///
|
||||
/// The lighting matrix (0746) samples the lit and shadowed lanes as SEPARATE cells at separate
|
||||
/// positions, so a regression that scaled both equally could pass both. Here the two states share
|
||||
/// one frame, one model and one texture, so only their DIFFERENCE can carry the shot — the control
|
||||
/// is inside the picture. It also lights the third lane neither matrix subject touches: a
|
||||
/// world-placed **doodad**, which takes its shade per-vertex at bake rather than through
|
||||
/// `entity_shade`'s per-object ramp.
|
||||
///
|
||||
/// Found by the director, who framed it because they had already spotted it was "both lit and unlit
|
||||
/// from sun, half half".
|
||||
pub(super) const FENCE_EYE: [f32; 3] = [-9511.9, -4.0, 61.9];
|
||||
pub(super) const FENCE_LOOK: [f32; 3] = [-9552.0, 18.6, 42.4];
|
||||
|
||||
/// Director shot 5 (decision 0743) — a Felwood hollow on **Kalimdor** (`MAP_KALIMDOR`): the
|
||||
/// corrupted forest floor's root mat, a stand of emissive `felwoodmushroom` doodads, a pool of
|
||||
/// green sludge (a liquid type no other golden shot contains), the vast trunks behind, and the
|
||||
|
||||
@@ -57,6 +57,14 @@ pub(super) struct EntityPart {
|
||||
/// (creatures + M2 GameObjects, the CGObjects that appear-fade); `None` for WMO-display parts.
|
||||
pub(super) fade_blend: Option<Handle<WowModelMaterial>>,
|
||||
pub(super) blend: ModelBlend,
|
||||
/// Whether this part blends **additively** (M2 blend mode 3 `NoAlphaAdd` / 4 `Add`).
|
||||
/// [`ModelBlend`] deliberately folds "alpha-blended / additive" into its single `Blend`
|
||||
/// variant, so [`Self::blend`] alone CANNOT answer this — the material path recovers it from
|
||||
/// [`crate::model_render`]'s separate `is_additive` input (marker bit 2 of `clutter_fade.z`,
|
||||
/// which `specialize` turns into the pure-add state). Any consumer that re-derives a blend
|
||||
/// state from `blend` needs this alongside it, or every additive batch silently draws
|
||||
/// alpha-blended — decision 0748, the black ground-decal tile.
|
||||
pub(super) additive: bool,
|
||||
/// Whether this part renders two-sided (M2 material `0x04`). Retained so a per-appearance material
|
||||
/// swap (the character hair, decision 0045 — hair cards are two-sided) preserves it; `part.material`
|
||||
/// already bakes it for the un-swapped parts.
|
||||
@@ -544,6 +552,7 @@ pub(super) fn build_parts(
|
||||
material_interior_bake_blend: interior_bake_blend,
|
||||
fade_blend,
|
||||
blend: sub.blend,
|
||||
additive: sub.additive,
|
||||
two_sided: sub.two_sided,
|
||||
geoset_id: sub.geoset_id,
|
||||
char_slot: sub.char_slot,
|
||||
@@ -598,6 +607,7 @@ pub(super) fn build_parts(
|
||||
material_interior_bake_blend: None,
|
||||
fade_blend: None, // WMO-display GameObjects don't appear-fade (rare; M2 only)
|
||||
blend: sub.blend,
|
||||
additive: false, // WMO MOMT carries no additive mode (`RenderSubmesh::additive`)
|
||||
two_sided: sub.two_sided,
|
||||
billboard: None, // WMO groups have no billboard bones
|
||||
alpha_anim: None, // …nor colour/weight loops
|
||||
|
||||
@@ -481,7 +481,7 @@ pub(super) fn attach_effect_visuals(
|
||||
let decal = crate::ground_fx::spawn_ground_fx_decal(
|
||||
commands,
|
||||
texture,
|
||||
crate::particles::buffer::EffectBlend::from_model(part.blend),
|
||||
crate::particles::buffer::EffectBlend::from_model(part.blend, part.additive),
|
||||
fog,
|
||||
part.rgb_anim.as_ref().map(|a| (a.clone(), now)),
|
||||
&quad,
|
||||
|
||||
@@ -456,13 +456,17 @@ mod tests {
|
||||
assert!((rgb[2] - 0.4).abs() < 1e-4, "blue commits raw: {rgb:?}");
|
||||
}
|
||||
|
||||
/// GOLDEN — the exterior M2 lane's contract, end to end through the packed rows: mirroring
|
||||
/// `wow_model.wgsl`'s eval (DC lane + grade.yzw × I + I × (linear + quad + x²−y²)) over the
|
||||
/// rows this packer writes must reproduce the disassembled `Model2.bls` closed form
|
||||
/// `clamp01(ambient + D·I·(3 + 16μ + 15μ²)/34)` at every intensity rung (2.5 lit / 1.0
|
||||
/// mid-band / 0.5 MCSH-shadowed). Pins the "every sun band scales by I, never I²" law and the
|
||||
/// row homes (ambient in the DC lanes, the sun's DC redistribution on grade.yzw) — the two
|
||||
/// ways this lane has silently drifted.
|
||||
/// GOLDEN — the PACKER's SH block: evaluating the rows this packer writes (DC lane +
|
||||
/// grade.yzw × I + I × (linear + quad + x²−y²)) must reproduce the disassembled `Model2.bls`
|
||||
/// closed form `clamp01(ambient + D·I·(3 + 16μ + 15μ²)/34)` at every intensity rung (2.5 lit /
|
||||
/// 1.0 mid-band / 0.5 MCSH-shadowed). Pins the "every sun band scales by I, never I²" law and
|
||||
/// the row homes (ambient in the DC lanes, the sun's DC redistribution on grade.yzw).
|
||||
///
|
||||
/// NB (0747): no shader currently READS rows 6-12.xyz / 17.yzw — the live exterior lane in
|
||||
/// `wow_model.wgsl` implements the same closed form inline (sun side only, `max(0, f(μ))`;
|
||||
/// the full block's back-side wrap stays out per the anti-sun ruling). This golden pins the
|
||||
/// packed block itself; the flagged cleanup is either wiring a lane to the rows or retiring
|
||||
/// the dead fold together with this test.
|
||||
#[test]
|
||||
fn exterior_lane_reproduces_the_closed_form_at_every_intensity_rung() {
|
||||
let ambient = [0.30, 0.32, 0.38];
|
||||
|
||||
@@ -72,11 +72,25 @@ impl From<ParticleBlend> for EffectBlend {
|
||||
}
|
||||
|
||||
impl EffectBlend {
|
||||
/// The ground-fx mapping from a part's authored [`ModelBlend`] — `model_render.rs`'s law
|
||||
/// with the lane's two named approximations: `AlphaTest` folds to `Alpha` (no Mask variant
|
||||
/// here; the groundscan census says flat `Spells\` quads are blend batches), and the part
|
||||
/// renders unlit (spell fx are; a lit ground quad would differ — none observed).
|
||||
pub fn from_model(blend: ModelBlend) -> Self {
|
||||
/// The ground-fx mapping from a part's authored blend — `model_render.rs`'s law with the
|
||||
/// lane's two named approximations: `AlphaTest` folds to `Alpha` (no Mask variant here; the
|
||||
/// groundscan census says flat `Spells\` quads are blend batches), and the part renders unlit
|
||||
/// (spell fx are; a lit ground quad would differ — none observed).
|
||||
///
|
||||
/// `additive` is a SECOND, non-optional input because [`ModelBlend`] cannot express additive:
|
||||
/// M2 blend modes 3/4 fold into its `Blend` variant (see its own doc, "Alpha-blended /
|
||||
/// additive"), and the material path recovers them from `model_render`'s separate
|
||||
/// `is_additive` flag. Taking only the enum made this function *unable* to be right for an
|
||||
/// additive batch — every `Spells\` ground quad is mode 4, so Arcane Explosion / Blast Wave /
|
||||
/// Battle Shout drew their black-backed additive art alpha-blended: an opaque black tile
|
||||
/// (decision 0748). Keeping it in the signature is what stops the next caller repeating it.
|
||||
pub fn from_model(blend: ModelBlend, additive: bool) -> Self {
|
||||
if additive {
|
||||
// `BLEND_ADD` is byte-for-byte the material path's additive: the shader gamma-
|
||||
// premultiplies (`rgb·α`) and returns α = 0, turning `(One, 1−srcα)` into pure
|
||||
// addition — the same fold `specialize` gates on marker bit 2 (0160/0161).
|
||||
return EffectBlend::Add;
|
||||
}
|
||||
match blend {
|
||||
ModelBlend::Opaque => EffectBlend::Opaque,
|
||||
ModelBlend::AlphaTest | ModelBlend::Blend => EffectBlend::Alpha,
|
||||
@@ -271,3 +285,57 @@ pub fn begin_effect_frame(mut quads: ResMut<EffectQuads>) {
|
||||
quads.verts.clear();
|
||||
quads.draws.clear();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The additive flag OVERRIDES the folded enum (decision 0748). `ModelBlend::Blend` means
|
||||
/// "alpha-blended **or** additive" — M2 modes 2/3/4 all land there — so a mapping that reads
|
||||
/// only the enum cannot be right. Every `Spells\` flat ground quad in the 1.12.1 corpus is
|
||||
/// mode 4 (`m2batch`: ArcaneExplosion_Base, BattleShout_Cast_Base, …), and drawing those
|
||||
/// alpha-blended painted their black-backed additive art as an opaque black tile.
|
||||
#[test]
|
||||
fn additive_wins_over_the_folded_blend_enum() {
|
||||
for blend in [
|
||||
ModelBlend::Opaque,
|
||||
ModelBlend::AlphaTest,
|
||||
ModelBlend::Blend,
|
||||
ModelBlend::Mod,
|
||||
ModelBlend::Mod2x,
|
||||
] {
|
||||
assert_eq!(
|
||||
EffectBlend::from_model(blend, true),
|
||||
EffectBlend::Add,
|
||||
"{blend:?} + additive must reach the pure-add state, not {:?}",
|
||||
EffectBlend::from_model(blend, false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-additive law is unchanged — `model_render.rs`'s mapping, with `AlphaTest` folded
|
||||
/// to `Alpha` (the lane has no Mask variant; flat `Spells\` quads are blend batches).
|
||||
#[test]
|
||||
fn non_additive_keeps_the_material_paths_law() {
|
||||
assert_eq!(
|
||||
EffectBlend::from_model(ModelBlend::Opaque, false),
|
||||
EffectBlend::Opaque
|
||||
);
|
||||
assert_eq!(
|
||||
EffectBlend::from_model(ModelBlend::AlphaTest, false),
|
||||
EffectBlend::Alpha
|
||||
);
|
||||
assert_eq!(
|
||||
EffectBlend::from_model(ModelBlend::Blend, false),
|
||||
EffectBlend::Alpha
|
||||
);
|
||||
assert_eq!(
|
||||
EffectBlend::from_model(ModelBlend::Mod, false),
|
||||
EffectBlend::Multiply
|
||||
);
|
||||
assert_eq!(
|
||||
EffectBlend::from_model(ModelBlend::Mod2x, false),
|
||||
EffectBlend::Mod2x
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user