Files
benilla-octo/crates/benilla/assets/shaders/terrain.wgsl
T
SamandClaude Fable 5 3fbfebd604 stealth you can see, item glows, and smooth character-select skies
- stealth reads as stealth now: the body fades to the reference's 30 % and creeps with the proper stealth walk and stand poses
- the item and enchant glow subsystem exists: permanent weapon glows and enchant visuals show in the world and at character select
- the character-select background's colour banding (the reported "16-bit" look) is fixed at the render target, and the tauren stage's black corners with it
- aura body tints got a real per-instance channel, so a spell can tint the whole unit without touching its neighbours

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 19:40:54 +07:00

323 lines
20 KiB
WebGPU Shading Language
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Phase 6 terrain splat shader — extends Bevy's StandardMaterial with a CUSTOM vertex + fragment stage.
//
// Blends up to 4 tiled layer textures by a per-chunk alpha map, then lights the result with WoW's
// faithful terrain lighting (fixed-function GL_LIGHTING, reproduced):
// • DIFFUSE — `clamp(ambient_row1 + diffuse_row0·max(N·L,0) + Σ point·att·N·L)`, modulated into
// the texture (MOD 1×). Evaluated AND CLAMPED per VERTEX (the GL T&L locus), then
// Gouraud-interpolated — the clamp position matters once an over-gamut point light is in the
// sum (see the `primary` varying note).
// • SPECULAR (the bare-ground "sheen") — `clamp(row9 · pow(max(N·H,0),20)) · sheen_mask`, added
// AFTER the texture modulate (separate-specular, LOCAL_VIEWER). The Blinn term is computed
// PER-VERTEX (clamped per vertex, Gouraud-interpolated); the `sheen_mask` is the `_s` texture's
// per-texel ALPHA, blended like the colour (so the sheen rides only shiny texels — stone, not
// grass). See docs/knowledge/lighting.md
// ⚠️ Two things bound it, BOTH needed (each was a separate blow-out): (a) per-vertex undersampling
// of the sharp lobe (sampled only at the ~4 yd MCVT vertices, clamped, linearly smeared) vs a
// per-PIXEL eval that saturates; (b) the `_s` alpha sheen-mask (ref mean ≈ 0.1) vs applying it at
// a uniform 1.0 — row 9 ≈ near-white, so unmasked it washes the whole lit face to cream.
//
// Because Bevy's `VertexOutput` has no slot for the interpolated specular, we do our own (minimal)
// vertex transform and a custom IO struct, and drop `main_pass_post_lighting_processing` (fog returns
// IN-SHADER at Step 5, per docs/plans/lighting-rebuild.md — not Bevy's DistanceFog).
//
// One material serves a whole ADT tile (see terrain.rs): layer textures live in a `texture_2d_array`
// (binding 100), per-chunk alpha maps in another (104). Each merged-mesh vertex carries its chunk's 4
// layer indices in `color` (vertex COLOR) and its alpha-layer index in `uv_b.x` (UV1) — constant across
// the chunk, so interpolation lands back on the integer index.
#import bevy_pbr::{
mesh_functions,
forward_io::Vertex,
view_transformations::position_world_to_clip,
mesh_view_bindings::view,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var layer_array: texture_2d_array<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var alpha_array: texture_2d_array<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var splat_samp: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(110) var shadow_array: texture_2d_array<f32>;
// Per-tile Vec4 uniforms packed into ONE buffer (binding 106) — the field order here MUST match the
// Rust `TerrainExtension` struct. Light + fog live in the shared global-light storage buffer (below);
// what's left is just the per-tile layer tiling factor.
// params — x = layer tiling factor; yzw unused.
struct TerrainParams {
params: vec4<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var<uniform> t: TerrainParams;
// 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 `apply_wow_lighting` re-pushed
// each frame. Rows 0-5 (of the 18-row LightStd430) are the light + fog terrain needs, plus the grade
// row at the end (rows 6-16 — model SH + water swatches — are skipped as a pad). Read in BOTH stages
// (the per-vertex sheen needs light_sun/spec in the vertex stage).
struct WowLight {
light_ambient: vec4<f32>, // rgb = row 1 ambient; w = Mod2x scale (×1).
light_diffuse: vec4<f32>, // rgb = row 0 sun diffuse; w = clamp-light flag (>0.5 ⇒ saturate).
light_sun: vec4<f32>, // xyz = world-space sun TRAVEL direction (to-light = −xyz); w unused.
light_spec: vec4<f32>, // rgb = row 9 specular color; w = shininess (20). rgb == 0 disables.
fog_color: vec4<f32>, // rgb = row 7 fog (raw, gamma 0..1); w = enable (>0.5 ⇒ blend).
fog_params: vec4<f32>, // x = fog_start yd; y = fog_end yd; z unused; w = farclip wall.
_sh: array<vec4<f32>, 6>, // rows 6-11: the model SH coeffs (live in wow_model.wgsl, 0354) — unread by terrain.
sh_c16: vec4<f32>, // row 12: xyz the models' c16 quad band; .w a FREE lane (the 0273 point gain is retired).
_water: array<vec4<f32>, 4>, // rows 13-16: the liquid swatches — unread by terrain.
grade: vec4<f32>, // reserved (the 0282 interior A/B retired; 0163). Layout only.
_wmo_fog: array<vec4<f32>, 2>, // rows 18-19: the interior fog triple — unread by terrain.
// 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]`.
point_count: vec4<f32>,
points: array<vec4<f32>, 512>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
// Custom vertex→fragment payload: the standard fields the splat fragment needs, PLUS `specular` — the
// per-vertex-clamped sun glint that must be Gouraud-interpolated (Q14). Same struct on both stages, so
// `@builtin(position)` is clip-position out / frag-coord in (`world_position` is unused until Step 5 fog).
struct TerrainVsOut {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_position: vec4<f32>,
@location(2) uv: vec2<f32>,
@location(3) uv_b: vec2<f32>,
@location(4) color: vec4<f32>,
@location(5) specular: vec3<f32>,
// The full Gouraud diffuse — `clamp01(ambient + sun·max(N·L,0) + Σ point·att·N·L)`, evaluated
// AND CLAMPED per VERTEX: the GL T&L locus. The clamp position is load-bearing for a hot
// carried light (the raw committed torch is (1.4, 0.87, 0.40)): GL clamps the summed vertex
// colour BEFORE interpolation, so an over-gamut torch saturates one vertex and Gouraud falls
// linearly away from it. Interpolating the raw sum and clamping per pixel — what we did
// before — keeps pixels pinned at white far from the hot vertex: a wide flat plateau with a
// hard knee, the director's "hard square". Sun N·L moving per-pixel → per-vertex is the
// faithful direction (Step 3's own note: indistinguishable on the smooth term).
@location(6) primary: vec3<f32>,
}
// Terrain's point-light **candidacy half-width** (yd): the reference's guaranteed covered box is
// `w + 10` where `w` is the chunk's bounding-sphere radius — `sqrt(2·16.666666² + (zExtent/2)²)`,
// i.e. 23.570166 on flat ground (`0x68df70` hard-codes that same constant at `68dfac`). Relief
// widens a chunk's own `w`; we use the flat value, so on steep ground we gather very slightly
// narrower than the reference — the lights that differ are ≥33 yd out, where `att ≈ 0.02`.
const TERRAIN_REACH: f32 = 33.570166;
// 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 (the gather sorts by squared distance from the unit, the commit seats GL slots 1-3 —
// wow-re `wmo-surface-dynamic-light` §4/§6). Terrain's unit is the 33.33-yd MCNK chunk, so the anchor
// is the chunk cell center under the vertex (our tiles merge chunks into one world-space mesh —
// `mcnk_cell_anchor` recovers the chunk analytically). Pass 1 selects; pass 2 evaluates only the
// selected lights at the vertex — the byte-verified falloff `1/(0.7·d + 0.03·d²)`, diffuse-only
// (committed ambient/specular are zero — VERIFIED: the staging struct's ambient/specular slots are
// explicitly zeroed at `71c7e3`-`71c7ec` right before the point loop), on the submitted vertex
// normal, no distance cutoff on the EVALUATION (a committed GL light has none; candidacy alone
// bounds the set).
//
// **Candidacy is the reference's spatial-hash sweep, not a radius** (VERIFIED, wow-re
// `terrain/scratch/terrain-dynamic-light-gather.md`): the gather walks a 20-yd-cell hash over a cell
// span `[floor((c − w − 10)/20), floor((c + w + 10)/20)]` — a **Chebyshev box**, no circular reject —
// where terrain's own `w` is the chunk's bounding-SPHERE radius `chunk+0x68`
// (`sqrt(2·16.666666² + (zExtent/2)²)` = 23.570166 on flat ground, `0x71bc30` copies it at
// `71bc47`-`71bc5f`). That gives a guaranteed covered half-width of `w + 10` = [`TERRAIN_REACH`].
// We used a 48-yd SPHERE here — an INFERRED stand-in from decision 0285, before anyone had read the
// terrain side. It over-gathered: with 29 live point lights measured around Darkshire against three
// committed slots, every extra candidate is another chance for a chunk's ≤3 set to flip as an
// emitter moves, which is chunk-shaped popping the reference never has.
//
// The M2 lane in wow_model.wgsl still uses the packed per-light range — its own `w` is 0 (a ±10 yd
// box, a *different* byte-pinned number), a change that would move the approved interior look, so it
// is the director's to weigh. The two lanes diverge on purpose; do not "fix" them back into one.
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);
// The hash sweep is horizontal (the grid keys on WoW x/y = Bevy z/x) and has no vertical
// bound; ranking below is the full 3-D distance, as `0x71bf90` does.
if (max(abs(dv.x), abs(dv.z)) > TERRAIN_REACH) {
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 — terrain's light-selection anchor (the terrain
// draw unit; the grid is fixed world-wide, chunk = 533.3333/16 yd, 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.
//
// **The XY snap is VERIFIED** (wow-re `terrain-dynamic-light-gather.md`: `CMapChunk+0x5c..0x64` is
// the chunk's world AABB centre, written by the MCVT world-vert builder `0x6b0e50` at
// `6b106c`/`6b10ec`, and the 33.33-yd chunk really is the draw unit — gather → commit → draw pair
// 1:1 per record at `68478a`/`68479c`/`6847bb`/`6847c4`).
//
// **The height is KNOWN-WRONG and deliberately not fixed here.** The reference's anchor Z is the
// chunk's own mid-height `(minH + maxH)/2` over its 145 verts — one constant per chunk. We keep the
// vertex's own y, which means the anchor VARIES within a chunk, so on relief two vertices of the
// same chunk can select different light sets and seam mid-chunk — the reference cannot do that.
// Fixing it needs a per-chunk constant plumbed to the vertex stage (a custom mesh attribute + a
// `specialize` on the terrain material, which has none today), so it is scoped as its own change;
// on flat ground the two anchors coincide and nothing differs. Mirrored in wow_model.wgsl (clutter
// snaps to the same cells).
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);
}
@vertex
fn vertex(in: Vertex) -> TerrainVsOut {
var out: TerrainVsOut;
let world_from_local = mesh_functions::get_world_from_local(in.instance_index);
out.world_position =
mesh_functions::mesh_position_local_to_world(world_from_local, vec4<f32>(in.position, 1.0));
out.clip_position = position_world_to_clip(out.world_position.xyz);
let world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
out.uv = in.uv;
out.uv_b = in.uv_b;
out.color = in.color;
// STEP 3b: terrain SUN SPECULAR, computed PER-VERTEX (Q14). Material spec = white, shininess =
// wow_light.light_spec.w (20), spec color = row 9 (wow_light.light_spec.rgb); H = halfway(to-light, to-eye) with
// the LOCAL viewer (per-vertex eye dir). Clamp HERE, per vertex — rasterizer Gouraud-interpolates.
let n = normalize(world_normal);
let l = -normalize(wow_light.light_sun.xyz); // to-light (the sun travels along +light_sun)
let v = normalize(view.world_position.xyz - out.world_position.xyz); // to-eye (local viewer)
let h = normalize(l + v);
let ndoth = max(dot(n, h), 0.0);
out.specular = clamp(wow_light.light_spec.rgb * pow(ndoth, wow_light.light_spec.w), vec3<f32>(0.0), vec3<f32>(1.0));
// STEP 3 at the faithful locus — the WHOLE diffuse sum, clamped HERE per vertex (see the
// struct note). Sun: `ambient + diffuse·max(N·L,0)` on the MCNR normal; points: the ≤3-nearest
// committed lights of this vertex's MCNK chunk cell — the terrain draw unit (0285) — at the
// byte-verified `1/(0.7d + 0.03d²)`, raw over-gamut colours in.
let ndotl = max(dot(n, l), 0.0);
let points = point_light_sum(out.world_position.xyz, n, mcnk_cell_anchor(out.world_position.xyz));
out.primary = clamp(
wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl + points,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
return out;
}
@fragment
fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
// HARD FAR-CLIP WALL (faithful `farclip`): the reference clips the detailed world at its projection
// far plane (~777 yd). Geometry beyond is GPU-clipped per-pixel, so a tall object reveals
// closest-part-first as you approach and the sky/WDL shows behind it. We reproduce that with a
// per-fragment discard on PLANAR eye-Z (same coordinate as the fog) beyond `fog_params.w` = farclip
// (0 ⇒ disabled). WDL/sky use other shaders, so they still render to the horizon behind the wall.
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;
}
}
// Per-chunk array indices, baked into the merged mesh: COLOR = 4 layer indices, UV1.x = alpha.
let li = vec4<i32>(round(in.color));
let ai = i32(round(in.uv_b.x));
let tiled = in.uv * t.params.x;
// Full RGBA: `.rgb` = diffuse, `.a` = the `_s` texture's per-texel SHEEN MASK (see assets.rs /
// terrain.rs — the base `.blp` has no alpha, so we load the `_s` variant; matte where no mask).
let s0 = textureSample(layer_array, splat_samp, tiled, li.x);
let s1 = textureSample(layer_array, splat_samp, tiled, li.y);
let s2 = textureSample(layer_array, splat_samp, tiled, li.z);
let s3 = textureSample(layer_array, splat_samp, tiled, li.w);
// Alpha + shadow maps are one 64² grid per chunk in 0..1 (NOT tiled), yet they share the layer
// textures' Repeat sampler. The old "repeat == clamp here" assumption is FALSE under LINEAR
// filtering: at a chunk edge (uv→0/1) the bilinear footprint WRAPS to the chunk map's opposite
// edge, blending unrelated weights → a thin seam at every chunk border (the creases; introduced
// when 228d336 switched Nearest→Linear). Inset by half a texel so the footprint clamps instead.
let auv = clamp(in.uv, vec2<f32>(0.5 / 64.0), vec2<f32>(1.0 - 0.5 / 64.0));
let a = textureSample(alpha_array, splat_samp, auv, ai);
var color = s0.rgb;
color = mix(color, s1.rgb, a.r);
color = mix(color, s2.rgb, a.g);
color = mix(color, s3.rgb, a.b);
// Sheen mask: blend the layers' `_s` alpha with the SAME splat weights as the colour, so the sun
// specular rides only the shiny texels (stone/pebbles), not matte grass/dirt. This per-pixel mask
// is what bounds the highlight — the real client's mask averages ≈ 0.1 (Q14); applying the sheen at
// a uniform 1.0 is what blew out the floor. (FP: `secondary · texture[0].w`.)
var specmask = s0.a;
specmask = mix(specmask, s1.a, a.r);
specmask = mix(specmask, s2.a, a.g);
specmask = mix(specmask, s3.a, a.b);
// STEP 3 (diffuse) arrives from the vertex stage, ALREADY summed (ambient + sun N·L + the
// chunk's ≤3 committed point lights) and ALREADY clamped — Gouraud of the clamped vertex
// colour, byte-faithful to GL T&L (see the `primary` struct note; the MCNR normal and the
// DayNight sun share a space per the Phase-0 validation). The MCSH ×(0.3·s + 0.7) factor below
// still scales the whole modulate, as the traced combine does.
let primary = in.primary;
// STEP 4: MCSH baked shadow. On the reference path (pixelShaders+specular) terrain is ONE pass
// through `terrainp_s.bls`, which carries the MCSH bit in the blend texture's alpha (1.0 lit / 0.0
// shadowed) and uses it TWO ways (Q15) — NOT the Q11 separate ambient-tint overlay (that's the
// legacy/non-pixelShaders path). Our `shadow_array` R8 is 255=shadowed/0=lit, so `shadow_lit =
// 1 - mcsh`. Chunks with no MCSH map (`uv_b.y < 0`) are fully lit.
var shadow_lit = 1.0;
let si = in.uv_b.y;
if (si >= 0.0) {
let mcsh = textureSample(shadow_array, splat_samp, auv, i32(round(si))).r;
shadow_lit = 1.0 - mcsh;
}
// The faithful `terrainp_s` combine (Q15):
// diffuse = tex · primary · (0.3·shadow + 0.7) → a flat −30% in shadow, NO colour tint
// specular = per-vertex sheen · gloss_mask · shadow → gated to ZERO in shadow (no sheen in shade)
// (`tex·primary` is the MODULATE-1× diffuse; the sheen is added after, separate-specular.) Then
// LDR-clamp; gamma/byte throughout; raw gamma out (GAMMA LANE, 0161).
let diffuse_term = color * primary * (0.3 * shadow_lit + 0.7);
let spec_term = in.specular * specmask * shadow_lit;
var tuned = clamp(diffuse_term + spec_term, vec3<f32>(0.0), vec3<f32>(1.0));
// STEP 5: gamma-space linear fog (q6). Applied AFTER tone/curve (so the diagnostic knobs see
// the unfogged lit pixel) and BEFORE the viz bypass + raw output. The fog coordinate is
// PLANAR eye-Z (view-space depth along the camera forward axis), NOT radial distance: the
// reference VS computes `fogcoord` as a linear function of a single axial coordinate (eye-Z) —
// VERIFIED from the host GLSL fog source in two apitraces (WoW.5/WoW.8). Radial `length()` is
// ≥ planar everywhere off-axis, so it OVER-fogs the screen edges (worst in short-fog zones like
// Duskwood, +0.26–0.32 fog factor at the corners). The per-vertex T&L original and this
// per-pixel form land on the same byte under LINEAR fog (the factor interpolates exactly), and
// the per-pixel form is what survives Bevy's mesh interpolators without a custom slot.
if (wow_light.fog_color.w > 0.5) {
let eye_z = -(view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).z;
let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001);
let factor = clamp((wow_light.fog_params.y - eye_z) / denom, 0.0, 1.0);
tuned = mix(wow_light.fog_color.xyz, tuned, factor);
}
// GAMMA LANE (decision 0161): the framebuffer HOLDS gamma bytes — all blending happens in
// gamma exactly like the reference's byte framebuffer; the frame's ONE decode lives in the
// FFXGlow combine (the last node). Output raw.
return vec4<f32>(tuned, 1.0);
}