Skip to main content

rlx_core/milk/
shader.rs

1//! The converted-shader interface (Plan 0100 Phase 6): what a translated
2//! MilkDrop 2 `warp`/`comp` fragment shader may bind, the uniform block behind
3//! its ~40-name input surface, and the naga gate every bundle shader passes at
4//! load.
5//!
6//! # This is the contract, in one place
7//!
8//! `milkconv` emits a complete WGSL fragment module: [`fragment_prelude`] (the
9//! bindings and helper functions below) followed by the preset's translated
10//! code. The engine builds the matching pipeline from the same constants —
11//! [`BINDINGS`], the group indices, the varying locations — in
12//! `render/scenes/warp_mesh/shader.rs`. Keeping both halves keyed off this one
13//! module is what stops the converter and the engine drifting into two
14//! incompatible interfaces that fail only at pipeline creation.
15//!
16//! **No HLSL and no translator is here** (ADR-0113): what this module knows is
17//! the *WGSL* surface, which is as much a runtime interface as the C ABI is.
18//!
19//! # Why validation happens twice
20//!
21//! [`validate_wgsl`] runs in `milkconv` the moment a shader is translated — so
22//! an emitter bug is a named conversion failure in Phase 5's ranking — and again
23//! in the preset loader, because a bundle on disk is untrusted text and the
24//! boundary rule (validate at the boundary, trust inside) applies to it exactly
25//! as it applies to sample rates. **A failed compile rejects that preset by
26//! name and loads the rest** — the directory loader already skips a bad preset
27//! per file, so the second check needs no new machinery.
28//!
29//! naga itself is not a new dependency: wgpu compiles every shader in this
30//! engine through it already, and `wgpu::naga` re-exports the same version.
31
32// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to core/src/milk by
33// Plan 0100 Phase 2). Validation runs at load, but the pragma is the module
34// convention.
35#![deny(
36    clippy::unwrap_used,
37    clippy::expect_used,
38    clippy::indexing_slicing,
39    clippy::panic,
40    clippy::unreachable
41)]
42
43/// The bind-group slot a converted **warp** fragment shader uses. Group 0 is the
44/// warp pass's own vertex-stage uniform (the mesh transform), so the shader
45/// surface sits beside it.
46pub const WARP_GROUP: u32 = 1;
47/// The bind-group slot a converted **comp** fragment shader uses. The fullscreen
48/// vertex prelude binds nothing, so the shader surface is the only group.
49pub const COMP_GROUP: u32 = 0;
50
51/// The binding roster of the shader surface, in binding order. The names are the
52/// WGSL identifiers [`fragment_prelude`] declares; the engine's bind-group
53/// layout mirrors this list positionally.
54pub const BINDINGS: &[&str] = &[
55    "U",               // 0: the uniform block below
56    "t_main",          // 1: the field — the past for warp, this frame for comp
57    "s_fw",            // 2: filtering + wrap
58    "s_fc",            // 3: filtering + clamp
59    "s_pw",            // 4: point + wrap
60    "s_pc",            // 5: point + clamp
61    "t_noise_lq",      // 6
62    "t_noise_lq_lite", // 7
63    "t_noise_mq",      // 8
64    "t_noise_hq",      // 9
65    "t_noisevol_lq",   // 10 (3D)
66    "t_noisevol_hq",   // 11 (3D)
67    "t_blur1",         // 12
68    "t_blur2",         // 13
69    "t_blur3",         // 14
70];
71
72/// How many `rot_*` matrices the surface carries: the six families MilkDrop
73/// declares (`s`, `d`, `f`, `vf`, `uf`, `rand`), four of each. Stored as four
74/// `vec4` rows per matrix — the shape `float4x3` indexes as.
75pub const ROT_MATRICES: usize = 24;
76
77/// **The feedback-field quantizer** (ADR-0118), as WGSL — the *one* text,
78/// emitted into every converted module by [`fragment_prelude`] and concatenated
79/// onto the engine's own built-in warp fragment by `render/scenes/warp_mesh`. A
80/// transfer function written out twice is a transfer function that drifts.
81///
82/// # Why the round trip
83///
84/// The reference's feedback target is 8-bit, so `decay` times a dim pixel
85/// **truncates to zero** and a classic preset's background stays black. This
86/// engine's field is `Rgba16Float`, nothing truncates, and every dim residual
87/// integrates — the wash, glow, runaway and inversion Plan 0100 Phase 7 judged.
88///
89/// The field is **linear light** (ADR-0046) and the reference quantizes in its
90/// **gamma-encoded** target, so the step has to be taken in the encoded domain.
91/// One number carries the whole decision: one 8-bit sRGB step is `1/255 =
92/// 0.00392` encoded, which is `3.03e-4` in linear light. A literal `1/255` floor
93/// applied *in linear* would truncate everything below encoded `0.0498` — sRGB
94/// level ~13, **thirteen times too aggressive** — and would crush the dim trails
95/// the reference keeps rather than the dimmer ones it discards.
96///
97/// # Why sRGB and not the reference's own ~2.2 gamma
98///
99/// DX9-era MilkDrop wrote to an 8-bit target with no explicit encoding, which in
100/// practice is a plain 2.2 power curve — and it differs from sRGB's piecewise
101/// one *only* in the near-black region this whole decision is about, so the
102/// choice was rendered rather than assumed (ADR-0118's `Outcome`, 2026-08-17).
103/// The two agree to within one 8-bit level at every frame where the picture
104/// reads. They part in the tail, and there sRGB's floor lands on linear
105/// `3.03e-4` — **exactly 8-bit display level 1**, so what it discards is
106/// precisely what a viewer could not have seen. The 2.2 curve floors at
107/// `(1/255)^2.2 = 5.1e-6`, 59x lower, keeping six more e-foldings of invisible
108/// light alive to accumulate. sRGB is also already here (ADR-0046).
109///
110/// # The lane
111///
112/// `steps` is a **count**, not a flag, so the look gate can A/B a tuning without
113/// a rebuild:
114///
115/// - `|steps| < 1` — off, and off is an **exact identity**: the early return
116///   hands back the argument, so a native `warp_mesh` preset renders the bytes it
117///   rendered before this existed.
118/// - `steps > 0` — the decision: encode, floor to `steps` levels, decode.
119/// - `steps < 0` — ADR-0118's **Alternative D**, the named fallback: floor to
120///   zero at one encoded step and leave the levels between alone. It is the half
121///   of the mechanism that does the visible work (dim residuals die instead of
122///   accumulating) without re-introducing the banding ADR-0096 dithers away.
123///   Reachable from the same lane on purpose — the fallback is a parameter
124///   change, not a rebuild.
125pub const QUANTIZE_WGSL: &str = "\
126fn rlx_srgb_encode(c: vec3<f32>) -> vec3<f32> {
127    let x = max(c, vec3<f32>(0.0));
128    let lo = x * 12.92;
129    let hi = 1.055 * pow(x, vec3<f32>(1.0 / 2.4)) - 0.055;
130    return select(hi, lo, x <= vec3<f32>(0.0031308));
131}
132fn rlx_srgb_decode(c: vec3<f32>) -> vec3<f32> {
133    let x = max(c, vec3<f32>(0.0));
134    let lo = x / 12.92;
135    let hi = pow((x + 0.055) / 1.055, vec3<f32>(2.4));
136    return select(hi, lo, x <= vec3<f32>(0.04045));
137}
138fn rlx_quantize(c: vec3<f32>, steps: f32) -> vec3<f32> {
139    let n = abs(steps);
140    if (n < 1.0) { return c; }
141    let e = rlx_srgb_encode(clamp(c, vec3<f32>(0.0), vec3<f32>(1.0)));
142    if (steps < 0.0) {
143        return select(c, vec3<f32>(0.0), e < vec3<f32>(1.0 / n));
144    }
145    return rlx_srgb_decode(floor(e * n) / n);
146}
147";
148
149/// The uniform block, as WGSL. **Field-for-field with `MilkUniform` in
150/// `render/scenes/warp_mesh/shader.rs`** — every member is 16-byte data, so the
151/// Rust `#[repr(C)]` layout and the WGSL std140-ish layout agree by
152/// construction, and a test naga-parses this text so the two cannot drift
153/// silently.
154pub const UNIFORM_WGSL: &str = "\
155struct MilkU {
156    // x: time (s), y: fps (the nominal 30, as the EEL side reports), z: frame, w: progress
157    clock: vec4<f32>,
158    // bass, mid, treb, vol — MilkDrop-scaled, exactly what the EEL programs read
159    bands: vec4<f32>,
160    // the attenuated versions of the same four
161    bands_att: vec4<f32>,
162    // w, h, 1/w, 1/h of the field
163    texsize: vec4<f32>,
164    // aspectx, aspecty, 1/aspectx, 1/aspecty — the EEL convention (longer axis reads 1)
165    aspect: vec4<f32>,
166    // four uniform randoms, fresh each frame, deterministic from the preset salt
167    rand_frame: vec4<f32>,
168    // four uniform randoms, fixed for the preset's life
169    rand_preset: vec4<f32>,
170    // x: decay for this frame (already rate-converted), y: brightness, z: occlude,
171    // w: feedback quantize steps (0 = off, negative = ADR-0118 Alternative D)
172    misc: vec4<f32>,
173    // the four corner colours hue_shader interpolates between
174    hue: array<vec4<f32>, 4>,
175    // q1..q32 as _qa.._qh
176    q: array<vec4<f32>, 8>,
177    // slow_roam_cos, roam_cos, slow_roam_sin, roam_sin
178    roam: array<vec4<f32>, 4>,
179    // the 24 rot_* matrices, 4 rows each; .xyz of a row is what float4x3 indexing reads
180    rot: array<vec4<f32>, 96>,
181}
182";
183
184/// The whole fixed half of a converted fragment module: the uniform block, the
185/// binding declarations at `group`, and MilkDrop's own prelude helpers
186/// (`lum`, `GetPixel`, `GetBlur1..3`) under collision-proof `rlx_` names.
187///
188/// Helpers are declared whether or not the preset calls them — an uncalled
189/// function does not put its bindings in the entry point's resource set, so a
190/// shader that never blurs needs no blur textures in its layout.
191pub fn fragment_prelude(group: u32) -> String {
192    let mut out = String::with_capacity(2048);
193    out.push_str(UNIFORM_WGSL);
194    let g = group;
195    let decls = [
196        format!("@group({g}) @binding(0) var<uniform> U: MilkU;"),
197        format!("@group({g}) @binding(1) var t_main: texture_2d<f32>;"),
198        format!("@group({g}) @binding(2) var s_fw: sampler;"),
199        format!("@group({g}) @binding(3) var s_fc: sampler;"),
200        format!("@group({g}) @binding(4) var s_pw: sampler;"),
201        format!("@group({g}) @binding(5) var s_pc: sampler;"),
202        format!("@group({g}) @binding(6) var t_noise_lq: texture_2d<f32>;"),
203        format!("@group({g}) @binding(7) var t_noise_lq_lite: texture_2d<f32>;"),
204        format!("@group({g}) @binding(8) var t_noise_mq: texture_2d<f32>;"),
205        format!("@group({g}) @binding(9) var t_noise_hq: texture_2d<f32>;"),
206        format!("@group({g}) @binding(10) var t_noisevol_lq: texture_3d<f32>;"),
207        format!("@group({g}) @binding(11) var t_noisevol_hq: texture_3d<f32>;"),
208        format!("@group({g}) @binding(12) var t_blur1: texture_2d<f32>;"),
209        format!("@group({g}) @binding(13) var t_blur2: texture_2d<f32>;"),
210        format!("@group({g}) @binding(14) var t_blur3: texture_2d<f32>;"),
211    ];
212    for d in decls {
213        out.push_str(&d);
214        out.push('\n');
215    }
216    out.push_str(
217        "\nfn rlx_lum(c: vec3<f32>) -> f32 { return dot(c, vec3<f32>(0.32, 0.49, 0.29)); }\n\
218         fn rlx_GetPixel(uv: vec2<f32>) -> vec3<f32> {\n\
219         \x20   return textureSampleLevel(t_main, s_fc, uv, 0.0).xyz;\n\
220         }\n\
221         fn rlx_GetBlur1(uv: vec2<f32>) -> vec3<f32> {\n\
222         \x20   return textureSampleLevel(t_blur1, s_fc, uv, 0.0).xyz;\n\
223         }\n\
224         fn rlx_GetBlur2(uv: vec2<f32>) -> vec3<f32> {\n\
225         \x20   return textureSampleLevel(t_blur2, s_fc, uv, 0.0).xyz;\n\
226         }\n\
227         fn rlx_GetBlur3(uv: vec2<f32>) -> vec3<f32> {\n\
228         \x20   return textureSampleLevel(t_blur3, s_fc, uv, 0.0).xyz;\n\
229         }\n\n",
230    );
231    // The feedback quantizer, declared for both stages though only the warp
232    // epilogue calls it — the same "helpers are declared whether or not the
233    // preset calls them" rule as above, and it keeps the two stages' preludes
234    // one text.
235    out.push_str(QUANTIZE_WGSL);
236    out.push('\n');
237    out
238}
239
240/// Parse and validate one WGSL module through naga — the same frontend wgpu
241/// hands every shader in this engine to, so passing here is passing the real
242/// gate rather than a lookalike.
243///
244/// The error is a `String` because both callers (the converter's per-preset
245/// ranking and the loader's per-file skip) want text with the preset's name
246/// wrapped around it, not a type to match on.
247pub fn validate_wgsl(source: &str) -> Result<(), String> {
248    use wgpu::naga;
249    let module = naga::front::wgsl::parse_str(source)
250        .map_err(|e| e.emit_to_string_with_path(source, "shader"))?;
251    naga::valid::Validator::new(
252        naga::valid::ValidationFlags::all(),
253        naga::valid::Capabilities::default(),
254    )
255    .validate(&module)
256    .map_err(|e| e.emit_to_string_with_path(source, "shader"))?;
257    Ok(())
258}
259
260#[cfg(test)]
261mod tests {
262    // Test asserts panic on failure; allowed over the file pragma.
263    #![allow(clippy::unwrap_used, clippy::panic)]
264
265    use super::*;
266
267    /// The uniform block and the prelude are valid WGSL on their own — the
268    /// converter concatenates translated code after them, so a syntax error here
269    /// would surface as "every preset fails", attributed to the wrong side.
270    #[test]
271    fn the_prelude_is_valid_wgsl_at_both_groups() {
272        for group in [WARP_GROUP, COMP_GROUP] {
273            let module = format!(
274                "{}\n@fragment fn fs_main() -> @location(0) vec4<f32> {{\n\
275                 \x20   return vec4<f32>(U.clock.x, rlx_lum(rlx_GetPixel(vec2<f32>(0.5))), 0.0, 1.0);\n\
276                 }}\n",
277                fragment_prelude(group)
278            );
279            validate_wgsl(&module).unwrap();
280        }
281    }
282
283    /// The gate actually gates: junk and *valid-but-broken* WGSL are both
284    /// refused with text a loader can print.
285    #[test]
286    fn validate_refuses_what_naga_refuses() {
287        assert!(validate_wgsl("this is not wgsl").is_err());
288        // Parses, but the entry point returns the wrong type — a validation
289        // failure rather than a parse failure, so both halves of the gate run.
290        assert!(
291            validate_wgsl("@fragment fn fs_main() -> @location(0) vec4<f32> { return 1.0; }")
292                .is_err()
293        );
294    }
295
296    /// The binding roster and the prelude declare the same surface — the
297    /// engine's layout is built positionally from [`BINDINGS`].
298    #[test]
299    fn the_roster_matches_the_prelude() {
300        let prelude = fragment_prelude(WARP_GROUP);
301        for (index, name) in BINDINGS.iter().enumerate() {
302            assert!(
303                prelude.contains(&format!("@binding({index}) var")),
304                "binding {index} missing from the prelude"
305            );
306            assert!(
307                prelude.contains(&format!(" {name}:")),
308                "`{name}` missing from the prelude"
309            );
310        }
311    }
312}