diff --git a/crates/benilla-app/src/shaders/ui_add.wgsl b/crates/benilla-app/src/shaders/ui_add.wgsl index 66c26f59..7d8e77ac 100644 --- a/crates/benilla-app/src/shaders/ui_add.wgsl +++ b/crates/benilla-app/src/shaders/ui_add.wgsl @@ -1,21 +1,15 @@ -// WoW `alphaMode="ADD"` for Bevy UI — the glue highlight overlays (`ButtonHilight-Square`, the -// arrow/panel/scroll `Highlight`s, `UI-Common-MouseHilight`) draw with a TRUE additive blend -// state (set Rust-side in `AddUiMaterial::specialize`); this fragment only samples the authored -// sub-rect (uv min/max — the sheets store the button region in a corner/quarter). -// -// On the UI gamma composite lane (decision 0254) like its siblings `ui_node_gamma.wgsl` / -// `ui_slice_gamma.wgsl`: the texel goes back to the client's byte space, so the `SrcAlpha/One` -// blend state resolves to the reference's byte add `dst + texel·α` (EGxBlend 3 = -// `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`; wow-re `system/gx/gx.md`). Emitting the linear texel instead -// would let the hardware add a linearised glow, landing every highlight's dim skirt at a different -// weight than the reference's — the same error 0160 caught for the world's additive sources. +// WoW `alphaMode="ADD"` for Bevy UI, the glue highlight overlays: `AddUiMaterial::specialize` sets +// the additive blend state, and this stage samples the authored sub-rect (`rect` is uv min/max; the +// sheets keep the button region in a corner or quarter). The texel goes back to gamma bytes, so the +// `SrcAlpha, One` blend is the reference's byte add `dst + texel * a` (EGxBlend 3, +// `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`). #import bevy_ui::ui_vertex_output::UiVertexOutput @group(1) @binding(0) var texture: texture_2d; @group(1) @binding(1) var texture_sampler: sampler; @group(1) @binding(2) var rect: vec4; -// Linear → sRGB (IEC 61966-2-1) — the exact inverse of the sampler's sRGB decode. +// Linear to sRGB (IEC 61966-2-1), the exact inverse of the sampler's sRGB decode. fn linear_to_srgb(c: vec3) -> vec3 { let higher = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; let lower = c * 12.92; diff --git a/crates/benilla-app/src/shaders/ui_gamma.wgsl b/crates/benilla-app/src/shaders/ui_gamma.wgsl index 854dfb82..cfc1243d 100644 --- a/crates/benilla-app/src/shaders/ui_gamma.wgsl +++ b/crates/benilla-app/src/shaders/ui_gamma.wgsl @@ -1,23 +1,17 @@ -// The UI gamma lane's ONE decode (decision 0254) — the twin of `ffx_glow.wgsl`'s combine, which owns -// the world lane's single decode (0161) — and the client's display-gamma ramp (2182), which rides in -// front of it because the value this pass samples is exactly the byte the RAMDAC would have read. -// -// `ui_quad.wgsl` composites the whole UI in gamma bytes, like the reference's fixed-function -// backbuffer. The UI camera's target is `Rgba8UnormSrgb`, so a stored byte round-trips through the -// hardware's encode/decode and the sampler hands this pass back the gamma value the UI pass wrote. -// Emitting `srgb_to_linear` of it re-encodes on write to the exact client byte — written straight -// into the swapchain (decision 2206: the camera's output mode is `Skip`, so no blit follows). -// -// RGB is premultiplied by coverage; alpha is coverage and carries no gamma, so it passes through. +// The UI lane's one gamma decode, with the client's display-gamma ramp applied first: the value +// sampled here is the byte the reference's RAMDAC would read. `ui_quad.wgsl` composites gamma bytes +// into an `Rgba8UnormSrgb` target, so the sampler returns the value written and `srgb_to_linear` +// re-encodes it to the exact byte in the swapchain (output mode `Skip`, no blit follows). RGB is +// premultiplied by coverage; alpha is coverage, carries no gamma, and passes through. #import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput @group(0) @binding(0) var screen_texture: texture_2d; @group(0) @binding(1) var screen_sampler: sampler; -// `.x` = the `gamma` CVar. The rest is padding — a uniform struct is 16-byte aligned. +// `.x` = the `gamma` CVar; the rest is padding (a uniform is 16-byte aligned). @group(0) @binding(2) var ramp: vec4; -// sRGB → linear (IEC 61966-2-1), the exact inverse of `ui_quad.wgsl`'s `linear_to_srgb`. +// sRGB to linear (IEC 61966-2-1), the exact inverse of `ui_quad.wgsl`'s `linear_to_srgb`. fn srgb_to_linear(c: vec3) -> vec3 { let higher = pow((max(c, vec3(0.0)) + vec3(0.055)) / 1.055, vec3(2.4)); let lower = c / 12.92; @@ -28,13 +22,9 @@ fn srgb_to_linear(c: vec3) -> vec3 { fn fs_decode(in: FullscreenVertexOutput) -> @location(0) vec4 { let ui = textureSample(screen_texture, screen_sampler, in.uv); var rgb = ui.rgb; - // The reference's hardware ramp `ramp[i] = pow(i/255, gamma)` (`0x591680`), in its continuous - // form: `i/255` IS `rgb` here, so this is the same curve over the same values. - // - // **The branch is not an optimisation.** It is uniform across the draw, so it costs nothing — - // and at the registered default the pass has to be the exact identity it was before this - // existed, which `pow` is not: `pow(x, 1.0)` compiles to `exp2(log2(x))` and `log2(0)` is - // undefined. The floor inside the taken arm is the same guard for a non-default gamma. + // The reference's hardware ramp `pow(i / 255, gamma)` (`0x591680`), with `rgb` as `i / 255`. + // The uniform branch keeps the default an exact identity: `pow(x, 1.0)` compiles to + // `exp2(log2(x))` and `log2(0)` is undefined, which the floor also guards for other gammas. if (ramp.x != 1.0) { rgb = pow(max(rgb, vec3(1e-6)), vec3(ramp.x)); } diff --git a/crates/benilla-app/src/shaders/ui_node_gamma.wgsl b/crates/benilla-app/src/shaders/ui_node_gamma.wgsl index a215e482..9f8fa095 100644 --- a/crates/benilla-app/src/shaders/ui_node_gamma.wgsl +++ b/crates/benilla-app/src/shaders/ui_node_gamma.wgsl @@ -1,23 +1,12 @@ -// The glue screens' Bevy-UI node shader, on the UI **gamma composite lane** (decision 0254). +// The glue screens' Bevy UI node shader on the UI gamma composite lane, vendored from +// `bevy_ui_render` 0.18.1 `src/ui.wgsl`: upstream verbatim except `linear_to_srgb`, the fragment's +// colour computation, and the dropped `#define_import_path` line (two modules claiming +// `bevy_ui::ui_node` would collide). Re-diff against upstream on every Bevy upgrade: a structural +// change fails pipeline validation, a fragment-side one drifts silently. // -// VENDORED from `bevy_ui_render` 0.18.1 `src/ui.wgsl`. Everything below is that file verbatim — -// the rounded-box SDF, the border/AA math, the vertex stage — EXCEPT the fragment's colour -// computation, which is moved into the client's byte space (marked THE ONE CHANGE below). Bevy UI -// exposes no colour-space hook, and its pipeline takes one shader handle for both stages, so the -// whole file has to come along. Re-diff against upstream on every Bevy upgrade: a structural change -// (vertex attributes, bind groups) fails loudly at pipeline validation, but a fragment-side tweak -// would drift silently. `bevy = "0.18.1"` is pinned in Cargo.toml. -// -// Why: the reference draws its glue screens through a fixed-function device into an 8-bit -// backbuffer, so every UI multiply AND every UI blend is arithmetic on GAMMA BYTES. Bevy UI -// composites in linear, which over the login scene's bright sky washed the edit boxes from the -// reference's byte ~77 to ~138 — the director's "input and button are too faint", measured. This -// fragment hands the pipeline a RAW GAMMA value; the target is `Rgba8UnormSrgb`, so the hardware -// blend reads back exactly what was written and the `(SrcAlpha, OneMinusSrcAlpha)` blend runs on -// gamma values — the reference's byte arithmetic. `ui_gamma.wgsl` then owns the frame's ONE decode. -// -// The `#define_import_path` line of the original is deliberately dropped: two modules claiming -// `bevy_ui::ui_node` would collide. +// The reference draws the glue screens into an 8-bit backbuffer, so every UI multiply and blend is +// arithmetic on gamma bytes. This fragment outputs raw gamma into the `Rgba8UnormSrgb` target, so +// the `(SrcAlpha, OneMinusSrcAlpha)` blend runs on gamma values; `ui_gamma.wgsl` decodes once. #import bevy_render::view::View @@ -35,9 +24,8 @@ fn enabled(flags: u32, mask: u32) -> bool { return (flags & mask) != 0u; } -// Linear → sRGB (the exact IEC 61966-2-1 curve the hardware's sRGB store uses, so this inverts the -// sampler's decode bit-for-bit in f32). Alpha carries no gamma and never passes through here. -// Identical to `ui_quad.wgsl`'s — the player-UI lane's twin of this conversion. +// Linear → sRGB on the IEC 61966-2-1 curve the hardware's sRGB store uses, so it inverts the +// sampler's decode bit-for-bit in f32; keep identical to `ui_quad.wgsl`'s. fn linear_to_srgb(c: vec3) -> vec3 { let higher = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; let lower = c * 12.92; @@ -242,16 +230,10 @@ fn draw_uinode_background( fn fragment(in: VertexOutput) -> @location(0) vec4 { let texture_color = textureSample(sprite_texture, sprite_sampler, in.uv); - // ── THE ONE CHANGE from the vendored original ──────────────────────────────────────────────── - // Upstream computes `select(in.color, in.color * texture_color, TEXTURED)` — a LINEAR product - // handed to a linear blend. Both factors go back to the client's byte space FIRST, and the - // multiply happens there: `in.color` is a linearised authored sRGB colour (FrameXML ``, - // the glue palette) and the sampler already decoded the sRGB art, so encoding each factor - // recovers exactly the two authored bytes the reference's FFP multiplies. Encoding each factor - // separately — rather than the product — matters: sRGB's linear toe makes `encode(a*b)` and - // `encode(a)*encode(b)` diverge in the darks, which is precisely where the edit-box fills live - // (byte 13 vs byte 7 for the `DEFAULT_TOOLTIP_COLOR` fill). - // Alpha is coverage, carries no gamma, and multiplies unchanged. + // ── changed from upstream ── + // Upstream multiplies in linear. Each factor is encoded back to its authored sRGB byte and the + // multiply runs there, as the reference's FFP does; encoding the product instead diverges in + // the darks (sRGB's linear toe). Alpha is coverage and multiplies unchanged. let textured = enabled(in.flags, TEXTURED); let tint = linear_to_srgb(in.color.rgb); let texel = linear_to_srgb(texture_color.rgb); @@ -259,7 +241,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { select(tint, tint * texel, textured), select(in.color.a, in.color.a * texture_color.a, textured), ); - // ── end of the change; the rest is upstream verbatim ───────────────────────────────────────── + // ── end of the change ── if enabled(in.flags, BORDER_ANY) { return draw_uinode_border(color, in.point, in.size, in.radius, in.border, in.flags); diff --git a/crates/benilla-app/src/shaders/ui_quad.wgsl b/crates/benilla-app/src/shaders/ui_quad.wgsl index a28df927..8ba8af8e 100644 --- a/crates/benilla-app/src/shaders/ui_quad.wgsl +++ b/crates/benilla-app/src/shaders/ui_quad.wgsl @@ -1,33 +1,24 @@ -// The player-UI quad material (decision 0068 §2's sorted-quad pass), on the UI **gamma composite -// lane** (decision 0254 — the UI sibling of 0161's world lane). -// -// The reference draws the whole UI through its fixed-function device into an 8-bit backbuffer, so -// every UI multiply AND every UI blend is arithmetic on GAMMA BYTES. We reproduce that here: the -// fragment puts its texel back into the client's byte space, does the tint/premultiply there, and -// hands the pipeline a RAW GAMMA value. Under the `(One, OneMinusSrcAlpha)` blend state the -// hardware then composes in gamma, and (because the target is 8-bit unorm) clamps at every write, -// exactly like the reference's byte buffer: +// The player-UI quad material, on the UI gamma composite lane. The reference draws the UI +// fixed-function into an 8-bit backbuffer, so every UI multiply and blend is arithmetic on gamma +// bytes: the fragment returns its texel to byte space, tints and premultiplies there, and outputs +// raw gamma, which the `(One, OneMinusSrcAlpha)` blend composes in gamma, clamped at every write by +// the 8-bit unorm target: // BLEND (EGxBlend 2, `SrcAlpha/OneMinusSrcAlpha`): out = (rgb·a, a) ⇒ dst·(1−a) + rgb·a // ADD (EGxBlend 3, `SrcAlpha/One`): out = (rgb·a, 0) ⇒ dst + rgb·a -// so a per-material flag picks the mode without splitting the pipeline. The frame's single -// gamma→linear decode happens once afterwards, in `ui_gamma.wgsl` — the twin of the FFXGlow -// combine owning the world lane's one decode. +// so a per-material flag picks the mode on one pipeline. The frame's one gamma-to-linear decode +// is `ui_gamma.wgsl`. // -// Why the encode below, rather than uploading UI art as raw `Rgba8Unorm`: our BLPs load as -// `Rgba8UnormSrgb`, so the sampler hands us a LINEARIZED texel. `linear_to_srgb` puts the authored -// byte back (exact in f32: encode ∘ decode = identity), and it is the rule for every texture the -// pass samples — sRGB art, the sRGB glyph atlas, and the portrait booth's `Rgba8Unorm` bake (which -// stores linear bytes) — EXCEPT a texture that says otherwise: a SKIP_DECODE upload (`gamma_texel` -// below — the minimap tiles) already holds the authored byte, and encoding it again is the -// double-gamma that washed the outdoor minimap bright. Uploading everything raw would be cheaper -// and would also move bilinear filtering into gamma (as the reference filters), but it forces the -// booth to emit gamma — a contained follow-up, not a look change. +// BLPs load as `Rgba8UnormSrgb`, so the sampler returns a linearized texel and `linear_to_srgb` +// restores the authored byte (exact in f32). That holds for every texture here, the portrait +// booth's `Rgba8Unorm` bake (which stores linear bytes) included, except a SKIP_DECODE upload +// (`gamma_texel`), which already holds the byte and must not be encoded twice. +// Deviation: bilinear filtering runs on the decoded texel, not on the gamma byte the reference +// filters, because the booth bake stores linear bytes. #import bevy_sprite::mesh2d_functions as mesh_functions -// The mesh's own vertex layout, at bevy's fixed Mesh2d locations (`Mesh2dPipeline::specialize` -// binds POSITION at 0, UV_0 at 2, COLOR at 4 and defines `VERTEX_UVS` / `VERTEX_COLORS` for the -// attributes the mesh actually carries — the minimap's composite tile quad has no colours). +// The mesh vertex at bevy's fixed Mesh2d locations (`Mesh2dPipeline::specialize`: POSITION 0, UV_0 +// 2, COLOR 4); `VERTEX_UVS`/`VERTEX_COLORS` are defined only for attributes the mesh carries. struct Vertex { @builtin(instance_index) instance_index: u32, @location(0) position: vec3, @@ -40,28 +31,17 @@ struct Vertex { } struct VertexOutput { - // Clip position out of the vertex stage, the FRAGMENT COORDINATE (physical px) into the - // fragment — the screen mask below compares against it. + // Clip position out; in the fragment, the pixel coordinate (physical px) the screen mask uses. @builtin(position) position: vec4, @location(0) uv: vec2, #ifdef VERTEX_COLORS @location(1) color: vec4, #endif - // The instance's whole-run colour, read off bevy's per-instance mesh TAG (`MeshTag`, a u32 the - // mesh uniform carries for every Mesh2d entity, re-uploaded with its transform each frame). - // A run with one colour draws WHITE vertices and carries that colour here — so a colour - // pulse is one component write on the batch entity: not an `Assets` write (which arms - // bevy's asset-changed probes over every `Mesh3d` row in the scene, 1982) and not a material - // write (which re-creates the material's bind group and uniform buffers on the render thread, - // 2–3 of them a frame at a parked pin costing ~9 ms on Intel's DX12 driver, 2236). - // - // Packed as the reference packs a vertex colour: one BYTE per channel — `CImVector`, the - // client's `SetVertexColor` quantising `×255 + 0.5` and folding the frame's alpha in bytes - // (wow-re `system/ui/scratch/texture-color-composition.md`) — so this carries exactly the - // precision the real client draws with. Stored COMPLEMENTED (`!packed`): an entity with no - // `MeshTag` reads 0 from bevy, and the complement makes that opaque white, i.e. untinted, - // rather than transparent black — every Mesh2d that draws with this material and never - // asked for a tint (the minimap's interior tiles) is right by default. + // The run's colour off the per-instance `MeshTag` (a one-colour run draws white vertices), so a + // colour change is a component write, not a mesh or material change. One byte per channel, as + // the reference's `CImVector` (`SetVertexColor` quantises `×255 + 0.5` and folds the frame's + // alpha in bytes). Stored complemented, so an entity with no `MeshTag` (read as 0) is opaque + // white, untinted. @location(2) @interpolate(flat) tint: vec4, } @@ -90,110 +70,61 @@ fn vertex(vertex: Vertex) -> VertexOutput { @group(2) @binding(0) var additive: u32; @group(2) @binding(1) var quad_texture: texture_2d; @group(2) @binding(2) var quad_sampler: sampler; -// Mask the quad to its inscribed circle — the live unit PORTRAIT: the real client stamps a round -// alpha stencil into its 64² bake; ours cuts the same circle at draw time, so the opaque booth -// backdrop never pokes past the frame ring's thin band. UV-space, so it holds at any quad size. +// Mask the quad to its inscribed circle in UV space: the unit portrait, whose round alpha stencil +// the reference stamps into its 64² bake and this cuts at draw time. @group(2) @binding(3) var circular: u32; -// The screen-anchored alpha mask (the MINIMAP's MinimapMask.blp circle, decision 0203): the mask -// spans mask_rect (physical framebuffer px: min.xy, max.xy; z <= x disables), and the fragment's -// alpha multiplies by the mask's ALPHA channel there (MinimapMask.blp is DXT3 — white color, the -// circle ramp authored in its 8-bit alpha; header-verified this session after a mis-read as -// palettized cost a debug cycle). Screen-anchored — not quad UV space — so world-anchored tile -// quads pan under a fixed window. Outside the rect drops. Sampled at level 0 (a branch on -// per-fragment coords would break the uniform control flow implicit derivatives need). +// The screen-anchored alpha mask (the minimap's `MinimapMask.blp`, DXT3, the circle ramp in its +// alpha): `mask_rect` is its span in physical framebuffer px (min.xy, max.xy; z <= x disables), so +// world-anchored tile quads pan under a fixed window; outside the rect is dropped. Sampled at +// level 0: the sample sits in a branch on per-fragment coordinates, outside the uniform control +// flow implicit derivatives need. @group(2) @binding(4) var mask_rect: vec4; @group(2) @binding(5) var mask_texture: texture_2d; @group(2) @binding(6) var mask_sampler: sampler; -// `Texture:SetDesaturated(1)` — the greyed-out icon (decisions 1327, 1330). This is the verb every -// disabled affordance in the reference is built on: a bag icon under the game menu, an unusable pet -// action, a disconnected party portrait — and the unavailable talent B162 reported. -// -// It is not a tint and not a stage flag: `+0x128` on the texture object is a `CGxShader*`, and the -// one shader the UI ever loads is `Shaders\Pixel\Desaturate.bls` (wow-re -// `system/ui/scratch/texture-desaturate-law.md`, VERIFIED at the asset's own bytes). Its whole body -// is four instructions, and the two that matter are reproduced exactly below: -// +// `Texture:SetDesaturated(1)`: the texture object's `+0x128` `CGxShader*` binds +// `Shaders\Pixel\Desaturate.bls`, whose two working instructions are // MUL result.color.w , fragment.color.primary, texel ; a = vertexColour.a x texel.a // DP3 result.color.xyz , texel, c[0] ; rgb = dot(texel.rgb, LUMA) -// -// **The vertex colour's RGB is DISCARDED, not modulated in.** A bound fragment program supersedes -// the fixed-function stage chain entirely, so the `MODULATE(TEXTURE, DIFFUSE)` law that governs -// every other UI quad simply does not run here — there is no desaturate-then-tint or -// tint-then-desaturate, the tint is *absent*. FrameXML walks straight into this: -// `SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)` still SETS that 0.65, and on -// shader-capable hardware it has no effect on colour. Only its ALPHA survives, which is why the -// alpha multiply below is shared with the ordinary path rather than special-cased — dropping it -// would make every desaturated icon ignore `SetAlpha` and its frame's alpha. -// -// The fold runs on the GAMMA byte (after `linear_to_srgb`) because the reference's UI is 8-bit -// arithmetic end to end; greying a linearized texel would land a different byte than the client's. +// A bound fragment program replaces the fixed-function MODULATE, so the vertex colour's RGB is +// discarded (the 0.65 of FrameXML's `SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)` has +// no effect) and only its alpha survives. The dot runs on the gamma byte, after `linear_to_srgb`. @group(2) @binding(7) var desaturate: u32; -// The sampled texture is already PREMULTIPLIED — a portrait/paper-doll/dressing-room booth bake, -// and nothing else (see `UiQuad::premultiplied`). -// -// Every other texture this pass samples is authored STRAIGHT (a BLP's rgb means nothing where its -// alpha is 0), so the premultiply below has to happen here. A booth render target is the opposite -// by construction: its opaque geometry wrote `a = 1`, its alpha batches blended over that, and its -// ADDITIVE particles added light while contributing NO coverage (`wow_effect.wgsl` returns -// `(rgb·a, 0)`). Colour is emitted light, alpha is coverage — premultiplied. Weighting it by its own -// alpha again multiplies exactly the emitted light that sits over EMPTY pane space by zero, which is -// how a transparent-clear pane lost the R14 pauldrons' fire entirely and kept a weapon glow only -// where it overlapped the model's own opaque pixels. +// The texture is already premultiplied: only a portrait, paper-doll or dressing-room booth bake +// (`UiQuad::premultiplied`), whose additive particles add light with no coverage. Every other +// texture is straight alpha and is premultiplied here. @group(2) @binding(8) var premultiplied: u32; -// **Alpha TEST** reference — `<= 0` disables. The WMO-interior minimap tiles, and nothing else so -// far: the reference draws them under EGxBlend **1**, whose applicator `glDisable`s blending -// outright, with the `SetRenderState` id-7→id-8 cascade arming `glAlphaFunc(GL_GEQUAL, -// 0.87843144)` — `.data 0x85ad20[1] = 224`, times the f32 reciprocal of 255 (wow-re -// `system/minimap/scratch/wmo-interior-minimap-composite.md`). So a tile fragment either writes -// FULLY OPAQUE or is discarded; partial coverage does not exist on that path, and two overlapping -// group tiles can never leave the clear colour showing between them. Blending them the ordinary -// way leaves `(1−a)(1−b)` of the black clear at every boundary — B141's "odd black lines". -// -// The tested value is `texel.a × colour.a`, the client's MODULATE of the texel against the -// stride-0 vertex dword `(frameAlpha << 24) | 0xFFFFFF`. The SCREEN MASK is deliberately NOT in -// it: the reference alpha-tests each tile into an offscreen and cuts the round mask at the BLIT, -// so folding the mask ramp into the test would saw the disc's soft rim into a hard, undersized -// circle. +// Alpha-test reference, <= 0 disables; only the WMO-interior minimap tiles. The reference draws +// them under EGxBlend 1: blending off and `glAlphaFunc(GL_GEQUAL, 0.87843144)` +// (`.data 0x85ad20[1]` = 224, times the f32 reciprocal of 255), so a tile fragment is fully opaque +// or discarded. The tested value is `texel.a × colour.a`, the MODULATE against the vertex dword +// `(frameAlpha << 24) | 0xFFFFFF`. The screen mask stays out of it: the reference tests each tile +// into an offscreen and cuts the mask at the blit. @group(2) @binding(9) var alpha_ref: f32; -// The bound texture was uploaded UNDECODED — `BlpVariant::MapTile`'s `GL_SKIP_DECODE_EXT`, the -// minimap tiles — so the sampler hands back the authored GAMMA byte itself and the ordinary arm's -// `linear_to_srgb` has no decode to undo: the texel passes through as-is. The alpha-test arm -// ignores this flag — its target is the un-encoded composite, so it decodes explicitly instead. +// The texture was uploaded undecoded (`BlpVariant::MapTile`'s `GL_SKIP_DECODE_EXT`, the minimap +// tiles), so the texel is already the authored gamma byte and skips `linear_to_srgb`. The +// alpha-test arm ignores this flag and decodes explicitly for its un-encoded target. @group(2) @binding(10) var gamma_texel: u32; -// The **UV window this quad may sample** — `(u_min, v_min, u_max, v_max)`, already inset by half a -// texel by the producer; an axis whose `min > max` is left unclamped. The atlas-cell guard, -// decision 1608. -// -// `CLAMP_TO_EDGE` clamps at the IMAGE's edge, not at a `SetTexCoord` crop's, so a magnified atlas -// cell's outermost destination pixels sample half a texel past the crop and linear-filter in the -// NEIGHBOURING cell. `POIIcons` cell 15 (the generic zone landmark) is fully transparent and the -// cell above it is a coffin whose bottom row is opaque black: the world map drew a ~24%-black -// hairline along the top edge of every zone POI. Clamping here makes a cell sample exactly what a -// standalone clamped texture of it would. Producers leave it off for UVs that run past `[0,1]` — -// that is the backdrop's tiling idiom, not a cell. +// The UV window this quad may sample, `(u_min, v_min, u_max, v_max)`, inset half a texel by the +// producer; an axis with `min > max` is unclamped (UVs past `[0,1]`, the backdrop's tiling). +// `CLAMP_TO_EDGE` clamps at the image edge, not at a `SetTexCoord` crop, so without this a +// magnified atlas cell's outer pixels filter in the neighbouring cell. @group(2) @binding(11) var uv_clamp: vec4; -// ITU-R BT.601 luma — the `PARAM c[0]` of that shader, read as raw f32 words: `0x3E991687`, -// `0x3F1645A2`, `0x3DE978D5`. Not `(0.3, 0.3, 0.3)`, not `(0.3, 0.59, 0.11)` (this file's own first -// guess, corrected by the carve), not BT.709. -// -// **Do not normalise these and do not compute them in f64.** They sum to 1.0000000074505806, so a -// white texel evaluates just above 1.0 and relies on the output clamp; the ARB text and the D3D -// blob round-trip to the same three f32 words, so the backend cannot change a bit and neither may -// we. +// BT.601 luma, `Desaturate.bls`'s `PARAM c[0]` as raw f32 words `0x3E991687`, `0x3F1645A2`, +// `0x3DE978D5`. Do not normalise them or compute them in f64: they sum to 1.0000000074505806, so +// white lands just above 1.0 and relies on the output clamp. const LUMA: vec3 = vec3(0.299, 0.587, 0.114); -// Linear → sRGB (the exact IEC 61966-2-1 curve the hardware's sRGB store uses, so this inverts the -// sampler's decode bit-for-bit in f32). Alpha carries no gamma and never passes through here. +// Linear to sRGB: the IEC 61966-2-1 curve the hardware's sRGB conversion uses, so this inverts the +// sampler's decode exactly in f32. Alpha has no gamma and never passes through here. fn linear_to_srgb(c: vec3) -> vec3 { let higher = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; let lower = c * 12.92; return select(higher, lower, c <= vec3(0.0031308)); } -// sRGB → linear — the inverse of `linear_to_srgb`, for a texture uploaded UNDECODED so the hardware -// filters its authored bytes (the minimap tiles' `GL_SKIP_DECODE_EXT`). The conversion happens here, -// after the filter, which is exactly the order the reference's fixed-function pipe uses. +// sRGB to linear, for an undecoded texture (the minimap tiles): the hardware filters the authored +// bytes and the conversion follows the filter, the reference's fixed-function order. fn srgb_to_linear(c: vec3) -> vec3 { let higher = pow((max(c, vec3(0.0)) + 0.055) / 1.055, vec3(2.4)); let lower = c / 12.92; @@ -202,38 +133,34 @@ fn srgb_to_linear(c: vec3) -> vec3 { @fragment fn fragment(in: VertexOutput) -> @location(0) vec4 { - // Branchless so the sample stays in uniform control flow (implicit derivatives), and the - // bounds are sorted before `clamp` so a DISABLED (`min > max`) axis never forms an - // ill-ordered range — `select` evaluates both arms. + // Branchless, so the sample stays in uniform control flow for implicit derivatives. The bounds + // are sorted first because `select` evaluates both arms: a disabled (`min > max`) axis must + // still hand `clamp` an ordered range. let lo = min(uv_clamp.xy, uv_clamp.zw); let hi = max(uv_clamp.xy, uv_clamp.zw); let uv = select(in.uv, clamp(in.uv, lo, hi), uv_clamp.xy <= uv_clamp.zw); let t = textureSample(quad_texture, quad_sampler, uv); - // The quad's colour: its vertex colour (white for a one-colour run) times the run's tint off - // the instance tag (see `VertexOutput::tint`) — a mesh without colours is the tint alone. #ifdef VERTEX_COLORS let c = in.color * in.tint; #else let c = in.tint; #endif - // Back to the client's byte space, then tint there: `UiQuad.color` is already a client-space - // sRGB value (FrameXML's ``, `|cff…`, quality colors), so this multiply IS the FFP's - // gamma-space `tint × texel`. A SKIP_DECODE texture (`gamma_texel`) is already IN byte space — - // re-encoding it is the double-gamma that washed the outdoor minimap bright. + // Back to the client's byte space, then tint there: `UiQuad.color` is a client-space sRGB value + // (FrameXML ``, `|cff…`, quality colours), so this is the fixed-function gamma-space + // `tint × texel`. A SKIP_DECODE texture (`gamma_texel`) is already in byte space. let texel = select(linear_to_srgb(t.rgb), t.rgb, gamma_texel != 0u); - // The desaturated arm REPLACES the modulate — see the `desaturate` binding above. `c.rgb` is - // deliberately unread here; only `c.a` carries into the alpha below, as it does on both paths. + // Desaturate replaces the modulate: `c.rgb` is unread there, and only `c.a` reaches the alpha. var rgb = texel * c.rgb; if desaturate != 0u { rgb = vec3(dot(texel, LUMA)); } - // `k` is the COVERAGE the UI itself imposes — the vertex colour's alpha (`SetAlpha`, the frame's - // inherited alpha) and the two masks. It is kept apart from the texel's OWN alpha `t.a` because - // the two premultiply differently: `k` scales a premultiplied source's colour and alpha alike, - // while `t.a` must weight the colour only when the source is straight. + // `k` is the coverage the UI imposes: the vertex alpha (`SetAlpha`, the inherited frame alpha) + // and the two masks. It stays apart from the texel's own `t.a` because they premultiply + // differently: `k` scales a premultiplied source's colour and alpha alike, `t.a` weights the + // colour only on a straight source. var k = c.a; if circular != 0u { - // Soft ~2%-of-width edge: reads as the ref's stencil at portrait size, no jaggies. + // A soft edge 2% of the width wide, like the reference's stencil at portrait size. k *= 1.0 - smoothstep(0.48, 0.5, distance(in.uv, vec2(0.5))); } if mask_rect.z > mask_rect.x { @@ -242,29 +169,20 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { let m = textureSampleLevel(mask_texture, mask_sampler, clamp(muv, vec2(0.0), vec2(1.0)), 0.0).a; k *= m * inside; } - // The alpha TEST arm (see `alpha_ref`): pass ⇒ fully opaque, fail ⇒ nothing. - // - // It returns the **un-encoded** texel, not `rgb`: this arm draws only into the minimap's own - // 256² composite target, which is un-encoded float like the portrait booths' (decisions - // 0254/0804 — the UI arc does its one sRGB encode at the end, so a target that pre-encoded - // would land a second one downstream). The blit quad that samples that target is an ordinary - // quad and takes the `linear_to_srgb` above, which is where the authored byte comes back. - // Nothing is lost by skipping it here: the composite does no colour arithmetic at all — no - // blend, no day-night tint, a white vertex colour — so this path is a pure copy, and there is - // no gamma-space multiply to preserve. + // The alpha-test arm (`alpha_ref`): pass is fully opaque, fail draws nothing. It returns the + // un-encoded texel, not `rgb`: it draws only into the minimap's 256² composite, an un-encoded + // float target whose blit quad takes `linear_to_srgb`, and the composite does no colour + // arithmetic, so there is no gamma-space multiply to keep. if alpha_ref > 0.0 { if t.a * c.a < alpha_ref { discard; } - // The tile arrived as gamma bytes (SKIP_DECODE) and the composite target is un-encoded, so - // the conversion the sampler did not do happens HERE — after the filter, which is the point. return vec4(srgb_to_linear(t.rgb) * c.rgb, 1.0); } let a = t.a * k; - // Premultiply in GAMMA (decision 0160's lesson, here for the UI): the hardware `SrcAlpha` factor - // would weight a linearized colour and inflate every soft edge and every dim additive skirt. - // An already-premultiplied source (a booth bake) takes `k` alone — folding in `t.a` a second - // time is the double multiply that erased the panes' effects. + // Premultiply in gamma: the hardware `SrcAlpha` factor would weight a linearized colour and + // inflate every soft edge and dim additive skirt. A premultiplied source (a booth bake) takes + // `k` alone; folding in `t.a` again would zero its additive light over empty pane space. let weight = select(a, k, premultiplied != 0u); if additive != 0u { return vec4(rgb * weight, 0.0); diff --git a/crates/benilla-app/src/shaders/ui_slice_gamma.wgsl b/crates/benilla-app/src/shaders/ui_slice_gamma.wgsl index de024dc6..af7d76a3 100644 --- a/crates/benilla-app/src/shaders/ui_slice_gamma.wgsl +++ b/crates/benilla-app/src/shaders/ui_slice_gamma.wgsl @@ -1,11 +1,6 @@ -// The glue screens' Bevy-UI nine-patch / tiled shader, on the UI **gamma composite lane** -// (decision 0254) — the sibling of `ui_node_gamma.wgsl`, which carries the same rationale. -// -// VENDORED from `bevy_ui_render` 0.18.1 `src/ui_texture_slice.wgsl`, verbatim except the fragment's -// final colour (marked THE ONE CHANGE). This is the pipeline every glue `Backdrop` draws through — -// the `Glue-Tooltip-Border` edge nine-patch and the tiled `UI-Tooltip-Background` fill — so it is -// exactly where the edit boxes' washed-out fill and their bevel's vanished dark ramp came from. -// Re-diff against upstream on every Bevy upgrade. +// The glue screens' Bevy UI nine-patch and tiled shader on the UI gamma composite lane: every glue +// `Backdrop` draws through it. Vendored from `bevy_ui_render` 0.18.1 `src/ui_texture_slice.wgsl`, +// verbatim except `linear_to_srgb` and the final colour; re-diff on every Bevy upgrade. #import bevy_render::view::View; #import bevy_render::globals::Globals; @@ -18,8 +13,7 @@ var globals: Globals; @group(1) @binding(0) var sprite_texture: texture_2d; @group(1) @binding(1) var sprite_sampler: sampler; -// Linear → sRGB (IEC 61966-2-1) — the exact inverse of the sampler's decode. See -// `ui_node_gamma.wgsl` for why each factor is encoded before the multiply. +// Linear to sRGB (IEC 61966-2-1), the exact inverse of the sampler's decode. fn linear_to_srgb(c: vec3) -> vec3 { let higher = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; let lower = c * 12.92; @@ -140,10 +134,9 @@ fn fragment(in: UiVertexOutput) -> @location(0) vec4 { // map the slice coords to texture coords let atlas_uv = in.atlas_rect.xy + uv * (in.atlas_rect.zw - in.atlas_rect.xy); - // ── THE ONE CHANGE from the vendored original ──────────────────────────────────────────────── - // Upstream returns `in.color * textureSample(...)` — a linear product for a linear blend. Both - // factors return to the client's byte space before the multiply, so the tint IS the reference's - // gamma-space `SetBackdropColor` multiply and the blend that follows is byte arithmetic. + // ── Changed from upstream ── + // Upstream returns `in.color * textureSample(...)`. Both factors go back to gamma bytes before + // the multiply, the reference's gamma-space `SetBackdropColor` tint, so the blend is byte math. let texel = textureSample(sprite_texture, sprite_sampler, atlas_uv); return vec4( linear_to_srgb(in.color.rgb) * linear_to_srgb(texel.rgb), diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index 72c23cef..4b4d5636 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -1,93 +1,32 @@ -// Liquid shader — a port of the reference's **ADT** water path (`ocean0_s.bls`) to WGSL. +// Liquid shader: every liquid surface, one arm per reference liquid renderer. +// ADT MCLQ river/ocean (`0x6851b0`/`0x685010`): the `ocean0_s.bls` combine of the depth swatch +// on stage 0 and the animated sheet on stage 1; the only arm with a depth ramp. +// WMO MLIQ water (`0x6b62e0` category 0), split on `MOGP.flags & 0x48`: exterior `0x6b6630` +// binds `MapObjExtWater0.bls`, interior `0x6b6420` is fixed-function and unlit; one texture +// stage, alpha from the per-vertex authored byte. +// Magma/slime (`0x6b68f0` WMO, `0x68dca0` ADT): the sheet is the opaque body, ADT or WMO alike. // -// ## SCOPE — this file implements ONE of the reference's three liquid renderers +// The ADT combine, `Shaders\Pixel\ocean0_s.bls` in `patch.MPQ` (0.25 is the program's own `PARAM`): +// rgb = primary·colorTex.rgb + detail.rgb + (secondary + 0.25)·detail.a +// alpha = colorTex.a +// colorTex is the depth swatch (`swatch_row`) off the zone's `Light.dbc` water bands, taken raw +// (IntBand 16/17 river/lake, 14/15 ocean) and rebuilt every frame (`0x680b90`, refill `0x58acd0`). +// detail is the `lake_a`/`ocean_h` frame: RGB near-black, alpha the ripple, which the authored mips +// fade out with distance, so the sampler's mips and anisotropy matter. +// primary = clamp(ambient + diffuse·N·L): the vertex has no colour, so the default white is tracked +// into ambient+diffuse by `glColorMaterial`, and lighting is on at both water draws. +// secondary is the sun sheen (`sun_sheen`). +// Deviation: the reference's ADT water alpha is the `0xc7fbc0` LUT curve `1.6·(i/63)^8`; this keeps +// the linear swatch alpha, because switching changes the look of every ADT water surface. // -// This is the ADT MCLQ river/ocean path and nothing else. It is also, today, what benilla runs on -// WMO-embedded liquid, and that is **wrong** — see `liquid/surface.rs`'s `spawn_wmo_liquids` and the -// `wmo_liquid_arms` census. The reference has three: +// Deviation: this is the `specular`/`pixelShaders` = 1 leg. Both CVars (`0x6886a0`/`0x688712`) +// default to 0, where water has no program and no specular, a plain ADD combine and no blend; the +// reference install runs both at 1. An active ARB program bypasses the texture environment. // -// * **ADT MCLQ river/ocean** — passes `0x6851b0`/`0x685010`. Two texture stages: a static depth-ramp -// on stage 0, the animated sheet on stage 1, combined by the pixel program `ocean0_s.bls`. The -// only liquid path with a depth ramp, and the only one whose vertex carries NO colour. **This -// file.** -// * **WMO MLIQ water** — `0x6b62e0` category 0, split again on the group's `MOGP.flags & 0x48`: -// exterior `0x6b6630` (binds `MapObjExtWater0.bls`), interior `0x6b6420` (no shader, lighting -// forced off). One texture stage, no depth ramp at all — zero references to the ADT ramp globals -// `0xc7fbc0`/`0xc81768`/`0xc7fcd8` anywhere in `[0x6b0000, 0x6c4000)` — and alpha comes from a -// per-vertex authored byte, not from a depth swatch. 164 groups; 90% of them interior. -// * **magma/slime** — `0x6b68f0` (WMO) / `0x68dca0` (ADT), arm-blind, the sheet IS the body. -// -// ## The ADT combine (VERIFIED — the program is an asset, extracted and read) -// -// `Shaders\Pixel\ocean0_s.bls` out of `patch.MPQ`, verbatim ARB: -// -// PARAM c[1] = { { 0.25 } }; -// TEX R0, fragment.texcoord[0], texture[0], 2D; # colorTex = the depth ramp -// TEX R1, fragment.texcoord[1], texture[1], 2D; # detailTex = the animated sheet -// MAD R1.xyz, fragment.color.primary, R0, R1; -// ADD R0.xyz, fragment.color.secondary, c[0].x; -// MAD result.color.xyz, R0, R1.w, R1; -// MOV result.color.w, R0; # R0.w still holds colorTex.a -// -// ⇒ rgb = primary*colorTex.rgb + detailTex.rgb + (secondary + 0.25)*detailTex.a -// alpha = colorTex.a -// -// The `+0.25` is the program's own scalar `PARAM`, not an FFP env colour (`glTexEnvfv` has zero call -// sites image-wide) and not a material. **The formula this file has always carried is right, verbatim -// — but its provenance was fiction**: the header used to cite "apitrace WoW.17 program 159" and a -// `docs/knowledge/terrain.md`, neither of which exists, and wow-re had the program attributed to a -// *character* draw (`Model2.bls` ships 32 ARBfp permutations, none containing `0.25` or -// `fragment.color.secondary`). Corrected and recorded: wow-re `terrain/scratch/water-shading-law.md`. -// -// * colorTex (unit 0) is the depth swatch — a 2-endpoint linear lerp of the zone's dedicated -// `Light.dbc` water rows, RAW (no ×0.711): `water_shallow.rgb` = IntBand row 16 (river/lake) / 14 -// (ocean), `water_deep.rgb` = row 17 / 15. Rebuilt **per world frame**, not baked once: the dirty -// flags `[0xc8117c]`/`[0xc81b70]` clear at `0x680b90`/`0x680b97` and refill via `0x58acd0`, so the -// colour and the opacity track the zone and the clock. (The earlier "reflected sky × 0.711 via -// `FUN_0068c250`" model fingered the WRONG builder — a separate grey edge texture never bound on -// the water unit. Rows 14–17 were right all along.) -// * detailTex (unit 1) is the animated `lake_a`/`ocean_h` frame: RGB near-black, ALPHA = the ripple. -// MEASURED off the shipped BLPs (DXT3, 9 authored mips): lake_a RGB mean 0.0140 and achromatic to -// ±1 LSB, alpha mean 0.21 / p50 51 / p99 255. So it adds a faint flat lift + an achromatic shimmer -// on the crests — NOT the body. The authored mip chain deliberately kills the shimmer with -// distance (per-mip alpha max 255, 255, 255, 136, 68, then flat 51), which is why the sampler's -// mips and 16× aniso are load-bearing rather than a nicety. -// * primary = the vertex's lit colour `clamp(ambient + N·L·sun)`. The ADT liquid vertex has no -// colour element, so `glColor` is the device default `(1,1,1,1)` tracked into material -// ambient+diffuse by `glColorMaterial(FRONT_AND_BACK, AMBIENT_AND_DIFFUSE)`, and `GL_LIGHTING` is -// ON at both water draws (lava explicitly turns it off; water does not). -// * secondary = the specular sheen — see `sun_sheen` for what is verified in it and what is not. -// * alpha = swatch.a over the SAME V as the colour. LightParams endpoints: river 0.5→1.0, ocean -// 0.75→1.0. Deeper water = more opaque. **Open**: wow-re reads the byte-verified ADT water alpha -// as the `0xc7fbc0` LUT's `1.6·(i/63)^8` curve rather than the linear `127+2·row` this file -// applies — a much later-breaking ramp. Not changed here; it is a look change to every ADT water -// surface in the game and it belongs in the same A/B as the WMO arms. -// -// **Both `ocean0_s.bls` and `MapObjExtWater0.bls` are CVar-gated** — `specular` and `pixelShaders` -// (registered `0x6886a0`/`0x688712`) default to `"0"`, and with them off there is no program, no -// specular, the stage-1 combine is a plain ADD, and blend is never set so water draws OPAQUE. Our -// reference install's `Config.wtf` sets both to `"1"`, so every capture and every director comparison -// is against the shader leg — which is the leg we implement. The two do not compose: an active ARB -// program bypasses the texture environment entirely. -// -// A SINGLE swatch row (V) indexes both the colour and the alpha — they track together. Each kind reads -// its OWN verified LUT, built side by side in `FUN_0068c4c0`: `clamp(byte/42)` for river/lake -// (`c81768`, `FUN_0068d790`, saturates ~5 yd → the channel middle hits the deep teal row) and -// `clamp(byte/255)` for ocean (`c7fcd8`, `FUN_0068d690`, saturates ~148 yd). The divisors differ -// because the authored bytes do — the sea ramps 1.72 byte/yd against a river's 8.96, and 83 % of ocean -// vertices are pinned at 255 outright (decision 2069, `examples/liquid_depth_census`). (Earlier cuts: -// ripple-as-colour → black; ×8 over-saturated; FLAT colour killed the gradient; sky×0.711 was the wrong -// builder; `byte/255` on the RIVER was the wrong LUT → river middle never went teal. Corrected to -// rows 14–17 raw lerp + the /42 V on rivers, 2026-05-31.) -// -// Two-sided comes from the material (cull off) and is right for EVERY kind: all four reference liquid -// passes force GL_CULL_FACE off at pass entry against a cull-ON device baseline, and `glFrontFace` is -// not even imported (VERIFIED wow-re `liquid-render-state-sided` §6). Blending is per KIND, decided in -// liquid.rs: water/ocean blend with depth-write off; magma/slime are opaque and depth-write. Fog + gamma -// mirror terrain.wgsl (planar eye-Z GL_LINEAR fog in gamma space; raw gamma out — GAMMA LANE, 0161), fog -// applies to every kind — magma and slime included — and WHICH fog block a surface takes is per-surface: -// a WMO interior group's own pool fogs with the interior block, everything else with the scene block -// (see `apply_fog`). Light, fog and both water swatches all come off the ONE shared global-light buffer. +// The material turns culling off for every kind: all four reference liquid passes disable +// GL_CULL_FACE at entry. Blend is per kind, set CPU-side: water blends with depth write off, +// magma/slime are opaque and write depth. Every kind takes planar eye-Z GL_LINEAR fog in gamma +// space (`apply_fog`, as terrain.wgsl); output is raw gamma. #import bevy_pbr::{ mesh_functions, @@ -102,35 +41,29 @@ struct LiquidParams { // x = fullbright (magma/slime); y = ocean swatch; z = interior fog; w = sun-sheen shininess. kind: vec4, - // x = WHICH RENDERER (see `LiquidPath`): 0 = ADT MCLQ, 1 = WMO exterior, 2 = WMO interior. - // y/z/w reserved. + // x = renderer (`LiquidPath`): 0 = ADT MCLQ, 1 = WMO exterior, 2 = WMO interior; yzw reserved. path: vec4, - // x = reserved (frame 0); y = frame count; z = the SCROLL FLAG (1 only on the nibble-6/7 - // WMO magma/slime lane — the reference's animated stage-0 texture matrix; see - // `liquid/surface.rs`'s `scrolls`, and `apply_scroll` below); w = the clock enable (0 on a - // deterministic run — the whole animation freezes at frame 0 / scroll 0, the 0600 capture - // pin, baked at material build). + // x reserved; y = frame count; z = scroll flag (1 only on WMO magma/slime, liquid nibbles 6/7); + // w = clock enable (0 on a deterministic run: frame 0, no scroll). anim: vec4, }; @group(#{MATERIAL_BIND_GROUP}) @binding(102) var w: LiquidParams; -// The shared global light (`lighting::global_light`) — the SAME buffer terrain and the models read, -// mirrored here as its canonical row prefix. Liquid used to carry its own copy of every one of these -// values, re-pushed per material by `apply_wow_lighting`; that copy is what left it with only the -// scene fog and no way to see the interior block (decision 0691). +// The shared global light (`lighting::global_light`) that terrain and the models also read, +// mirrored as the prefix of its row layout, which must match row for row. struct WowLight { light_ambient: vec4, // 0 rgb = ambient; w = Mod2x scale light_diffuse: vec4, // 1 rgb = sun diffuse; w = clamp flag - light_sun: vec4, // 2 xyz = sun TRAVEL dir (to-light = −xyz) - light_spec: vec4, // 3 rgb = row-9 specular colour; w = TERRAIN shininess (liquid uses w.kind.w) + light_sun: vec4, // 2 xyz = sun travel direction (to-light = −xyz) + light_spec: vec4, // 3 rgb = row-9 specular colour; w = terrain shininess, unread fog_color: vec4, // 4 rgb = scene fog (block 1, gamma 0..1); w = enable (>0.5) fog_params: vec4, // 5 x = start yd; y = end yd; w = the farclip wall - _sh: array, 6>, // 6-11 model SH coeffs — unread here + _sh: array, 6>, // 6-11 model SH coefficients, unread here _sh_c16: vec4, // 12 - water_river: array, 2>, // 13-14 shallow/deep river-lake swatch (IntBand 16/17); w = alpha - water_ocean: array, 2>, // 15-16 shallow/deep ocean swatch (IntBand 14/15); w = alpha + water_river: array, 2>, // 13-14 river/lake shallow, deep (IntBand 16/17); w = alpha + water_ocean: array, 2>, // 15-16 ocean shallow, deep (IntBand 14/15); w = alpha _grade: vec4, // 17 - wmo_fog_color: vec4, // 18 rgb = INTERIOR fog (block 2); w = enable + wmo_fog_color: vec4, // 18 rgb = interior fog (block 2); w = enable wmo_fog_params: vec4, // 19 x = start yd; y = end yd }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight; @@ -141,112 +74,66 @@ struct LiquidVsOut { @location(1) world_normal: vec3, @location(2) uv: vec2, @location(3) depth: f32, - // The sun-sheen `secondary` evaluated PER-VERTEX (the faithful 1.12 path: the real client computes - // the Blinn highlight in its FFP vertex shader and interpolates it across the coarse water mesh). - // The fragment shader uses this interpolated value directly. + // The sun sheen, evaluated per vertex and interpolated, as the reference's fixed-function + // vertex stage computes it. @location(4) secondary_vtx: vec3, - // The mesh's vertex COLOUR, which on a WMO INTERIOR pool carries its `MOMT.diffColor` body - // colour — the reference's own interior water vertex carries a colour dword for exactly this. - // White on every other lane (and on any mesh with no colour attribute), where nothing reads it. + // Vertex colour: a WMO interior pool's `MOMT.diffColor`, as the reference's interior water + // vertex carries it; white elsewhere. @location(5) vcolor: vec4, - // The per-frame INTERIOR FOG lane for this surface's own room (decision 1787): the client's - // `[0xca7f00]`, which gates the WMO liquid pass's block-2 submit (`0x6b6323`–`0x6b6342`) - // exactly as it gates the geometry pass — so a pool and the walls around it can never - // disagree about which fog they wear. Carried on `MeshTag` bit 30, read once in the vertex - // stage and flat-interpolated (the whole surface is one instance). + // `MeshTag` bit 30: the room's per-frame interior-fog gate, the reference's `[0xca7f00]`; flat, + // since each surface is one instance. @location(6) @interpolate(flat) room_fog: u32, } -// Sun sheen (`secondary`): a Blinn highlight of the sun on the flat water surface — the glint that's -// strongest at grazing (sunrise/sunset) sun. `secondary = light_spec.rgb · (N·H)^shininess`. Shared by -// both stages so the per-vertex (faithful) and per-pixel (current) paths run IDENTICAL math — only the -// EVALUATION DOMAIN differs (interpolated vs evaluated per fragment). +// Sun sheen (`secondary`): the Blinn highlight `light_spec.rgb · (N·H)^shininess`. fn sun_sheen(world_normal: vec3, world_pos: vec3) -> vec3 { let n = normalize(world_normal); let to_light = -normalize(wow_light.light_sun.xyz); - // LOCAL viewer, not the infinite-viewer constant: the reference sets - // `GL_LIGHT_MODEL_LOCAL_VIEWER = 1` at `0x59cf89`, so `H = normalize(L + normalize(eye − vertex))` - // and the eye vector is recomputed per vertex (VERIFIED wow-re `water-shading-law.md`). It is - // the difference between a highlight and a flood: with the infinite viewer `N·H` is very nearly - // CONSTANT over a flat water plane, so at high sun the whole sheet saturates at once instead of - // carrying a glint that moves with the camera. + // Local viewer: the reference sets `GL_LIGHT_MODEL_LOCAL_VIEWER = 1` at `0x59cf89`, so the eye + // vector is per vertex. With an infinite viewer N·H is nearly constant over flat water and the + // whole sheet saturates at once. let to_view = normalize(view.world_position.xyz - world_pos); let half_v = normalize(to_light + to_view); let ndoth = max(dot(n, half_v), 0.0); - // The reference's specular is additionally gated on `N·L > 0` (the fixed-function rule: a light - // behind the surface contributes no specular). **Deliberately not ported**, because it cannot - // fire here: `DayNight::SetDirection` holds the LIGHTING sun's azimuth constant and wobbles its - // elevation only between +20° and +37° — it is always above the horizon, night being the colours - // going dark rather than the sun setting (`lighting::daynight::sun_direction`, and the separate - // *visible* sun is the one that rises and sets). Against a liquid surface's flat up normal `N·L` - // is therefore positive at every minute of the day, so the gate is a branch that can only ever - // take one arm. Named rather than added: it is real, it is verified, and it is inapplicable. + // The fixed-function `N·L > 0` specular gate is left out: the lighting sun's elevation stays + // between +20° and +37° (`DayNight::SetDirection`), so N·L on the flat up normal is always > 0. // - // Shininess is WATER's own (`w.kind.w`), not the shared row-3 terrain exponent. Material - // specular is white (`SetRenderState(3, 0xffffffff)`) and the exponent is 6.0 (`[0x8102e8]`), - // both VERIFIED — so `light_spec.rgb` is the whole scale, and it is the one input here that is - // still INFERRED: wow-re pinned the mechanism (`CGLight+0x48` → `collector+0x6c` → `0x589d80(0)` - // → `glLightfv(GL_SPECULAR)`) but not the number, and reads it as a warm ≈(1.0, 0.91, 0.76) - // against the row-9 feed we use. Left on row 9 until that lands rather than swapped for an - // estimate. + // Shininess is water's own `w.kind.w` (6.0, `[0x8102e8]`), not the terrain exponent; material + // specular is white (`SetRenderState(3, 0xffffffff)`), so `light_spec.rgb` is the whole scale. + // The reference's specular light colour (`CGLight+0x48` to `glLightfv(GL_SPECULAR)`) is not + // pinned; row 9 stands in for it. return wow_light.light_spec.rgb * pow(ndoth, max(w.kind.w, 1.0)); } -// The lava/slime **surface scroll**: the reference's animated stage-0 texture matrix, which for -// liquid-type nibbles 6 and 7 is the identity with element 13 — the **v translate** — set to -// `fmod(uptime_s, 10.0) · 0.1` (VERIFIED wow-re `liquid-uv-scroll-law.md`, six-agent §5, matrix -// built at `0x6b68f0` and pushed at stage 0 by `0x6b6ae3`). A texture matrix times `(s, t, 0, 1)` -// with only element 13 non-identity is exactly `t += phase`, so the whole mechanism is this add — -// no matrix, no second sampler, no cost on the paths that do not scroll (`anim.z` is a hard 0 -// there, which the CPU side guarantees rather than the shader branching on it). -// -// A full repeat every 10 s, so `REPEAT` wrapping makes the sawtooth's reset invisible. Only the -// rate and the period are reproducible — the reference's phase comes off `GetTickCount`, i.e. the -// machine's uptime, so its absolute value is not a thing to match. -// The liquid clock: `globals.time` (the same wall-elapsed seconds the CPU cycler used to read) -// under the build-time enable. Both animations below are pure functions of it — the CPU-side -// 24 Hz `Assets::get_mut` tick this replaces mutated ~14 materials a tick and its Modified -// fallout (uniform re-uploads, bind-group rebuilds, whole-population `AssetChanged` arming) -// measured 0.28 cpu_ms/frame at the SW pin (2026-08-18 bracket). +// The liquid clock: `globals.time` seconds under the build-time enable `anim.w`. fn anim_time() -> f32 { return w.anim.w * globals.time; } -// The 24 fps frame flip — 30 frames over 1.25 s (VERIFIED `FUN_0068aac0`), floor-quantized to -// the tick exactly as the reference's integer frame index is. +// The 24 fps frame flip, 30 frames over 1.25 s (`FUN_0068aac0`), floored to an integer frame as in +// the reference. fn frame_layer() -> i32 { return i32(floor(anim_time() * 24.0) % max(w.anim.y, 1.0)); } fn apply_scroll(uv: vec2) -> vec2 { - // v += (t mod 10) · 0.1 — repeats/s `[0x801620]` = 0.1, period `[0x80e5a0]` = 10.0 (VERIFIED - // wow-re `liquid-uv-scroll-law.md` §5): a sawtooth over exactly one repeat, invisible under - // REPEAT wrapping. CONTINUOUS now, where the CPU tick quantized it to 1/24 s — the reference - // itself rebuilds the matrix per draw off a millisecond clock, so this is the more faithful - // reading, not a new liberty. anim.z is the flag: hard 0 on every non-scrolling lane. + // Magma/slime scroll (liquid nibbles 6/7): the reference's stage-0 texture matrix (built at + // `0x6b68f0`, pushed at `0x6b6ae3`) is the identity with element 13, the v translate, set to + // `fmod(t, 10) · 0.1` (rate `[0x801620]`, period `[0x80e5a0]`), which is `t += phase`. It is + // rebuilt per draw off a millisecond clock, so the scroll is continuous; its phase is machine + // uptime (`GetTickCount`), so only rate and period match. REPEAT wrapping hides the reset. return vec2(uv.x, uv.y + w.anim.z * fract(anim_time() / 10.0)); } -// Distance fog — planar eye-Z, GL_LINEAR, gamma space (mirrors terrain.wgsl). Applied to EVERY liquid -// kind, because the reference never disables fog for a liquid batch: the device default for GL_FOG is -// **ON** (`0x593bf0` writes state id `0x0f` = 1) and all 42 fog-enable setters in the binary are -// Push/Pop-scoped, so what a batch inherits at its draw is that default. The ADT lava pass sets only -// cull/lighting/blend (`0x6855ca`/`0x6855d6`/`0x6855e2`) and the WMO magma/slime arm sets only lighting -// (`0x6b6afe`) — neither touches fog — while the WMO *river* arm goes out of its way to re-assert -// `(0x0f, 1)`, which only makes sense in a world where fog-on is liquid's intended state. (VERIFIED -// wow-re `liquid-render-state-sided` §1–§3, §5.) -// -// WHICH fog is a per-surface choice, and it is the reference's own (VERIFIED wow-re `fog-env-state` -// §5, the complete 6-site submit census). The device holds two fog blocks: **block 1** (`+0x70/74/78`) -// is the scene fog, submitted once a frame from `WorldFrame::Render` (`0x66ff20`), and **block 2** -// (`+0x80/84/88`) is block 1 smoothed toward the MFOG/zone target over ~4 s (`0x6cf054`+) — the -// interior haze. Only two call sites in the whole binary re-submit block 2, and they are the WMO -// *geometry* pass (`0x6b51d9`/`0x6b51ea`) and the WMO *liquid* pass (`0x6b6323`–`0x6b6342`), both under -// the same `[0xca7f00]` gate. So an interior room's pool takes the room's fog, in lockstep with the -// walls around it; ADT liquid submits nothing and draws under the scene block. `w.kind.z` is the STATIC -// half of that gate, resolved at spawn from the group's `MOGI & 0x48`; the per-frame half is the -// room's own `[0xca7f00]` bit on `MeshTag` bit 30 (decision 1787 — the flood decides it, and the -// WMO geometry lane reads the same answer, which is what keeps the two in step). +// Distance fog: planar eye-Z GL_LINEAR in gamma space, as terrain.wgsl. Every liquid kind is +// fogged: GL_FOG defaults on (`0x593bf0` sets state `0x0f` = 1) and no liquid pass turns it off. +// The device holds two fog blocks: block 1 (`+0x70/74/78`) is the scene fog, submitted each frame +// from `WorldFrame::Render` (`0x66ff20`); block 2 (`+0x80/84/88`) is the interior haze, block 1 +// eased toward the MFOG/zone target over about 4 s (`0x6cf054`). Only the WMO geometry pass +// (`0x6b51d9`/`0x6b51ea`) and the WMO liquid pass (`0x6b6323` to `0x6b6342`) submit block 2, both +// under the room gate `[0xca7f00]`, so an interior pool fogs like its walls and ADT liquid takes +// block 1. `w.kind.z` is the gate's static half (the group's `MOGI & 0x48`), `room_fog` the +// per-frame half. fn apply_fog(rgb: vec3, world_pos: vec3, room_fog: u32) -> vec3 { var fog_color = wow_light.fog_color; var fog_span = wow_light.fog_params.xy; @@ -277,56 +164,31 @@ fn vertex(in: Vertex) -> LiquidVsOut { #else out.vcolor = vec4(1.0); #endif - // Per-vertex MCLQ depth (0..1) packed into UV1.x; drives the opacity ramp. + // Depth coordinate V (0..1) in UV1.x: the swatch row on ADT water, the alpha ramp on WMO. out.depth = in.uv_b.x; - // The faithful per-vertex sun sheen — interpolated across the coarse mesh by the fragment stage. out.secondary_vtx = sun_sheen(out.world_normal, out.world_position.xyz); - // `MeshTag` bit 30 — see `LiquidVsOut::room_fog`. ADT surfaces carry no tag (0 ⇒ scene fog, - // which is their only lane anyway). + // ADT surfaces carry no `MeshTag`, so they take the scene fog. out.room_fog = mesh_functions::get_tag(in.instance_index) & 0x40000000u; return out; } -// ── The ADT depth swatch, as the reference actually BUILDS and SAMPLES it ──────────────────── +// ── The ADT depth swatch ───────────────────────────────────────────────────────────────────── // -// `FUN_0068a830` fills an 8×64 texture (U inert — each row is `rep stosd`-replicated across all 8 -// columns, matching the vertex fill's pinned `u = 0.5`). Its rows are an exact 32-bit integer -// accumulator in **byte space**, not a float lerp: -// -// step = ((c1 - c0) << 8) >> 6 ; == 4*(c1 - c0) EXACTLY (six zero low bits, so the sar -// acc = c0 << 8 ; acc += step ; cannot truncate -> no accumulator drift over 64 rows) -// row(i) = (acc >> 8) & 0xff ; == c0 + floor(i*(c1 - c0) / 64), i = 0..63 -// -// Two things that costs us, both missed until wow-re's ocean §5 round (decision 2074): -// -// * **the ramp never reaches the deep endpoint.** Row 63 is `c0 + floor(63*d/64)`, ≈98.4 % of the -// way, not `c1`. Our old `mix(shallow, deep, V)` ran the last 1/64 of the ramp that does not -// exist. -// * **the ocean's last row alone is darkened.** The tail `[0x68a9c0, 0x68aa36)` is gated on *last -// row* AND *selector == 0* (ocean; river is selector 1 and gets neither): RGB→HSV, -// `0x68aa13 fmul [0x8102ec]` — **V *= 0.9** (`0x3f666666`, the f32 nearest 0.9) — HSV→RGB, then -// `0x7bbec0`/`0x7bbec8` forcing that row's alpha to 255. `0x7bbd60`'s HSV→RGB writes every -// channel as a product with V and the tail touches neither H nor S (and `S == 0` returns -// `(V,V,V)` without reading H, so achromatic input takes no hue shift), so the colour half is -// exactly `floor(0.9 * byte)` per channel — transcribed at f32 and run over all 2^24 byte -// triples: 99.03 % bit-exact, **max deviation 1/255**. -// -// It is not a corner case: ~80 % of the ocean vertices in the shipped world carry depth byte 255, -// so `V = 1.0` and this row IS the open sea (decision 2069's census). -// -// Sampling is **LINEAR/LINEAR, no mip, D3DTADDRESS_CLAMP** — flags word `0x201`, `0x5a2a18 and 7` -// -> `0x85c7d8` row 1 `{MAG, MIN, MIP} = {2, 2, 0}`, `0x5a2a62 shr 3` -> `0x80a254[0] = 3`; -// corroborated by a GL capture of this exact texture (8x64, levels 1, CLAMP_TO_EDGE, LINEAR/LINEAR). -// So V maps to the texel coordinate `V*64 - 0.5` and blends across neighbouring rows: the darkening -// **ramps in over the final 1/64 of V**, it does not step. That band is the only place any of this -// is visible on a shore. -// -// The WMO arms do NOT come through here — their opacity is a different, 256-entry ramp -// (`0xca7f10`), whose 1/256 granularity the plain lerp above reproduces. +// `FUN_0068a830` fills an 8×64 texture, each row replicated across the 8 columns, with an exact +// byte-space integer accumulator rather than a float lerp (`step = ((c1 - c0) << 8) >> 6` loses +// no bits): `row(i) = c0 + floor(i * (c1 - c0) / 64)`, i = 0..63, so row 63 stops short of the +// deep endpoint. +// On the ocean only (selector 0), the last row's HSV value channel is scaled by 0.9 (`0x68aa13 +// fmul [0x8102ec]`), which `floor(0.9 * byte)` per channel reproduces to within 1/255, and its +// alpha is forced to 255 (`0x7bbec0`/`0x7bbec8`). +// Sampling is LINEAR/LINEAR, no mip, clamped (flags `0x201`), so V maps to texel `V*64 - 0.5` and +// the ocean darkening ramps in over the last 1/64 of V instead of stepping. +// The WMO arms do not use this: their opacity is the 256-entry ramp `0xca7f10`, which a plain lerp +// reproduces. fn swatch_row(shallow: vec4, deep: vec4, i: f32, ocean: bool) -> vec4 { - // RGB endpoints arrive already BYTES (`0x68a8fb`/`0x68a902` read two packed dwords straight out - // of DayNight state — there is no quantization step for RGB at all); the ALPHA endpoints are - // `LightParams` floats the reference quantizes `floor(v*255)` first. + // RGB endpoints are bytes already (`0x68a8fb`/`0x68a902` read packed dwords from DayNight + // state), so they round back exactly; alpha endpoints are `LightParams` floats the reference + // quantizes with `floor(v*255)`. let c0 = vec4(round(shallow.rgb * 255.0), floor(shallow.w * 255.0)); let c1 = vec4(round(deep.rgb * 255.0), floor(deep.w * 255.0)); let row = c0 + floor(i * (c1 - c0) / 64.0); @@ -349,9 +211,7 @@ fn swatch_at(shallow: vec4, deep: vec4, v: f32, ocean: bool) -> vec4 @location(0) vec4 { - // HARD FAR-CLIP WALL (same as terrain/models, see terrain.wgsl): discard water beyond the - // projection far plane so lakes/rivers don't render past the wall. `fog_params.w` = farclip - // (0 ⇒ disabled). + // The far-clip wall, as terrain and models: discard beyond `fog_params.w` (0 disables it). if (wow_light.fog_params.w > 0.0) { let clip_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; if (clip_z > wow_light.fog_params.w) { @@ -359,9 +219,8 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { } } - // Animated frame. For water/ocean this is the DETAIL ripple (RGB ≈ near-black, ALPHA = ripple); - // for magma/slime it is the OPAQUE BODY texture. - // `view.mip_bias`: the render-scale LOD compensation (1639), 0.0 at native and above. + // The animated frame: the ripple on water, the opaque body on magma/slime. `view.mip_bias` is + // the render-scale LOD compensation, 0 at native scale and above. let detail = textureSampleBias( frames, frames_samp, @@ -370,69 +229,34 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { view.mip_bias, ); - // Magma / slime (kind.x > 0.5): the animated texture IS the opaque body colour — no depth swatch - // (the ADT liquid vertex format carries no colour element at all, and the WMO one is a hard - // `0xffffffff`, so there is nothing to modulate the sheet by) and no N·L (lighting state 0 on both - // paths). It IS fogged, like every other liquid batch. - // - // The earlier "emissive / no-darken / no fog" reading here was WRONG, and wrong twice over: it came - // from the ADT-lava row of `rf-water-liquid-type-texture-material`, which read GX state `0x37` as an - // emissive path when `0x37` is the per-stage TEXTURE-MATRIX enable pushing an identity — a texgen - // *reset*; and that row is the ADT queue, which never dispatches slime at all (Undercity's slime is - // WMO liquid). Skipping fog is what made a submerged slime surface a flat unshaded sheet at any - // depth instead of one that recedes into the murk. (VERIFIED wow-re `liquid-render-state-sided` - // §3/§3.1/§5, which corrects that row.) + // Magma/slime: the sheet is the opaque body, unmodulated (the ADT vertex has no colour, the WMO + // one is `0xffffffff`) and unlit (lighting off on both paths), but fogged. if (w.kind.x > 0.5) { return vec4(apply_fog(detail.rgb, in.world_position.xyz, in.room_fog), 1.0); } - // Per-vertex swatch coord V (in `in.depth`, computed CPU-side in wow-formats/liquid.rs): river/lake - // = `clamp(byte/42)` (VERIFIED WoW.exe `c81768` LUT / `FUN_0068d790`, saturating ~5 yd so the channel - // middle reaches the deep/teal row), ocean = `clamp(byte/255)` (VERIFIED `c7fcd8` / `FUN_0068d690`, - // its own LUT on its own authored byte scale — decision 2069). The depth swatch - // is a plain 2-endpoint lerp (`FUN_0068a830`), so a SINGLE V indexes BOTH the colour and the alpha - // row: colour `shallow→deep` and opacity `shallow_α→deep_α` track together. (Earlier `×4` colour - // compression + the gentle `byte/255` V were band-aids for a wrong "V tops at 0.31" belief — removed.) + // V, computed CPU-side from the authored depth byte: clamp(byte/42) on river/lake (LUT + // `0xc81768`, `FUN_0068d790`, saturating near 5 yd), clamp(byte/255) on ocean (LUT `0xc7fcd8`, + // `FUN_0068d690`); both LUTs are built in `FUN_0068c4c0`. One V indexes colour and alpha alike. let depth = clamp(in.depth, 0.0, 1.0); - // The kind's swatch endpoints, off the shared light: ocean reads rows 15/16 (IntBand 14/15), - // river/lake rows 13/14 (IntBand 16/17). Both are packed every frame by `build_light_data`. + // The kind's swatch endpoints, packed every frame by `build_light_data`. var shallow = wow_light.water_river[0]; var deep = wow_light.water_river[1]; if (w.kind.y > 0.5) { shallow = wow_light.water_ocean[0]; deep = wow_light.water_ocean[1]; } - // ---- The two WMO water arms ------------------------------------------------------------ - // - // Neither is the ADT combine below. `0x6b62e0`'s category 0 splits on the owning group's - // `MOGP.flags & 0x48`, and both halves bind ONE texture (the animated sheet) with no depth ramp - // anywhere — there is not a single reference to the ADT ramp globals `0xc7fbc0`/`0xc81768`/ - // `0xc7fcd8` in all of `[0x6b0000, 0x6c4000)`. Opacity on both is the per-vertex authored byte - // through the zone's linear alpha ramp, which `in.depth` carries and this lerp reproduces - // (`wmo_water_alpha_v`). VERIFIED wow-re `terrain/scratch/water-shading-law.md` §11. + // ---- The WMO water arms: opacity is the per-vertex authored byte through the zone's linear + // alpha ramp (`wmo_water_alpha_v`), carried in `in.depth`. let vtx_alpha = mix(shallow.w, deep.w, depth); if (w.path.x > 1.5) { - // ---- WMO INTERIOR (`0x6b6420`) — 134 of the game's 164 water groups, Blackfathom included. - // - // Fixed-function, always: the kernel body contains no `mov ecx,0x3f` at all (a positive - // finding — the same scan on the exterior kernel finds two), and `[0xc9607c]`, the - // specular/pixelShaders gate, is never read on this path. So it ignores those CVars, runs - // with lighting OFF (`0x0e = 0`) and fog ON (`0x0f = 1`), and its whole output is the - // combine preset `(0x1f, 3)` over one texture stage: - // + // ---- WMO interior (`0x6b6420`): fixed-function whatever the CVars (`[0xc9607c]` is never + // read here), lighting off (`0x0e = 0`), fog on (`0x0f = 1`), combine preset `(0x1f, 3)`: // rgb = clamp(Cf + Ct) alpha = clamp(Af + At) - // - // `Cf` is the pool's `MOMT[materialId].diffColor` taken RAW — baked into the mesh's vertex - // colour, which is where the reference's own 6-float vertex carries it. NO sun term and no - // sheen: that vertex has no normal to compute one from. - // - // The ALPHA op is the part worth stating. Preset 3 is `GL_ADD` on both channels via - // `GL_COMBINE` (`COMBINE_ALPHA = GL_ADD` @`0x85c2fc`, operands left at the GL default), NOT - // the legacy `GL_TEXTURE_ENV_MODE = GL_ADD` whose alpha would be `Af · At`. The client takes - // the COMBINE path because `GL_ARB_texture_env_combine` is GL 1.3 core. So the ripple **adds** - // to the pool's opacity instead of multiplying it away — a pool is at its authored opacity in - // the troughs and saturates opaque on the crests, which is the opposite of what the legacy - // reading would have drawn. + // Cf is the pool's `MOMT.diffColor`, raw, carried in the vertex colour; the vertex has no + // normal, so no sun term and no sheen. Preset 3 is `GL_ADD` through `GL_COMBINE` on both + // channels (`COMBINE_ALPHA` at `0x85c2fc`), not the legacy `GL_TEXTURE_ENV_MODE = GL_ADD` + // whose alpha would be `Af · At`: the ripple adds to the pool's opacity. let body = clamp(in.vcolor.rgb + detail.rgb, vec3(0.0), vec3(1.0)); return vec4( apply_fog(body, in.world_position.xyz, in.room_fog), @@ -440,42 +264,15 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { ); } if (w.path.x > 0.5) { - // ---- WMO EXTERIOR (`0x6b6630`) — Stormwind's canals and fountains. - // - // This arm DOES bind a pixel program, `Shaders\Pixel\MapObjExtWater0.bls` (bound at - // `0x6b6654`, unbound `0x6b689c`), under the `[0xc9607c]` specular/pixelShaders gate. Both - // CVars default to "0", but our reference install's `Config.wtf` sets both to "1", so the - // shader leg is what every director comparison is against and it is the leg we implement. - // Decoded verbatim from the asset: - // + // ---- WMO exterior (`0x6b6630`): `Shaders\Pixel\MapObjExtWater0.bls` (bound `0x6b6654`), + // the shader leg of the `[0xc9607c]` gate: // rgb = primary.rgb + detail.rgb + secondary·detail.a alpha = primary.a - // - // **No `+0.25`.** That constant is the ADT program's own `PARAM` and has no counterpart - // here, so carrying it over — which is what we did — added a flat achromatic lift to every - // canal pixel at all times, sun or no sun. Against the sheet's real texel distribution that - // is ~3.5x the ripple contrast off the glint, and it is why the reference's canal shimmers - // only where the sun is while ours sparkled everywhere. - // - // `primary` is FFP-lit over a constant up-normal with the vertex colour tracked into - // ambient+diffuse (`glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)`), so the band - // is the MATERIAL colour and `primary = band · clamp(ambient + diffuse·max(N·L, 0))`. - // Lighting really is on here: neither this kernel nor its dispatch touches render-state id - // `0x0e`, and the control that such a call would be findable is the interior kernel, which - // does exactly that at `0x6b65bf`. - // - // The band is a SINGLE one — there is no bathymetry to lerp by — and it is the **deep** river - // row, `LightIntBand` sub-17, i.e. `water_river[1]`. Read as a hard immediate - // (`0x6b66be add edi, 0xec`), so nibbles 0, 4 and 8 all take it; exterior ocean cannot arise - // (category 2 falls to a bare epilogue with no draw). - // - // **Sub-17, not sub-16, and the distinction cost a round trip.** A DayNight band *slot* is not - // a `LightIntBand` *sub*: `0x6d64d0` displaces sub-8 out to record `+0x4c`, so `sub = slot + 1` - // across slots 8–16, and the kernel's slot 16 is sub-17. The shipped data says the same thing - // twice over — across all 367 LightParams rows carrying river bands, sub-16 is browns, olives - // and muddy yellows (G > B in 71%: the shallow colour of water over a riverbed) while sub-17 is - // blues and teals. At Stormwind sub-16 is `(79, 93, 20)`, which renders the canals olive-green; - // sub-17 is `(51, 82, 85)`. The ADT ramp runs sub-16 → sub-17 across its 64 texels; WMO - // exterior water takes the deep end alone, flat. + // There is no `+0.25`: that is the ADT program's own `PARAM`. Lighting is on (the kernel + // never sets `0x0e`) with the vertex colour tracked into ambient+diffuse, so + // primary = band · clamp(ambient + diffuse·max(N·L, 0)). The band is one flat colour for + // every nibble, the deep river row `LightIntBand` sub-17 (`water_river[1]`), a hard + // immediate at `0x6b66be`. A DayNight slot is not a sub: `0x6d64d0` moves sub-8 out to + // `+0x4c`, so slots 8 to 16 are subs 9 to 17 and the kernel's slot 16 is sub-17. let n_ext = normalize(in.world_normal); let to_light_ext = -normalize(wow_light.light_sun.xyz); let primary_ext = clamp( @@ -485,12 +282,12 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { vec3(1.0), ) * deep.rgb; let rgb_ext = primary_ext + detail.rgb + in.secondary_vtx * detail.a; - // `result.color.w = fragment.color.primary` — the vertex alpha ALONE. The bound program - // bypasses the texture environment entirely, so the interior arm's `+ At` does not apply here. + // Alpha is `fragment.color.primary` alone: the bound program bypasses the texture + // environment, so the interior arm's `+ At` does not apply. return vec4(apply_fog(rgb_ext, in.world_position.xyz, in.room_fog), vtx_alpha); } - // Body colour: lit vertex colour × the depth-lerped water-row swatch colour (`primary·colorTex`). + // The ADT arm. `primary`: the lit white vertex, clamp(ambient + diffuse·N·L). let n = normalize(in.world_normal); let to_light = -normalize(wow_light.light_sun.xyz); let ndotl = max(dot(n, to_light), 0.0); @@ -500,33 +297,19 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { vec3(1.0), ); - // Sun sheen (`secondary`): the `ocean0_s.bls` Blinn highlight, computed PER-VERTEX in `fn vertex` - // and interpolated across the coarse ~4 yd MCLQ mesh — the faithful 1.12 path (the real client - // evaluates it in its FFP vertex stage). Per-pixel evaluation of the sharply-peaked `pow(N·H,6)` - // would fill its broad lobe at full value (a brighter, denser sheen); interpolating from the - // vertices flattens the peak to match the reference. (A per-pixel/per-vertex A/B toggle proved the - // two visually identical on our mesh — we keep per-vertex as the faithful mechanism; RE: - // `docs/knowledge/scratch/liquid-depth/fleck-deep.md`.) let secondary = in.secondary_vtx; - // The stage-0 depth swatch, built and sampled as the reference does (see `swatch_row`): a - // byte-space 64-row ramp that stops short of the deep endpoint, LINEAR across rows, with the - // ocean's last row darkened `floor(0.9*byte)` and its alpha forced opaque. + // colorTex: the stage-0 depth swatch. let swatch = swatch_at(shallow, deep, depth, w.kind.y > 0.5); - // primary·colorTex.rgb + detail.rgb + (secondary + 0.25)·detail.a (the ocean0_s.bls math) + // The `ocean0_s.bls` combine. var rgb = primary * swatch.rgb + detail.rgb + (secondary + vec3(0.25)) * detail.a; - // Opacity: depth ramp between the shallow/deep LightParams water alphas, over the SAME V as the - // colour. Deeper = more opaque, up to α=1.0 where V saturates (river/lake byte 42 ≈ 5 yd), so the - // channel middle is opaque + teal while the shore stays semi-transparent (V→0, α≈0.5) and the bottom - // shows through (faithful — the pale edge band). One steep V drives both colour and opacity together. + // Alpha is `colorTex.a`, over the same V as the colour: deeper water is more opaque. let alpha = swatch.w; - // Distance fog (see `apply_fog`) — the water fog colour is also teal, so far water converges on the - // haze. rgb = apply_fog(rgb, in.world_position.xyz, in.room_fog); - // GAMMA LANE (0161): raw gamma out; alpha blends in gamma like the reference's bytes. + // Raw gamma out; alpha blends in gamma space like the reference's bytes. return vec4(rgb, alpha); } diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl index e5ce32c2..11e898f9 100644 --- a/crates/benilla-assets/src/shaders/terrain.wgsl +++ b/crates/benilla-assets/src/shaders/terrain.wgsl @@ -1,29 +1,17 @@ -// Phase 6 terrain splat shader — extends Bevy's StandardMaterial with a CUSTOM vertex + fragment stage. +// Terrain splat shader: a custom vertex and fragment stage on Bevy's StandardMaterial. Blends up to +// four tiled layers by a per-chunk alpha map, lit as the reference's fixed-function terrain: +// diffuse = clamp(ambient (row 1) + diffuse (row 0)·max(N·L, 0) + Σ point·att·N·L), evaluated +// and clamped per vertex (GL T&L), Gouraud-interpolated, modulated 1× into the texture; +// specular = clamp(row 9 · max(N·H, 0)^20) per vertex (local viewer), times the sheen mask (the +// `_s` texture's per-texel alpha, blended like the colour), added after the modulate. +// The per-vertex evaluation and the mask both bound the sheen: evaluated per pixel, or unmasked, +// the near-white row 9 washes lit ground to cream. // -// 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. +// Bevy's `VertexOutput` has no slot for the interpolated specular, so the vertex transform and IO +// struct are our own and `main_pass_post_lighting_processing` is dropped; fog is in-shader. // -// 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. +// One material serves an ADT tile: layer textures in one `texture_2d_array` (binding 100), +// per-chunk alpha maps in another (104). #import bevy_pbr::{ mesh_functions, @@ -37,42 +25,36 @@ @group(#{MATERIAL_BIND_GROUP}) @binding(105) var splat_samp: sampler; @group(#{MATERIAL_BIND_GROUP}) @binding(110) var shadow_array: texture_2d_array; -// 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. +// Per-tile Vec4 uniforms, packed into one buffer (binding 106); the field order must match the Rust +// `TerrainExtension`. params.x = layer tiling factor; yzw unused. struct TerrainParams { params: vec4, }; @group(#{MATERIAL_BIND_GROUP}) @binding(106) var 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). +// The shared global light (`lighting::global_light`), one storage buffer updated in place once a +// frame; this mirrors its row layout, which must match. Read in both stages: the per-vertex sheen +// needs the sun and specular rows. struct WowLight { light_ambient: vec4, // rgb = row 1 ambient; w = Mod2x scale (×1). light_diffuse: vec4, // rgb = row 0 sun diffuse; w = clamp-light flag (>0.5 ⇒ saturate). - light_sun: vec4, // xyz = world-space sun TRAVEL direction (to-light = −xyz); w unused. + light_sun: vec4, // xyz = world-space sun travel direction (to-light = −xyz); w unused. light_spec: vec4, // rgb = row 9 specular color; w = shininess (20). rgb == 0 disables. fog_color: vec4, // rgb = row 7 fog (raw, gamma 0..1); w = enable (>0.5 ⇒ blend). fog_params: vec4, // x = fog_start yd; y = fog_end yd; z unused; w = farclip wall. - _sh: array, 6>, // rows 6-11: the model SH coeffs (live in wow_model.wgsl, 0354) — unread by terrain. - sh_c16: vec4, // row 12: xyz the models' c16 quad band; .w a FREE lane (the 0273 point gain is retired). - _water: array, 4>, // rows 13-16: the liquid swatches — unread by terrain. - grade: vec4, // reserved (the 0282 interior A/B retired; 0163). Layout only. - _wmo_fog: array, 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]`. + _sh: array, 6>, // rows 6-11: model SH coefficients, unread by terrain. + sh_c16: vec4, // row 12: xyz = the models' c16 quad band; w free. + _water: array, 4>, // rows 13-16: liquid swatches, unread by terrain. + grade: vec4, // row 17: reserved, layout only. + _wmo_fog: array, 2>, // rows 18-19: interior fog, unread by terrain. + // The dynamic point-light table (`global_light::build_light_data`): row 20 `.x` = live count, + // then two rows per light, `[pos.xyz, range]` and `[rgb, 0]`. point_count: vec4, points: array, 512>, }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var 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). +// The vertex-to-fragment payload; `specular` is clamped per vertex, then Gouraud-interpolated. struct TerrainVsOut { @builtin(position) clip_position: vec4, @location(0) world_position: vec4, @@ -80,50 +62,27 @@ struct TerrainVsOut { @location(3) uv_b: vec2, @location(4) color: vec4, @location(5) specular: vec3, - // 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). + // The Gouraud diffuse, summed and clamped per vertex as GL T&L does: an over-gamut light (a + // carried torch is (1.4, 0.87, 0.40)) saturates one vertex and falls off linearly from it, + // where clamping per pixel would pin a wide plateau at white. @location(6) primary: vec3, } -// 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`. +// Terrain's point-light candidacy half-width (yd): `w + 10`, `w` being the chunk's bounding-sphere +// radius `sqrt(2·16.666666² + (zExtent/2)²)`, 23.570166 on flat ground (constant at `0x68dfac`). +// Deviation: the flat `w` everywhere, so steep chunks gather slightly narrower than the reference, +// because the lights that differ are 33 yd out or more, 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. +// The dynamic point-light term at a world-space point. The reference commits at most three point +// lights per draw, the nearest by squared distance to the receiving unit, into GL slots 1-3; +// terrain's unit is the MCNK chunk, so `anchor` is its cell centre. Candidacy is a 20-yd +// spatial-hash sweep over `[floor((c - w - 10)/20), floor((c + w + 10)/20)]`, a Chebyshev box +// (`w` is `chunk+0x68`, copied at `0x71bc47`), whose guaranteed half-width `w + 10` is +// `TERRAIN_REACH`. +// A committed light is diffuse only (the staging ambient/specular slots are zeroed at `0x71c7e3`), +// falls off as `1/(0.7·d + 0.03·d²)` on the vertex normal, and has no distance cutoff. +// The M2 lane in wow_model.wgsl uses the packed per-light range: the lanes differ on purpose. fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 { let count = u32(wow_light.point_count.x); var sel = array(0u, 0u, 0u); @@ -163,23 +122,13 @@ fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 { 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). +// The MCNK chunk cell centre under a world point: the reference's light anchor, the chunk's world +// AABB centre `CMapChunk+0x5c..0x64` (written by `0x6b0e50`). The grid is fixed world-wide (chunk +// 533.33333/16 yd, half-extent 32 tiles) and symmetric; WoW x/y are Bevy −z/−x, so snapping Bevy +// x/z lands on the same cells. Mirrored in wow_model.wgsl, where clutter snaps to the same cells. +// Deviation: the anchor height is the vertex's own y, not the chunk's mid-height +// `(minH + maxH)/2`, so on relief one chunk's vertices can select different lights; the per-chunk +// constant is not plumbed to the vertex stage. On flat ground the two agree. fn mcnk_cell_anchor(P: vec3) -> vec3 { let cell = 533.33333 / 16.0; let half = 32.0 * 533.33333; @@ -200,9 +149,8 @@ fn vertex(in: Vertex) -> TerrainVsOut { 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. + // Sun specular per vertex: white material, row-9 colour, shininess `light_spec.w` (20), local + // viewer; clamped here, then Gouraud-interpolated. 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) @@ -210,10 +158,8 @@ fn vertex(in: Vertex) -> TerrainVsOut { let ndoth = max(dot(n, h), 0.0); out.specular = clamp(wow_light.light_spec.rgb * pow(ndoth, wow_light.light_spec.w), vec3(0.0), vec3(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. + // The whole diffuse sum on the MCNR normal, clamped per vertex: ambient, sun N·L, and the + // chunk's three nearest point lights with their over-gamut colours raw. 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( @@ -226,11 +172,9 @@ fn vertex(in: Vertex) -> TerrainVsOut { @fragment fn fragment(in: TerrainVsOut) -> @location(0) vec4 { - // 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. + // The far-clip wall: the reference clips the detailed world per pixel at its projection far + // plane (`farclip`, about 777 yd). Discard beyond `fog_params.w` (0 disables) on planar eye-Z, + // the fog's coordinate; WDL and sky draw behind it. if (wow_light.fog_params.w > 0.0) { let clip_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; if (clip_z > wow_light.fog_params.w) { @@ -238,27 +182,22 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { } } - // Per-chunk array indices, baked into the merged mesh: COLOR = 4 layer indices, UV1.x = alpha. + // Per-chunk array indices, baked into the merged mesh: COLOR = 4 layer indices, UV1.x = alpha; + // constant across a chunk, so they interpolate back to the integer. let li = vec4(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). - // `view.mip_bias` (decision 1639): a render scale below 1 doubles every derivative and so - // picks a coarser mip, and that blur is then stretched back up by the resolve. The bias undoes - // exactly that shift and is 0.0 at native and above, so this is an identity at scale 1. - // Only the LAYER array takes it: `alpha_array`/`shadow_array` are one 64x64 grid per chunk, - // built with a single mip level (terrain.rs `array_image`), so no bias could move them. + // `.rgb` = diffuse, `.a` = the `_s` texture's sheen mask (the base `.blp` has no alpha; matte + // without an `_s`). `view.mip_bias` undoes the coarser mip a render scale below 1 picks, 0 at + // native and above; the alpha and shadow arrays have a single mip, so only the layers take it. let s0 = textureSampleBias(layer_array, splat_samp, tiled, li.x, view.mip_bias); let s1 = textureSampleBias(layer_array, splat_samp, tiled, li.y, view.mip_bias); let s2 = textureSampleBias(layer_array, splat_samp, tiled, li.z, view.mip_bias); let s3 = textureSampleBias(layer_array, splat_samp, tiled, li.w, view.mip_bias); - // 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. + // The alpha and shadow maps (one 64² grid per chunk, in 0..1, not tiled) share the layers' + // repeat sampler, and under linear filtering the footprint at a chunk edge wraps to the map's + // opposite edge; the half-texel inset clamps it instead. let auv = clamp(in.uv, vec2(0.5 / 64.0), vec2(1.0 - 0.5 / 64.0)); let a = textureSample(alpha_array, splat_samp, auv, ai); @@ -267,27 +206,19 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { 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`.) + // The sheen mask, blended with the colour's splat weights (the fragment program's + // `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. + // MCSH baked shadow. With `pixelShaders` and `specular` on, terrain is one pass through + // `terrainp_s.bls`, which reads the MCSH bit from the blend texture's alpha (1 lit, 0 shadowed) + // and uses it twice, below; with them off the reference draws a separate ambient-tint overlay. + // Our `shadow_array` R8 is 255 shadowed, 0 lit. A chunk with no MCSH map (`uv_b.y < 0`) is lit. var shadow_lit = 1.0; let si = in.uv_b.y; if (si >= 0.0) { @@ -295,24 +226,16 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { 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). + // The `terrainp_s.bls` combine, then an LDR clamp: + // diffuse = tex · primary · (0.3·shadow + 0.7) (a flat −30% in shadow, no tint) + // specular = sheen · mask · shadow (none in shadow) 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(0.0), vec3(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. + // Gamma-space GL_LINEAR fog on planar eye-Z, not radial distance: the reference computes + // `fogcoord` from eye-Z, and radial distance over-fogs the screen edges. Per pixel matches the + // reference's per-vertex fog, since a linear factor interpolates exactly. if (wow_light.fog_color.w > 0.5) { let eye_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001); @@ -320,8 +243,7 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { 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. + // Raw gamma out: the framebuffer holds gamma bytes and blends in gamma like the reference's; + // the frame's one decode is the FFXGlow combine. return vec4(tuned, 1.0); } diff --git a/crates/benilla-assets/src/shaders/wdl.wgsl b/crates/benilla-assets/src/shaders/wdl.wgsl index 832600ba..d21bfa2b 100644 --- a/crates/benilla-assets/src/shaders/wdl.wgsl +++ b/crates/benilla-assets/src/shaders/wdl.wgsl @@ -1,56 +1,14 @@ -// WDL distant-terrain shader — the coarse horizon hills the reference draws beyond the streamed -// detailed tiles (docs/knowledge/terrain.md "WDL"). Both stages custom. +// WDL distant terrain: the coarse horizon hulls the reference draws past the detailed tiles, +// unlit, untextured, white vertex diffuse fogged with the scene's fog colour. The hull emitter +// `0x6bd780` sets its own fog pair, start 0 and end 1.0 (`0x6bd7ae`-`0x6bd7c8`), so beyond one +// yard the hull is the flat fog colour: a silhouette against the unfogged sky. // -// The reference draws WDL UNLIT + UNTEXTURED with white vertex diffuse under the scene's fog COLOUR -// (VERIFIED apitrace WoW.8: prog 96 vertex-white, fog.color matching the zone haze) — but NOT under -// the scene's fog DISTANCES. The hull emitter `0x6bd780` submits its own pair right before the draw -// (byte-read at `0x6bd7ae`–`0x6bd7c8`: `push 0; mov ecx,0xA; call 0x589e30` = fog start 0, then -// `push 1.0f; mov ecx,0xB; call 0x589e30` = fog end 1.0; wow-re `fog-env-state.md` §5's submit -// census, decision 1521). Linear fog `f = (end − z)/(end − start) = 1 − z` is zero for every fragment -// beyond one yard, so the hull is the fog colour, FLAT, at every distance — a "fog hull", which is -// what the binary calls it. What the band contributes is therefore pure silhouette: fog-coloured -// hills occluding the (un-fogged) sky. Gamma handling mirrors terrain.wgsl: the output is raw gamma -// — the buffer holds bytes and the frame's one decode lives in the FFXGlow combine (GAMMA LANE, 0161). -// -// It used to read the scene distances (`fog_params.xy`, start = frac × end, end = min(zone end, farclip)) -// and fog the white hull with them like a surface. Past the wall that is the same pure haze — but the -// 33 yd overlap below (`WDL_OVERLAP`) then sat INSIDE the fog ramp, mixed toward WHITE by up to -// `33 / (end − start)` = `44 / farclip` at the usual 0.25 start fraction: 25% at 177, 15% at 297, 13% at -// 350 — and 0 whenever the zone's own fog ends nearer than `farclip`, which is why the default 777 never -// showed it. Wherever the coarse hull rides above the fine mesh along a grazing ray — a crater rim, a -// dip the 33-yd sampling bridges — that pale hull showed above the fine terrain's fogged-out silhouette -// as a band brightest at its lower edge and fading upward, growing as the view distance dropped (the -// director's "white from beyond the cutoff showing through the fog", decision 1521). -// -// ## The far band is a BACKDROP, not the far half of a plane partition (decision 0684) -// -// The reference's far-band pass (`CWorldScene` drain `0x6841a0`, wow-re `terrain.md` + `rf35-render-list`) -// does NOT share a clip plane with the detailed world. It draws the coarse hulls under its own -// projection — `SetPerspective(fov, aspect, near = farclip − 33.0, far = horizonfarclip)`, byte-read at -// the two call sites `0x6842f2` (render) / `0x68317f` (the far walk's cull frustum), the `33.0` at -// `[0x8101b0]`, the horizon far from the runtime box `[0xc7b49c]` — into a **compressed depth range -// `[0.955, 0.96]`** (`[0x80febc]=0.96`, `[0x80fec4]=0.005`), i.e. squashed into the back half-percent of -// the depth buffer so it can never occlude anything the detailed world drew. Two properties fall out, -// and BOTH are load-bearing: -// -// * the band **overlaps** the detailed world by 33 yd (≈ one WDL outer cell, spacing 33.333) — so a -// coarse hull that crosses back inside the wall is still drawn, and no hole can open at the seam; -// * it is **depth-pushed**, never near-clipped at the wall — so that overlap cannot poke through the -// fine terrain, and nothing coarse near the camera can paint over the sky (its near plane stops it). -// -// We used to partition instead: terrain discarded past `farclip`, WDL discarded before it, sharing one -// plane with zero overlap. Two different surfaces cannot partition cleanly at one plane — wherever a ray -// grazed the coarse hull entirely INSIDE the wall while the fine mesh it approximates lay beyond it (a -// ridge crest, exactly), both passes discarded and the sky showed through as a band at the horizon -// (the director's "in-between area at the clip barrier", Weazel's Crater, 2026-07-26). -// -// Our depth buffer is Bevy's infinite REVERSE-Z (`perspective_infinite_reverse_rh`: depth = near/eye_z, -// 0 = infinitely far, and never exactly 0 for finite geometry), so the reference's `[0.955, 0.96]` -// window becomes a clamp rather than a `glDepthRange`: every detailed-world fragment lives at -// `depth ≥ near/farclip` (it is discarded past the wall), so clamping WDL just below that value puts the -// whole band behind the world by construction, while leaving it in front of the sky's forced `0.0` -// (`sky_order.rs`, "The depth law"). Order of the three backdrop tiers, all decided by depth and nothing -// else: sky (0.0) → WDL band → detailed world. +// The band is a backdrop, not the far half of a shared clip plane. The reference's far-band pass +// (`0x6841a0`, projection at `0x6842f2`) uses near `farclip - 33` (`[0x8101b0]`) and the depth +// range `[0.955, 0.96]` (`[0x80febc]` = 0.96, `[0x80fec4]` = 0.005): the band overlaps the detailed +// world by 33 yd, closing the seam, and stays behind all of it. On Bevy's infinite reverse-Z +// (depth = near / eye_z) that range becomes a clamp just below `near / farclip`, the farthest +// depth the detailed world keeps (it discards past the wall), still in front of the sky's 0.0. #import bevy_pbr::{ mesh_functions, @@ -59,28 +17,23 @@ mesh_view_bindings::view, } -/// How far the far band reaches back INSIDE the detailed world's far-clip wall — the reference's -/// far-band near plane `farclip − 33.0` (`[0x8101b0]`, one WDL outer cell). This overlap is what -/// closes the coarse-vs-fine seam; the depth clamp below is what keeps it harmless. +/// How far the band reaches inside the far-clip wall: the reference's far-band near plane +/// `farclip - 33.0` (`[0x8101b0]`, about one WDL outer cell). const WDL_OVERLAP: f32 = 33.0; -/// Nudge off the wall depth so the band is strictly BEHIND a detailed-world fragment sitting exactly -/// at the wall, instead of tying it (reverse-Z `GreaterEqual` lets a tie through, and the winner would -/// then be draw order). Multiplicative so it can never cross zero into the sky. +/// Keeps the band strictly behind a world fragment exactly at the wall (`GreaterEqual` passes a +/// tie); multiplicative, so it never crosses zero into the sky. const WDL_DEPTH_PUSH: f32 = 0.999; -// The shared global light (`lighting::global_light`) — the same buffer terrain, the models and liquid -// bind, mirrored here as the row prefix WDL needs. Rows 4/5 are the SCENE fog (block 1) plus the -// farclip wall, and that is the right block by construction: the band IS the horizon, so it is never -// inside a WMO and never wants the interior block. (WDL used to hold its own copy of these two rows, -// re-pushed per material by `apply_wow_lighting`.) +// The shared global light (`lighting::global_light`), mirrored up to the rows WDL reads. Rows 4 +// and 5 are the scene fog block, right by construction: the horizon band is never inside a WMO. struct WowLight { _light_ambient: vec4, // 0 _light_diffuse: vec4, // 1 _light_sun: vec4, // 2 _light_spec: vec4, // 3 fog_color: vec4, // 4 rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend) - fog_params: vec4, // 5 x/y = the SCENE fog start/end yd (unread here — the hull has its own pair); z unused; w = farclip wall (0 ⇒ off) + fog_params: vec4, // 5 xy = scene fog start/end (unread); w = farclip wall (0 = off) }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var w: WowLight; @@ -106,31 +59,27 @@ fn vertex(in: Vertex) -> WdlVsOut { @fragment fn fragment(in: WdlVsOut) -> WdlFsOut { - // PLANAR eye-Z (view-space depth), NOT radial — same as terrain.wgsl (apitrace-verified). Used for - // the band's near plane (below); the hull's own fog is saturated everywhere, so it needs no distance. + // Planar eye-z, not radial: the band's near plane is a projection plane. let eye_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; let farclip = w.fog_params.w; - // The far band's NEAR PLANE: `farclip − 33` (the reference's, see the header). Deliberately NOT the - // wall — the 33 yd overlap is what stops a hole opening at the coarse-vs-fine seam. (0 ⇒ disabled.) + // The near plane is `farclip - 33`, not the wall: the overlap closes the coarse-vs-fine seam. if (farclip > 0.0 && eye_z < farclip - WDL_OVERLAP) { discard; } - // White vertex diffuse under the hull pass's OWN fog pair — start 0, end 1.0 (the header; decision - // 1521): saturated for every fragment beyond one yard, so the hull is the fog colour, flat. The - // scene fog DISTANCES (`fog_params.xy`) are deliberately not read here — only its colour and the - // enable (with fog off, the reference shows the bare white hull, and so do we). + // White vertex diffuse under the hull's own fog pair, saturated beyond one yard: the flat fog + // colour, never the scene fog distances. With fog off the reference shows the bare white hull. var rgb = vec3(1.0); if (w.fog_color.w > 0.5) { rgb = w.fog_color.xyz; } var out: WdlFsOut; - // GAMMA LANE (0161): raw gamma out; the frame decodes once in the FFXGlow combine. + // Raw gamma out; the frame decodes once, in the FFXGlow combine. out.color = vec4(rgb, 1.0); - // The depth push (the reference's compressed far-band depth range). `view_z_to_depth_ndc` takes a - // view-space z — negative in front of the camera — so the wall is at `-farclip`. + // The depth clamp. `view_z_to_depth_ndc` takes view-space z, negative in front of the camera, + // so the wall is at `-farclip`. var depth = in.clip_position.z; if (farclip > 0.0) { depth = min(depth, view_z_to_depth_ndc(-farclip) * WDL_DEPTH_PUSH); diff --git a/crates/benilla-world/src/shaders/celestial.wgsl b/crates/benilla-world/src/shaders/celestial.wgsl index 6700b09d..2656a601 100644 --- a/crates/benilla-world/src/shaders/celestial.wgsl +++ b/crates/benilla-world/src/shaders/celestial.wgsl @@ -1,50 +1,12 @@ -// Celestial DISCS (the sun, the white moon, moon02) and their additive lens-flare GLARES — unlit, -// gamma-correct billboards drawn over the sky dome. Discs get a world-space HORIZON CLIP + the -// reference's PER-VERTEX ALPHA FADE, so a setting body melts into the horizon as a whole-disc gradient; -// glares (mode `celestial.z` = 1) skip the clip — the reference's flare draw `0x7e57e0` never calls the -// clip `0x6d1960`; the lens-flare envelope gates them — and ADD in gamma instead of blending. +// Celestial discs (sun, white moon, moon02) and their lens-flare glares: unlit sky billboards, +// blended in gamma space as the reference blends onto a framebuffer of gamma bytes. // -// HORIZON (RE: 0x6d1960, wow-re celestial-bodies note Addendum #7 / decisions 0485+0523+0529 — VERIFIED -// off the binary): every disc routes through the same clip+fade — the quad is clipped to the horizon -// plane (height 0; new crossing vertices get alpha 0, a fully-below body is skipped), and the per-vertex -// fade store is CONDITIONAL (guard `0x6d1ac5`): only vertices inside the 0.4-unit near-horizon band get -// `alpha = clamp(2.5·height, 0, 1)`; every vertex ABOVE the band keeps the disc COLOUR's own alpha byte -// — 0xFF for the sun/white moon (the per-frame diffuse broadcast), 0 for moon02 (its colour dword has no -// writer), which is why the reference never shows a second moon: the draw runs, the blender paints -// nothing. The rasterizer interpolates those vertex alphas, so a straddling sun grades 0-at-the-cut → -// full-at-the-top across the WHOLE disc. We reconstruct exactly that per-fragment, in distance-invariant -// sin-elevation terms (`k = 2.5·12 = 30` — the binary's band at its radius-12 disc) from the quad's -// per-frame `span`; the earlier per-fragment band approximation (0485, then 0524's workaround for the -// bar it caused) is superseded by decision 0529. -// -// GAMMA-SPACE BLEND — the gamma composite lane (decision 0161): the reference's framebuffer holds gamma -// bytes and ALL its hardware blending happens in gamma space, so the faithful sky-body blend is a -// GAMMA-space one. We emit a **premultiplied GAMMA** colour under `AlphaMode::Premultiplied`: -// `out = (disc_gamma · a, a)` — the disc blends over the (gamma) dome exactly like the reference, letting the -// blue night-dome bleed through the moon's feathered edges + horizon fade (the teal "halo"). Bevy hands us -// `base.rgb` already LINEARISED (sRGB texture × linear base_color), so we round-trip `linear_to_srgb` to -// recover the gamma disc colour first — the one conversion left in this shader. -// -// GAMMA-SPACE ADD (glare mode, `celestial.z` = 1; decision 0502): the reference's lens flares blend -// SRC_ALPHA, ONE onto the gamma framebuffer (`0x7e5a16`) — the flare's gamma texel × the intensity byte is -// ADDED to the scene's gamma bytes, saturating per channel (a warm disc + a warm flare clip to WHITE at -// the core — the blown-out reference sun). A plain Bevy `AlphaMode::Add` adds LINEAR values, which -// under-weights every mid-tone and can never saturate — the "too yellow" sun (the residual 0485/0500 -// recorded). Here: `out = (glare_gamma · a, 0.0)` under `AlphaMode::Add`'s (ONE, 1−src_alpha) blend -// ⇒ `dst + gamma·a` — the reference's byte addition, with `a` = the lens-flare intensity envelope. -// -// SKY-PASS DEPTH (celestial-frame-anatomy pin; the law + its history in `sky_order.rs`): the reference -// draws its whole sky in a squashed back depth slice with the depth TEST on — the glare last of all, -// further back still (`[0.995, 1.0]`) — so the z-buffer occludes every sky element per-pixel behind -// everything the world drew (a ridge, a wall, one leaf), while the sky itself (which writes no depth) -// never blocks it. Same mechanism here: the quads' geometry stays on their shells (12 units for the -// glare — its pinned screen footprint — and `far·0.85` for the discs), but EVERY vertex pins its -// clip z to 0.0 — reverse-Z "infinitely far" (`sky_vertex.wgsl`, the stage every sky shader shares) — -// under Bevy's GreaterEqual test: a fragment passes only where the depth buffer still holds the -// clear value, i.e. where no opaque geometry drew. Discs used to pass their own rasterized depth -// through, on the assumption that their shell sits beyond all world geometry; the WDL horizon ring -// reaches past it, so a distant hill lost to a disc it should occlude. Until 2016 the pin was a -// `frag_depth` write here, which cost the pipeline its early-Z; the vertex pin is the same number. +// Discs take the reference's horizon clip and per-vertex fade (`0x6d1960`): the quad is clipped at +// height 0 (crossing vertices get alpha 0, a body wholly below is skipped), vertices inside the +// 0.4-unit near-horizon band store `clamp(2.5 * height, 0, 1)` (guard `0x6d1ac5`), the rest keep +// the colour's alpha byte, and the rasterizer interpolates. Glares skip the clip (the flare draw +// `0x7e57e0` never calls it) and add like the reference's SRC_ALPHA, ONE blend (`0x7e5a16`), which +// saturates per channel. Depth is the far-plane pin in `sky_vertex.wgsl`; this stage writes colour. #import bevy_pbr::{ pbr_fragment::pbr_input_from_standard_material, @@ -53,22 +15,16 @@ mesh_view_bindings::view, } -// Per-material control (set in `sun/materials.rs` `CelestialExt`). `.x` = the horizon alpha-ramp scale `k` -// (`k = 30.0`: the binary's 0.4-unit band at its radius-12 disc, in sin-elevation terms — 0x6d1960). -// `.y` = brightness multiplier — 1.0 everywhere (the old moon HDR boost died with -// decision 0163; the moon's glow is FFXGlow's blur² on the disc's own bytes). `.z` = mode: 0 = disc -// (horizon clip + premultiplied gamma blend), 1 = additive glare (no clip, gamma add). `.w` = the disc -// COLOUR's own alpha byte (1.0 sun/white moon, 0.0 moon02 — its dword has no writer; Addendum #7). +// `CelestialExt::fade`. `.x`: the ramp slope `k` in sin-elevation (`2.5 * height` on the +// reference's radius-12 disc is `30 * y`). `.y`: brightness, on RGB only. `.z`: 0 disc, 1 glare. +// `.w`: the disc colour's alpha byte, kept above the band; 0 for moon02 in clear weather (its +// colour dword has no writer), so moon02 draws and paints nothing. @group(#{MATERIAL_BIND_GROUP}) @binding(100) var celestial: vec4; -// The disc quad's vertical span in sin-elevation: `.x` = bottom edge, `.y` = top edge (written per -// frame by the follow systems; unused in glare mode). The reference fade is PER-VERTEX (0x6d1960 -// stores alpha on the quad's vertices — conditionally, guard 0x6d1ac5 — and the rasterizer -// interpolates), so the fragment reconstructs that interpolation from the span (decision 0529). +// The disc quad's vertical span in sin-elevation, `.x` bottom edge and `.y` top edge, per frame. @group(#{MATERIAL_BIND_GROUP}) @binding(101) var span: vec4; -// The reference's per-VERTEX alpha rule at sin-elevation `y` (0x6d1960 + guard 0x6d1ac5): inside -// the near-horizon band (`k·y < 1`) the vertex takes the fade ramp; above it it keeps the disc -// colour's own alpha byte (`celestial.w`). +// The reference's per-vertex alpha at sin-elevation `y` (`0x6d1960`, guard `0x6d1ac5`): the ramp +// inside the band (`k * y < 1`), the colour's alpha byte above it. fn vertex_alpha(y: f32) -> f32 { let ramp = y * celestial.x; return select(celestial.w, clamp(ramp, 0.0, 1.0), ramp < 1.0); @@ -87,19 +43,9 @@ fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> @locatio var a = base.a; if celestial.z < 0.5 { - // DISC: horizon clip + the reference's PER-VERTEX fade, reconstructed (0x6d1960 + - // Addendum #7, decision 0529). The binary clips the quad at height 0 (new crossing - // vertices, alpha exactly 0 — a fully-below body is skipped) and stores the band ramp on - // its vertices; the rasterizer interpolates. A setting sun is therefore a whole-disc - // gradient melting to 0 at the horizon cut — not a solid disc with a thin faded strip - // (the old per-fragment band, 0485/0524, sighted twice: moon02's black bar and the - // too-hard sunset). Here: interpolate the vertex rule linearly between the quad's - // clipped bottom edge and its top edge (`span`, sin-elevation — camera-facing quad, so - // the vertical axis maps to elevation), clip below the horizon. Elevated bodies - // (`k·span.x ≥ 1`) reduce to `a × celestial.w` exactly — the fixtures' regime. Glares - // skip all of it (0x7e57e0 never calls the clip; the lens-flare envelope gates them). - // The reference also alpha-tests GEQUAL 1/255; under our premultiplied blend a 0-alpha - // fragment already contributes nothing, so no discard. + // Disc: interpolate the vertex rule from the clipped bottom edge to the top edge (the quad + // faces the camera, so its vertical axis is elevation). The reference's GEQUAL 1/255 alpha + // test needs no discard: a zero-alpha fragment adds nothing under the premultiplied blend. let dir = normalize(in.world_position.xyz - view.world_position.xyz); let y = dir.y; if y <= 0.0 { @@ -113,17 +59,13 @@ fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> @locatio } } - // GAMMA output — see the header. `base.rgb` is linear (Bevy); recover the gamma colour, - // premultiply by `a` in gamma space, emit raw onto the gamma buffer (0161). `celestial.y` = - // brightness (1.0 everywhere; multiplies the RGB only, so the blend's dst term is unaffected). + // `base.rgb` arrives linear (Bevy decodes the sRGB texture); the target takes gamma values. let gamma = linear_to_srgb(base.rgb); - // (The far depth for BOTH is the vertex stage's — see the header: a sky element survives only - // on pixels no opaque geometry claimed, the reference's back-slice depth test.) if celestial.z >= 0.5 { - // ADDITIVE GLARE: (rgb·a, alpha 0) under (ONE, 1−src_alpha) ⇒ `dst + gamma·a` — the - // reference's SRC_ALPHA, ONE byte addition, `a` = the lens-flare intensity envelope. + // Glare: alpha 0 under `AlphaMode::Add` (ONE, 1 - src_alpha) is `dst + gamma * a`, with + // `a` the lens-flare intensity envelope. return vec4(gamma * a * celestial.y, 0.0); } - // DISC: premultiplied gamma blend (AlphaMode::Premultiplied). + // Disc: premultiplied gamma under `AlphaMode::Premultiplied`. return vec4(gamma * a * celestial.y, a); } diff --git a/crates/benilla-world/src/shaders/cloud.wgsl b/crates/benilla-world/src/shaders/cloud.wgsl index ad0afed6..68bb7900 100644 --- a/crates/benilla-world/src/shaders/cloud.wgsl +++ b/crates/benilla-world/src/shaders/cloud.wgsl @@ -1,22 +1,11 @@ -// The visible CLOUD LAYER — the reference's sky-dome cloud strip (wow-re -// `cloud-coverage-pipeline.md` §3 + Addendum A). -// -// All color math already happened CPU-side, exactly like the reference: the kernel's `0x6cfb00` -// port builds the RGBA image per regen (gradient + sun-aligned glow in gamma bytes, alpha = the -// curve-mapped coverage byte) and uploads it — the reference binds that color buffer zero-copy to -// its gx texture (`0x58ac70`). The fragment therefore only samples the texel and applies the dome -// mesh's vertex-colour rim fade (ring alphas 0xff×9, 0x80, 0, 0 — `0x6d0530`). -// -// GAMMA: the texels are gamma bytes in a NON-sRGB texture (sampling returns them raw), and the -// output is a premultiplied-gamma blend over the (gamma) sky, like the celestial discs -// (decision 0161). +// The visible cloud layer, the reference's sky-dome cloud strip. Its colour math is CPU-side as in +// the reference: the `0x6cfb00` port builds the RGBA image per regen (gradient and sun-aligned glow +// in gamma bytes, alpha the curve-mapped coverage byte), the buffer the reference binds directly as +// its gx texture (`0x58ac70`). This stage samples it and applies the dome's vertex-colour rim fade +// (ring alphas: nine 0xff, then 0x80, 0, 0; `0x6d0530`). The texture is not sRGB, so texels come +// back as raw gamma bytes, blended premultiplied over the gamma sky. -// -// SKY-PASS DEPTH (see `sky_order.rs`, "The depth law"): the dome's radius does not decide occlusion — -// the reference draws the whole sky first, in a squashed back depth slice, and the opaque world paints -// over it. The far depth reproduces that: clouds survive only where no world geometry drew. It is -// pinned in the VERTEX stage (`sky_vertex.wgsl`, shared by every sky shader); this fragment writes -// colour only and keeps its early-Z (2016). +// Depth is the far-plane pin in `sky_vertex.wgsl`; this stage writes colour only, keeping early-Z. #import bevy_pbr::forward_io::VertexOutput diff --git a/crates/benilla-world/src/shaders/ffx_glow.wgsl b/crates/benilla-world/src/shaders/ffx_glow.wgsl index 965666be..8c43ca90 100644 --- a/crates/benilla-world/src/shaders/ffx_glow.wgsl +++ b/crates/benilla-world/src/shaders/ffx_glow.wgsl @@ -1,58 +1,35 @@ -// FFXGlow — the reference's full-screen glow, transcribed from the SHIPPED ARB programs -// (Shaders\Pixel\FFXGlow.bls / FFXGauss4.bls, read straight out of the MPQ) + wow-re's ffxeffects -// node (T3: the CPU-side pass math diffed bit-exact). -// -// blur = Gauss4(Gauss4(Box4(scene → ¼), horizontal), vertical) weights ⅛ ⅜ ⅜ ⅛ (shipped) -// out = lerp(screen, blur, z) + w · blur² (FFXGlow.bls, exact) -// -// `w` is the per-zone LightParams glow weight (authored data — ≈0.647 in Elwynn), `z` is the haze -// mix — the drunk/underwater screen-toward-blur cross-fade (0 sober and dry; wow-re -// drunk-blur-z.md, decision 1009 §A). The reference runs this in GAMMA bytes, and since 0161 the -// whole frame composites in gamma too — but in a FLOAT buffer, which unlike the reference's byte -// buffer does not saturate at 1.0 per draw. Every read of the scene RT below therefore clamps to -// 1.0 first (the byte semantics restored at the read), does the byte math, and decodes once — the -// present-encode then lands the reference's exact byte. -// The blur² is the whole character of the vanilla glow: mid-tones (0.5 → 0.25) barely bloom, -// highlights bloom fully — a square-law a linear-composite bloom cannot express (decision 0158). -// -// The geometry is fully byte-pinned (wow-re ffxeffects/scratch/blur-geometry.md): one Box4 -// downsample full→¼, Gauss4 at ±0.5/±2.5 texels, texel-centre base UVs with zero half-texel -// residual — offsets transcribe as exact source-texel counts. +// FFXGlow, the reference's full-screen glow, from the shipped Shaders\Pixel\FFXGlow.bls and +// FFXGauss4.bls: +// blur = Gauss4(Gauss4(Box4(scene → ¼), horizontal), vertical) weights ⅛ ⅜ ⅜ ⅛ +// out = lerp(screen, blur, z) + w · blur² +// `w` is the zone's LightParams glow weight, `z` the drunk/underwater haze mix (0 sober and dry). +// Tap offsets are exact source-texel counts from texel-centre UVs, with no half-texel shift. +// The math runs on gamma bytes; the float scene RT does not saturate per draw as the reference's +// byte RT does, so every scene read clamps to 1.0 first. #import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput @group(0) @binding(0) var in_tex: texture_2d; @group(0) @binding(1) var in_samp: sampler; -// Combine-pass extras (unused — and unbound — in the downsample/blur passes). +// Combine-pass bindings, unbound in the downsample and blur passes. @group(0) @binding(2) var blur_tex: texture_2d; struct FfxCombine { - // x = the zone glow weight `w` (BOTH combines' blur² weight — the death pack reuses it as - // primary.w); y = the FFXDeath gate (1 while ghost, else 0 — byte-verified instant, no ramp); - // z = the drunk/underwater haze mix; w = the deband-dither arm. + // x = zone glow weight `w` (both combines), y = FFXDeath gate (1 while a ghost, no ramp), + // z = haze mix, w = deband dither on. lane: vec4, - // xy = the GlowWave phases `(t mod 3174)/3174` and `(t mod 2805)/2805`; zw unused. Written on - // every frame, read only by `fs_combine_wave`. + // xy = the GlowWave phases `(t mod 3174)/3174` and `(t mod 2805)/2805`, t in ms; zw unused. wave: vec4, } @group(0) @binding(3) var ffx: FfxCombine; -// The 128×128 wave LUT and its own REPEAT sampler — bound on EVERY combine, sampled only by the -// underwater entry. A bound-but-unsampled texture costs a dry frame nothing, and one layout keeps -// the two entries interchangeable at draw time (the alternative, a second layout, would make the -// pipeline swap a bind-group swap too). +// The 128×128 wave LUT and its repeat sampler: bound on every combine, sampled only underwater. @group(0) @binding(4) var wave_tex: texture_2d; @group(0) @binding(5) var wave_samp: sampler; -// The reference's ONE downsample: full → ¼ directly, via Box4 — 4 bilinear taps at source-texel -// offsets {−1.5, +0.5}² (a 4×4 footprint; byte-pinned table 0xce89cc, wow-re blur-geometry.md). -// -// Each tap clamps to 1.0 BEFORE the average: the reference reads a BYTE scene RT, where an -// additive stack saturated at 255 draw by draw — our float buffer keeps summing past 1.0, and an -// unclamped read here feeds that super-white into `w·blur²`, which squares it into a huge hard -// glow disc (the Frost Nova mist stack — ~400 additive puffs — reaches ~5–10 in-buffer). The -// clamp is the byte buffer's saturating-add semantics applied at the lane's read (0161's own -// premise: the framebuffer holds bytes). +// Box4, the reference's one downsample, full → ¼: 4 bilinear taps at source-texel offsets +// {−1.5, +0.5}², a 4×4 footprint (table 0xce89cc). Each tap clamps to 1.0 before the average, +// as the byte RT saturated; an unclamped super-white would square into a hard glow disc. @fragment fn fs_downsample(in: FullscreenVertexOutput) -> @location(0) vec4 { let texel = 1.0 / vec2(textureDimensions(in_tex)); @@ -64,9 +41,7 @@ fn fs_downsample(in: FullscreenVertexOutput) -> @location(0) vec4 { * 0.25; } -// Gauss4: taps at ±0.5 (weight 0.375) and ±2.5 (weight 0.125) source texels — byte-pinned -// (0x6cad10 tap arrays; corrects the earlier INFERRED ±1.5 outer tap). Weights are the shipped -// FFXGauss4.bls constants. +// Gauss4: taps at ±0.5 and ±2.5 source texels (0x6cad10), weights from FFXGauss4.bls. fn gauss4(uv: vec2, axis: vec2) -> vec4 { let texel = axis / vec2(textureDimensions(in_tex)); return textureSample(in_tex, in_samp, uv - 2.5 * texel) * 0.125 @@ -91,41 +66,17 @@ fn srgb_to_linear(c: vec3) -> vec3 { return select(hi, lo, c <= vec3(0.04045)); } -// The FFXGlow combine, byte-for-byte in gamma space (z = 0: the standard glow pass) — and, while -// the ghost flag is up, the FFXDeath combine that REPLACES it (decision 0308 §7, byte-VERIFIED -// wow-re death-pass.md: one active-pass slot `[0xce8bb4]` — the death composite swaps in whole, -// activation is PLAYER_FLAGS_GHOST only, INSTANT, no time ramp). The shipped FFXDeath.bls: -// luma = sat(dot(screen + w·blur², (0.299, 0.587, 0.144))) (Blizzard's 0.144, not 0.114) -// out = luma + primary.rgb · sat(4·luma·(1−luma)) -// The CPU pack `0x6cb930` builds primary as `(LightParams.glow·255)<<24 | 0x5393A8`: rgb is the -// CONSTANT steel blue-gray 0x53/0x93/0xA8, and primary.w (the blur² weight) is the same zone glow -// the glow pass uses — so ffx.lane.x serves both combines and ffx.lane.y is a pure 0/1 gate. -// **Deband dither** (`ffx.lane.w`, off by default — see `ffx_glow.rs`). The frame's ONE quantization -// to 8 bits happens on `outg` below: it is a gamma-space float, and the surface's sRGB -// present-encode rounds it to a byte. A smooth surface therefore steps in 1/255, and at the -// gradients a lit character actually has (~1.3 levels/px on a bare arm) a body drifting the -// 0.11 px/frame of a breathing idle needs ~7 FRAMES to accumulate one step — so the shading -// updates at ~8 Hz on a 60 Hz display and every iso-luma contour crosses its threshold together. -// Large motions clear a level per frame and hide it completely, which is exactly the -// small-moves-tick / big-moves-smooth split the director reported. -// -// The hash is Bevy's own `screen_space_dither` (its tonemapping pass applies it and we skip that -// pass entirely — `Tonemapping::None` returns before the node does anything, so -// `DebandDither::Enabled` on our camera is dead code). It is a pure function of the PIXEL, not of -// time: a still frame stays bitwise still, while a moving gradient's contour dissolves across -// neighbouring pixels instead of snapping as one line. -// -// **This is a deliberate divergence.** The reference's framebuffer was 8-bit and undithered, so -// byte-exactness and this are mutually exclusive; that is why it is opt-in and why the default -// keeps the byte lane intact. +// Deviation: an opt-in deband dither (`WOW_DITHER=1`, `ffx.lane.w`); the reference's 8-bit +// framebuffer is undithered. The hash is Bevy's `screen_space_dither`, a pure function of the +// pixel; Bevy applies it only in its tonemapping pass, which `Tonemapping::None` skips. fn screen_space_dither(frag_coord: vec2) -> vec3 { var dither = vec3(dot(vec2(171.0, 231.0), frag_coord)).xxx; dither = fract(dither.rgb / vec3(103.0, 71.0, 97.0)); return (dither - vec3(0.5)) / 255.0; } -// The combine's one exit: dither (when armed) in GAMMA space — the space the present-encode -// rounds in — then the frame's single decode, or, under `GAMMA_OUT`, no decode at all. +// The combine's exit: dither in gamma space, where the present-encode rounds, then the frame's +// one decode, which the sRGB present-encode reverses byte-exactly; none under `GAMMA_OUT`. fn combine_out(outg: vec3, alpha: f32, frag_coord: vec2) -> vec4 { let dithered = clamp( outg + screen_space_dither(frag_coord) * step(0.5, ffx.lane.w), @@ -133,36 +84,26 @@ fn combine_out(outg: vec3, alpha: f32, frag_coord: vec2) -> vec4 vec3(1.0), ); #ifdef GAMMA_OUT - // The gamma-lane exit (decision 2234): this combine is the UI camera's own ground pass, so it - // stores the byte it computed the way that lane's quads store theirs (`ui_quad.wgsl`: colour - // premultiplied by its own alpha, alpha as coverage) and leaves the frame's decode to the - // lane's end. It is exactly the value the backdrop quad used to re-encode from the float - // image, minus the image. + // As the UI camera's ground pass, store the byte as `ui_quad.wgsl` does (colour premultiplied + // by alpha, alpha as coverage); the lane's end decodes the frame. return vec4(dithered * alpha, alpha); #else return vec4(srgb_to_linear(dithered), alpha); #endif } -// The combine both entries run — the dry one at the fragment's own UV, the underwater one at the -// warped UV. `frag_coord` stays UNwarped: it seeds the deband dither, which is a property of the -// destination pixel, not of where the scene was read from. +// The combine both entries run, at the fragment's UV or the warped one; `frag_coord` stays +// unwarped because the dither belongs to the destination pixel. fn combine_body(uv: vec2, frag_coord: vec2) -> vec4 { - // GAMMA LANE (0161): the whole frame is already gamma bytes — the blur ran on gamma (like - // the reference's byte RTs) and the combine is raw byte math. The single srgb_to_linear here - // is THE frame's one decode: the sRGB present-encode then restores every byte exactly. let scene = textureSample(in_tex, in_samp, uv); - // Clamped to [0, 1] like the downsample's taps: the byte framebuffer this term stands for - // could never exceed 255, and an unclamped super-white here keeps the whole additive stack - // hard-white after the min() below — the screen term must saturate BEFORE the glow add. + // Clamped like the downsample taps: the screen term must saturate before the glow add. let sg = clamp(scene.rgb, vec3(0.0), vec3(1.0)); let bg = max(textureSample(blur_tex, in_samp, uv).rgb, vec3(0.0)); - // The scene alpha rides through untouched: the window surface ignores it and the opaque-clear - // booths hold 1 everywhere — only the transparent-clear create booth reads it, to composite - // its baked model straight over the UI page. + // Scene alpha passes through; only the transparent-clear create booth reads it. if ffx.lane.y > 0.0 { - // The FFXDeath pack has no z lane (its COLOR rgb is the constant tint) — the ghost - // combine reads the un-hazed screen. + // FFXDeath replaces the glow while a ghost (one active-pass slot `[0xce8bb4]`), unhazed: + // luma = sat(dot(screen + w·blur², (0.299, 0.587, 0.144))), 0.144 as shipped, and + // out = luma + tint · sat(4·luma·(1 − luma)), tint 0x5393A8 from the pack at `0x6cb930`. let glowed = sg + ffx.lane.x * bg * bg; let luma = clamp(dot(glowed, vec3(0.299, 0.587, 0.144)), 0.0, 1.0); let p = clamp(4.0 * luma * (1.0 - luma), 0.0, 1.0); @@ -170,11 +111,8 @@ fn combine_body(uv: vec2, frag_coord: vec2) -> vec4 { let outg = min(vec3(luma) + ghost_tint * p, vec3(1.0)); return combine_out(outg, scene.a, frag_coord); } - // The z lane — the drunk/underwater haze: cross-fade the screen toward the ¼-res blur - // BEFORE the glow add (`out = lerp(screen, blur, z) + w·blur²`, the shipped FFXGlow.bls; - // wow-re drunk-blur-z.md). z = max(drunkFraction, 84/255 while the eye is submerged) — 0 in - // the ordinary sober pass, 1 = fully blurred at 100 inebriation. The blur term clamps like - // the screen term: it stands for the same byte RT. + // The haze cross-fades toward the blur before the glow add; z = max(drunk fraction, 84/255 + // while the eye is submerged), 1 at 100 inebriation. The blur term clamps like the screen. let hazed = mix(sg, min(bg, vec3(1.0)), ffx.lane.z); let outg = min(hazed + ffx.lane.x * bg * bg, vec3(1.0)); return combine_out(outg, scene.a, frag_coord); @@ -185,57 +123,29 @@ fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4 { return combine_body(in.uv, in.position.xy); } -// **FFXGlowWave — the underwater screen warp.** (wow-re `ffxeffects/scratch/glow-wave-underwater.md`, -// §5 cross-checked; benilla decision 1824.) -// -// While the camera eye is in ANY liquid the reference does not run the combine above at all: -// `CFFXGlow::Render 0x6cc630` walks a *second* pass list, whose third pass is **FFXGlowWave** -// (ctor `0x6cb1f0`, vtable `0x8114b4`, render `0x6cb310`) in place of FFXGlow. wow-re's note had -// long carried that swap as an undecided "GlowWave, *or* a duplicate Glow"; the arms are mutually -// exclusive (`ret` at `0x6cc403`, `push ebx` at `0x6cc404`) and the plain-Glow arm is unreachable -// on both backends, so underwater is ALWAYS the wave. -// -// The two passes are instruction-identical except that GlowWave binds a third texture — a 128×128 -// two-channel bump map holding `du = sin(2πx/128)`, `dv = sin(2πy/128)` — and **displaces both the -// screen and the blur texcoords by it**. Everything downstream (the haze cross-fade, the blur² -// add, the gamma lane) is unchanged, which is why both entries share [`combine_body`]: the warp is -// a change of *where* the combine reads, not of what it computes. -// -// The wave texcoord is an affine map of the screen UV. The reference builds it CPU-side per vertex -// (`0x7bca80`) and hands the GPU three interpolated texcoord streams; an affine function of an -// interpolated UV is the interpolation of that affine function, so evaluating it per fragment here -// is the same map, not an approximation of it: -// +// FFXGlowWave, the underwater screen warp: with the camera eye in any liquid the reference runs it +// in place of FFXGlow (`CFFXGlow::Render` `0x6cc630`, GlowWave render `0x6cb310`). It is the same +// combine with the screen and blur texcoords displaced by a 128×128 two-channel LUT holding +// `du = sin(2πx/128)`, `dv = sin(2πy/128)`. The reference builds the wave texcoord per vertex +// (`0x7bca80`); it is affine in the screen UV, so evaluating it per fragment is exact: // uv' = uv · R(10°) · S(W/128, 0.88·H/128) · T(p1, p2) -// -// with the two phases running INDEPENDENTLY off one millisecond clock — `(t mod 3174)/3174` and -// `(t mod 2805)/2805`, i.e. 3.174 s and 2.805 s, rejoining only every ~49 minutes. The scale -// constants multiply the destination's pixel dimensions (a `rewave.md` gloss had them multiplying -// the phases; corrected at the bytes), so the wavelength is 128 px across and 145.45 px down -// REGARDLESS of resolution, and the pattern is screen-fixed rather than world-locked. -// -// Amplitude is **3.0 full-res pixels** on both samples — the reference reaches that number twice -// over, `(3.0, 0, 0, 3.0)` texels of the full RT and `0.75` texels of the ¼ RT being the same -// distance, which is the cross-check that the blur sample warps by the same amount as the screen. +// The phases run off one millisecond clock with periods 3.174 s and 2.805 s. The scale is in +// destination pixels, so the pattern is screen-fixed: 128 px across, 145.45 px down at any +// resolution. Amplitude is 3.0 full-res pixels on both samples (0.75 texels of the ¼ RT). const WAVE_ROT_RAD: f32 = 0.174532925; // 10°, the reference's rotation row -const WAVE_LUT_EDGE: f32 = 128.0; // the LUT is 128×128 texels ([0xce89a0]'s 7-field descriptor) -const WAVE_V_SCALE: f32 = 0.88; // [0xce89c0] — the v axis alone carries it; u's twin is 1.0 +const WAVE_LUT_EDGE: f32 = 128.0; // the LUT is 128×128 texels ([0xce89a0]) +const WAVE_V_SCALE: f32 = 0.88; // [0xce89c0]: the v axis only; u's scale is 1.0 const WAVE_AMPLITUDE_PX: f32 = 3.0; // full-res pixels, both samples -// The screen-UV offset the warp applies, in UV units. `screen` is the full-res scene in pixels. +// The warp's screen-UV offset in UV units; `screen` is the full-res scene size in pixels. fn wave_offset(uv: vec2, screen: vec2) -> vec2 { let c = cos(WAVE_ROT_RAD); let s = sin(WAVE_ROT_RAD); let scale = vec2(screen.x, WAVE_V_SCALE * screen.y) / WAVE_LUT_EDGE; let tex = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y) * scale + ffx.wave.xy; - // SAMPLED, not evaluated: 128 texels of `sin` under LINEAR/REPEAT *is* the reference's sine — - // a piecewise-linear one, and its own. REPEAT is load-bearing and was the round's costliest - // near-miss: read as CLAMP, the effect dies over 90% of the screen (the scale runs the - // texcoord to 10 cycles across, so all but the first would pin to the edge texel). - // - // The 8-bit pack is biased back exactly as the shipped ps_2_0 permutation does — that blob - // carries `def c2 = (−0.5, 1.0, …)` and two extra instructions spelling `(x − 0.5)·2`, because - // the caps tier the live client selects hands it an UNSIGNED texture. + // Sampled, not evaluated: the reference's sine is the 128-texel LUT under linear filtering. + // The sampler must repeat: the texcoord spans about 10 cycles, and a clamp pins all but one. + // The unsigned 8-bit LUT is unbiased by (x − 0.5)·2, as the shipped ps_2_0 permutation does. let d = (textureSample(wave_tex, wave_samp, tex).rg - vec2(0.5)) * 2.0; return d * WAVE_AMPLITUDE_PX / screen; } diff --git a/crates/benilla-world/src/shaders/sky.wgsl b/crates/benilla-world/src/shaders/sky.wgsl index 2fabcc7d..00876fee 100644 --- a/crates/benilla-world/src/shaders/sky.wgsl +++ b/crates/benilla-world/src/shaders/sky.wgsl @@ -1,17 +1,7 @@ -// Sky-dome gradient — unlit. Paired with `sky.rs` (SkyExt). The five Light.dbc SkyColor stops -// (zenith→horizon) + the fog colour (row 7) are interpolated PER-FRAGMENT by the view-direction -// elevation. RE-VERIFIED mapping (WoW.exe FUN_006d0d10/FUN_006d0f50 + apitrace WoW.5 dome vertices): -// one stop per ring at GEOMETRIC elevations above the horizon — -// SkyColor0 @ 90° (zenith) … SkyColor1 @ 16.8° … SkyColor2 @ 9.8° … SkyColor3 @ 3.7° … -// SkyColor4 @ 1.8° … and the horizon (0°) and below = the FOG colour (row 7). -// So SkyColor0 dominates the whole upper sky; the gradient is crushed into the bottom ~17° and -// converges into the distance fog at the horizon. Linear interp in elevation ≈ WoW's ring Gouraud. -// SKY-PASS DEPTH (see `sky_order.rs`, "The depth law"): the dome's radius does not decide occlusion — -// the reference draws the sky FIRST and the opaque world paints over it. The far depth makes the -// dome a true backdrop: it fills only pixels no world geometry claimed, so a WDL hill drawn beyond -// the dome's shell can never be over-painted by the gradient. That depth is pinned in the VERTEX -// stage (`sky_vertex.wgsl`, shared by every sky shader) — this fragment writes colour only, so the -// rasterizer's early-Z rejects the covered fraction of the screen before the gradient runs (2016). +// The sky-dome gradient, unlit (`SkyExt`): the five Light.dbc SkyColor stops, one per dome ring at +// the reference's geometric elevations (the struct below), then the fog colour at the horizon and +// below (`0x6d0d10`, `0x6d0f50`), interpolated per fragment by view elevation. Depth is the +// far-plane pin in `sky_vertex.wgsl`; this stage writes colour only, which keeps early-Z. #import bevy_pbr::{ forward_io::VertexOutput, mesh_view_bindings::view, @@ -23,17 +13,13 @@ struct SkyColors { sky2: vec4, // 9.8° sky3: vec4, // 3.7° sky4: vec4, // 1.8° - fog: vec4, // horizon (0°) and below — LightIntBand row 7 + fog: vec4, // horizon (0°) and below: LightIntBand row 7 warp: vec4, // x = dawn/dusk warp strength S (0 = off), y = sun azimuth (rad), zw reserved }; @group(#{MATERIAL_BIND_GROUP}) @binding(100) var sky: SkyColors; -// Sun-relative azimuth phase → glow factor `g` (`FUN_006d0f50` table `0xce9af8`, VERIFIED byte-exact off -// its writer `FUN_006ce210`). 6 wrap-around keyframes, piecewise-linear like the engine's table sampler, -// symmetric about phase 0.625. **Semantics (capture-reconciled — see the warp note below):** `g = +1.0` -// (phase 0.125) is the IDENTITY case `(1−g)=0` → ring left untouched, and that phase lands on the SUN -// bearing → the sun side is the bright, unwarped DBC stop. `g < 0` (trough −0.7 at phase 0.625) is the -// ANTI-sun side → it pulls the ring toward the dark apex. `phase` is fract()'d into [0,1). +// Glow `g` for a sun-relative azimuth phase, sampled linearly like `0x6d0f50` from the reference's +// six-keyframe wrap-around table at `0xce9af8` (written by `0x6ce210`); 0.125 is the sun bearing. fn azimuth_glow(phase: f32) -> f32 { let p = fract(phase); if (p < 0.125) { return mix(0.0, 1.0, (p + 0.125) / 0.25); } // wrap 0.875(g0)→1.125(g1) @@ -45,12 +31,8 @@ fn azimuth_glow(phase: f32) -> f32 { else { return mix(0.0, 1.0, (p - 0.875) / 0.25); } // wrap 0.875(g0)→1.125(g1) } -// One mid-ring's warped colour for a single glow factor `g` (`FUN_006d0f50`, VERIFIED + dusk-capture -// reconciled). `g ≥ 0`: desaturate toward SkyColor1 by `(1−g)·S²` (so g=+1 on the sun bearing = identity = -// the bright raw stop). `g < 0` (anti-sun): a prepass toward SkyColor1 by `S`, then toward the apex -// SkyColor0 by `0.7·(−g)·S²` — the darkest, desaturated away side. (`S²` = the prepass-S × per-segment-S -// nesting; `0.7` is the binary const `0x7ffd7c`. All byte-verified; reproduces the WoW.10 dusk dome to -// ~2/channel.) +// One mid-ring's warped colour for glow `g` (`0x6d0f50`): `S^2` is the prepass S nested in the +// per-segment S, and 0.7 is the constant at `0x7ffd7c`. fn warp_one(base: vec3, g: f32, s: f32) -> vec3 { let s2 = s * s; if (g >= 0.0) { @@ -61,27 +43,13 @@ fn warp_one(base: vec3, g: f32, s: f32) -> vec3 { @fragment fn fragment(in: VertexOutput) -> @location(0) vec4 { - // Direction from the camera to this dome fragment; elevation above the world horizon. let dir = normalize(in.world_position.xyz - view.world_position.xyz); let elev = degrees(asin(clamp(dir.y, -1.0, 1.0))); // −90..90, 0 = horizon - // Dawn/dusk azimuthal warp (FUN_006d0f50) — only when S>0 (sunrise ~06:30 / sunset ~21:30 in - // highlightSky=1 zones; the S-curve decays to 0 by ~midnight). RE-VERIFIED + reconciled against the - // WoW.10 dusk capture. Two corrections over the old per-fragment version: - // (1) Warp ONLY the 4 mid rings (SkyColor1..4); the apex (SkyColor0) and the fog rim are UNWARPED. - // The engine's warp loop touches only ring offsets 4/8/12/16, and the capture shows the apex+rim - // uniform across azimuth. So we warp the mid-ring COLOURS here, then run the elevation gradient - // with the warped mid-rings + the *unwarped* apex/fog — the upper sky (>16.8°) interpolates the - // warped ring1 up to the unwarped apex, GL-Gouraud-faithful. (The old `ring_weight` smoothstep - // warped the whole upper sky → at night it dragged the entire dome toward the black apex.) - // (2) The lobe bearing: `+0.125` puts the SUN bearing at glow phase 0.125 (g=+1 → identity → the - // bright raw stop) and the anti-sun at 0.625 (g=−0.7 → darkest). The old `+0.625` had it 180° - // inverted — darkening the sun side and leaving the anti-sun bright. The warp does NOT brighten: - // the sunset glow IS the unwarped sun-side stop; the warp only darkens/desaturates the rest. - // Per-vertex Gouraud: the engine bakes the warp at 24 azimuth segments and interpolates between, which - // smears the genuine g=0 discontinuity (away `(1−g)·S²` vs warm prepass `S`) across one 15° segment - // rather than the hard fragment edge a smooth eval would give. We emulate that — evaluate the warp at - // the two bracketing segment phases and lerp. + // Dawn/dusk warp (`0x6d0f50`), while S > 0: the reference warps only the four mid rings + // (offsets 4/8/12/16), never the apex or fog rim, and never brightens. `+ 0.125` puts the sun + // bearing at glow phase 0.125. The reference bakes the warp per vertex at 24 azimuth segments + // and Gouraud-interpolates (the `g = 0` step smears over 15°), hence the lerp of two segments. var s1 = sky.sky1.rgb; var s2c = sky.sky2.rgb; var s3 = sky.sky3.rgb; @@ -100,7 +68,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { s4 = mix(warp_one(sky.sky4.rgb, g0, warp_s), warp_one(sky.sky4.rgb, g1, warp_s), f); } - // Elevation gradient: warped mid-rings + UNWARPED apex (SkyColor0) and fog rim. + // Elevation gradient, linear between rings like the reference's Gouraud-shaded dome. var col: vec3; if (elev <= 0.0) { col = sky.fog.rgb; // horizon and below = fog colour (row 7), unwarped @@ -113,11 +81,11 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { } else if (elev < 16.8) { col = mix(s2c, s1, (elev - 9.8) / (16.8 - 9.8)); } else { - col = mix(s1, sky.sky0.rgb, (elev - 16.8) / (90.0 - 16.8)); // warped ring1 → UNWARPED apex + col = mix(s1, sky.sky0.rgb, (elev - 16.8) / (90.0 - 16.8)); // warped ring1 to the raw apex } - // Raw gamma out (GAMMA LANE, 0161): the reference draws the sky with GL_FRAMEBUFFER_SRGB OFF - // (CSky::Render @0x6d4940) — raw DBC bytes — which is now the whole pipeline's convention. + // Raw gamma out: the reference draws the sky with GL_FRAMEBUFFER_SRGB off, as raw DBC bytes + // (`CSky::Render`, `0x6d4940`). let rgb = col; return vec4(rgb, 1.0); } diff --git a/crates/benilla-world/src/shaders/sky_vertex.wgsl b/crates/benilla-world/src/shaders/sky_vertex.wgsl index 4e952d54..e5251a91 100644 --- a/crates/benilla-world/src/shaders/sky_vertex.wgsl +++ b/crates/benilla-world/src/shaders/sky_vertex.wgsl @@ -1,24 +1,12 @@ -// The sky pass's shared VERTEX stage — bevy 0.18.1's `mesh.wgsl` vertex verbatim (VERTEX_* -// attributes; morph targets and skinning omitted — no sky mesh authors them) plus ONE line: the -// clip-space z is pinned to the far plane. Every sky element — the gradient dome (`sky.wgsl`), -// the cloud dome (`cloud.wgsl`), the star patches (`star.wgsl`), the celestial discs and glares -// (`celestial.wgsl`) — draws through this stage; their fragments write colour only. +// The shared vertex stage of every sky draw (`sky.wgsl`, `cloud.wgsl`, `star.wgsl`, +// `celestial.wgsl`): bevy 0.18.1's `mesh.wgsl` vertex verbatim, without morph targets and skinning +// (no sky mesh has them), plus one line pinning clip z to the far plane. A `MaterialExtension` +// swaps the whole stage, so this mirror must track bevy's on upgrades. // -// THE DEPTH LAW, moved up a stage (`sky_order.rs`, "The depth law"; decision 2016). The reference -// draws its whole sky first, in a squashed back depth slice, and the opaque world paints over it; -// we draw the sky after the world, so the depth TEST does that job, and it only does it if the -// sky's depth is behind everything. Until 2016 each sky fragment shader wrote -// `@builtin(frag_depth) = 0.0` (reverse-Z "infinitely far"). That is the same number this stage -// produces — clip z = 0 interpolates to NDC z = 0 for every fragment, exactly, whatever w is — but -// a fragment-stage depth write costs the whole pipeline its early-Z: the hardware cannot reject a -// fragment before the shader that decides its depth has run, so every sky fragment under a hill, -// a wall or a leaf was shaded in full and then thrown away. A dome is a full-screen draw and its -// gradient is not cheap; on an immediate-mode GPU (the Steam Deck's RDNA2, every Windows part) -// that was the whole covered fraction of the screen shaded for nothing, twice a frame at night. -// With the depth known at the vertex, the rasterizer's early test rejects those fragments -// before the shader runs. Same pixels, less work — the change is invisible by construction. -// -// A `MaterialExtension` swaps the whole stage, so the mirror below must track bevy's on upgrades. +// The reference draws its sky first, in a squashed back depth slice, and the world paints over it; +// here the sky draws after the world and the depth test does that job. The pin sits in this stage +// because a fragment depth write disables early-Z, so an immediate-mode GPU would shade every sky +// fragment the world covers; the fragment stages write colour only. #import bevy_pbr::{ mesh_functions, @@ -26,9 +14,8 @@ view_transformations::position_world_to_clip, } -/// Reverse-Z "infinitely far" — clip-space z = 0 ⇒ NDC depth 0.0 for every fragment, whatever w. -/// Under bevy's `GreaterEqual` test a sky fragment then passes only where the depth buffer still -/// holds its clear value (0.0): exactly the pixels no world geometry claimed. +/// Reverse-Z "infinitely far": clip z = 0 gives NDC depth exactly 0.0 whatever w, so under bevy's +/// `GreaterEqual` test a sky fragment passes only where the depth buffer still holds its clear 0.0. const SKY_FAR_CLIP_Z: f32 = 0.0; @vertex @@ -55,8 +42,7 @@ fn vertex(vertex_no_morph: Vertex) -> VertexOutput { #ifdef VERTEX_POSITIONS out.world_position = mesh_functions::mesh_position_local_to_world(world_from_local, vec4(vertex.position, 1.0)); out.position = position_world_to_clip(out.world_position.xyz); - // The one line that is ours (the header): the sky's depth is the far plane, decided here so - // the fragment stage writes none and keeps its early-Z. + // The one line not from bevy: the far-plane pin. out.position.z = SKY_FAR_CLIP_Z; #endif diff --git a/crates/benilla-world/src/shaders/star.wgsl b/crates/benilla-world/src/shaders/star.wgsl index 66f746e7..f45c97b4 100644 --- a/crates/benilla-world/src/shaders/star.wgsl +++ b/crates/benilla-world/src/shaders/star.wgsl @@ -1,19 +1,7 @@ -// Night-sky stars (the `Stars.m2` patches) — premultiplied white dots over the sky dome. -// -// GAMMA-SPACE BLEND — the gamma composite lane (decision 0161): the reference's framebuffer holds -// gamma bytes and all its hardware blending happens in gamma space, so the stars' faithful blend is -// a GAMMA-space alpha blend of white-on-transparent dots (`Stars.blp`, ~98% transparent, mostly low -// alpha). Under that lane our framebuffer likewise holds gamma bytes and ALL blending is gamma, so the -// faithful output is simply premultiplied white at the dot's alpha: `(a, a, a, a)` with -// `a` = texture alpha × the star-curve global alpha (`StandardMaterial::base_color` alpha, set -// per-frame). Over the near-black night sky that lands on screen at gamma value `a` — the -// reference's byte — with zero conversion math. +// Night-sky stars (the `Stars.m2` patches): white `Stars.blp` dots, alpha-blended in gamma space +// like the reference as premultiplied white, landing on the near-black sky as the reference's +// byte. Depth is the far-plane pin in `sky_vertex.wgsl`; this stage writes colour only. -// SKY-PASS DEPTH (see `sky_order.rs`, "The depth law"): the star dome's own geometry depth is not -// what decides occlusion — the reference draws its whole sky FIRST, in a squashed back depth slice, -// and the opaque world paints over it. The far depth reproduces that from the transparent pass: -// stars survive only on pixels no world geometry claimed. It is pinned in the VERTEX stage -// (`sky_vertex.wgsl`, shared by every sky shader); this fragment writes colour only (2016). #import bevy_pbr::{ pbr_fragment::pbr_input_from_standard_material, forward_io::VertexOutput, @@ -22,6 +10,6 @@ @fragment fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> @location(0) vec4 { let pbr_input = pbr_input_from_standard_material(in, is_front); - let a = pbr_input.material.base_color.a; // dot texture alpha × the star-curve global alpha - return vec4(vec3(a), a); // premultiplied white — raw (GAMMA LANE, 0161) + let a = pbr_input.material.base_color.a; // texel alpha × the per-frame `base_color` alpha + return vec4(vec3(a), a); // premultiplied white, raw gamma } diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index 45d8984a..fd759bcf 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -1,38 +1,15 @@ -// The B1 retained static pass (decision 1429; `static_gx/mod.rs`): the never-fade order-free -// static-world law of `wow_model.wgsl`, re-expressed over retained buffers — per-vertex -// texture-array layer + flag bits (+ a per-item record) where the entity path had one -// material per batch. Slice 1 = exterior ADT doodads; slice 2 adds WMO group geometry. -// -// **The law here MIRRORS `benilla_assets/shaders/wow_model.wgsl` and must track it** (the -// terrain.wgsl precedent: naga_oil cannot import functions that reference another module's -// bindings, so the shared pieces are copied with keep-in-sync notes at both ends). Mirrored -// pieces, in order: the `WowLight` per-frame PREFIX (the buffer is bigger — probe/palette -// regions ride the tail; binding a prefix-shaped struct against the same buffer is exactly -// what terrain.wgsl does), `wow_normalize`, `point_light_sum`, the exterior-doodad sun family -// (the Model2.bls order-2 lobe, 0803 — `min(I,1)` cap included, with its recorded caveat), -// the WMO surface lanes (slice 2: MOCV inside the clamp under GL_COLOR_MATERIAL, the -// INT/TRANS/EXT batch-class lanes, the WINDOW midpoint light, SIDN × night on lit lanes, -// ZERO point lights, the authored batch-order clip-z nudge, the interior fog triple), the -// interior-PROP probe lane (B4, decision 1433: the per-item slot rides the record table and -// the probe rows live in the TAIL of the same shared buffer — zero live point lights, the -// group-MOLR lobes are folded into the probe at spawn), the exterior-prop Matte fixed-1.0 -// family (B4), and the step-5 fog. Deliberately ABSENT (this lane never carries them): the -// clutter/rig/fade/env-map/highlight/mat-anim lanes and per-instance tint (identity for -// statics). -// -// Population contract (enforced by the collector's divert — `static_gx::StaticGx::divert`): -// never-fade order-free non-animated statics (interior props are never-fade by law), -// Opaque/AlphaTest only, no env-map, no depth flags; cells additionally -// `ShadeSel::Lit`/`Shaded` only (the WMO lane never reads the selector; the prop lane adds -// `Matte`). Output alpha is pinned 1.0 — every draw here is opaque-intent by admission -// (wow_model.wgsl's bit-3 armor, as a constant). +// The retained static-world pass: `wow_model.wgsl`'s shading for never-fade, order-free, +// non-animated statics (ADT doodads, WMO groups, interior M2 props) over retained buffers, with +// per-vertex flag words and a per-item record in place of materials. Admission +// (`static_gx::StaticGx::divert`): Opaque/AlphaTest only, no env map, no depth flags. +// Keep in sync with `wow_model.wgsl`: naga_oil cannot import functions that reference another +// module's bindings, so the light prefix, the helpers and the lighting lanes are copies. #import bevy_render::view::View @group(0) @binding(0) var view: View; -// The shared global light's per-frame PREFIX (lighting::global_light; the full layout lives in -// wow_model.wgsl — keep field order in sync with BOTH). +// Prefix of lighting::global_light's buffer; field order in sync with it and wow_model.wgsl. struct WowLight { light_ambient: vec4, light_diffuse: vec4, @@ -53,52 +30,45 @@ struct WowLight { wmo_fog_params: vec4, point_count: vec4, points: array, 512>, - // The interior-prop SH probe table (B4 — lighting::prop_probes, 7 rows per slot, 8192 - // slots; wow_model.wgsl owns the layout note). Sits immediately after the point table in - // the shared buffer, so extending the mirrored prefix by one region is all it takes; the - // rig/tint/mat-anim regions beyond it stay unmirrored (statics never read them). + // lighting::prop_probes: 8192 slots of 7 rows; the buffer's later regions are not mirrored. prop_probes: array, 57344>, } @group(0) @binding(1) var wow_light: WowLight; // Per-cell state (static_gx/render.rs `cell_layout`). struct GxCell { - origin: vec4, // xyz = the bake's recentring origin (0974): world = vertex + origin + origin: vec4, // xyz = the bake's recentring origin } @group(1) @binding(0) var cell: GxCell; -// The per-ITEM record table, indexed by the vertex word's low bits: -// x = texture-array layer, y = the WMO authored batch order (the clip-z nudge; 0 on cells — -// an exact no-op), z = the MOMT SIDN colour packed r|g<<8|b<<16 (gamma bytes), w = flags: -// bit 0 the exile kill bit (B2), bits 1..=13 the interior-prop probe slot (B4), bit 14 the -// per-frame INTERIOR FOG lane (1787 — the client's per-group `[0xca7f00]`). +// The per-item record table, indexed by the vertex word's low 16 bits: x = texture-array layer, +// y = WMO authored batch order (0 on cells), z = MOMT SIDN colour r|g<<8|b<<16 (gamma bytes), +// w = flags: bit 0 exile kill, bits 1..=13 interior-prop probe slot, bit 14 interior fog (the +// client's per-group `[0xca7f00]`). @group(1) @binding(1) var recs: array>; @group(1) @binding(2) var tex_array: texture_2d_array; -// Repeat and clamp variants of the BLP model-albedo sampler (linear tri-filtered, aniso 8 — -// exact parity with the entity path's per-image samplers; render.rs owns the rationale). +// Repeat and clamp model-albedo samplers: trilinear, aniso 8, the same as the entity path's. @group(1) @binding(3) var samp_repeat: sampler; @group(1) @binding(4) var samp_clamp: sampler; -// Vertex word bits — keep in sync with static_gx/mod.rs WORD_*. +// Vertex word bits; keep in sync with static_gx/mod.rs WORD_*. const WORD_WRAP_X: u32 = 65536u; // 1 << 16 const WORD_WRAP_Y: u32 = 131072u; // 1 << 17 const WORD_UNLIT: u32 = 262144u; // 1 << 18 const WORD_FOG_OFF: u32 = 524288u; // 1 << 19 -const WORD_SHADE_LIT: u32 = 1048576u; // 1 << 20 — no producer since 2050 (entities never divert here); kept, retiring it is a word-layout round +const WORD_SHADE_LIT: u32 = 1048576u; // 1 << 20: ShadeSel::Lit, which no static carries const WORD_TEXTURED: u32 = 2097152u; // 1 << 21 -// The WMO lane (slice 2) — the entity path's per-material facts as bits: -const WORD_WMO: u32 = 4194304u; // 1 << 22 — model_flags.x (the WMO surface laws) -const WORD_INTERIOR: u32 = 8388608u; // 1 << 23 — model_flags.z (interior group) -const WORD_CLASS_INT: u32 = 16777216u; // 1 << 24 — tint.w == 1 (INT batch) -const WORD_CLASS_TRANS: u32 = 33554432u; // 1 << 25 — tint.w == 2 (TRANS batch) -const WORD_WINDOW: u32 = 67108864u; // 1 << 26 — sidn.w (WINDOW midpoint light) -const WORD_HAS_VC: u32 = 134217728u; // 1 << 27 — the batch AUTHORED vertex colours - // (the entity shader's VERTEX_COLORS def) -// The prop lane (B4). INTERIOR without WMO = an interior M2 prop — the entity shader's own -// `interior_prop = flags.z && !flags.x` split: probe lighting, interior fog, zero live -// point lights. -const WORD_MATTE: u32 = 268435456u; // 1 << 28 — EITHER doodad class (ADT MDDF or exterior MODD prop): intensity FIXED 1.0 (2050) +// The WMO lane: the entity path's per-material facts as bits. +const WORD_WMO: u32 = 4194304u; // 1 << 22: model_flags.x, a WMO surface +const WORD_INTERIOR: u32 = 8388608u; // 1 << 23: model_flags.z, an interior group +const WORD_CLASS_INT: u32 = 16777216u; // 1 << 24: tint.w == 1, an INT batch +const WORD_CLASS_TRANS: u32 = 33554432u; // 1 << 25: tint.w == 2, a TRANS batch +const WORD_WINDOW: u32 = 67108864u; // 1 << 26: sidn.w, the WINDOW midpoint light +const WORD_HAS_VC: u32 = 134217728u; // 1 << 27: the batch authors vertex colours +// INTERIOR without WMO is an interior M2 prop, the entity shader's `interior_prop = +// flags.z && !flags.x`: probe lighting, interior fog, no point lights. +const WORD_MATTE: u32 = 268435456u; // 1 << 28: ShadeSel::Matte, fixed intensity 1.0 -// Vanilla cutout ref (224/255) — wow_model.wgsl's VANILLA_ALPHA_KEY. +// The alpha-test cutout reference 224/255, as wow_model.wgsl's VANILLA_ALPHA_KEY. const VANILLA_ALPHA_KEY: f32 = 0.8784314; // ---- mirrored law (wow_model.wgsl) ---- @@ -153,8 +123,7 @@ struct GxVertex { @location(2) uv: vec2, @location(3) word: u32, @location(4) anchor: vec3, - // MOCV / the baked constant tint (white where the batch authors none — WORD_HAS_VC says - // which; the entity path's ATTRIBUTE_COLOR, interpolated exactly like it). + // MOCV or the baked constant tint; white where the batch authors none (WORD_HAS_VC). @location(5) color: vec4, } @@ -171,10 +140,8 @@ struct GxVsOut { @vertex fn vertex(v: GxVertex) -> GxVsOut { var out: GxVsOut; - // The exile kill bit (B2, 1431 — record column w, bit 0): a punched-out item's vertices - // all collapse to one constant point, so every triangle is zero-area and the rasterizer - // drops it — no fragments, no state change, one u32 write flipped it. Triangles never - // span items (the bake is item-contiguous), so a partial collapse cannot exist. + // Exile kill bit (record w bit 0): every vertex of the item collapses to one point, so its + // triangles are zero-area and rasterize nothing; triangles never span items. if ((recs[v.word & 0xffffu].w & 1u) != 0u) { out.position = vec4(0.0, 0.0, 2.0, 1.0); out.world_position = vec4(0.0); @@ -185,8 +152,7 @@ fn vertex(v: GxVertex) -> GxVsOut { out.color = vec4(1.0); return out; } - // Camera-relative clip (0974/wow_model.wgsl): both addends are small — the recentred - // vertex and (cell origin − camera). + // Camera-relative for f32 precision: the recentred vertex plus (cell origin - camera). let p_cam = v.position + (cell.origin.xyz - view.world_position); let world = v.position + cell.origin.xyz; out.world_position = vec4(world, 1.0); @@ -196,21 +162,15 @@ fn vertex(v: GxVertex) -> GxVsOut { view.view_from_world[2].xyz, ); out.position = view.clip_from_view * vec4(view_rot * p_cam, 1.0); - // WMO authored batch order (the per-item record's y; wow_model.wgsl's `m.sun_scale.y` - // nudge verbatim): a later coplanar MOBA batch must WIN the reverse-Z GreaterEqual test - // in any draw order — load-bearing here, where the (bucket, texture, group) sort erases - // authored order by design. 0 on every cell item ⇒ ×1.0, an exact no-op. + // Authored MOBA batch order (record y, 0 on cells): a later coplanar batch wins reverse-Z + // GreaterEqual in any draw order, because the draw sort does not keep authored order. out.position.z *= 1.0 + f32(recs[v.word & 0xffffu].y) * 1.1920929e-7; out.world_normal = v.normal; out.uv = v.uv; out.word = v.word; out.color = v.color; - // The ≤3-nearest FFP selection, anchored at the PLACEMENT origin exactly like the entity - // path (the baked per-vertex anchor — 1429's parity note; the blob path coarsened this). - // WMO surfaces take ZERO point lights — the entity path zeroes them in the vertex stage - // (wow-re trace-forensics-abbey-interior-d3d §2: zero on every observed WMO surface) — - // and so do interior M2 props (B4): their group-MOLR point lobes are folded into the - // per-item SH probe at spawn, the entity path's own vertex-stage zeroing. + // Up to 3 nearest point lights, chosen from the placement origin. WMO surfaces take none, + // as in the reference; interior props neither: the probe holds their group's MOLR lights. if ((v.word & (WORD_WMO | WORD_INTERIOR)) != 0u) { out.point_lit = vec3(0.0); } else { @@ -221,16 +181,13 @@ fn vertex(v: GxVertex) -> GxVsOut { @fragment fn fragment(in: GxVsOut) -> @location(0) vec4 { - // The hard farclip wall — per-pixel planar eye-Z, same plane as the entity path. + // The hard farclip wall: per-pixel planar eye-Z, the same plane as the entity path. let eye_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; if (wow_light.fog_params.w > 0.0 && eye_z > wow_light.fog_params.w) { discard; } - // Sample: the item's array layer through the sampler matching its wrap flags. Both-repeat - // and both-clamp ride real samplers (exact parity); the rare MIXED batch keeps the repeat - // sampler plus the half-texel inset clamp on its clamped axis (0763's silhouette clamp, - // per axis). Both samples are taken unconditionally — `textureSample` needs uniform - // control flow for its derivatives — and the select is per fragment. + // The sampler follows the wrap flags; a mixed batch clamps its clamped axis a half texel in. + // Every sample runs unconditionally: implicit derivatives need uniform control flow. var base = vec4(1.0); let wrap_x = (in.word & WORD_WRAP_X) != 0u; let wrap_y = (in.word & WORD_WRAP_Y) != 0u; @@ -245,11 +202,7 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { if (!wrap_y) { uv_mixed.y = clamp(uv_mixed.y, inset.y, 1.0 - inset.y); } - // `view.mip_bias` (decision 1639) — the render-scale LOD compensation, 0.0 at native. - // Carried here as well as in wow_model.wgsl because this lane draws the same statics - // through its own sampler rather than through `pbr_input_from_standard_material`, which - // applies the bias for free; a lane that skipped it would blur out of step with the - // entity path drawing the identical art. + // The render-scale LOD bias: bevy's material path applies it for free, this lane by hand. let c_repeat = textureSampleBias(tex_array, samp_repeat, in.uv, layer, view.mip_bias); let c_clamp = textureSampleBias(tex_array, samp_clamp, in.uv, layer, view.mip_bias); let c_mixed = textureSampleBias(tex_array, samp_repeat, uv_mixed, layer, view.mip_bias); @@ -266,13 +219,8 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { discard; } #endif - // Lighting — the exterior-doodad lane verbatim (wow_model.wgsl steps 7+, 0803's lobe): - // both faces light from the SAME submitted normal (the FFP never enables - // GL_LIGHT_MODEL_TWO_SIDE). The entity shader's `select(-n, n, is_front)` exists to UNDO - // bevy_pbr's double-sided back-face negation; this pipeline never negated, so the raw - // baked normal IS the submitted normal and any select here would INTRODUCE the flip the - // entity path removes (the first capture A/B lit every back-facing canopy card from the - // wrong side — the felwood heatmap's bright clusters). + // Both faces light from the submitted normal (no GL_LIGHT_MODEL_TWO_SIDE in the reference); + // this pipeline never negates back faces, so it has no front-face select like wow_model.wgsl. let n_lit = wow_normalize(in.world_normal); let L = -normalize(wow_light.light_sun.xyz); let ndotl = max(dot(n_lit, L), 0.0); @@ -281,31 +229,23 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { vec3(0.0), vec3(1.0), ); - // The order-2 SH basis products over the fragment normal — shared by the exterior - // doodad lobe and the interior-prop probe lane (wow_model.wgsl computes them once too). + // Order-2 SH basis terms of the fragment normal, for the doodad lobe and the probe lane. let quad = vec4(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 x2y2 = n_lit.x * n_lit.x - n_lit.y * n_lit.y; - // ATTRIBUTE_COLOR, pre-folded into the sampled base exactly where bevy_pbr folds it - // (`base_color *= in.color` before the texture multiply — commutative, so tex×vc is the - // same bits). **A colour-less batch takes the CONSTANT 1.0, never the interpolated - // white**: the entity path's fold literally does not exist without its VERTEX_COLORS - // shader-def, and GPU interpolation of a constant attribute is NOT exact — a barycentric - // sum lands at 1.0±ε, and `base × 0.99999994` re-rounds ~half of all textured pixels one - // byte down. That was the ±1/255 film over 300k pixels of house-south with the WMO lane - // OFF — the attribution sweep's cells-only leg is what separated it from the WMO bake's - // own (f64-fixed) quantization film. + // A colour-less batch takes a constant 1.0, not interpolated white: interpolating a constant + // attribute gives 1.0±ε, and base × 0.99999994 rounds about half the pixels one byte down. let has_vc = (in.word & WORD_HAS_VC) != 0u; let vc = select(vec4(1.0), in.color, has_vc); let folded = select(base.rgb, base.rgb * vc.rgb, has_vc); var rgb: vec3; if ((in.word & WORD_WMO) != 0u) { - // ---- the WMO surface lanes (slice 2) — wow_model.wgsl's is_wmo branch verbatim ---- + // ---- WMO surfaces: wow_model.wgsl's is_wmo branch ---- let interior = (in.word & WORD_INTERIOR) != 0u; let class_int = (in.word & WORD_CLASS_INT) != 0u; let class_trans = (in.word & WORD_CLASS_TRANS) != 0u; - let trans_a = vc.a; // 1.0 where no MOCV authored — the entity path's default - // WINDOW (MOMT 0x20) — interior drawer only: GL_LIGHT0 swapped to the brighter - // Direct/Ambient midpoint pair, ambient +16/255 saturating (0x6d37e0). + let trans_a = vc.a; // 1.0 where no MOCV is authored + // WINDOW (MOMT 0x20), interior drawer only: GL_LIGHT0 becomes the Direct/Ambient + // midpoint pair, ambient +16/255 saturating (0x6d37e0). let window_mid = 0.5 * (wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb); let lit_window = clamp( window_mid + vec3(16.0 / 255.0) + window_mid * ndotl, @@ -313,10 +253,9 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { vec3(1.0), ); let lit_int_base = select(lit_nl, lit_window, (in.word & WORD_WINDOW) != 0u); - // The interior BATCH-CLASS lanes (trace-forensics-abbey-interior-d3d §2): INT = unlit - // (the bake IS the room's light), TRANS = the per-vertex MOCV-alpha lit↔bake lerp, - // EXT = plain lit_nl. Exterior groups take lit_nl at sun-scale 1 (prog 198/VS 151 — - // no terrain shade, no SH lobe). + // Interior batch classes: INT is unlit (the bake is the room's light), TRANS lerps lit + // to bake by MOCV alpha, EXT is plain lit_nl. Exterior groups take lit_nl at sun scale 1, + // no terrain shade and no SH lobe (prog 198/VS 151). var lit_wmo_interior = vec3(1.0); if (class_trans) { lit_wmo_interior = mix(vec3(1.0), lit_int_base, trans_a); @@ -324,9 +263,8 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { lit_wmo_interior = lit_int_base; } let lit_wmo = select(lit_nl, lit_wmo_interior, interior); - // SIDN night glow (MOMT 0x10): the authored emissive × the live night fraction, an - // EMISSION term — inside the clamped sum, never MOCV-multiplied; dead on the unlit - // INT lane, TRANS-weighted by the lit-pass alpha (wmo-interior-night-light §4). + // SIDN night glow (MOMT 0x10): authored emissive × night fraction, an emission term + // inside the clamp and never MOCV-multiplied; zero on INT, MOCV-alpha weighted on TRANS. let rec = recs[in.word & 0xffffu]; let sidn_rgb = vec3( f32(rec.z & 0xffu), @@ -342,23 +280,18 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { } } let sidn_e = sidn_rgb * (wow_light.grade.x * sidn_w); - // GL_COLOR_MATERIAL: MOCV multiplies the lit terms INSIDE the clamp (point term is - // zero on WMO surfaces, kept in the sum for exact mirroring), emission adds beside. + // GL_COLOR_MATERIAL: MOCV multiplies the lit terms inside the clamp; emission adds beside. let primary = clamp( vc.rgb * (lit_wmo + in.point_lit) + sidn_e, vec3(0.0), vec3(1.0), ); - // The entity algebra EXACTLY: bevy pre-folds vc into base and the shader divides it - // back out — (tex×vc)/max(vc, 1/255) is not tex in floats, so simplifying would - // break the pixel-identity bar. + // The entity path's algebra: (tex×vc)/max(vc, 1/255) is not tex in floats; keep it. let tex_rgb = folded / max(vc.rgb, vec3(1.0 / 255.0)); rgb = tex_rgb * primary; if (interior && class_int && has_vc) { - // INT self-illumination: tex·MOCV·(1 + 4·MOCV.a), only the final [0,1] clamp — - // the client's own interior pixel shader (literal 4.0). Gated on AUTHORED - // colours: a colour-less INT batch takes the entity path's no-COLORS combine - // (plain tex × lit), which the white-vc algebra above already reproduces. + // INT self-illumination, the reference's interior pixel shader (literal 4.0): + // tex·MOCV·(1 + 4·MOCV.a), clamped once. A colour-less INT batch keeps tex × lit. rgb = clamp( tex_rgb * vc.rgb * (1.0 + 4.0 * trans_a), vec3(0.0), @@ -366,14 +299,9 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { ); } } else if ((in.word & WORD_INTERIOR) != 0u) { - // ---- the interior M2-PROP lane (B4, decision 1433) — wow_model.wgsl's - // interior-prop branch verbatim: the folded per-instance SH probe (MODD ambient + - // the fixed-axis diffuse lobe + the owning group's MOLR lobes, folded ONCE at - // spawn), evaluated over the fragment normal. The slot rides the record table - // (w bits 1..14) where the entity path rode MeshTag bits 6..19. The soft SH wrap - // is the reference's authored response — deliberately NOT a hard max(N·L,0). - // point_lit is zero (vertex stage); inst_tint identity, no highlight, SIDN zero on - // every M2 batch — the combine collapses to folded × clamp(probe eval). + // ---- interior M2 props: wow_model.wgsl's interior-prop branch ---- + // The spawn-folded SH probe (MODD ambient, fixed-axis diffuse, the group's MOLR lobes) + // over the fragment normal; its soft wrap is the reference's, not a hard max(N·L, 0). let probe = 7u * ((recs[in.word & 0xffffu].w >> 1u) & 0x1fffu); let n1 = vec4(n_lit, 1.0); let lit_prop = clamp( @@ -394,13 +322,10 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { let primary = clamp(lit_prop + in.point_lit, vec3(0.0), vec3(1.0)); rgb = folded * primary; } else { - // ---- the exterior ADT-doodad lane (slice 1) + the exterior MODD-prop family (B4) -- - // The intensity family: statics are `mat_shade` only (no per-instance ramp byte — - // that is an entity concern): lit ground ⇒ mix(2.5,0.5,0)=2.5, MCSH-shadowed ⇒ 0.5, - // and the Matte mid-band (an exterior WMO MODD prop) FIXED 1.0 — the 2.5 site is one - // a MODD prop never reaches (§8b); then the recorded `min(I,1)` cap (unfaithful, - // kept in exact sync — 0803 §3; today it makes Matte and Lit read identically, and - // the distinct bit exists so lifting the cap cannot silently split this lane). + // ---- exterior ADT doodads and exterior MODD props ---- + // Model2.bls order-2 SH sun lobe at intensity 0.5 Shaded (MCSH), 1.0 Matte, 2.5 Lit. + // Deviation: the `min(I,1)` cap, in sync with wow_model.wgsl; Matte keeps its own bit so + // lifting the cap leaves it at 1.0. let shade_t = select(1.0, 0.0, (in.word & WORD_SHADE_LIT) != 0u); let intensity = min( select(mix(2.5, 0.5, shade_t), 1.0, (in.word & WORD_MATTE) != 0u), @@ -424,27 +349,17 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { let lit_doodad = clamp(sun_lobe, vec3(0.0), vec3(1.0)); // Sun disabled (light_sun.w) falls back to the FFP matte, like the entity path. let lit = select(lit_nl, lit_doodad, wow_light.light_sun.w > 0.5); - // FFP combine: the light sum saturates FIRST, the texture (× the baked constant - // tint, when authored) modulates the clamped result. Statics: inst_tint identity, - // no highlight, no SIDN. + // FFP combine: the light sum saturates first, then the texture modulates it. let primary = clamp(lit + in.point_lit, vec3(0.0), vec3(1.0)); rgb = folded * primary; } - // Unlit fullbright (M2 UNLIT 0x01 / WMO UNLIT on an exterior-group batch): the albedo — - // texture × the folded vertex colour — with lighting off; no emission terms reach it. + // Unlit (M2 UNLIT 0x01, WMO UNLIT on an exterior group): texture × vertex colour, no light. if ((in.word & WORD_UNLIT) != 0u) { rgb = folded; } - // Step-5 fog (planar eye-Z; policy: Scene, or Off via the render-flag bit — the - // Black/White/Grey families belong to blends this lane excludes by admission). Interior - // WMO surfaces fog with the INTERIOR triple — the room keeps its warm MFOG haze - // (round-6 Q-I: one flag gates walls, pools and props alike). - // The interior fog lane is the per-frame record bit, NOT the baked `WORD_INTERIOR`: the - // client pushes the interior triple per GROUP under `[0xca7f00]` (`0x6b5190` for surfaces, - // `0x6b62e0` for the group's doodads — wow-re round-6 Q-I), and the flood decides it each - // frame (decision 1787, `wow_model.wgsl` carries the same law on its tag bit 30). Keyed on - // the batch's static interior flag instead, every true-interior group of a building wore the - // building's MFOG the moment the camera stood anywhere in it — B335. + // Planar eye-Z fog; other fog modes belong to blends never admitted here. The interior triple + // keys on the per-frame record bit, not `WORD_INTERIOR`: the client sets it per group under + // `[0xca7f00]` (`0x6b5190` for surfaces, `0x6b62e0` for the group's doodads). var fog_color = wow_light.fog_color; var fog_span = wow_light.fog_params.xy; if ((recs[in.word & 0xffffu].w & 16384u) != 0u) { @@ -456,6 +371,6 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0); rgb = mix(fog_color.xyz, rgb, factor); } - // Raw gamma out (0161's lane); alpha pinned 1.0 — opaque-intent by admission. + // Gamma-space output; alpha pinned 1.0, every draw here is opaque. return vec4(rgb, 1.0); } diff --git a/crates/benilla-world/src/shaders/wow_effect.wgsl b/crates/benilla-world/src/shaders/wow_effect.wgsl index 1b945d5a..1f4a7952 100644 --- a/crates/benilla-world/src/shaders/wow_effect.wgsl +++ b/crates/benilla-world/src/shaders/wow_effect.wgsl @@ -1,30 +1,21 @@ -// WoW effect-lane shader — the shared effect stream's own pipeline (decisions 0732 P1/P2, -// 0733: particles, ribbons, the decal family, water foam, precipitation). The fragment IS -// wow_particle.wgsl's combine, freed of the StandardMaterial shell: the whole product is -// authored gamma bytes — texture (Rgba8Unorm, deliberately NOT decoded on sample, the house -// invariant) × the raw authored track colour riding the vertex colour. Multiplying them in -// gamma space IS the vanilla math; the output is raw gamma — the buffer holds bytes end-to-end -// and the frame's one decode lives in the FFXGlow combine (GAMMA LANE, 0161). +// The effect-lane pass (particles, ribbons, decals, water foam, precipitation): texture bytes +// (Rgba8Unorm, never decoded on sample) × the authored track colour, multiplied in gamma space as +// the reference does. The output stays gamma; the FFXGlow combine decodes the frame once. // -// Blend variants (pipeline shader defs, one per EffectBlend — 0733 §4): -// - BLEND_ADD: the premultiplied-alpha fold in GAMMA space (rgb ×= α, α = 0 under the -// (One, 1−srcα) blend state = pure addition) — the reference adds `src_g·α` bytes; -// premultiplying after a linear conversion inflates every soft edge by α^(1/2.2) -// (decision 0160, the fat glow-disc family). +// Blend variants, one shader def per EffectBlend: +// - BLEND_ADD: (rgb·α, 0) under (One, 1−srcα): the reference adds `src·α` bytes, and +// premultiplying in linear space instead would fatten every soft edge by α^(1/2.2). // - BLEND_ALPHA: straight (rgb, α) under standard alpha blending. -// - BLEND_OPAQUE: (rgb, 1) with blending off — the alpha-ignoring shape the material path's -// AlphaMode::Opaque produced. -// - BLEND_ALPHAKEY: BLEND_OPAQUE's output behind the fixed-function ALPHA TEST — `discard` below -// 224/255. wgpu has no `glAlphaFunc`, so the reference's EGxRs id 0x08 lands here. -// - BLEND_MULTIPLY: premultiplied (rgb·α, α) under (Dst, 1−srcα) — `dst·lerp(1, rgb, α)`, the -// blob shadow's modulate-with-fade (and ModelBlend::Mod at α = 1). -// - BLEND_MOD2X: (rgb, 1) under (Dst, Src) = `2·src·dst` — rain's state; reads no alpha. +// - BLEND_OPAQUE: (rgb, 1) with blending off. +// - BLEND_ALPHAKEY: BLEND_OPAQUE behind the fixed-function alpha test (EGxRs id 0x08); wgpu has +// no `glAlphaFunc`, so the fragment discards below 224/255. +// - BLEND_MULTIPLY: (rgb·α, α) under (Dst, 1−srcα) = `dst·lerp(1, rgb, α)`, the blob shadow's +// modulate-with-fade and ModelBlend::Mod at α = 1. +// - BLEND_MOD2X: (rgb, 1) under (Dst, Src) = `2·src·dst`, rain's state; reads no alpha. #import bevy_render::view::View -// The shared global light/fog buffer (`lighting::global_light`) — the SAME layout every world -// shader mirrors (see wow_model.wgsl's declaration, the canonical copy). This lane reads only -// the fog rows; the rest is layout ballast. +// Prefix of `lighting::global_light`'s buffer; keep in sync with wow_model.wgsl's copy. struct WowLight { light_ambient: vec4, light_diffuse: vec4, @@ -47,20 +38,11 @@ struct WowLight { @group(1) @binding(0) var effect_texture: texture_2d; @group(1) @binding(1) var effect_sampler: sampler; @group(1) @binding(2) var wow_light: WowLight; -// Per-draw params (a dynamic offset into the canonical trio): x = the fog COLOUR policy -// (0 = off — the "unfogged" file flag 0x8; 1 = scene — the ordinary day-night fog; 2 = BLACK — -// an Add-blend emitter fades under a veil instead of gaining grey, the same per-blend fog -// table M2 batches take, `0x70baf0`). y = forced-fog mode with zw = its start/end (the rain -// lanes — unused until precipitation joins the lane in slice P2, kept so the struct is P2's -// already). -// -// `clip` is the draw's **render-target rectangle in target pixels** — `(min.x, min.y, max.x, -// max.y)`, and `z <= x` means the whole target, which is every world draw. The UI model tiles -// are the one customer (decision 2093): every visible `` pane renders into its own cell -// of ONE shared atlas, so a cloud reaching past its cell would land in the cell beside it and -// be composited onto a different widget. The reference clips this with the widget's own rect as -// the VIEWPORT (`modelframe-render-law.md` §6); one shared camera cannot carry a viewport per -// pane, so the rect rides the draw and the fragment discards outside it. +// Per-draw params. `fog`: x = fog colour policy, the per-blend table of `0x70baf0` (0 off for +// file flag 0x8, 1 scene, 2 black for Add, 3 white for Mod, 4 grey for Mod2x); y = rain's forced +// fog, zw = its start/end. `clip`: the render-target rect in target pixels (min.xy, max.xy), the +// whole target when z <= x. The reference clips a UI model to its widget's viewport; model panes +// share one atlas, so the rect rides the draw and the fragment discards outside it. struct EffectParams { fog: vec4, clip: vec4, @@ -71,12 +53,8 @@ struct EffectParams { const RAIN_FOG_GREY: vec3 = vec3(0.50196078, 0.50196078, 0.50196078); struct Vertex { - // CAMERA-RELATIVE (0733 §2): prepare rebased the stream against this view's own - // world_position, so the big-coordinate cancellation never reaches the matrix product — - // absolute-coordinate verts through `clip_from_world` shear thin geometry apart far from - // the origin (the precip module's empirically-learned law at ~9000 yd). - // EXCEPT under DECAL_WORLD_CLIP (decision 0781): the decal family's verts arrive ABSOLUTE - // (prepare skips their rebase) and take the mesh path's own matrix in the vertex stage. + // Camera-relative for f32 precision (absolute verts through `clip_from_world` shear thin + // geometry far from the origin), except decals under DECAL_WORLD_CLIP, which arrive absolute. @location(0) position: vec3, @location(1) uv: vec2, @location(2) color: vec4, // raw authored gamma RGBA; α is the blend weight @@ -86,8 +64,7 @@ struct VertexOutput { @builtin(position) clip_position: vec4, @location(0) uv: vec2, @location(1) color: vec4, - // View-space depth (+ in front of the camera) — the fog/farclip planar eye-Z (exact on - // the cam-relative path, where the view transform is pure rotation). + // Planar eye-Z for fog and the farclip wall, positive in front of the camera. @location(2) view_z: f32, }; @@ -95,20 +72,15 @@ struct VertexOutput { fn vertex(v: Vertex) -> VertexOutput { var out: VertexOutput; #ifdef DECAL_WORLD_CLIP - // The decal family (raster_bias ≠ 0): ABSOLUTE world verts through `clip_from_world` — - // the SAME matrix every world-mesh shader runs (`position_world_to_clip` in terrain.wgsl / - // wow_model.wgsl). A decal's depth must tie against the drawn ground within the small - // rasterizer bias; the cam-relative route below reaches the same plane through different - // arithmetic, and at WoW-scale coordinates the two disagree by more than the bias — the - // flaky/wedge-shaped blob shadow (decision 0781). Same matrix, same jitter: the decal - // wobbles WITH its receiver instead of against it. + // Decals (raster_bias ≠ 0) take absolute verts through `clip_from_world`, the world meshes' + // own matrix: their depth must tie with the ground within the small raster bias, and the + // cam-relative route rounds differently by more than that at world-scale coordinates. out.clip_position = view.clip_from_world * vec4(v.position, 1.0); - // Fog/farclip eye-Z via the full affine transform (yards-scale — rounding is irrelevant). + // Eye-Z via the full affine transform; its rounding is yard-scale and harmless. out.view_z = -(view.view_from_world * vec4(v.position, 1.0)).z; #else - // Free-floating families: cam-relative verts (0733 §2). view_from_world = [R | −R·cam]; - // for a cam-relative point the translation column is exactly the subtraction prepare - // already did, so only the rotation remains. + // Cam-relative verts: view_from_world is [R | −R·cam] and the rebase already subtracted cam, + // so only the rotation applies. let view_pos = mat3x3( view.view_from_world[0].xyz, view.view_from_world[1].xyz, @@ -124,9 +96,7 @@ fn vertex(v: Vertex) -> VertexOutput { @fragment fn fragment(in: VertexOutput) -> @location(0) vec4 { - // The draw's target-rect clip (see `EffectParams::clip`). `@builtin(position)` is the - // framebuffer pixel, which for the UI tile atlas IS the atlas texel — so this is the - // scissor the reference gets from the widget's viewport, per fragment. + // The target-rect clip; `@builtin(position)` is the framebuffer pixel, a UI tile's atlas texel. if (wow_params.clip.z > wow_params.clip.x) { let p = in.clip_position.xy; if (p.x < wow_params.clip.x || p.y < wow_params.clip.y @@ -134,71 +104,35 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { discard; } } - // `$WOW_PARTICLE_FLAT` (B16 instrument): solid magenta, no inputs — see render.rs. + // `$WOW_PARTICLE_FLAT` debug instrument: solid magenta, no inputs. #ifdef WOW_PARTICLE_FLAT return vec4(1.0, 0.0, 1.0, 1.0); #else - // HARD FAR-CLIP WALL (faithful `farclip` ~777 yd) — see terrain.wgsl for the law. The - // reference has ONE projection far plane clipping the whole detailed world including the - // effect family; ours sits at ~3000 yd so every world shader emulates the wall per-pixel. - // Missing it here was bug B39. No pop: the scene fog end is `min(fog_end, farclip)`, so a - // quad reaching the wall has already faded to the fog colour — toward BLACK for an - // Add-blend emitter, i.e. to nothing under `One` blend. + // The hard farclip wall (`farclip`, about 777 yd): the reference's far plane clips effects + // too, ours is farther, so every world shader discards per pixel. The scene fog ends by the + // farclip, so a quad at the wall has already faded to the fog colour. if (wow_light.fog_params.w > 0.0 && in.view_z > wow_light.fog_params.w) { discard; } - // The gamma-space product — texture bytes × raw authored vertex colour, exactly what the - // reference multiplies. - // `view.mip_bias`: the render-scale LOD compensation (1639), 0.0 at native and above. + // Texture bytes × authored vertex colour in gamma space; `view.mip_bias` is the render-scale + // LOD compensation, 0.0 at native and above. let c = textureSampleBias(effect_texture, effect_sampler, in.uv, view.mip_bias) * in.color; #ifdef BLEND_ALPHAKEY - // THE FIXED-FUNCTION ALPHA TEST (EGxRs id 0x08 — `glAlphaFunc(GL_GEQUAL, ref/255)` + - // `glEnable(GL_ALPHA_TEST)`). `0x70c256` computes the ref from the RAW blend mode, so an - // AlphaKey emitter keeps `round(instanceAlpha × 224.0)` — 224 at full alpha — and `c.a` here - // is exactly the reference's fragment alpha: texture α × the over-life track α, GL_MODULATE. - // GEQUAL, so the test PASSES at the ref: discard strictly below it. - // - // Without it the debris family's transparent field paints solid — the Lesser Rock Elemental's - // chips drew as pale squares, PARTROCK.BLP's α = 0 surround at full opacity. + // The fixed-function alpha test (EGxRs id 0x08, `glAlphaFunc(GL_GEQUAL, ref/255)`): `0x70c256` + // sets ref = round(instanceAlpha × 224), 224 at full alpha, and `c.a` is the reference's + // fragment alpha (texture α × track α, GL_MODULATE). GEQUAL passes at the ref. if (c.a < 224.0 / 255.0) { discard; } #endif var rgb = c.rgb; #ifdef EFFECT_LIT - // SCENE LIGHTING (EGxRs id 0x0e), for the emitters that ask for it. A particle emitter owns - // no material: the reference synthesizes an `M2Material` from the emitter's file record every - // draw (`0x70d8b0`) and runs the SAME batch state producer as an ordinary mesh submesh, so - // `GL_LIGHTING` lands on a particle quad exactly as on geometry — enabled iff the emitter - // CLEARS file flag 0x1 and its blend mode is not Mod/Mod2x (wow-re `part-scene-multipliers.md` - // §1, byte-verified at `0x70baf0` @`0x70bb00`; the gate itself is `EffectDrawSpec::lit`). - // - // File bit 0x1 is the UNLIT flag — the inverse of the wowdev-wiki lore — so the fire and spell - // corpus (which sets it) stays bright at night, while the environment sheets that clear it - // take the world's light: waterfall spray, chimney smoke, blown dust, snow. Rendering those - // unlit is a full-white cutout against shaded terrain (the Zul'Gurub waterfall foam). - // - // The term is the fixed-function matte `wow_model.wgsl` applies to a mesh — - // `clamp(ambient + diffuse·max(N·L, 0))` — and **N is the WORLD UP axis, one constant for the - // whole draw** (wow-re `part-lit-normal-space.md`, §5-converged). The quad writer stores the - // same triple into all four vertices of every quad from a single per-draw global - // (`0xcf5878..80`, nine identical stores across the head/tail legs), and that global is set - // once per draw by `0x7b3fd0` from **row 2 of the VIEW matrix** (`0x58b0b0` = GetView, not - // GetWorld) — i.e. world +Z carried into eye space. The light is carried into the same frame - // (`0x71bce0` pre-multiplies the M2 light direction by `CM2Scene+0x9c`, and `glLightfv` is - // issued under the VIEW slot alone), so the rotation cancels and the product the hardware - // computes is exactly `worldUp · worldLightDir` — **invariant as the camera orbits**. - // - // We had a camera-facing normal here, read off a parenthetical in `part-scene-multipliers.md` - // §1 that was written about an emitter that is UNLIT and has since been corrected at the bytes - // (its polarity was inverted too: the LIT leg is the one that binds a real normal stream — - // vertex format 4, normal at +0x0c — while an UNLIT emitter's format has no normal element at - // all). It made this multiplier swing with the viewer: in Onyxia's Lair, where the trap dust is - // lit and additive, `ambient (0.22,0.26,0.29)` → `ambient + diffuse (1.00,0.93,0.74)` — a ~4× - // brightness change from nothing but turning the camera (decision 1696). - // - // N and L must be in the SAME frame; that is where the invariance comes from. Both are world - // here (Bevy axes: WoW's +Z up is Bevy +Y). + // Scene lighting (EGxRs id 0x0e): the reference builds an `M2Material` from the emitter record + // each draw (`0x70d8b0`) and lights it iff the emitter clears file flag 0x1 (the unlit flag) + // and its blend is not Mod/Mod2x (`0x70bb00`; gated by `EffectDrawSpec::lit`). The term is the + // fixed-function matte with N = world up for the whole draw: `0x7b3fd0` sets the quad normal + // from row 2 of the view matrix and `0x71bce0` moves the light into the same frame, so N·L does + // not change as the camera orbits. Bevy +Y is WoW +Z. let L = -normalize(wow_light.light_sun.xyz); let N = vec3(0.0, 1.0, 0.0); let lit = clamp( @@ -208,32 +142,26 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { ); rgb = rgb * lit; #endif - // The scene day-night fog — byte-verified ON the particle path (wow-re - // part-scene-multipliers.md): the reference runs the SAME per-vertex linear fog as world - // geometry — same start/end, same day-night colour, applied in gamma space BEFORE blend, - // with NO additive special-case. The blend equation then does the whole night split by - // itself. Missing fog was the "night smoke glows" bug. (Planar eye-Z, like terrain fog.) + // Scene fog: the world's linear fog (same start and end, planar eye-Z) in gamma space before + // the blend; only its colour follows the per-blend policy in `wow_params.fog.x`. if (wow_light.fog_color.w > 0.5 && wow_params.fog.x > 0.5) { let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001); let factor = clamp((wow_light.fog_params.y - in.view_z) / denom, 0.0, 1.0); - // The fog COLOUR follows the policy in wow_params.fog.x (see its declaration). var fog_rgb = wow_light.fog_color.xyz; if (wow_params.fog.x > 1.5 && wow_params.fog.x < 2.5) { fog_rgb = vec3(0.0); } else if (wow_params.fog.x > 2.5 && wow_params.fog.x < 3.5) { fog_rgb = vec3(1.0); } else if (wow_params.fog.x > 3.5) { fog_rgb = RAIN_FOG_GREY; } rgb = mix(fog_rgb, rgb, factor); } - // The FORCED fog (rain weather): grey-0.5 over the params' own start/end, regardless of - // the scene fog state — under Mod2x the grey is neutral, so this IS the streak/patter - // distance fade (rf-weather-render Q3). + // Rain's forced fog: grey over the draw's own start/end, whatever the scene fog; grey is + // neutral under Mod2x, so this is the streaks' distance fade. if (wow_params.fog.y > 0.5) { let denom = max(wow_params.fog.w - wow_params.fog.z, 0.001); let factor = clamp((wow_params.fog.w - in.view_z) / denom, 0.0, 1.0); rgb = mix(RAIN_FOG_GREY, rgb, factor); } #ifdef BLEND_ADD - // GAMMA LANE (0161): gamma-premultiplied, raw out — N stacked faint quads SUM in gamma - // like the reference's bytes. α = 0 turns (One, 1−srcα) into pure addition. + // Premultiplied in gamma, so stacked quads sum like the reference's bytes. return vec4(rgb * c.a, 0.0); #else #ifdef BLEND_OPAQUE @@ -244,12 +172,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { return vec4(rgb, 1.0); #else #ifdef BLEND_MULTIPLY - // The modulate family: premultiplied for bevy's Multiply state — `dst·lerp(1, rgb, α)` - // (the blob shadow's fade-riding darken; `ModelBlend::Mod` at α = 1). return vec4(rgb * c.a, c.a); #else #ifdef BLEND_MOD2X - // `(Dst, Src)` reads no alpha (0528's no-alpha law). return vec4(rgb, 1.0); #else return vec4(rgb, c.a);