comments: the shaders keep the reference facts and lose the history
Each header names its pass and the fixed-function state, blend and formula it reproduces, with the client address; GPU traps, precision rules and marked deviations stay. Bevy's own comments in the two vendored UI shaders stay verbatim so an upgrade re-diffs cleanly. With comments stripped every shader is identical. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<f32>;
|
||||
@group(1) @binding(1) var texture_sampler: sampler;
|
||||
@group(1) @binding(2) var<uniform> rect: vec4<f32>;
|
||||
|
||||
// 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<f32>) -> vec3<f32> {
|
||||
let higher = 1.055 * pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055;
|
||||
let lower = c * 12.92;
|
||||
|
||||
@@ -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<f32>;
|
||||
@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<uniform> ramp: vec4<f32>;
|
||||
|
||||
// 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<f32>) -> vec3<f32> {
|
||||
let higher = pow((max(c, vec3<f32>(0.0)) + vec3<f32>(0.055)) / 1.055, vec3<f32>(2.4));
|
||||
let lower = c / 12.92;
|
||||
@@ -28,13 +22,9 @@ fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
|
||||
fn fs_decode(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
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<f32>(1e-6)), vec3<f32>(ramp.x));
|
||||
}
|
||||
|
||||
@@ -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<f32>) -> vec3<f32> {
|
||||
let higher = 1.055 * pow(max(c, vec3<f32>(0.0)), vec3<f32>(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<f32> {
|
||||
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 `<Color>`,
|
||||
// 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<f32> {
|
||||
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);
|
||||
|
||||
@@ -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<f32>,
|
||||
@@ -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<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
#ifdef VERTEX_COLORS
|
||||
@location(1) color: vec4<f32>,
|
||||
#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<Mesh>` 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<f32>,
|
||||
}
|
||||
|
||||
@@ -90,110 +70,61 @@ fn vertex(vertex: Vertex) -> VertexOutput {
|
||||
@group(2) @binding(0) var<uniform> additive: u32;
|
||||
@group(2) @binding(1) var quad_texture: texture_2d<f32>;
|
||||
@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<uniform> 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<uniform> mask_rect: vec4<f32>;
|
||||
@group(2) @binding(5) var mask_texture: texture_2d<f32>;
|
||||
@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<uniform> 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<uniform> 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<uniform> 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<uniform> 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<uniform> uv_clamp: vec4<f32>;
|
||||
|
||||
// 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<f32> = vec3<f32>(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<f32>) -> vec3<f32> {
|
||||
let higher = 1.055 * pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055;
|
||||
let lower = c * 12.92;
|
||||
return select(higher, lower, c <= vec3<f32>(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<f32>) -> vec3<f32> {
|
||||
let higher = pow((max(c, vec3<f32>(0.0)) + 0.055) / 1.055, vec3<f32>(2.4));
|
||||
let lower = c / 12.92;
|
||||
@@ -202,38 +133,34 @@ fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
@fragment
|
||||
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// 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 `<Color>`, `|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 `<Color>`, `|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<f32>(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<f32>(0.5)));
|
||||
}
|
||||
if mask_rect.z > mask_rect.x {
|
||||
@@ -242,29 +169,20 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let m = textureSampleLevel(mask_texture, mask_sampler, clamp(muv, vec2<f32>(0.0), vec2<f32>(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<f32>(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<f32>(rgb * weight, 0.0);
|
||||
|
||||
@@ -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<uniform> globals: Globals;
|
||||
@group(1) @binding(0) var sprite_texture: texture_2d<f32>;
|
||||
@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<f32>) -> vec3<f32> {
|
||||
let higher = 1.055 * pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055;
|
||||
let lower = c * 12.92;
|
||||
@@ -140,10 +134,9 @@ fn fragment(in: UiVertexOutput) -> @location(0) vec4<f32> {
|
||||
// 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<f32>(
|
||||
linear_to_srgb(in.color.rgb) * linear_to_srgb(texel.rgb),
|
||||
|
||||
@@ -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<f32>,
|
||||
// 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<f32>,
|
||||
// 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<f32>,
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> 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<f32>, // 0 rgb = ambient; w = Mod2x scale
|
||||
light_diffuse: vec4<f32>, // 1 rgb = sun diffuse; w = clamp flag
|
||||
light_sun: vec4<f32>, // 2 xyz = sun TRAVEL dir (to-light = −xyz)
|
||||
light_spec: vec4<f32>, // 3 rgb = row-9 specular colour; w = TERRAIN shininess (liquid uses w.kind.w)
|
||||
light_sun: vec4<f32>, // 2 xyz = sun travel direction (to-light = −xyz)
|
||||
light_spec: vec4<f32>, // 3 rgb = row-9 specular colour; w = terrain shininess, unread
|
||||
fog_color: vec4<f32>, // 4 rgb = scene fog (block 1, gamma 0..1); w = enable (>0.5)
|
||||
fog_params: vec4<f32>, // 5 x = start yd; y = end yd; w = the farclip wall
|
||||
_sh: array<vec4<f32>, 6>, // 6-11 model SH coeffs — unread here
|
||||
_sh: array<vec4<f32>, 6>, // 6-11 model SH coefficients, unread here
|
||||
_sh_c16: vec4<f32>, // 12
|
||||
water_river: array<vec4<f32>, 2>, // 13-14 shallow/deep river-lake swatch (IntBand 16/17); w = alpha
|
||||
water_ocean: array<vec4<f32>, 2>, // 15-16 shallow/deep ocean swatch (IntBand 14/15); w = alpha
|
||||
water_river: array<vec4<f32>, 2>, // 13-14 river/lake shallow, deep (IntBand 16/17); w = alpha
|
||||
water_ocean: array<vec4<f32>, 2>, // 15-16 ocean shallow, deep (IntBand 14/15); w = alpha
|
||||
_grade: vec4<f32>, // 17
|
||||
wmo_fog_color: vec4<f32>, // 18 rgb = INTERIOR fog (block 2); w = enable
|
||||
wmo_fog_color: vec4<f32>, // 18 rgb = interior fog (block 2); w = enable
|
||||
wmo_fog_params: vec4<f32>, // 19 x = start yd; y = end yd
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
|
||||
@@ -141,112 +74,66 @@ struct LiquidVsOut {
|
||||
@location(1) world_normal: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
@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<f32>,
|
||||
// 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<f32>,
|
||||
// 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<f32>, world_pos: vec3<f32>) -> vec3<f32> {
|
||||
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<f32>) -> vec2<f32> {
|
||||
// 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<f32>(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<f32>, world_pos: vec3<f32>, room_fog: u32) -> vec3<f32> {
|
||||
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<f32>(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<f32>, deep: vec4<f32>, i: f32, ocean: bool) -> vec4<f32> {
|
||||
// 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<f32>(round(shallow.rgb * 255.0), floor(shallow.w * 255.0));
|
||||
let c1 = vec4<f32>(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<f32>, deep: vec4<f32>, v: f32, ocean: bool) -> vec4<f
|
||||
|
||||
@fragment
|
||||
fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
|
||||
// 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<f32>(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<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<f32> {
|
||||
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<f32>(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<f32>(0.0), vec3<f32>(1.0));
|
||||
return vec4<f32>(
|
||||
apply_fog(body, in.world_position.xyz, in.room_fog),
|
||||
@@ -440,42 +264,15 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
|
||||
);
|
||||
}
|
||||
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<f32> {
|
||||
vec3<f32>(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<f32>(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<f32> {
|
||||
vec3<f32>(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<f32>(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<f32>(rgb, alpha);
|
||||
}
|
||||
|
||||
@@ -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<f32>;
|
||||
|
||||
// Per-tile Vec4 uniforms packed into ONE buffer (binding 106) — the field order here MUST match the
|
||||
// Rust `TerrainExtension` struct. Light + fog live in the shared global-light storage buffer (below);
|
||||
// what's left is just the per-tile layer tiling factor.
|
||||
// params — x = layer tiling factor; yzw unused.
|
||||
// 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<f32>,
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var<uniform> t: TerrainParams;
|
||||
|
||||
// The shared global light (lighting::global_light): ONE storage buffer every material reads, updated
|
||||
// once/frame in place — replaces the per-material light/fog uniforms `apply_wow_lighting` re-pushed
|
||||
// each frame. Rows 0-5 (of the 18-row LightStd430) are the light + fog terrain needs, plus the grade
|
||||
// row at the end (rows 6-16 — model SH + water swatches — are skipped as a pad). Read in BOTH stages
|
||||
// (the per-vertex sheen needs light_sun/spec in the vertex stage).
|
||||
// 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<f32>, // rgb = row 1 ambient; w = Mod2x scale (×1).
|
||||
light_diffuse: vec4<f32>, // rgb = row 0 sun diffuse; w = clamp-light flag (>0.5 ⇒ saturate).
|
||||
light_sun: vec4<f32>, // xyz = world-space sun TRAVEL direction (to-light = −xyz); w unused.
|
||||
light_sun: vec4<f32>, // xyz = world-space sun travel direction (to-light = −xyz); w unused.
|
||||
light_spec: vec4<f32>, // rgb = row 9 specular color; w = shininess (20). rgb == 0 disables.
|
||||
fog_color: vec4<f32>, // rgb = row 7 fog (raw, gamma 0..1); w = enable (>0.5 ⇒ blend).
|
||||
fog_params: vec4<f32>, // x = fog_start yd; y = fog_end yd; z unused; w = farclip wall.
|
||||
_sh: array<vec4<f32>, 6>, // rows 6-11: the model SH coeffs (live in wow_model.wgsl, 0354) — unread by terrain.
|
||||
sh_c16: vec4<f32>, // row 12: xyz the models' c16 quad band; .w a FREE lane (the 0273 point gain is retired).
|
||||
_water: array<vec4<f32>, 4>, // rows 13-16: the liquid swatches — unread by terrain.
|
||||
grade: vec4<f32>, // reserved (the 0282 interior A/B retired; 0163). Layout only.
|
||||
_wmo_fog: array<vec4<f32>, 2>, // rows 18-19: the interior fog triple — unread by terrain.
|
||||
// The dynamic point-light table (decision 0278), packed by `global_light::build_light_data`:
|
||||
// row 20 `.x` = live entry count; then TWO rows per light — `[pos.xyz, range]`, `[rgb, 0]`.
|
||||
_sh: array<vec4<f32>, 6>, // rows 6-11: model SH coefficients, unread by terrain.
|
||||
sh_c16: vec4<f32>, // row 12: xyz = the models' c16 quad band; w free.
|
||||
_water: array<vec4<f32>, 4>, // rows 13-16: liquid swatches, unread by terrain.
|
||||
grade: vec4<f32>, // row 17: reserved, layout only.
|
||||
_wmo_fog: array<vec4<f32>, 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<f32>,
|
||||
points: array<vec4<f32>, 512>,
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
|
||||
|
||||
// Custom vertex→fragment payload: the standard fields the splat fragment needs, PLUS `specular` — the
|
||||
// per-vertex-clamped sun glint that must be Gouraud-interpolated (Q14). Same struct on both stages, so
|
||||
// `@builtin(position)` is clip-position out / frag-coord in (`world_position` is unused until Step 5 fog).
|
||||
// The vertex-to-fragment payload; `specular` is clamped per vertex, then Gouraud-interpolated.
|
||||
struct TerrainVsOut {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) world_position: vec4<f32>,
|
||||
@@ -80,50 +62,27 @@ struct TerrainVsOut {
|
||||
@location(3) uv_b: vec2<f32>,
|
||||
@location(4) color: vec4<f32>,
|
||||
@location(5) specular: vec3<f32>,
|
||||
// The full Gouraud diffuse — `clamp01(ambient + sun·max(N·L,0) + Σ point·att·N·L)`, evaluated
|
||||
// AND CLAMPED per VERTEX: the GL T&L locus. The clamp position is load-bearing for a hot
|
||||
// carried light (the raw committed torch is (1.4, 0.87, 0.40)): GL clamps the summed vertex
|
||||
// colour BEFORE interpolation, so an over-gamut torch saturates one vertex and Gouraud falls
|
||||
// linearly away from it. Interpolating the raw sum and clamping per pixel — what we did
|
||||
// before — keeps pixels pinned at white far from the hot vertex: a wide flat plateau with a
|
||||
// hard knee, the director's "hard square". Sun N·L moving per-pixel → per-vertex is the
|
||||
// faithful direction (Step 3's own note: indistinguishable on the smooth term).
|
||||
// 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<f32>,
|
||||
}
|
||||
|
||||
// Terrain's point-light **candidacy half-width** (yd): the reference's guaranteed covered box is
|
||||
// `w + 10` where `w` is the chunk's bounding-sphere radius — `sqrt(2·16.666666² + (zExtent/2)²)`,
|
||||
// i.e. 23.570166 on flat ground (`0x68df70` hard-codes that same constant at `68dfac`). Relief
|
||||
// widens a chunk's own `w`; we use the flat value, so on steep ground we gather very slightly
|
||||
// narrower than the reference — the lights that differ are ≥33 yd out, where `att ≈ 0.02`.
|
||||
// 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<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
|
||||
let count = u32(wow_light.point_count.x);
|
||||
var sel = array<u32, 3>(0u, 0u, 0u);
|
||||
@@ -163,23 +122,13 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
|
||||
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<f32>) -> vec3<f32> {
|
||||
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<f32>(0.0), vec3<f32>(1.0));
|
||||
|
||||
// STEP 3 at the faithful locus — the WHOLE diffuse sum, clamped HERE per vertex (see the
|
||||
// struct note). Sun: `ambient + diffuse·max(N·L,0)` on the MCNR normal; points: the ≤3-nearest
|
||||
// committed lights of this vertex's MCNK chunk cell — the terrain draw unit (0285) — at the
|
||||
// byte-verified `1/(0.7d + 0.03d²)`, raw over-gamut colours in.
|
||||
// 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<f32> {
|
||||
// HARD FAR-CLIP WALL (faithful `farclip`): the reference clips the detailed world at its projection
|
||||
// far plane (~777 yd). Geometry beyond is GPU-clipped per-pixel, so a tall object reveals
|
||||
// closest-part-first as you approach and the sky/WDL shows behind it. We reproduce that with a
|
||||
// per-fragment discard on PLANAR eye-Z (same coordinate as the fog) beyond `fog_params.w` = farclip
|
||||
// (0 ⇒ disabled). WDL/sky use other shaders, so they still render to the horizon behind the wall.
|
||||
// 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<f32>(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<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<i32>(round(in.color));
|
||||
let ai = i32(round(in.uv_b.x));
|
||||
|
||||
let tiled = in.uv * t.params.x;
|
||||
// Full RGBA: `.rgb` = diffuse, `.a` = the `_s` texture's per-texel SHEEN MASK (see assets.rs /
|
||||
// terrain.rs — the base `.blp` has no alpha, so we load the `_s` variant; matte where no mask).
|
||||
// `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<f32>(0.5 / 64.0), vec2<f32>(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<f32> {
|
||||
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<f32> {
|
||||
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<f32>(0.0), vec3<f32>(1.0));
|
||||
|
||||
// STEP 5: gamma-space linear fog (q6). Applied AFTER tone/curve (so the diagnostic knobs see
|
||||
// the unfogged lit pixel) and BEFORE the viz bypass + raw output. The fog coordinate is
|
||||
// PLANAR eye-Z (view-space depth along the camera forward axis), NOT radial distance: the
|
||||
// reference VS computes `fogcoord` as a linear function of a single axial coordinate (eye-Z) —
|
||||
// VERIFIED from the host GLSL fog source in two apitraces (WoW.5/WoW.8). Radial `length()` is
|
||||
// ≥ planar everywhere off-axis, so it OVER-fogs the screen edges (worst in short-fog zones like
|
||||
// Duskwood, +0.26–0.32 fog factor at the corners). The per-vertex T&L original and this
|
||||
// per-pixel form land on the same byte under LINEAR fog (the factor interpolates exactly), and
|
||||
// the per-pixel form is what survives Bevy's mesh interpolators without a custom slot.
|
||||
// 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<f32>(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<f32> {
|
||||
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<f32>(tuned, 1.0);
|
||||
}
|
||||
|
||||
@@ -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<f32>, // 0
|
||||
_light_diffuse: vec4<f32>, // 1
|
||||
_light_sun: vec4<f32>, // 2
|
||||
_light_spec: vec4<f32>, // 3
|
||||
fog_color: vec4<f32>, // 4 rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend)
|
||||
fog_params: vec4<f32>, // 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<f32>, // 5 xy = scene fog start/end (unread); w = farclip wall (0 = off)
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> 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<f32>(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<f32>(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<f32>(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);
|
||||
|
||||
@@ -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<uniform> celestial: vec4<f32>;
|
||||
// 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<uniform> span: vec4<f32>;
|
||||
|
||||
// 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<f32>(gamma * a * celestial.y, 0.0);
|
||||
}
|
||||
// DISC: premultiplied gamma blend (AlphaMode::Premultiplied).
|
||||
// Disc: premultiplied gamma under `AlphaMode::Premultiplied`.
|
||||
return vec4<f32>(gamma * a * celestial.y, a);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<f32>;
|
||||
@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<f32>;
|
||||
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<f32>,
|
||||
// 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<f32>,
|
||||
}
|
||||
@group(0) @binding(3) var<uniform> 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<f32>;
|
||||
@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<f32> {
|
||||
let texel = 1.0 / vec2<f32>(textureDimensions(in_tex));
|
||||
@@ -64,9 +41,7 @@ fn fs_downsample(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
* 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<f32>, axis: vec2<f32>) -> vec4<f32> {
|
||||
let texel = axis / vec2<f32>(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<f32>) -> vec3<f32> {
|
||||
return select(hi, lo, c <= vec3<f32>(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<f32>) -> vec3<f32> {
|
||||
var dither = vec3<f32>(dot(vec2<f32>(171.0, 231.0), frag_coord)).xxx;
|
||||
dither = fract(dither.rgb / vec3<f32>(103.0, 71.0, 97.0));
|
||||
return (dither - vec3<f32>(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<f32>, alpha: f32, frag_coord: vec2<f32>) -> vec4<f32> {
|
||||
let dithered = clamp(
|
||||
outg + screen_space_dither(frag_coord) * step(0.5, ffx.lane.w),
|
||||
@@ -133,36 +84,26 @@ fn combine_out(outg: vec3<f32>, alpha: f32, frag_coord: vec2<f32>) -> vec4<f32>
|
||||
vec3<f32>(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<f32>(dithered * alpha, alpha);
|
||||
#else
|
||||
return vec4<f32>(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<f32>, frag_coord: vec2<f32>) -> vec4<f32> {
|
||||
// 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<f32>(0.0), vec3<f32>(1.0));
|
||||
let bg = max(textureSample(blur_tex, in_samp, uv).rgb, vec3<f32>(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<f32>(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<f32>, frag_coord: vec2<f32>) -> vec4<f32> {
|
||||
let outg = min(vec3<f32>(luma) + ghost_tint * p, vec3<f32>(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<f32>(1.0)), ffx.lane.z);
|
||||
let outg = min(hazed + ffx.lane.x * bg * bg, vec3<f32>(1.0));
|
||||
return combine_out(outg, scene.a, frag_coord);
|
||||
@@ -185,57 +123,29 @@ fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
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<f32>, screen: vec2<f32>) -> vec2<f32> {
|
||||
let c = cos(WAVE_ROT_RAD);
|
||||
let s = sin(WAVE_ROT_RAD);
|
||||
let scale = vec2<f32>(screen.x, WAVE_V_SCALE * screen.y) / WAVE_LUT_EDGE;
|
||||
let tex = vec2<f32>(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<f32>(0.5)) * 2.0;
|
||||
return d * WAVE_AMPLITUDE_PX / screen;
|
||||
}
|
||||
|
||||
@@ -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<f32>, // 9.8°
|
||||
sky3: vec4<f32>, // 3.7°
|
||||
sky4: vec4<f32>, // 1.8°
|
||||
fog: vec4<f32>, // horizon (0°) and below — LightIntBand row 7
|
||||
fog: vec4<f32>, // horizon (0°) and below: LightIntBand row 7
|
||||
warp: vec4<f32>, // x = dawn/dusk warp strength S (0 = off), y = sun azimuth (rad), zw reserved
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> 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<f32>, g: f32, s: f32) -> vec3<f32> {
|
||||
let s2 = s * s;
|
||||
if (g >= 0.0) {
|
||||
@@ -61,27 +43,13 @@ fn warp_one(base: vec3<f32>, g: f32, s: f32) -> vec3<f32> {
|
||||
|
||||
@fragment
|
||||
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// 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<f32> {
|
||||
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<f32>;
|
||||
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<f32> {
|
||||
} 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<f32>(rgb, 1.0);
|
||||
}
|
||||
|
||||
@@ -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<f32>(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
|
||||
|
||||
|
||||
@@ -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<f32> {
|
||||
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<f32>(vec3<f32>(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<f32>(vec3<f32>(a), a); // premultiplied white, raw gamma
|
||||
}
|
||||
|
||||
@@ -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<uniform> 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<f32>,
|
||||
light_diffuse: vec4<f32>,
|
||||
@@ -53,52 +30,45 @@ struct WowLight {
|
||||
wmo_fog_params: vec4<f32>,
|
||||
point_count: vec4<f32>,
|
||||
points: array<vec4<f32>, 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<vec4<f32>, 57344>,
|
||||
}
|
||||
@group(0) @binding(1) var<storage, read> wow_light: WowLight;
|
||||
|
||||
// Per-cell state (static_gx/render.rs `cell_layout`).
|
||||
struct GxCell {
|
||||
origin: vec4<f32>, // xyz = the bake's recentring origin (0974): world = vertex + origin
|
||||
origin: vec4<f32>, // xyz = the bake's recentring origin
|
||||
}
|
||||
@group(1) @binding(0) var<uniform> 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<storage, read> recs: array<vec4<u32>>;
|
||||
@group(1) @binding(2) var tex_array: texture_2d_array<f32>;
|
||||
// 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<f32>,
|
||||
@location(3) word: u32,
|
||||
@location(4) anchor: vec3<f32>,
|
||||
// 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<f32>,
|
||||
}
|
||||
|
||||
@@ -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<f32>(0.0, 0.0, 2.0, 1.0);
|
||||
out.world_position = vec4<f32>(0.0);
|
||||
@@ -185,8 +152,7 @@ fn vertex(v: GxVertex) -> GxVsOut {
|
||||
out.color = vec4<f32>(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<f32>(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<f32>(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<f32>(0.0);
|
||||
} else {
|
||||
@@ -221,16 +181,13 @@ fn vertex(v: GxVertex) -> GxVsOut {
|
||||
|
||||
@fragment
|
||||
fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
// 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<f32>(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<f32>(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<f32> {
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
vec3<f32>(0.0),
|
||||
vec3<f32>(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<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
|
||||
let 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<f32>(1.0), in.color, has_vc);
|
||||
let folded = select(base.rgb, base.rgb * vc.rgb, has_vc);
|
||||
var rgb: vec3<f32>;
|
||||
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<f32>(16.0 / 255.0) + window_mid * ndotl,
|
||||
@@ -313,10 +253,9 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
vec3<f32>(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<f32>(1.0);
|
||||
if (class_trans) {
|
||||
lit_wmo_interior = mix(vec3<f32>(1.0), lit_int_base, trans_a);
|
||||
@@ -324,9 +263,8 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
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>(
|
||||
f32(rec.z & 0xffu),
|
||||
@@ -342,23 +280,18 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
}
|
||||
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<f32>(0.0),
|
||||
vec3<f32>(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<f32>(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<f32>(0.0),
|
||||
@@ -366,14 +299,9 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
);
|
||||
}
|
||||
} 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<f32>(n_lit, 1.0);
|
||||
let lit_prop = clamp(
|
||||
@@ -394,13 +322,10 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
let primary = clamp(lit_prop + in.point_lit, vec3<f32>(0.0), vec3<f32>(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<f32> {
|
||||
let lit_doodad = clamp(sun_lobe, vec3<f32>(0.0), vec3<f32>(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<f32>(0.0), vec3<f32>(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<f32> {
|
||||
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<f32>(rgb, 1.0);
|
||||
}
|
||||
|
||||
@@ -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<f32>,
|
||||
light_diffuse: vec4<f32>,
|
||||
@@ -47,20 +38,11 @@ struct WowLight {
|
||||
@group(1) @binding(0) var effect_texture: texture_2d<f32>;
|
||||
@group(1) @binding(1) var effect_sampler: sampler;
|
||||
@group(1) @binding(2) var<storage, read> 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 `<Model>` 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<f32>,
|
||||
clip: vec4<f32>,
|
||||
@@ -71,12 +53,8 @@ struct EffectParams {
|
||||
const RAIN_FOG_GREY: vec3<f32> = vec3<f32>(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<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
@location(2) color: vec4<f32>, // raw authored gamma RGBA; α is the blend weight
|
||||
@@ -86,8 +64,7 @@ struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
// 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<f32>(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<f32>(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<f32>(
|
||||
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<f32> {
|
||||
// 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<f32> {
|
||||
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<f32>(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<f32>(0.0, 1.0, 0.0);
|
||||
let lit = clamp(
|
||||
@@ -208,32 +142,26 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
);
|
||||
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<f32>(0.0); }
|
||||
else if (wow_params.fog.x > 2.5 && wow_params.fog.x < 3.5) { fog_rgb = vec3<f32>(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<f32>(rgb * c.a, 0.0);
|
||||
#else
|
||||
#ifdef BLEND_OPAQUE
|
||||
@@ -244,12 +172,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(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<f32>(rgb * c.a, c.a);
|
||||
#else
|
||||
#ifdef BLEND_MOD2X
|
||||
// `(Dst, Src)` reads no alpha (0528's no-alpha law).
|
||||
return vec4<f32>(rgb, 1.0);
|
||||
#else
|
||||
return vec4<f32>(rgb, c.a);
|
||||
|
||||
Reference in New Issue
Block a user