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}