onedrop_renderer/
config.rs

1//! Configuration for the renderer.
2
3use serde::{Deserialize, Serialize};
4
5/// Renderer configuration.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct RenderConfig {
8    /// Output resolution width
9    pub width: u32,
10
11    /// Output resolution height
12    pub height: u32,
13
14    /// Texture format
15    pub texture_format: TextureFormat,
16
17    /// Enable multisampling
18    pub msaa_samples: u32,
19
20    /// Enable VSync
21    pub vsync: bool,
22
23    /// Target FPS (0 = unlimited)
24    pub target_fps: u32,
25
26    /// Warp-mesh column count (MilkDrop `nMeshSize` analogue). Higher =
27    /// smoother per-vertex warp at the cost of CPU eval. MD2 ships
28    /// 32 → 192; we default to 48 ("Medium").
29    pub mesh_cols: u32,
30
31    /// Warp-mesh row count. MD2 ships 24 → 96; default 36 ("Medium").
32    pub mesh_rows: u32,
33}
34
35impl Default for RenderConfig {
36    fn default() -> Self {
37        let MeshSize { cols, rows } = MeshQuality::Medium.size();
38        Self {
39            width: 1280,
40            height: 720,
41            texture_format: TextureFormat::Bgra8UnormSrgb,
42            msaa_samples: 1,
43            vsync: true,
44            target_fps: 60,
45            mesh_cols: cols,
46            mesh_rows: rows,
47        }
48    }
49}
50
51/// MilkDrop-style warp-mesh quality presets.
52///
53/// MD2's `nMeshSize` config knob hopped between 32×24 (default) and
54/// 192×96 (max). We expose four named tiers covering that range plus a
55/// `Custom` escape hatch so the UI can show a single dropdown.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57pub enum MeshQuality {
58    /// 32×24 — MD2 default, fastest. Visible faceting on warp curves.
59    Low,
60    /// 48×36 — sweet spot for modern hardware.
61    Medium,
62    /// 64×48 — smooth warps, 4× the eval cost of Low.
63    High,
64    /// 96×72 — near-max quality, 9× Low.
65    Ultra,
66}
67
68/// Concrete `cols × rows` dimensions resolved from a [`MeshQuality`].
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct MeshSize {
71    pub cols: u32,
72    pub rows: u32,
73}
74
75impl MeshQuality {
76    /// Resolve to concrete mesh dimensions. Aspect ratio is roughly 4:3
77    /// to match MD2's reference shipping ratios.
78    pub fn size(self) -> MeshSize {
79        match self {
80            MeshQuality::Low => MeshSize { cols: 32, rows: 24 },
81            MeshQuality::Medium => MeshSize { cols: 48, rows: 36 },
82            MeshQuality::High => MeshSize { cols: 64, rows: 48 },
83            MeshQuality::Ultra => MeshSize { cols: 96, rows: 72 },
84        }
85    }
86
87    /// Best-fit quality name for an arbitrary `cols × rows`. Returns
88    /// `None` if the dimensions don't match a preset — the caller can
89    /// label this as "Custom" in the UI.
90    pub fn from_size(cols: u32, rows: u32) -> Option<Self> {
91        for q in [
92            MeshQuality::Low,
93            MeshQuality::Medium,
94            MeshQuality::High,
95            MeshQuality::Ultra,
96        ] {
97            let s = q.size();
98            if s.cols == cols && s.rows == rows {
99                return Some(q);
100            }
101        }
102        None
103    }
104
105    /// Human-readable label used by the GUI's options panel.
106    pub fn label(self) -> &'static str {
107        match self {
108            MeshQuality::Low => "Low (32×24)",
109            MeshQuality::Medium => "Medium (48×36)",
110            MeshQuality::High => "High (64×48)",
111            MeshQuality::Ultra => "Ultra (96×72)",
112        }
113    }
114}
115
116/// Texture format options.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118pub enum TextureFormat {
119    Bgra8UnormSrgb,
120    Rgba8UnormSrgb,
121    Bgra8Unorm,
122    Rgba8Unorm,
123}
124
125impl TextureFormat {
126    /// Convert to wgpu texture format.
127    pub fn to_wgpu(&self) -> wgpu::TextureFormat {
128        match self {
129            TextureFormat::Bgra8UnormSrgb => wgpu::TextureFormat::Bgra8UnormSrgb,
130            TextureFormat::Rgba8UnormSrgb => wgpu::TextureFormat::Rgba8UnormSrgb,
131            TextureFormat::Bgra8Unorm => wgpu::TextureFormat::Bgra8Unorm,
132            TextureFormat::Rgba8Unorm => wgpu::TextureFormat::Rgba8Unorm,
133        }
134    }
135}
136
137/// Render state containing dynamic parameters.
138#[derive(Debug, Clone, Copy)]
139pub struct RenderState {
140    /// Current time in seconds
141    pub time: f32,
142
143    /// Current frame number
144    pub frame: u32,
145
146    /// MD2 `progress` — `[0, 1]` position within the current preset's
147    /// display window. `0.0` at preset load, ramps to `1.0` just
148    /// before the next hard cut. Presets read it for once-per-display
149    /// effects (slow fade-ins, build-ups before the next cut, intro
150    /// flashes). Forwarded into the comp/warp `uniforms.progress` field
151    /// each frame so user shaders see the canonical MD2 value.
152    pub progress: f32,
153
154    /// Audio levels (bass, mid, treble)
155    pub audio: AudioLevels,
156
157    /// Motion parameters
158    pub motion: MotionParams,
159
160    /// Wave parameters
161    pub wave: WaveParams,
162
163    /// Frame-buffer decay multiplier applied during the warp pass.
164    /// Typical Milkdrop range: ~0.5 (very fast fade) to 1.0 (no fade).
165    pub decay: f32,
166
167    /// Display-time brightness multiplier applied in the comp pass
168    /// (`f_gamma_adj` in the MD2 spec). MilkDrop default is 2.0; not a true
169    /// gamma exponent — a flat multiplier.
170    pub gamma_adj: f32,
171
172    /// MilkDrop's `f_video_echo_zoom`. Scales the UV around 0.5 before
173    /// sampling the previous frame for the comp pass's echo blend.
174    /// `1.0` = same scale as current (passthrough sample).
175    pub echo_zoom: f32,
176    /// MilkDrop's `f_video_echo_alpha`. Mix weight between current frame
177    /// (0.0) and echoed previous frame (1.0). Default `0.0` → echo
178    /// off entirely.
179    pub echo_alpha: f32,
180    /// MilkDrop's `n_video_echo_orientation`. 0 = no flip, 1 = flip X,
181    /// 2 = flip Y, 3 = flip both.
182    pub echo_orient: u32,
183
184    /// MilkDrop's `b_red_blue_stereo`. When true the comp pass produces an
185    /// anaglyph red/cyan split — display-only, never enters the feedback
186    /// loop. Typically set once at preset load, but per-frame eqs may
187    /// flip it (mirrors `darken_center` semantics).
188    pub red_blue_stereo: bool,
189
190    /// Feedback filter toggles (b_invert / b_brighten / etc.).
191    pub feedback: FeedbackParams,
192
193    /// Outer + inner border draws. The renderer trims the frame edges
194    /// into two coloured rings; both contribute to the feedback loop so
195    /// a high-decay preset trails them inward.
196    pub borders: BorderParams,
197
198    /// Motion-vector grid. A `n_x × n_y` grid of short line segments
199    /// drawn into the warp output; over successive frames the feedback
200    /// pulls them into trails that visualise the motion field.
201    pub motion_vectors: MotionVectorParams,
202
203    /// Snapshot of `q1..q32` after the current frame's per-frame phase.
204    /// Forwarded to the comp pass so user comp shaders can read the same
205    /// q-channel state per-frame equations wrote.
206    pub q_snapshot: [f32; 32],
207}
208
209impl Default for RenderState {
210    fn default() -> Self {
211        Self {
212            time: 0.0,
213            frame: 0,
214            progress: 0.0,
215            audio: AudioLevels::default(),
216            motion: MotionParams::default(),
217            wave: WaveParams::default(),
218            decay: 0.98,
219            gamma_adj: 2.0,
220            echo_zoom: 1.0,
221            echo_alpha: 0.0,
222            echo_orient: 0,
223            red_blue_stereo: false,
224            feedback: FeedbackParams::default(),
225            borders: BorderParams::default(),
226            motion_vectors: MotionVectorParams::default(),
227            q_snapshot: [0.0; 32],
228        }
229    }
230}
231
232/// Per-frame feedback filter toggles applied during the warp pass.
233///
234/// All fields map to MilkDrop 2 boolean flags (`bTexWrap`, `bDarkenCenter`,
235/// `bInvert`, `bBrighten`, `bDarken`, `bSolarize`). The renderer packs them
236/// into a single `u32` flags uniform.
237#[derive(Debug, Clone, Copy, Default)]
238pub struct FeedbackParams {
239    /// `bTexWrap` — wrap UVs at the borders. When false, UVs are clamped.
240    pub wrap: bool,
241    /// `bDarkenCenter` — subtle radial darkening toward the screen center.
242    pub darken_center: bool,
243    /// `bInvert` — `color = 1 - color`.
244    pub invert: bool,
245    /// `bBrighten` — `color = sqrt(color)` (gamma 0.5).
246    pub brighten: bool,
247    /// `bDarken` — `color = color²` (gamma 2).
248    pub darken: bool,
249    /// `bSolarize` — `color = 4·color·(1 - color)`.
250    pub solarize: bool,
251}
252
253impl FeedbackParams {
254    /// Pack the flags into the `u32` bitfield consumed by the warp shader.
255    /// Bit layout must match `WARP_FLAG_*` constants in `warp.wgsl`.
256    pub fn to_flags(self) -> u32 {
257        let mut f = 0u32;
258        if self.wrap {
259            f |= 1 << 0;
260        }
261        if self.darken_center {
262            f |= 1 << 1;
263        }
264        if self.invert {
265            f |= 1 << 2;
266        }
267        if self.brighten {
268            f |= 1 << 3;
269        }
270        if self.darken {
271            f |= 1 << 4;
272        }
273        if self.solarize {
274            f |= 1 << 5;
275        }
276        f
277    }
278}
279
280/// Audio levels.
281#[derive(Debug, Clone, Copy)]
282pub struct AudioLevels {
283    pub bass: f32,
284    pub mid: f32,
285    pub treb: f32,
286    pub bass_att: f32,
287    pub mid_att: f32,
288    pub treb_att: f32,
289}
290
291impl Default for AudioLevels {
292    fn default() -> Self {
293        Self {
294            bass: 0.0,
295            mid: 0.0,
296            treb: 0.0,
297            bass_att: 0.0,
298            mid_att: 0.0,
299            treb_att: 0.0,
300        }
301    }
302}
303
304/// Motion parameters.
305#[derive(Debug, Clone, Copy)]
306pub struct MotionParams {
307    pub zoom: f32,
308    pub rot: f32,
309    pub cx: f32,
310    pub cy: f32,
311    pub dx: f32,
312    pub dy: f32,
313    pub warp: f32,
314    pub sx: f32,
315    pub sy: f32,
316}
317
318impl Default for MotionParams {
319    fn default() -> Self {
320        Self {
321            zoom: 1.0,
322            rot: 0.0,
323            cx: 0.5,
324            cy: 0.5,
325            dx: 0.0,
326            dy: 0.0,
327            warp: 0.0,
328            sx: 1.0,
329            sy: 1.0,
330        }
331    }
332}
333
334/// Wave parameters mirrored from a `MilkPreset` every frame.
335///
336/// Holds the full MD2 `wave_*` cluster: colour, position, mode, the
337/// boolean toggles (`b_wave_dots`, `b_wave_thick`, `b_additive_waves`,
338/// `b_maximize_wave_color`, `b_mod_wave_alpha_by_volume`), the scalar
339/// shaping params (`f_wave_scale`, `f_wave_param`, `f_wave_smoothing`),
340/// and the volume-modulation envelope (`f_mod_wave_alpha_start/_end`).
341/// Per-frame eqs can flip any of these, so the engine re-reads them
342/// every frame — same pattern as [`FeedbackParams`].
343#[derive(Debug, Clone, Copy)]
344pub struct WaveParams {
345    pub r: f32,
346    pub g: f32,
347    pub b: f32,
348    pub a: f32,
349    pub x: f32,
350    pub y: f32,
351    /// MD2 `nWaveMode`, 0..7. See `WaveformMode::from_i32` for the
352    /// mapping; out-of-range values clamp to mode 0 (Circle).
353    pub mode: i32,
354    /// `f_wave_scale` — amplitude multiplier (typical 0.5..2.0).
355    pub scale: f32,
356    /// `f_wave_param` — mode-specific shaping (rotation, separation, …).
357    pub param: f32,
358    /// `f_wave_smoothing` — single-pole IIR α driver applied to samples
359    /// CPU-side before upload. 0 = passthrough.
360    pub smoothing: f32,
361    /// `b_wave_thick` — draw thick segments.
362    pub thick: bool,
363    /// `b_wave_dots` — draw points instead of segments.
364    pub dots: bool,
365    /// `b_additive_waves` — use additive blend instead of alpha blend.
366    pub additive: bool,
367    /// `b_maximize_wave_color` — normalize rgb so max channel = 1.
368    pub maximize_color: bool,
369    /// `b_mod_wave_alpha_by_volume` — scale alpha by current vol.
370    pub mod_alpha_by_volume: bool,
371    /// `f_mod_wave_alpha_start` — lower bound of the volume envelope.
372    pub mod_alpha_start: f32,
373    /// `f_mod_wave_alpha_end` — upper bound of the volume envelope.
374    pub mod_alpha_end: f32,
375    /// Renderer-side top-vs-bottom stereo split. When `true`, the
376    /// waveform pass dispatches twice — left channel on the upper
377    /// screen half (`wave_y - 0.25`) and right on the lower
378    /// (`wave_y + 0.25`). Independent of any preset flag; surfaced
379    /// at engine config level so the player can toggle it without
380    /// editing presets.
381    pub split_lr: bool,
382}
383
384impl Default for WaveParams {
385    fn default() -> Self {
386        Self {
387            r: 1.0,
388            g: 1.0,
389            b: 1.0,
390            a: 1.0,
391            x: 0.5,
392            y: 0.5,
393            mode: 0,
394            scale: 1.0,
395            param: 0.0,
396            smoothing: 0.0,
397            thick: false,
398            dots: false,
399            additive: false,
400            maximize_color: false,
401            mod_alpha_by_volume: false,
402            mod_alpha_start: 0.75,
403            mod_alpha_end: 0.95,
404            split_lr: false,
405        }
406    }
407}
408
409/// Outer + inner border parameters mirrored from a `MilkPreset` every
410/// frame.
411///
412/// The renderer paints two rectangular rings just after the custom
413/// shapes pass, before the blur pyramid, so the rings both feed back
414/// next frame (a high-decay preset will trail them inward) and
415/// contribute to `GetBlur*`.
416///
417/// Sizes are in MD2 preset units — a fraction of the **shorter** screen
418/// dimension, identical to the MD2 reference renderer. `outer_size` /
419/// `inner_size` past `0.5` clamp at half because the rings would
420/// otherwise overlap themselves; that's a no-op visually since the
421/// inner ring is drawn just inboard of the outer.
422#[derive(Debug, Clone, Copy)]
423pub struct BorderParams {
424    /// Outer ring thickness in `[0, 0.5]` (fraction of the shorter
425    /// screen dimension).
426    pub outer_size: f32,
427    /// Outer ring RGBA. Skipping the pass when `outer_color.a == 0.0`
428    /// matches MD2's default behaviour of an invisible outer border.
429    pub outer_color: [f32; 4],
430    /// Inner ring thickness in `[0, 0.5]`.
431    pub inner_size: f32,
432    /// Inner ring RGBA. Skipping the pass when `inner_color.a == 0.0`.
433    pub inner_color: [f32; 4],
434}
435
436impl Default for BorderParams {
437    fn default() -> Self {
438        // Mirrors `default_preset.rs`: 1 % outer black border (alpha 0
439        // → invisible), 1 % inner mid-grey border (alpha 0 → invisible).
440        Self {
441            outer_size: 0.01,
442            outer_color: [0.0, 0.0, 0.0, 0.0],
443            inner_size: 0.01,
444            inner_color: [0.25, 0.25, 0.25, 0.0],
445        }
446    }
447}
448
449/// Motion-vector grid parameters.
450///
451/// MD2 draws an `n_x × n_y` grid of short line segments into the warp
452/// output every frame; the feedback path then pulls them into trails
453/// that visualise the per-pixel motion field. Trails emerge from the
454/// warp, the segments themselves are stateless — we just paint them
455/// at the same screen-space grid every frame, offset by `(dx, dy)`.
456///
457/// MD2 caps the grid at `64 × 48` and the renderer enforces the same
458/// clamp regardless of what the preset declares.
459#[derive(Debug, Clone, Copy)]
460pub struct MotionVectorParams {
461    /// Grid width in cells, clamped to `[0, 64]`.
462    pub grid_x: u32,
463    /// Grid height in cells, clamped to `[0, 48]`.
464    pub grid_y: u32,
465    /// Horizontal grid offset in MD2 preset-UV units `[0, 1]`. Typical
466    /// presets animate this so the grid drifts across the screen.
467    pub dx: f32,
468    /// Vertical grid offset in MD2 preset-UV units `[0, 1]`.
469    pub dy: f32,
470    /// Segment length as a fraction of horizontal cell spacing.
471    pub length: f32,
472    /// Segment colour RGBA. `a == 0.0` → no draw.
473    pub color: [f32; 4],
474}
475
476impl Default for MotionVectorParams {
477    fn default() -> Self {
478        // Mirrors `default_preset.rs`: 12×9 grid (the MD2 default),
479        // length 0.9, fully transparent so the pass is a no-op until
480        // a preset enables it.
481        Self {
482            grid_x: 12,
483            grid_y: 9,
484            dx: 0.0,
485            dy: 0.0,
486            length: 0.9,
487            color: [1.0, 1.0, 1.0, 0.0],
488        }
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn feedback_default_is_all_false() {
498        let f = FeedbackParams::default();
499        assert_eq!(f.to_flags(), 0);
500    }
501
502    #[test]
503    fn feedback_each_flag_has_distinct_bit() {
504        let cases = [
505            (
506                FeedbackParams {
507                    wrap: true,
508                    ..Default::default()
509                },
510                1u32 << 0,
511            ),
512            (
513                FeedbackParams {
514                    darken_center: true,
515                    ..Default::default()
516                },
517                1u32 << 1,
518            ),
519            (
520                FeedbackParams {
521                    invert: true,
522                    ..Default::default()
523                },
524                1u32 << 2,
525            ),
526            (
527                FeedbackParams {
528                    brighten: true,
529                    ..Default::default()
530                },
531                1u32 << 3,
532            ),
533            (
534                FeedbackParams {
535                    darken: true,
536                    ..Default::default()
537                },
538                1u32 << 4,
539            ),
540            (
541                FeedbackParams {
542                    solarize: true,
543                    ..Default::default()
544                },
545                1u32 << 5,
546            ),
547        ];
548        for (params, expected) in cases {
549            assert_eq!(params.to_flags(), expected, "params = {:?}", params);
550        }
551    }
552
553    #[test]
554    fn feedback_all_flags_combine() {
555        let f = FeedbackParams {
556            wrap: true,
557            darken_center: true,
558            invert: true,
559            brighten: true,
560            darken: true,
561            solarize: true,
562        };
563        assert_eq!(f.to_flags(), 0b111111);
564    }
565}