- the granted movement modes are one system now: slow fall slows, levitate hovers, water walking holds the surface, a root pins you even mid-air - casting while moving refuses with the real error, and a jump cast plays its animation in the air - Z cycles melee, ranged, stowed, every landed swing draws the weapon, and Alt-Z hides the UI - login faces where you logged out, and the dev shortcuts moved to Ctrl+Shift off the Windows key Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
941 lines
60 KiB
WebGPU Shading Language
941 lines
60 KiB
WebGPU Shading Language
// WoW model lighting — paired with terrain.wgsl. Step 7 (matte) restores the faithful gamma-space
|
||
// combine for M2/WMO/creature meshes; trees, doodads, and buildings stop being black. **Step 8d:**
|
||
// ground clutter (grass/flowers) is lit by the **terrain ground normal under each tuft** (baked onto
|
||
// its vertices in clutter.rs), not the grass-quad's own normal — VERIFIED faithful: WoW writes the
|
||
// terrain quadrant-plane normal onto the clutter vertex normal channel and lights per-vertex with it
|
||
// (ground-effects.md), so a tuft darkens with the dirt it stands on as slope / shade / sun change.
|
||
// MCSH grey from q12 still rides on `ATTRIBUTE_COLOR → pbr_input.material.base_color` so per-doodad
|
||
// shadowing remains.
|
||
//
|
||
// color = clamp(A + D·I·f(N·u)) × tex × tint // M2 doodads: the Model2.bls order-2 lobe (0803)
|
||
// color = clamp(ambient + diffuse·max(N·L,0)) × … // clutter / WMO: FFP directional matte (sun-scale 1)
|
||
// color = mix(fog_color, color, fog_factor) // Step 5 fog
|
||
// out = color // raw gamma — the frame's ONE decode is in FFXGlow (0161)
|
||
//
|
||
// For trees / WMOs / creatures, `material_tint` is the StandardMaterial base_color (white by default
|
||
// — vertex colour attribute absent → VERTEX_COLORS shader-def not set → no per-vertex factor). For
|
||
// detail clutter, the merged mesh ships `ATTRIBUTE_COLOR = (mcsh_tint, mcsh_tint, mcsh_tint, 1)`,
|
||
// which Bevy folds into base_color → the lit factor is multiplied by the MCSH grey per-doodad.
|
||
//
|
||
// **Two different lighting laws live in this file, and confusing them has cost us three decisions.**
|
||
// Terrain, clutter and WMO genuinely ARE fixed-function (GL_LIGHTING + GL_LIGHT0 + GL_COLOR_MATERIAL,
|
||
// byte-verified off WoW.exe 5875 — and all five FFP light-commit call sites are terrain's), so they take
|
||
// `clamp(ambient + diffuse·max(N·L,0))`. The exterior M2 lane is NOT fixed-function: the reference loads
|
||
// `Shaders\Vertex\Model2.bls` out of misc.MPQ (gated on the VERTEX cvar `M2UseShaders`, which defaults to
|
||
// "1") and that program is an order-2 irradiance lobe, running on every exterior M2 it draws — doodad,
|
||
// GameObject, creature and player alike. So M2 doodads take that lobe, `E = A + D·I·(4/17)(0.375 + 2μ +
|
||
// 1.875μ²)` with μ = N·u toward-light, clamp01 on the SUM (0803, and the lane comment at step 7).
|
||
// Per-instance `I` is the terrain-shade family sampled at the doodad's base (2.5 lit / 0.5 MCSH-shadowed,
|
||
// `[def+0xa4]`) — see step 7 for the one part of that still open.
|
||
//
|
||
// Two retracted claims are recorded rather than deleted, because each stood long enough to seed a
|
||
// decision record and the next reader should know they were retired, not that they were never made:
|
||
// (a) "there is NO M2 irradiance lobe … no such program runs — M2UsePixelShaders defaults off" — wrong
|
||
// twice, wrong cvar and the program does run; (b) "on the exterior M2 lane that matte is OUR choice"
|
||
// (0796's framing) — true while 0410's cutoff stood, retired by 0803, which put the lane back on the
|
||
// reference's own curve.
|
||
//
|
||
// Specular (row 9 separate-specular, local viewer) is verified by q4/q5/Q13 but kept OUT of this
|
||
// step — M2 per-material shininess (q4 §6 INFERRED) is its own A/B and lives in Step 7b. WMO
|
||
// per-group authored colour (q4 §5) is also deferred.
|
||
//
|
||
// The clutter distance-fade alpha ramp (~52.5→70 yd) still applies on top — that's a draw-distance
|
||
// concern, not a lighting one (ground-effects.md Q4/Q10).
|
||
|
||
#import bevy_pbr::{
|
||
pbr_fragment::pbr_input_from_standard_material,
|
||
pbr_functions::alpha_discard,
|
||
forward_io::{VertexOutput, FragmentOutput},
|
||
mesh_view_bindings::view,
|
||
view_transformations::position_world_to_clip,
|
||
mesh_functions,
|
||
}
|
||
|
||
// Per-material model uniforms packed at binding 100 (see `WowModelExt` in terrain.rs). Light + fog + the
|
||
// SH coeffs moved OUT to the shared global-light storage buffer (below); only the per-material draw flags
|
||
// remain. Field order MUST match the Rust struct.
|
||
// clutter_fade — x = full-opacity radius (yd); y = fully-gone radius (yd); w = enabled (>0.5).
|
||
// The client draws clutter only within ~70 yd with a ramp over the last quarter;
|
||
// we reproduce by multiplying cutout alpha by clamp((y−d)/(y−x)) so distant grass
|
||
// erodes away through the alpha test (ground-effects.md Q4/Q10). 0 = off.
|
||
// model_flags — x = is_wmo (>0.5 ⇒ the WMO surface lanes); y = fade-blend twin;
|
||
// z = interior (>0.5): a WMO interior group (with is_wmo ⇒ the INT/TRANS batch-class
|
||
// lanes below) OR an interior M2 doodad prop (without is_wmo ⇒ lit by its folded
|
||
// SH probe, slot per-instance in MeshTag — day/night-independent);
|
||
// w = unlit fullbright (>0.5 ⇒ bypass lighting): M2 UNLIT (0x01), or WMO UNLIT on an
|
||
// exterior-group batch (the interior drawer ignores the flag — section law).
|
||
struct ModelParams {
|
||
clutter_fade: vec4<f32>,
|
||
model_flags: vec4<f32>,
|
||
// x = per-material MCSH terrain-shade SELECTOR (≥0.5 ⇒ lit ground, <0.5 ⇒ MCSH-shadowed); the shader
|
||
// thresholds it into the lit/shaded doodad sun INTENSITY family below. yzw reserved.
|
||
sun_scale: vec4<f32>,
|
||
// xyz = the M2Color RGB tint for batches whose colour track ANIMATES (the static vertex bake is
|
||
// skipped for those — WowModelExt::tint): folded into the albedo exactly where the vertex tint
|
||
// folds. (1,1,1) — identity — for everything else. w = the WMO interior BATCH-CLASS lane
|
||
// (wow-re trace-forensics-abbey-interior-d3d §2): 0 = exterior law, 1 = interior INT (unlit
|
||
// tex × MOCV), 2 = interior TRANS (per-vertex MOCV-alpha lit↔bake lerp).
|
||
tint: vec4<f32>,
|
||
// The WMO window/glass law (wow-re wmo-lit-selector / wmo-interior-night-light; 0 for all M2):
|
||
// xyz = the MOMT SIDN (0x10) authored emissive colour (gamma bytes /255) — multiplied by the live
|
||
// night fraction (wow_light.grade.x) and added INSIDE the lit sum on lit lanes, like the
|
||
// reference's glMaterialfv(GL_EMISSION): tex × (lit + sidn·night). Windows glow warm at night,
|
||
// nothing by day; dead on the unlit INT lane and under UNLIT, exactly like the FFP.
|
||
// w = the MOMT WINDOW (0x20) flag (>0.5): an interior-group batch swaps GL_LIGHT0 to the brighter
|
||
// midpoint pair — ambient AND diffuse = (Direct + Ambient)/2, ambient +16/255 — the warm pane
|
||
// seen from inside a building (derivation 0x6d37e0, byte-verified).
|
||
sidn: vec4<f32>,
|
||
};
|
||
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> m: ModelParams;
|
||
|
||
// The shared global light (lighting::global_light): ONE storage buffer every material reads, updated
|
||
// once/frame in place — replaces the per-material light/fog uniforms the old apply_wow_lighting re-pushed
|
||
// each frame. The model reads rows 0-2 (ambient/diffuse/sun) + fog, plus rows 6-12: the disassembled
|
||
// `Model2.bls` probe of the day/night scene light at intensity 1 — the reference's own exterior M2
|
||
// response, and since 0803 the live law on the doodad/entity lane.
|
||
// Shared with terrain.wgsl — a tree and the dirt under it fade into the same haze.
|
||
// (light_spec is part of the prefix layout but the model path never reads it.)
|
||
struct WowLight {
|
||
light_ambient: vec4<f32>, // rgb ambient; w = Mod2x scale
|
||
light_diffuse: vec4<f32>, // rgb sun diffuse; w = clamp-light flag (>0.5 ⇒ saturate)
|
||
light_sun: vec4<f32>, // xyz sun TRAVEL dir (to-light = −xyz); w = directional-light enable (>0.5)
|
||
light_spec: vec4<f32>, // rgb spec color; w = shininess (terrain's — unused by the matte model path)
|
||
fog_color: vec4<f32>, // rgb row-7 fog (gamma); w = enable (>0.5)
|
||
fog_params: vec4<f32>, // x=start y=end z=linear-lighting A/B flag w=farclip wall
|
||
// The global Model2.bls SH rows (6-12): the scene day/night light as an order-2 probe at
|
||
// intensity 1 — DC (ambient, `.w` of c10) + the sun's linear/quad bands in c10.xyz / c13 /
|
||
// c16.xyz, the disassembled closed form (wow-re model2-bls-vertex-sh.md). Every sun band is
|
||
// linear in the committed colour, so a consumer scales them ALL by the per-instance intensity
|
||
// (never I²); the sun's DC redistribution rides `grade.yzw` (× intensity).
|
||
//
|
||
// **LIVE — the exterior doodad/entity response (0803).** History worth knowing before you touch
|
||
// it: 0410 took this lane OFF the curve onto a hard-cutoff FFP matte on the director's look call,
|
||
// and the block then sat packed-but-unread for months. 0796 refuted the fidelity premise behind
|
||
// that retirement (the reference's M2 lane IS this SH shader, not an FFP light slot — the FFP
|
||
// commits in a world frame are terrain's), which reopened the call as a pure look question;
|
||
// 0799 restored the eval behind an A/B so it could be seen; 0803 is the director calling it,
|
||
// and the cutoff branch came out with the flag.
|
||
sh_c10_r: vec4<f32>,
|
||
sh_c10_g: vec4<f32>,
|
||
sh_c10_b: vec4<f32>,
|
||
sh_c13_r: vec4<f32>,
|
||
sh_c13_g: vec4<f32>,
|
||
sh_c13_b: vec4<f32>,
|
||
// xyz = the true c16 quad band (x²−y², per channel), live with the block above. w = FREE — it
|
||
// carried the 0273 point gain, then the 0750/0751 sun dial, then 0799's response A/B, and every
|
||
// one of those is retired. Nothing writes it and nothing reads it; claim it if you need a lane.
|
||
sh_c16: vec4<f32>,
|
||
_water: array<vec4<f32>, 4>, // rows 13-16: the liquid swatches — unread by models.
|
||
// x = SIDN night fraction (1 overnight, 0 by day — scales m.sidn.rgb).
|
||
// yzw = the sun's SH DC redistribution per channel, at intensity 1 (× the per-instance I).
|
||
grade: vec4<f32>,
|
||
// Rows 18-19: the INTERIOR fog triple — the 4 s camera-in-WMO MFOG crossfade (== the scene fog
|
||
// outdoors). Consumed by the interior lanes only (round-6 Q-I): interior WMO-group surfaces
|
||
// and that group's doodads (`0x6b5190` / `0x6b62e0`) — selected below by `m.model_flags.z`.
|
||
wmo_fog_color: vec4<f32>, // rgb interior fog (gamma); w = enable (mirrors fog_color.w)
|
||
wmo_fog_params: vec4<f32>, // x = start yd; y = end yd; zw = free lanes (retired A/B dials)
|
||
// The dynamic point-light table (decision 0278), packed by `global_light::build_light_data`:
|
||
// row 20 `.x` = live entry count; then TWO rows per light — `[pos.xyz, range]`, `[rgb, 0]`.
|
||
// Rides this buffer (not bevy's clusterables) because the view layout exposes those to the
|
||
// fragment stage only, and the Gouraud term is evaluated in the VERTEX stage.
|
||
point_count: vec4<f32>,
|
||
points: array<vec4<f32>, 512>,
|
||
// The interior-prop SH probe table (lighting::prop_probes — 7 rows per slot, 8192 slots; keep in
|
||
// sync with MAX_PROP_PROBES): the folded committed light of each lit interior MODD prop. The
|
||
// prop's MeshTag payload is its slot; the interior-prop lane below evaluates rows
|
||
// [7·slot .. 7·slot+7) over the fragment normal. Only this shader declares the region — the
|
||
// other shaders mirror the buffer PREFIX and bind the same (larger) buffer.
|
||
prop_probes: array<vec4<f32>, 57344>,
|
||
// The owned skin palette (decision 0720; rig_palette.rs mirrors both sizes). `rig_table`:
|
||
// one base bone index per rig slot (2048 = mesh_tag's 11-bit rig field; the instance's slot
|
||
// rides its MeshTag bits 19-29). `palettes`: 3 vec4 rows per bone — the rows of
|
||
// `world_from_joint × inverse_bindpose`, the same matrix Bevy's skin lane would feed
|
||
// `skin_model` — blended in the vertex stage below (WOW_RIG_SKIN).
|
||
rig_table: array<u32, 2048>,
|
||
// The per-instance body TINT, on the SAME slot index as `rig_table` (instance_tint.rs, decision
|
||
// 0812): the CM2 `model+0x184/188/18c` modulate colour, packed `0xFFRRGGBB` exactly as the
|
||
// reference packs its node value (`0x60d840`: `param | 0xff000000`). A word of **0 is identity**
|
||
// — so slot 0 (every unskinned instance in the world), a zeroed studio buffer and an untinted
|
||
// frame all cost nothing, while a genuine authored BLACK tint still reads as 0xFF000000.
|
||
rig_tint: array<u32, 2048>,
|
||
palettes: array<vec4<f32>>,
|
||
};
|
||
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
|
||
|
||
// Vanilla M2 cutout alpha-test reference (224/255 on ≤ WotLK) — kept in sync with
|
||
// `debug_panel::VANILLA_ALPHA_KEY_REF`. Used to re-apply the hard cutout on the distance-fade blend
|
||
// twin so its silhouette matches the steady cutout exactly.
|
||
const VANILLA_ALPHA_KEY: f32 = 0.8784314;
|
||
|
||
// 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
|
||
// tangents / morphs / visibility ranges, so those defs stay unset and unmirrored.)
|
||
struct WowVsOut {
|
||
@builtin(position) position: vec4<f32>,
|
||
@location(0) world_position: vec4<f32>,
|
||
@location(1) world_normal: vec3<f32>,
|
||
#ifdef VERTEX_UVS_A
|
||
@location(2) uv: vec2<f32>,
|
||
#endif
|
||
#ifdef VERTEX_UVS_B
|
||
@location(3) uv_b: vec2<f32>,
|
||
#endif
|
||
#ifdef VERTEX_COLORS
|
||
@location(5) color: vec4<f32>,
|
||
#endif
|
||
#ifdef VERTEX_OUTPUT_INSTANCE_INDEX
|
||
@location(6) @interpolate(flat) instance_index: u32,
|
||
#endif
|
||
// The Gouraud-interpolated point-light sum (decision 0278): `Σ att·sat(N·L)·colour` evaluated at
|
||
// the VERTEX, like the reference FFP — the tessellation-scale smoothing IS the authored look
|
||
// (wide floor pools, dim hoods). The fragment folds in the live gain and the saturating clamp.
|
||
@location(8) point_lit: vec3<f32>,
|
||
}
|
||
|
||
// The dynamic point-light term at a world-space point (decisions 0016/0273/0278, selection 0285) —
|
||
// the reference commits AT MOST THREE point lights per draw, the nearest to the RECEIVING UNIT'S OWN
|
||
// position (byte law: the gather `0x71bf90` keeps the nearest by squared distance from the
|
||
// caller-supplied unit position — never the camera, never the vertex — and the commit `0x71c730`
|
||
// seats slots 1-3, dropping the 4th; wow-re `wmo-surface-dynamic-light` §4/§6). Summing the whole
|
||
// table instead lit every candelabra pole in the abbey from a dozen sideways fixtures the real
|
||
// client never commits for it — the director's "stands must not light up with the point gain".
|
||
//
|
||
// So: pass 1 picks the ≤3 nearest table entries to `anchor` (the unit position — a light's packed
|
||
// range bounds candidacy); pass 2 evaluates ONLY those at the vertex — the byte-verified falloff
|
||
// `1/(0.7·d + 0.03·d²)`, diffuse-only (committed ambient/specular are zero), on the SUBMITTED normal
|
||
// (the FFP never enables GL_LIGHT_MODEL_TWO_SIDE — no per-face flip). A selected light reaches every
|
||
// vertex of its unit with no distance cutoff, exactly like a committed GL light — selection pops at
|
||
// unit granularity (the authored vanilla behaviour), never mid-surface. Mirrored in terrain.wgsl.
|
||
fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
|
||
let count = u32(wow_light.point_count.x);
|
||
var sel = array<u32, 3>(0u, 0u, 0u);
|
||
var sd = array<f32, 3>(1e30, 1e30, 1e30);
|
||
for (var i = 0u; i < count; i = i + 1u) {
|
||
let pos_range = wow_light.points[2u * i];
|
||
let dv = pos_range.xyz - anchor;
|
||
let d2 = dot(dv, dv);
|
||
if (d2 > pos_range.w * pos_range.w) {
|
||
continue;
|
||
}
|
||
if (d2 < sd[0]) {
|
||
sd[2] = sd[1]; sel[2] = sel[1];
|
||
sd[1] = sd[0]; sel[1] = sel[0];
|
||
sd[0] = d2; sel[0] = i;
|
||
} else if (d2 < sd[1]) {
|
||
sd[2] = sd[1]; sel[2] = sel[1];
|
||
sd[1] = d2; sel[1] = i;
|
||
} else if (d2 < sd[2]) {
|
||
sd[2] = d2; sel[2] = i;
|
||
}
|
||
}
|
||
var sum = vec3<f32>(0.0);
|
||
for (var s = 0u; s < 3u; s = s + 1u) {
|
||
if (sd[s] > 9.9e29) {
|
||
break;
|
||
}
|
||
let pos_range = wow_light.points[2u * sel[s]];
|
||
let to_light = pos_range.xyz - P;
|
||
let d = length(to_light);
|
||
let atten = 1.0 / (0.7 * d + 0.03 * d * d);
|
||
let nl = max(dot(N, to_light / max(d, 1e-4)), 0.0);
|
||
sum += wow_light.points[2u * sel[s] + 1u].rgb * (atten * nl);
|
||
}
|
||
return sum;
|
||
}
|
||
|
||
// The MCNK chunk cell center under a world point — the light-selection anchor for geometry merged in
|
||
// WORLD space (clutter; terrain.wgsl mirrors this): the reference draws terrain per 33.33-yd MCNK
|
||
// chunk and gathers that unit's lights like any other. Grid constants per the ADT format (chunk =
|
||
// 533.3333/16 yd, world half-extent 32 tiles); WoW x/y are Bevy −z/−x, and the grid is symmetric, so
|
||
// snapping Bevy x/z directly lands on the same cells. Height keeps the vertex's own y (the ref anchors
|
||
// at the chunk record's position; lights sit near the surface, so the horizontal snap is what matters).
|
||
fn mcnk_cell_anchor(P: vec3<f32>) -> vec3<f32> {
|
||
let cell = 533.33333 / 16.0;
|
||
let half = 32.0 * 533.33333;
|
||
let ix = floor((half + P.x) / cell);
|
||
let iz = floor((half + P.z) / cell);
|
||
return vec3<f32>((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half);
|
||
}
|
||
|
||
// The vertex input — bevy 0.18's `forward_io::Vertex` fields at bevy's shader locations (the
|
||
// VERTEX_* defs come from the base mesh-pipeline specialize, driven by what the mesh authors;
|
||
// tangents / morphs never — no model mesh has them), PLUS the owned-palette joint attributes at
|
||
// locations 10/11 under WOW_RIG_SKIN (`WowModelExt::specialize` sets the def and appends the
|
||
// attributes to the buffer layout when the mesh carries `ATTRIBUTE_WOW_JOINT_INDEX` — decision
|
||
// 0720; Bevy's `forward_io::Vertex` only declares joints under its own SKINNED path, which no
|
||
// benilla mesh triggers anymore).
|
||
struct WowVertex {
|
||
@builtin(instance_index) instance_index: u32,
|
||
#ifdef VERTEX_POSITIONS
|
||
@location(0) position: vec3<f32>,
|
||
#endif
|
||
#ifdef VERTEX_NORMALS
|
||
@location(1) normal: vec3<f32>,
|
||
#endif
|
||
#ifdef VERTEX_UVS_A
|
||
@location(2) uv: vec2<f32>,
|
||
#endif
|
||
#ifdef VERTEX_UVS_B
|
||
@location(3) uv_b: vec2<f32>,
|
||
#endif
|
||
#ifdef VERTEX_COLORS
|
||
@location(5) color: vec4<f32>,
|
||
#endif
|
||
#ifdef WOW_RIG_SKIN
|
||
@location(10) joint_indices: vec4<u32>,
|
||
@location(11) joint_weights: vec4<f32>,
|
||
#endif
|
||
}
|
||
|
||
#ifdef WOW_RIG_SKIN
|
||
// The owned-palette skin model (decision 0720): the instance's rig slot from its MeshTag rig
|
||
// field (bits 19-29) → the rig's base bone index → the four indexed bones' palette rows blended
|
||
// by the vertex weights. Returns `world_from_local` exactly like Bevy's `skin_model` — the rows
|
||
// ARE `world_from_joint × inverse_bindpose`, so the reconstruction below is the same matrix
|
||
// Bevy's lane produced, and it REPLACES the mesh's world matrix (never composes with it).
|
||
fn wow_skin_model(instance_index: u32, indices: vec4<u32>, weights: vec4<f32>) -> mat4x4<f32> {
|
||
let tag = mesh_functions::get_tag(instance_index);
|
||
let base = wow_light.rig_table[(tag >> 19u) & 0x7ffu];
|
||
let b0 = 3u * (base + indices.x);
|
||
let b1 = 3u * (base + indices.y);
|
||
let b2 = 3u * (base + indices.z);
|
||
let b3 = 3u * (base + indices.w);
|
||
let r0 = weights.x * wow_light.palettes[b0]
|
||
+ weights.y * wow_light.palettes[b1]
|
||
+ weights.z * wow_light.palettes[b2]
|
||
+ weights.w * wow_light.palettes[b3];
|
||
let r1 = weights.x * wow_light.palettes[b0 + 1u]
|
||
+ weights.y * wow_light.palettes[b1 + 1u]
|
||
+ weights.z * wow_light.palettes[b2 + 1u]
|
||
+ weights.w * wow_light.palettes[b3 + 1u];
|
||
let r2 = weights.x * wow_light.palettes[b0 + 2u]
|
||
+ weights.y * wow_light.palettes[b1 + 2u]
|
||
+ weights.z * wow_light.palettes[b2 + 2u]
|
||
+ weights.w * wow_light.palettes[b3 + 2u];
|
||
// r0/r1/r2 are the affine's ROWS; a wgsl matrix is column-major.
|
||
return mat4x4<f32>(
|
||
vec4<f32>(r0.x, r1.x, r2.x, 0.0),
|
||
vec4<f32>(r0.y, r1.y, r2.y, 0.0),
|
||
vec4<f32>(r0.z, r1.z, r2.z, 0.0),
|
||
vec4<f32>(r0.w, r1.w, r2.w, 1.0),
|
||
);
|
||
}
|
||
|
||
// bevy_pbr::skinning's normal math verbatim (inverse-transpose via the adjugate), on our matrix.
|
||
fn inverse_transpose_3x3m(in: mat3x3<f32>) -> mat3x3<f32> {
|
||
let x = cross(in[1], in[2]);
|
||
let y = cross(in[2], in[0]);
|
||
let z = cross(in[0], in[1]);
|
||
let det = dot(in[2], z);
|
||
return mat3x3<f32>(x / det, y / det, z / det);
|
||
}
|
||
|
||
fn wow_skin_normals(world_from_local: mat4x4<f32>, normal: vec3<f32>) -> vec3<f32> {
|
||
return normalize(
|
||
inverse_transpose_3x3m(mat3x3<f32>(
|
||
world_from_local[0].xyz,
|
||
world_from_local[1].xyz,
|
||
world_from_local[2].xyz
|
||
)) * normal
|
||
);
|
||
}
|
||
#endif
|
||
|
||
// Custom vertex stage — bevy 0.18's `mesh.wgsl` vertex verbatim (VERTEX_* attributes; morph
|
||
// targets omitted — no model mesh authors them) with the owned-palette skinning in place of
|
||
// Bevy's SKINNED path (decision 0720), plus the per-vertex point-light evaluation on the
|
||
// post-skin world position/normal. A `MaterialExtension` swaps the whole stage, so the mirror
|
||
// must track bevy's on upgrades.
|
||
@vertex
|
||
fn vertex(vertex: WowVertex) -> WowVsOut {
|
||
var out: WowVsOut;
|
||
|
||
let mesh_world_from_local = mesh_functions::get_world_from_local(vertex.instance_index);
|
||
#ifdef WOW_RIG_SKIN
|
||
var world_from_local = wow_skin_model(
|
||
vertex.instance_index,
|
||
vertex.joint_indices,
|
||
vertex.joint_weights
|
||
);
|
||
#else
|
||
var world_from_local = mesh_world_from_local;
|
||
#endif
|
||
|
||
#ifdef VERTEX_NORMALS
|
||
#ifdef WOW_RIG_SKIN
|
||
out.world_normal = wow_skin_normals(world_from_local, vertex.normal);
|
||
#else
|
||
out.world_normal = mesh_functions::mesh_normal_local_to_world(
|
||
vertex.normal,
|
||
vertex.instance_index
|
||
);
|
||
#endif
|
||
#endif
|
||
|
||
#ifdef VERTEX_POSITIONS
|
||
out.world_position =
|
||
mesh_functions::mesh_position_local_to_world(world_from_local, vec4<f32>(vertex.position, 1.0));
|
||
out.position = position_world_to_clip(out.world_position.xyz);
|
||
// WMO authored batch order (`m.sun_scale.y`; 0 = non-WMO ⇒ exact no-op): the client resolves
|
||
// coplanar batches (wall + decal/trim) by strict MOBA draw order under depth-write + LEQUAL
|
||
// (wow-5875-re `wmo-batch-blend-depth-state.md`, byte-verified); Bevy orders draws for
|
||
// batching, so a later batch must instead WIN the reverse-Z GreaterEqual test. Scaling clip z
|
||
// by (1 + n·2⁻²³) raises the interpolated depth z/w by exactly n ULP-steps per fragment —
|
||
// the same one-unit-per-index nudge the old fixed-function `DepthBiasState` constant applied,
|
||
// 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;
|
||
#endif
|
||
|
||
#ifdef VERTEX_UVS_A
|
||
out.uv = vertex.uv;
|
||
#endif
|
||
#ifdef VERTEX_UVS_B
|
||
out.uv_b = vertex.uv_b;
|
||
#endif
|
||
|
||
#ifdef VERTEX_COLORS
|
||
out.color = vertex.color;
|
||
#endif
|
||
|
||
#ifdef VERTEX_OUTPUT_INSTANCE_INDEX
|
||
out.instance_index = vertex.instance_index;
|
||
#endif
|
||
|
||
// Dynamic point lights, by receiver class (wow-re trace-forensics-abbey-interior-d3d §2/§4):
|
||
// WMO surfaces take NONE (zero point lights on every observed WMO surface batch — the earlier
|
||
// "point-lit abbey wall" was a mis-identified unit draw), and interior M2 props take none HERE
|
||
// (their group-MOLR point lobes are folded into the per-instance SH probe). Everything else —
|
||
// exterior doodads, entities, clutter — keeps the FFP ≤3-nearest selection: clutter (merged in
|
||
// world space) anchors at its MCNK chunk cell (the terrain draw unit it belongs to), every M2
|
||
// at its INSTANCE origin (wow-re wmo-surface-dynamic-light §6 — the receiving unit's own
|
||
// position, deliberately not the skinned per-vertex matrix).
|
||
if (m.model_flags.x > 0.5 || m.model_flags.z > 0.5) {
|
||
out.point_lit = vec3<f32>(0.0);
|
||
} else {
|
||
var anchor = mesh_world_from_local[3].xyz;
|
||
if (m.clutter_fade.w > 0.5) {
|
||
anchor = mcnk_cell_anchor(out.world_position.xyz);
|
||
}
|
||
out.point_lit = point_light_sum(out.world_position.xyz, out.world_normal, anchor);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
@fragment
|
||
fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutput {
|
||
// HARD FAR-CLIP WALL (faithful `farclip` ~777 yd) — see terrain.wgsl. Per-pixel discard beyond the
|
||
// projection far plane (planar eye-Z), so distant buildings/trees reveal closest-part-first and the
|
||
// sky/WDL shows behind. `wow_light.fog_params.w` = farclip (0 ⇒ disabled). Clutter (≤70 yd) never hits it.
|
||
if (wow_light.fog_params.w > 0.0) {
|
||
let clip_z = -(view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).z;
|
||
if (clip_z > wow_light.fog_params.w) {
|
||
discard;
|
||
}
|
||
}
|
||
// 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.
|
||
var vo: VertexOutput;
|
||
vo.position = in.position;
|
||
vo.world_position = in.world_position;
|
||
vo.world_normal = in.world_normal;
|
||
#ifdef VERTEX_UVS_A
|
||
vo.uv = in.uv + m.sun_scale.zw;
|
||
#endif
|
||
#ifdef VERTEX_UVS_B
|
||
vo.uv_b = in.uv_b;
|
||
#endif
|
||
#ifdef VERTEX_COLORS
|
||
vo.color = in.color;
|
||
#endif
|
||
#ifdef VERTEX_OUTPUT_INSTANCE_INDEX
|
||
vo.instance_index = in.instance_index;
|
||
#endif
|
||
var pbr_input = pbr_input_from_standard_material(vo, is_front);
|
||
// Ground-clutter distance fade: multiply the cutout alpha by the camera-distance ramp BEFORE the
|
||
// alpha test, so distant detail doodads erode out by `clutter_fade.y` yd (the client's ~70-yd
|
||
// horizon). Disabled (no-op) for normal models where `clutter_fade.w == 0`. This is a draw-distance
|
||
// concern, not lighting, so it survives the Phase-0 strip.
|
||
var base_color = pbr_input.material.base_color;
|
||
// Interior WMO batches carry LIGHTING data in the MOCV ALPHA — TRANS (tint.w == 2) the
|
||
// lit↔bake lerp factor, INT (tint.w == 1) the ×4 self-illumination mask. Bevy pre-folds
|
||
// ATTRIBUTE_COLOR (rgba) into base_color, so un-fold the alpha before any cutout test —
|
||
// coverage stays the texel alpha, exactly the reference's interior pixel shader
|
||
// (its output alpha is tex.a; MOCV.a never reaches coverage).
|
||
#ifdef VERTEX_COLORS
|
||
if (m.model_flags.x > 0.5 && m.tint.w > 0.5) {
|
||
base_color.a = base_color.a / max(in.color.a, 1.0 / 255.0);
|
||
}
|
||
#endif
|
||
// Ground-clutter distance fade: multiply the cutout alpha by the camera-distance ramp BEFORE the
|
||
// alpha test, so distant detail doodads ERODE out by `clutter_fade.y` yd (the client's ~70-yd
|
||
// horizon — clutter's own faithful alpha-test fade). No-op where `clutter_fade.w == 0`. Distinct
|
||
// from the world-doodad fade below; this stays before the test, that one does not.
|
||
if (m.clutter_fade.w > 0.5) {
|
||
let d = distance(view.world_position.xyz, in.world_position.xyz);
|
||
let f = clamp((m.clutter_fade.y - d) / max(m.clutter_fade.y - m.clutter_fade.x, 0.001), 0.0, 1.0);
|
||
base_color.a = base_color.a * f;
|
||
}
|
||
// Faithful per-object WORLD-DOODAD distance fade (`FUN_00683f80`/`model_fade.rs`): the fade alpha
|
||
// (1.0 = opaque) rides in the per-instance `MeshTag`; tag 0 (clutter/WMO) ⇒ 1.0 no-op.
|
||
// VERIFIED reference behaviour (`RECONCILE-fade-render-state.md`): a fading doodad is the SAME draw
|
||
// as the steady cutout — the alpha-test ref scales WITH the fade so the effective cutoff stays a
|
||
// constant `tex0.a < 224/255` (STABLE silhouette, never grows/snaps) — with blend on and source
|
||
// alpha = `tex0.a × fade`. On the blend twin `AlphaMode::Blend` does no discard, so we re-apply
|
||
// that hard cutout here on the UNFADED alpha — but ONLY for a twin whose SOURCE batch alpha-tests
|
||
// (clutter_fade.z bit 10, model_render's TWIN_CUTOUT_MARKER): the reference keys ALPHAREF on the
|
||
// STORED blend mode (m2-blend-promotion-zfill.md §2), so a fading/stealthed OPAQUE batch blends
|
||
// with no alpha test at all. Keying the discard on the twin bit itself cut every texel under
|
||
// 224/255 out of promoted Opaque batches — Gressil's blade body erased to its rune pattern under
|
||
// stealth (decision 0842). `specialize` keeps depth-write ON for every twin (`model_flags.y`).
|
||
// The payload is TYPED (mesh_tag.rs, decisions 0173/0720): bits 0-5 = the fade alpha (6-bit
|
||
// fraction; a whole payload of 0 = the untagged ⇒ opaque sentinel), bits 19-29 = the skin
|
||
// rig slot (vertex-stage concern — the fragment never reads it, but it rides every payload,
|
||
// so the 0-sentinel test uses the WHOLE masked payload as before). Between them the exterior
|
||
// payload carries the per-instance ground-shade byte in bits 6-13 (0 lit → 255 MCSH-shadowed
|
||
// — decoded at the doodad sun below; entities ramp it, statics leave 0). On an interior-mode
|
||
// material (interior z, not WMO x) bits 6-18 carry the SH-probe SLOT instead — static MODD
|
||
// props at spawn, and every indoor entity on the footprint-bake law (decision 0354: units
|
||
// keep the probe lane indoors; the day/night state is the exterior material at the
|
||
// intensity-1.0 shade byte, not a mode of its own).
|
||
// Tag bits 31/30 are standalone flags (mesh_tag.rs), split off before the payload decode so the
|
||
// 0-sentinel and both payload modes read the masked value: bit 31 = hover/target HIGHLIGHT,
|
||
// bit 30 = INTERIOR FOG — the instance's model stands in a WMO interior, so it fogs with the
|
||
// interior triple below (the reference stages unit fog by the unit's own classification,
|
||
// wow-re m2-unit-interior-fog.md).
|
||
let interior_prop = m.model_flags.z > 0.5 && m.model_flags.x < 0.5;
|
||
let raw_tag = mesh_functions::get_tag(in.instance_index);
|
||
let highlighted = (raw_tag & 0x80000000u) != 0u;
|
||
let interior_fogged = (raw_tag & 0x40000000u) != 0u;
|
||
// Bits 0-5 are the fade alpha in BOTH payload modes, so a feathering indoor entity keeps its
|
||
// probe AND its alpha ramp — and a skinned part keeps its rig slot through either.
|
||
let fade_tag = raw_tag & 0x3fffffffu;
|
||
let alpha6 = f32(fade_tag & 0x3fu) / 63.0;
|
||
let obj_fade = select(alpha6, 1.0, fade_tag == 0u);
|
||
// The per-instance body TINT (instance_tint.rs, decision 0812) — the aura state kit's CharProc 1:
|
||
// an aura painting the whole model one colour (ghost pale blue, poison green, Frostbolt blue).
|
||
// Indexed by the SAME rig slot the vertex stage skins from (bits 19-29), so it needs no tag bits
|
||
// of its own; a `0` word is identity, which is every unskinned instance (slot 0) and every
|
||
// untinted unit. It is the material's ambient+diffuse colour (gx SetState(1) →
|
||
// GL_COLOR_MATERIAL), so it multiplies the light sum INSIDE the clamp below and never the
|
||
// emission terms — the same placement the WMO branch already gives MOCV.
|
||
let tint_word = wow_light.rig_tint[(fade_tag >> 19u) & 0x7ffu];
|
||
let inst_tint = select(
|
||
vec3<f32>(
|
||
f32((tint_word >> 16u) & 0xffu),
|
||
f32((tint_word >> 8u) & 0xffu),
|
||
f32(tint_word & 0xffu),
|
||
) * (1.0 / 255.0),
|
||
vec3<f32>(1.0),
|
||
tint_word == 0u,
|
||
);
|
||
if ((u32(m.clutter_fade.z) & 1024u) != 0u && base_color.a < VANILLA_ALPHA_KEY) {
|
||
discard;
|
||
}
|
||
// DEPTH-PRIME TWIN (the zfill pipeline variant — terrain.rs `specialize`, the reference's
|
||
// M2UseZFill clone command, wow-re m2-blend-promotion-zfill.md §4): colour writes are masked
|
||
// off at the pipeline, so on that variant only the discards above (farclip wall, the
|
||
// bit-10 twin cutout) shape what depth gets written; the lighting/fog below is computed and
|
||
// thrown away. (A shader-def early return here would be the natural spelling, but naga's MSL
|
||
// backend miscompiles the dead tail — "redefinition of '_tmp'" — so the twin pays the colour
|
||
// math instead. Episodes are transient; the cost is bounded.)
|
||
// Blend source alpha = texel alpha × fade (translucent fade of the fixed cutout shape). For steady
|
||
// cutout/opaque draws blend is off so this is ignored; for the fade twin it drives the feather.
|
||
let faded_alpha = base_color.a * obj_fade;
|
||
// Steady cutout (Mask) / opaque discard per the StandardMaterial alpha mode (no-op for the blend twin).
|
||
let base = alpha_discard(pbr_input.material, base_color);
|
||
|
||
// --- STEPS 7+8d: matte lighting, split on clutter ---------------------------------------------
|
||
// M2/WMO meshes get the same directional matte lighting as terrain — `lit = clamp(ambient +
|
||
// diffuse·max(N·L,0))`, where N is the model's authored vertex normal and L is the Bevy-space
|
||
// sun travel dir (to-light = `−light_sun`).
|
||
//
|
||
// **Clutter is lit by the GROUND normal under each tuft** (Step 8d). VERIFIED faithful: WoW's
|
||
// CreateDetailDoodads computes the terrain quadrant-plane normal under the tuft and writes it onto
|
||
// the clutter vertex's normal channel (docs/knowledge/ground-effects.md), and the reference's
|
||
// clutter draw (apitrace WoW.5, prog 186 / alpha-ref 128/255) lights per-vertex with
|
||
// `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 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);
|
||
// Bevy negates `world_normal` on the back faces of any DOUBLE-SIDED material — two-sided foliage
|
||
// cross-quads (grass tufts, leaf cards) AND every WMO group face (our WMO loader marks all WMO
|
||
// submeshes two-sided, models.rs). The reference has NO such per-face negation: WoW sets the GL
|
||
// lighting model once (`FUN_0059ce30`) and NEVER enables `GL_LIGHT_MODEL_TWO_SIDE`, so BOTH faces of
|
||
// every polygon — clutter, M2 doodad, WMO group — are lit from the SAME submitted normal. So we
|
||
// un-flip Bevy's negation here and light EVERY path below from `n_lit`; the raw `n_m2` is never used
|
||
// for lighting directly. For single-sided materials back faces are culled ⇒ `is_front` always true
|
||
// ⇒ `n_lit == n_m2` (no-op). Universal "two-side-off" fix → no view-dependent lit/unlit seam, on
|
||
// M2 foliage (doodad matte path) OR WMO group geometry (the FFP N·L path below) (foliage.md).
|
||
let n_lit = select(-n_m2, n_m2, is_front);
|
||
let ndotl = max(dot(n_lit, L), 0.0);
|
||
let lit_nl = clamp(wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl, vec3<f32>(0.0), vec3<f32>(1.0));
|
||
// The order-2 SH basis products over the fragment normal — shared by the global exterior eval
|
||
// below and the interior-prop probe lane further down.
|
||
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: **the reference's own response** —
|
||
// `clamp01(A + C·I·(4/17)(0.375 + 2μ + 1.875μ²))`, μ = N·u toward-light, the order-2
|
||
// `Model2.bls` lobe evaluated over the SH rows above (0803; read that record and 0796 before
|
||
// touching this lane).
|
||
//
|
||
// The long way round, because the scar tissue matters: 0410 replaced this curve with a
|
||
// hard-cutoff FFP matte on the director's look call — a sunlit character's shadow side must read
|
||
// the SAME as the same skin standing in shade, and the lobe's authored wrap (0.059·C on the
|
||
// anti-sun side, dipping −0.037·C mid-back) tinted it warm and lifted it. 0753 then read the
|
||
// reference's own apitrace as CORROBORATING the cutoff (an FFP light slot, "no SH constants
|
||
// anywhere in the trace"). **0796 refuted that**: wow-re's §5 cross-check shows the reference's
|
||
// M2 lane — ADT doodad, GameObject, creature, player alike — is its own authored `Model2.bls`
|
||
// vertex shader running order-2 SH, and the FFP light commits visible in a world frame belong to
|
||
// TERRAIN (`0x71c730`'s five `0x68xxxx` call sites) and the WMO path. M2's single FFP call site
|
||
// (`70bdf6`) is reachable only with `M2UseShaders` off. "No SH constants" was a measurement
|
||
// artifact — constants upload as dirty-range deltas whose start register varies. That left 0410
|
||
// standing as a look preference against a verified curve, so 0799 put the two behind an A/B and
|
||
// 0803 is the director choosing the curve. The cutoff branch left with the flag.
|
||
//
|
||
// The CPU light animator targets the per-instance intensity `[node+0xa4]` — 2.5 on lit ground
|
||
// / 0.5 on MCSH-shadowed ground / 1.0 on the interior/WMO-prop leg (`0x69e4ad`, `0x69e280`;
|
||
// the force-1.0 leg is `0x69e36b`). Lane history: `Model2.bls` SH lobe (0354/0358) → FFP
|
||
// matte, hard cutoff (0410, director's call) → unclamped source (0706) → verified curve
|
||
// (0747) → peak-norm (0750) → calibration dial (0751) → the 0753 trace law → 0796 (kept the
|
||
// pixels, demoted the justification) → 0799 (the A/B) → 0803, back on the curve for good.
|
||
//
|
||
// 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 — animator target 2.5, mixed toward
|
||
// 0.5 by the per-instance tag shade byte, which units/players/GameObjects ramp CPU-side like
|
||
// the binary's `0x69e770`; statics leave it 0), 0.5..0.85 = fixed intensity 1.0 (an exterior
|
||
// WMO MODD prop — the 2.5 site is one a MODD prop never reaches, §8b), <0.5 = statically
|
||
// MCSH-shadowed (0.5). The ramp runs in animator units and the COMMIT clamps.
|
||
//
|
||
// **`min(I, 1)` is OURS, and it is the one unfaithful term in this lane (0803 §3, 0814, 0821).**
|
||
// The reference does not cap the gain. On the VS/SH lane — the default config, and the lane this
|
||
// shader IS — `0x71c4e0` bakes `[node+0xa4]` straight into the SH moments with no clamp; on the
|
||
// FFP lane `0x71c730`→`0x71ca80` clamps the **product** `D × I` (by max-channel, preserving hue),
|
||
// never the multiplier. So the cap below is a benilla choice, and it costs us twice:
|
||
//
|
||
// 1. **Brightness.** A lit ADT doodad commits ×1.0 where the reference gives ×2.5, so doodads
|
||
// and characters in sun read dimmer than the reference. Still open — lifting it pushes a
|
||
// sun-facing surface well past 1.0 (with an over-gamut sun, into green as well), which is a
|
||
// world-wide look change and the director's call, not one to self-grade.
|
||
// 2. **Timing — fixed CPU-side (0821).** Because the cap sits on the multiplier, every target
|
||
// from 2.5 down to 1.0 renders identically, so a unit ramping 2.5 → 0.5 spent its first
|
||
// 0.45 s (75 % of the chase) invisibly pinned at 1.0 and then dropped in 0.15 s. It read as a
|
||
// dead pause then a snap — what the director reported walking sun → shade.
|
||
// `entity_shade::LIT_T` now aims the LIT target at the value this cap can actually show
|
||
// (1.0), so the chase moves only through visible range, at the reference's own
|
||
// 3.3333 intensity-units/s. The cap is a backstop here, not the thing the ramp fights.
|
||
//
|
||
// Two faces of one bug, and they unwind together: **cut `min(I, 1)` and `LIT_T` goes back to 0.0**
|
||
// so the full 2.5 → 0.5 sweep becomes visible on its own. Do not cut one alone. (Units are on this
|
||
// chain again — 0809's flat ×1.0 pin was wrong and 0814 reverted it; the null fallback that
|
||
// motivated it is real, but it is a lifecycle state we do not model.)
|
||
let inst_shade = select(f32((fade_tag >> 6u) & 0xffu) / 255.0, 0.0, interior_prop);
|
||
let mat_shade = select(0.0, 1.0, m.sun_scale.x < 0.5);
|
||
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 = min(select(mix(2.5, 0.5, shade_t), 1.0, mid_band), 1.0);
|
||
// The lobe. Same closed form the interior-prop lane below runs and the same one
|
||
// `pack_model_core_rows` folds. Every sun band is linear in the committed colour, so ONE
|
||
// `intensity` multiply covers the whole lobe (never I²); ambient rides the c10 `.w` lanes and
|
||
// does NOT scale with it, and the sun's DC redistribution rides `grade.yzw`.
|
||
let sun_dc = wow_light.grade.yzw * intensity;
|
||
let sun_lobe = vec3<f32>(
|
||
wow_light.sh_c10_r.w + sun_dc.x
|
||
+ intensity
|
||
* (dot(wow_light.sh_c10_r.xyz, n_lit) + dot(wow_light.sh_c13_r, quad)
|
||
+ wow_light.sh_c16.x * x2y2),
|
||
wow_light.sh_c10_g.w + sun_dc.y
|
||
+ intensity
|
||
* (dot(wow_light.sh_c10_g.xyz, n_lit) + dot(wow_light.sh_c13_g, quad)
|
||
+ wow_light.sh_c16.y * x2y2),
|
||
wow_light.sh_c10_b.w + sun_dc.z
|
||
+ intensity
|
||
* (dot(wow_light.sh_c10_b.xyz, n_lit) + dot(wow_light.sh_c13_b, quad)
|
||
+ wow_light.sh_c16.z * x2y2),
|
||
);
|
||
// clamp01 the SUM, never per term — the lobe dips to −0.037·C around μ≈−0.53 (low-order-SH
|
||
// ringing, the reference's own authored response), and clamping the sun term alone would floor
|
||
// that away and erase the mid-back dip. That dip is part of the response 0803 chose, not noise
|
||
// to tidy up.
|
||
let lit_doodad = clamp(sun_lobe, vec3<f32>(0.0), vec3<f32>(1.0));
|
||
// WMO buildings (model_flags.x) use the FFP directional N·L at sun-scale 1.0 — the reference lights them
|
||
// with `ambient + sun·max(N·L,0)` and does NOT apply the exterior doodad terrain-shade (verified: prog
|
||
// 198/VS 151). Their per-vertex MOCV shade rides in `base_color` (ATTRIBUTE_COLOR) → folds into `albedo`,
|
||
// giving tex × MOCV × lit. Exterior world doodads (not clutter, not WMO) take the terrain-shaded matte;
|
||
// clutter uses its own ground-normal matte. `light_sun.w` is the directional-enable flag (on outdoors).
|
||
let is_wmo = m.model_flags.x > 0.5;
|
||
let is_interior = m.model_flags.z > 0.5;
|
||
let use_doodad_shade = (wow_light.light_sun.w > 0.5) && !is_clutter && !is_wmo;
|
||
let lit_exterior = select(lit_nl, lit_doodad, use_doodad_shade);
|
||
// WMO INTERIOR surfaces (model_flags.z; groupFlags & 0x48 == 0) — by BATCH CLASS (tint.w; wow-re
|
||
// trace-forensics-abbey-interior-d3d §2, observed on the abbey at close range):
|
||
// INT (tint.w = 1): UNLIT — the draw is pure `tex × MOCV`; the baked vertex colours (the
|
||
// artists' lamp/forge/hearth/candle warmth) ARE the room's light, constant day and night.
|
||
// No exterior light, no point lights (the reference commits zero to any WMO surface).
|
||
// TRANS (tint.w = 2): the per-vertex MOCV-ALPHA LERP between the day/night-lit surface and
|
||
// that unlit bake — the reference's two-pass (lit × SRC_ALPHA + unlit × (1−SRC_ALPHA))
|
||
// collapsed to one pass: `mix(1, extLit, MOCV.a)` as the lit factor.
|
||
// EXT (tint.w = 0): an interior group's exterior-law batches — plain `lit_nl`.
|
||
//
|
||
// WINDOW (MOMT 0x20, m.sidn.w) — interior drawer only: the batch's lit lanes swap GL_LIGHT0 to
|
||
// the brighter interior pair, ambient AND diffuse = the MIDPOINT of the Direct (sun diffuse) and
|
||
// Ambient bands, ambient +16/255 saturating (wow-re wmo-interior-night-light §2, 0x6d37e0). It
|
||
// folds the warm Direct band in at full weight, so an interior pane reads bright and warm in
|
||
// daylight instead of taking the flat exterior ambient — and still tracks time of day. The
|
||
// exterior drawer has no WINDOW machinery, so exterior-group batches keep plain lit_nl.
|
||
var trans_a = 1.0;
|
||
#ifdef VERTEX_COLORS
|
||
trans_a = in.color.a;
|
||
#endif
|
||
let window_mid = 0.5 * (wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb);
|
||
let lit_window = clamp(
|
||
window_mid + vec3<f32>(16.0 / 255.0) + window_mid * ndotl,
|
||
vec3<f32>(0.0),
|
||
vec3<f32>(1.0),
|
||
);
|
||
let lit_int_base = select(lit_nl, lit_window, m.sidn.w > 0.5);
|
||
var lit_wmo_interior = vec3<f32>(1.0);
|
||
if (m.tint.w > 1.5) {
|
||
lit_wmo_interior = mix(vec3<f32>(1.0), lit_int_base, trans_a);
|
||
} else if (m.tint.w < 0.5) {
|
||
lit_wmo_interior = lit_int_base;
|
||
}
|
||
// INTERIOR M2 PROPS — WMO MODD doodads only (inn kegs/tables/candelabra; is_interior but NOT
|
||
// is_wmo). The real client fills the prop's base ONCE at create from the MODD entry's own baked
|
||
// colour (never a footprint sample — that chain is the ADT-MDDF path) and commits it with the
|
||
// fixed-axis diffuse lobe + the owning group's MOLR point lobes as an order-2 SH probe the
|
||
// vertex shader evaluates (wow-re trace-forensics-abbey-interior-d3d §1, decoded live off the
|
||
// abbey stands to ~1e-7). benilla folds the identical closed form at spawn
|
||
// (`lighting::prop_probe_coeffs`) into the per-instance probe table; the MeshTag payload is the
|
||
// slot. Evaluated here per fragment over the same basis — note the SH lobe's soft wrap (side-on
|
||
// ≈ 0.088·C) is the reference's authored response, deliberately NOT a hard max(N·L,0).
|
||
// Units/GameObjects never reach this lane (base CGLight — plain day/night ×1.0, §8/§9).
|
||
let probe = 7u * ((fade_tag >> 6u) & 0x1fffu);
|
||
let lit_m2_interior = clamp(
|
||
vec3<f32>(
|
||
dot(wow_light.prop_probes[probe + 0u], n1)
|
||
+ dot(wow_light.prop_probes[probe + 3u], quad)
|
||
+ wow_light.prop_probes[probe + 6u].x * x2y2,
|
||
dot(wow_light.prop_probes[probe + 1u], n1)
|
||
+ dot(wow_light.prop_probes[probe + 4u], quad)
|
||
+ wow_light.prop_probes[probe + 6u].y * x2y2,
|
||
dot(wow_light.prop_probes[probe + 2u], n1)
|
||
+ dot(wow_light.prop_probes[probe + 5u], quad)
|
||
+ wow_light.prop_probes[probe + 6u].z * x2y2,
|
||
),
|
||
vec3<f32>(0.0),
|
||
vec3<f32>(1.0),
|
||
);
|
||
let lit_interior = select(lit_m2_interior, lit_wmo_interior, is_wmo);
|
||
// AUTHORED-RIG lane (`ShadeSel::Rig`, sun_scale.x = 2.0 — the glue create booth, decision
|
||
// 0429): the lit value is the probe-slot SH eval — the scene M2's ambient + directional
|
||
// lights, folded into slot 0 of the material's OWN buffer (booth instances carry tag 0, so
|
||
// `lit_m2_interior` above already evaluated exactly that probe). No sun, no intensity family,
|
||
// no day/night — a glue scene's light is entirely its authored rig. Rig materials are neither
|
||
// WMO nor interior, so the per-vertex point term (the rig's authored point lights) flows in
|
||
// through `point_diffuse` below like any exterior entity.
|
||
let is_rig = m.sun_scale.x >= 1.5;
|
||
let lit = select(select(lit_exterior, lit_interior, is_interior), lit_m2_interior, is_rig);
|
||
// Gamma-space albedo — the lane (0161): the buffer holds bytes, lighting math runs on the
|
||
// authored values. (The old fog_params.z linear-space A/B is dead — settled by the lane.)
|
||
// `m.tint` is the animated M2Color RGB (identity 1 for static batches) — the same per-batch
|
||
// tint the vertex colours carry for constant tracks, so it folds at the same point.
|
||
let albedo = base.rgb * m.tint.rgb;
|
||
// Unlit fullbright (model_flags.w): M2 UNLIT (0x01) glass/glow cards, or WMO UNLIT on an
|
||
// exterior-group batch (`tex × white` — the inn's always-lit outside panes). Wins over the lit
|
||
// path; faithfully receives NO emission terms (lighting is off, so GL_EMISSION is dead there).
|
||
let is_emissive = m.model_flags.w > 0.5;
|
||
// SIDN night glow (MOMT 0x10, m.sidn.rgb): the authored emissive × the live night fraction
|
||
// (grade.x — 1 overnight, 0 all day, ramps 20:30→21:30 / 06:00→07:00). A GL material EMISSION
|
||
// term, so it adds INSIDE the clamped lit sum (tex × (lit + sidn·night)) and reaches LIT lanes
|
||
// only: exterior-drawer lit batches and an interior group's EXT lane at full weight, TRANS by
|
||
// its lit-pass weight (MOCV.a), and never the unlit INT lane (wow-re wmo-interior-night-light
|
||
// §4, wmo-lit-selector §1.3). Zero for every M2 batch.
|
||
var sidn_w = 1.0;
|
||
if (is_interior && is_wmo) {
|
||
if (m.tint.w > 1.5) {
|
||
sidn_w = trans_a; // TRANS: emission rides the lit pass A, weighted by the lerp
|
||
} else if (m.tint.w > 0.5) {
|
||
sidn_w = 0.0; // INT: lighting off — the emissive write is dead, like the FFP
|
||
}
|
||
}
|
||
let sidn_e = m.sidn.rgb * (wow_light.grade.x * sidn_w);
|
||
// WoW dynamic point lights (decisions 0016/0273/0278, selection 0285) — exterior doodads,
|
||
// entities, clutter, and terrain receive their unit's committed lights: the ≤3 NEAREST to the
|
||
// receiving unit's own position, never the whole scene (`point_light_sum`). WMO surfaces take
|
||
// ZERO (observed on every WMO surface batch in the abbey capture) and interior props fold their
|
||
// group-MOLR lobes into the SH probe instead — both zeroed in the VERTEX stage. The term
|
||
// arrives GOURAUD-INTERPOLATED — per-vertex like the reference FFP, whose tessellation-scale
|
||
// smoothing is the authored look. Diffuse-only (committed ambient/specular are zero).
|
||
let point_diffuse = in.point_lit;
|
||
|
||
// Hover/target model brighten (tag bit 31): the client's per-model highlight emissive —
|
||
// `glMaterialfv(GL_EMISSION, +64/255)` per channel (shipped config default 0xff404040), verified
|
||
// wow-re selection-circle PART 2. GL_EMISSION adds INSIDE the lighting sum, which is clamped [0,1]
|
||
// BEFORE the texture modulate — darks lift toward fully-lit, already-bright spots saturate. It
|
||
// rides the lighting equation, so the fullbright/UNLIT path below faithfully never receives it.
|
||
let highlight = select(0.0, 0.2509804, highlighted);
|
||
// FFP combine (byte + trace verified, decision 0273): the LIGHT SUM — matte base + point lights +
|
||
// the highlight emission — saturates per fragment FIRST, and the texture modulates the clamped
|
||
// result, so a surface never exceeds its own fully-lit texture (a fixture light at zero distance
|
||
// drives the prop to tex×1, not past it — the old `clamp(albedo·sum)` order blew emitter props to
|
||
// saturated gold). WMO surfaces fold their vertex colour INSIDE the clamp (GL_COLOR_MATERIAL:
|
||
// MOCV is the material ambient+diffuse — `tex × clamp(MOCV·sum + emission)`), so a strong fixture
|
||
// light overdrives a dim bake toward the full texture exactly like the reference. Bevy pre-folds
|
||
// ATTRIBUTE_COLOR into `base`, so un-fold it with a guarded divide (a dim channel's product is ~0
|
||
// either way). At zero point contribution every factor is ≤1 and both forms collapse to the old
|
||
// product — the approved interior/exterior base looks are preserved bit-for-bit.
|
||
var lit_rgb: vec3<f32>;
|
||
#ifdef VERTEX_COLORS
|
||
if (is_wmo) {
|
||
let vc = in.color.rgb;
|
||
// MOCV multiplies the lit terms (GL_COLOR_MATERIAL) but NOT the emission terms — SIDN and
|
||
// the highlight add alongside, exactly the FFP's material-emission placement.
|
||
let primary = clamp(
|
||
vc * (lit + point_diffuse) + sidn_e + vec3<f32>(highlight),
|
||
vec3<f32>(0.0),
|
||
vec3<f32>(1.0),
|
||
);
|
||
let tex_rgb = base.rgb / max(vc, vec3<f32>(1.0 / 255.0));
|
||
lit_rgb = tex_rgb * m.tint.rgb * primary;
|
||
if (is_interior && m.tint.w > 0.5 && m.tint.w < 1.5) {
|
||
// INT: the MOCV-ALPHA SELF-ILLUMINATION term. The reference's interior pixel shader is
|
||
// `tex·MOCV.rgb·(1 + 4·MOCV.a)` with only the framebuffer's final [0,1] clamp — read
|
||
// off the client's own D3D pixel shader in the Goldshire-inn trace (literal 4.0 in the
|
||
// source, no lights referenced), so the glow multiplies the FULL product and may
|
||
// overdrive it to white, never pre-clamped like the FFP light sum above. The alpha
|
||
// channel is an authored emissive mask: the inn fireplace surround bakes α≈100 (×2.6),
|
||
// hearths glow, and the FixColorVertexAlpha 255 at interior↔exterior portal seams
|
||
// lifts doorways to full brightness. Near-zero everywhere unpainted (the abbey rooms),
|
||
// where this collapses to the plain tex×MOCV it replaces.
|
||
lit_rgb = clamp(
|
||
tex_rgb * m.tint.rgb * vc * (1.0 + 4.0 * trans_a),
|
||
vec3<f32>(0.0),
|
||
vec3<f32>(1.0),
|
||
);
|
||
}
|
||
} else {
|
||
// An M2 instance: `inst_tint` is its CM2 modulate colour, multiplying the light terms inside
|
||
// the clamp exactly where the WMO branch above multiplies MOCV — both are the material's
|
||
// ambient+diffuse under GL_COLOR_MATERIAL — and never the emission terms beside them. It is
|
||
// identity for everything untinted, so this is the old product bit-for-bit until an aura
|
||
// writes a colour. (Left off the `is_wmo` branch on purpose: a WMO surface is not a CM2
|
||
// instance and has no tint slot of its own.)
|
||
let primary = clamp(
|
||
inst_tint * (lit + point_diffuse) + sidn_e + vec3<f32>(highlight),
|
||
vec3<f32>(0.0),
|
||
vec3<f32>(1.0),
|
||
);
|
||
lit_rgb = albedo * primary;
|
||
}
|
||
#else
|
||
// (`sidn_e` is zero for every M2 batch; a WMO batch without MOCV lands here too and keeps it —
|
||
// harmlessly, since a WMO instance's tint slot is the identity slot 0.)
|
||
let primary = clamp(
|
||
inst_tint * (lit + point_diffuse) + sidn_e + vec3<f32>(highlight),
|
||
vec3<f32>(0.0),
|
||
vec3<f32>(1.0),
|
||
);
|
||
lit_rgb = albedo * primary;
|
||
#endif
|
||
// The fullbright/UNLIT path takes the tint too, unlike the highlight: with GL_LIGHTING off the
|
||
// same gx state (SetState(1)) is a plain `glColor` modulate on the texture, while GL_EMISSION is
|
||
// dead. So a ghost's glow cards tint with its body.
|
||
var rgb = select(lit_rgb, albedo * inst_tint, is_emissive);
|
||
|
||
// Step 5 fog — same gamma-space linear fog as terrain.wgsl. Same DBC values are pushed onto
|
||
// both materials by `apply_wow_lighting`, so a tree and the dirt under it land on the same
|
||
// haze byte at the same distance. Fog coordinate is PLANAR eye-Z (view-space depth), NOT radial
|
||
// distance — see terrain.wgsl for the apitrace-verified rationale (radial over-fogs the edges).
|
||
// The fog COLOUR is per-batch policy (M2 state setter 0x70baf0, wow-re ROUND 4): scene for
|
||
// opaque/alpha, BLACK for additive (the batch fades under the storm veil instead of adding grey
|
||
// — the level-up fix), WHITE for Mod, GREY for Mod2x; policy 4 (render flag 0x02) = unfogged.
|
||
// Encoded in clutter_fade.z bits 4-6.
|
||
// Interior lanes fog with the INTERIOR triple — the room keeps its warm MFOG haze while the
|
||
// storm's veil stays on everything seen through the door. Two routes in: the MATERIAL flag
|
||
// (WMO interior-group batches and their doodad props, m.model_flags.z — round-6 Q-I:
|
||
// `0x6b5190`/`0x6b62e0` push DNState+0x80/84/88) and the per-INSTANCE tag bit 30 (an entity
|
||
// M2 whose model stands in a WMO interior — the reference stages a unit's fog by the unit's
|
||
// own light-node classification, `0x71c110`/`[node+0xc]`, wow-re m2-unit-interior-fog.md;
|
||
// at camera-out the triples are equal, so the bit only diverges inside a fogged WMO).
|
||
// Every other lane inherits the scene fog.
|
||
var fog_color = wow_light.fog_color;
|
||
var fog_span = wow_light.fog_params.xy;
|
||
if (m.model_flags.z > 0.5 || interior_fogged) {
|
||
fog_color = wow_light.wmo_fog_color;
|
||
fog_span = wow_light.wmo_fog_params.xy;
|
||
}
|
||
let fog_policy = (u32(m.clutter_fade.z) >> 4u) & 7u;
|
||
if (fog_color.w > 0.5 && fog_policy != 4u) {
|
||
let eye_z = -(view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).z;
|
||
let denom = max(fog_span.y - fog_span.x, 0.001);
|
||
let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0);
|
||
var fog_rgb = fog_color.xyz;
|
||
if (fog_policy == 1u) { fog_rgb = vec3<f32>(0.0); }
|
||
else if (fog_policy == 2u) { fog_rgb = vec3<f32>(1.0); }
|
||
else if (fog_policy == 3u) { fog_rgb = vec3<f32>(0.50196078); }
|
||
rgb = mix(fog_rgb, rgb, factor);
|
||
}
|
||
|
||
|
||
var out: FragmentOutput;
|
||
// 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
|
||
// batches): their output alpha is spec-meaningless — opaque/mask pipelines ignore it; only a blend
|
||
// pipeline would read it, and none should ever be bound for them. Pinning it to 1.0 is therefore a
|
||
// no-op under correct pipeline state, and armor under the observed multi-view pipeline mixup
|
||
// (macOS/Metal: with an extra camera, some opaque WMO/M2 draws intermittently bind a blending
|
||
// pipeline and bleed the BLP's garbage alpha — the "pale film on buildings"). Fade twins, genuine
|
||
// Blend batches (glass), and additive glow cards keep their real alpha.
|
||
let opaque_intent = (u32(m.clutter_fade.z) & 8u) != 0u;
|
||
// ADDITIVE batches (glow cards — model_flags.w == 2.0): fold the alpha weight into the colour
|
||
// HERE, in gamma space, exactly as the reference's byte pipeline weights its source term
|
||
// (src_g·α added in bytes). The old hardware `SrcAlpha` blend multiplied AFTER the linear
|
||
// conversion — α^(1/2.2) inflation that fattened every soft halo into a hard disc (the
|
||
// director's brazier, decision 0160). The pipeline blend state is now a pure (ONE, ONE) add.
|
||
// The additive marker is clutter_fade.z BIT 2 (the same word specialize keys on — NOT
|
||
// model_flags.w, whose stale "== 2.0" comment caused the flat-square regression the director
|
||
// caught: the gate never fired while the blend state had already become a pure add).
|
||
let is_additive = (u32(m.clutter_fade.z) & 4u) != 0u;
|
||
var out_rgb = rgb;
|
||
if (is_additive) {
|
||
out_rgb = out_rgb * faded_alpha;
|
||
}
|
||
// MULTIPLY batches (Mod bit 7 / Mod2x bit 8 — the ARMORREFLECT sheen family, 0528): their
|
||
// blend equation reads no source alpha, so the instance fade cannot ride the alpha channel —
|
||
// the reference genuinely pops these at full strength through every instanceAlpha ramp
|
||
// (byte-confirmed for items: wow-re `m2-item-texture-fill.md` — the type-3 stage is never
|
||
// bound and draws the flat primary colour, ~white, so Mod2x is a full-strength ×2 layer
|
||
// mid-fade). benilla fades them anyway, a deliberate deviation (decision 0865): lerp the
|
||
// source colour toward the blend IDENTITY — Mod: white (src·dst = dst); Mod2x: 0.5 grey
|
||
// (2·0.5·dst = dst) — by the instance fade `obj_fade` (never the texture alpha). At
|
||
// obj_fade 1 this is exact parity with the steady look; at 0 the layer contributes nothing,
|
||
// so the sheen rides the appear/despawn/stealth/distance ramps like every other batch
|
||
// instead of popping over a body that hasn't faded in.
|
||
let is_mod = (u32(m.clutter_fade.z) & 128u) != 0u;
|
||
let is_mod2x = (u32(m.clutter_fade.z) & 256u) != 0u;
|
||
if (is_mod || is_mod2x) {
|
||
let identity = select(vec3<f32>(1.0), vec3<f32>(0.5), is_mod2x);
|
||
out_rgb = mix(identity, out_rgb, obj_fade);
|
||
}
|
||
// GAMMA LANE (0161): raw gamma out — blending (alpha AND additive) happens in gamma like
|
||
// the reference's byte framebuffer; the frame decodes once in the FFXGlow combine. (The old
|
||
// `lin` A/B emitted linear for the sRGB encode — subsumed by the lane.)
|
||
out.color = vec4<f32>(out_rgb, select(faded_alpha, 1.0, opaque_intent));
|
||
return out;
|
||
}
|