onedrop_renderer/
custom_wave.rs

1//! Custom-wave (`wavecode_N`) pass.
2//!
3//! The MD2 `wavecode_N` block defines up to four user-driven waves that
4//! run their own `per_frame_init` / `per_frame` / `per_point` equations.
5//! Per frame the engine evaluates each enabled wave's per-point loop on
6//! the CPU, producing a stream of `(x, y, r, g, b, a)` 6-tuples; this
7//! module uploads that stream into a dynamic vertex buffer and dispatches
8//! either a line-list or triangle-list draw.
9//!
10//! Why CPU-emit vs. GPU-derive: each preset's per-point equations can
11//! reference arbitrary user state (`q*` channels, the audio buffer,
12//! whatever a preset author thought up) — running them on the GPU would
13//! require porting the evaluator. The line/dot geometry is tiny enough
14//! that uploading expanded vertices each frame is the cheaper path.
15//!
16//! Pipeline matrix:
17//! - **Topology** is chosen at submit time: `LineStrip` for `b_use_dots = 0`
18//!   (one strip per wave; segments break at wave boundaries via
19//!   per-wave draw calls), `TriangleList` for `b_use_dots = 1`
20//!   (each point expands CPU-side into a small 2-tri quad).
21//! - **Blend** is alpha or additive, picked by `b_additive`.
22//!
23//! Dispatch slot: the renderer calls into this pass between the legacy
24//! waveform pass and the blur pyramid, so custom waves feed back next
25//! frame and contribute to `GetBlur*`. Same slot the historical
26//! waveform pass uses.
27
28use bytemuck::{Pod, Zeroable};
29use wgpu::util::DeviceExt;
30
31/// One vertex of the custom-wave stream. The shader is pure passthrough
32/// — `pos` is already clip-space `[-1, 1]`, `color` is the per-point RGBA
33/// after `r/g/b/a` carried through the per-point loop.
34#[repr(C)]
35#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
36pub struct CustomWaveVertex {
37    pub pos: [f32; 2],
38    pub color: [f32; 4],
39}
40
41/// One dispatch unit: the slice of the vertex buffer that belongs to a
42/// single `wavecode_N` block, plus its blend/topology flags. The renderer
43/// reuses a single growable buffer across all waves and walks the batches
44/// to issue one draw call each.
45#[derive(Debug, Clone, Copy)]
46pub struct CustomWaveBatch {
47    /// Index into the renderer's vertex buffer where this wave's stream
48    /// starts.
49    pub start_vertex: u32,
50    /// Number of vertices this wave contributes.
51    pub vertex_count: u32,
52    /// `true` → triangle-list topology. Holds for both `b_use_dots = 1`
53    /// (one quad per point) and `b_draw_thick = 1` with lines (one quad
54    /// per *segment* between consecutive per-point outputs). `false`
55    /// → line-strip from the per-point trail (thin, 1 px wide).
56    /// Field name kept for backward compat — historically only `dots`
57    /// drove TriangleList.
58    pub dots: bool,
59    /// `true` → additive blend, otherwise alpha.
60    pub additive: bool,
61}
62
63/// Maximum total custom-wave vertices we'll buffer in a frame. Four
64/// waves × 512 samples × (2 lines = 6 verts/segment, dots = 6
65/// verts/point) ≈ 12 288. Round up to 16 384 to leave headroom for
66/// the next-sprint custom-shape pass, which will reuse this buffer
67/// layout.
68pub const MAX_CUSTOM_WAVE_VERTICES: usize = 16_384;
69
70/// Custom-wave GPU pass. Owns one growable vertex buffer + two
71/// pipelines (lines and dots topology, alpha and additive blend = 4
72/// combinations, indexed by `pipeline_index(dots, additive)`).
73pub struct CustomWaveRenderer {
74    pipelines: [wgpu::RenderPipeline; 4],
75    vertex_buffer: wgpu::Buffer,
76    /// Logical vertex count after the most recent upload (0 when no
77    /// custom waves are enabled).
78    vertex_count: u32,
79    /// Per-wave dispatch metadata, in stream order.
80    batches: Vec<CustomWaveBatch>,
81}
82
83#[inline]
84fn pipeline_index(dots: bool, additive: bool) -> usize {
85    (additive as usize) | ((dots as usize) << 1)
86}
87
88impl CustomWaveRenderer {
89    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
90        let shader = crate::pipeline_helpers::load_wgsl(
91            device,
92            "Custom Wave Shader",
93            include_str!("../shaders/custom_wave.wgsl"),
94        );
95
96        let initial: Vec<CustomWaveVertex> =
97            vec![CustomWaveVertex::default(); MAX_CUSTOM_WAVE_VERTICES];
98        let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
99            label: Some("Custom Wave Vertices"),
100            contents: bytemuck::cast_slice(&initial),
101            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
102        });
103
104        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
105            label: Some("Custom Wave Layout"),
106            bind_group_layouts: &[],
107            immediate_size: 0,
108        });
109
110        let vertex_attributes = [
111            wgpu::VertexAttribute {
112                offset: 0,
113                shader_location: 0,
114                format: wgpu::VertexFormat::Float32x2,
115            },
116            wgpu::VertexAttribute {
117                offset: std::mem::size_of::<[f32; 2]>() as u64,
118                shader_location: 1,
119                format: wgpu::VertexFormat::Float32x4,
120            },
121        ];
122        let vertex_layout = wgpu::VertexBufferLayout {
123            array_stride: std::mem::size_of::<CustomWaveVertex>() as u64,
124            step_mode: wgpu::VertexStepMode::Vertex,
125            attributes: &vertex_attributes,
126        };
127
128        let make_pipeline = |label: &str,
129                             topology: wgpu::PrimitiveTopology,
130                             additive: bool|
131         -> wgpu::RenderPipeline {
132            let blend = crate::pipeline_helpers::blend_state_for(additive);
133            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
134                label: Some(label),
135                layout: Some(&layout),
136                vertex: wgpu::VertexState {
137                    module: &shader,
138                    entry_point: Some("vs_main"),
139                    buffers: std::slice::from_ref(&vertex_layout),
140                    compilation_options: Default::default(),
141                },
142                fragment: Some(wgpu::FragmentState {
143                    module: &shader,
144                    entry_point: Some("fs_main"),
145                    targets: &[Some(wgpu::ColorTargetState {
146                        format,
147                        blend: Some(blend),
148                        write_mask: wgpu::ColorWrites::ALL,
149                    })],
150                    compilation_options: Default::default(),
151                }),
152                primitive: wgpu::PrimitiveState {
153                    topology,
154                    ..Default::default()
155                },
156                depth_stencil: None,
157                multisample: wgpu::MultisampleState::default(),
158                multiview_mask: None,
159                cache: None,
160            })
161        };
162
163        let pipelines = [
164            make_pipeline(
165                "CustomWave Lines Alpha",
166                wgpu::PrimitiveTopology::LineStrip,
167                false,
168            ),
169            make_pipeline(
170                "CustomWave Lines Additive",
171                wgpu::PrimitiveTopology::LineStrip,
172                true,
173            ),
174            make_pipeline(
175                "CustomWave Dots Alpha",
176                wgpu::PrimitiveTopology::TriangleList,
177                false,
178            ),
179            make_pipeline(
180                "CustomWave Dots Additive",
181                wgpu::PrimitiveTopology::TriangleList,
182                true,
183            ),
184        ];
185
186        Self {
187            pipelines,
188            vertex_buffer,
189            vertex_count: 0,
190            batches: Vec::new(),
191        }
192    }
193
194    /// Replace the per-frame vertex stream and batch list. The combined
195    /// vertex count is clamped to [`MAX_CUSTOM_WAVE_VERTICES`]; any tail
196    /// past that is dropped (with a `log::warn` so it's visible in
197    /// long-running sessions).
198    pub fn update(
199        &mut self,
200        queue: &wgpu::Queue,
201        vertices: &[CustomWaveVertex],
202        batches: &[CustomWaveBatch],
203    ) {
204        let n = vertices.len().min(MAX_CUSTOM_WAVE_VERTICES);
205        if vertices.len() > MAX_CUSTOM_WAVE_VERTICES {
206            log::warn!(
207                "custom-wave vertex stream truncated: {} > cap {}",
208                vertices.len(),
209                MAX_CUSTOM_WAVE_VERTICES
210            );
211        }
212        if n > 0 {
213            queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices[..n]));
214        }
215        self.vertex_count = n as u32;
216        self.batches.clear();
217        // Filter out any batch that points past the truncation boundary.
218        for b in batches {
219            if b.start_vertex + b.vertex_count <= self.vertex_count {
220                self.batches.push(*b);
221            }
222        }
223    }
224
225    /// Issue one draw call per stored batch into `view`. Loads existing
226    /// contents (no clear) — meant to draw on top of the warp + legacy
227    /// waveform output.
228    pub fn render(&self, encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
229        if self.batches.is_empty() || self.vertex_count == 0 {
230            return;
231        }
232        let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
233            label: Some("Custom Wave Pass"),
234            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
235                view,
236                depth_slice: None,
237                resolve_target: None,
238                ops: wgpu::Operations {
239                    load: wgpu::LoadOp::Load,
240                    store: wgpu::StoreOp::Store,
241                },
242            })],
243            depth_stencil_attachment: None,
244            timestamp_writes: None,
245            occlusion_query_set: None,
246            multiview_mask: None,
247        });
248        rp.set_vertex_buffer(0, self.vertex_buffer.slice(..));
249        for b in &self.batches {
250            if b.vertex_count == 0 {
251                continue;
252            }
253            let pidx = pipeline_index(b.dots, b.additive);
254            rp.set_pipeline(&self.pipelines[pidx]);
255            let end = b.start_vertex + b.vertex_count;
256            rp.draw(b.start_vertex..end, 0..1);
257        }
258    }
259
260    /// Number of vertices held by the most recent [`Self::update`] call.
261    /// Exposed for tests asserting the upload size.
262    pub fn vertex_count(&self) -> u32 {
263        self.vertex_count
264    }
265
266    /// Number of dispatch batches the next [`Self::render`] will issue.
267    pub fn batch_count(&self) -> usize {
268        self.batches.len()
269    }
270}
271
272/// Convert MD2 preset-space `(x, y)` (origin top-left, range `[0, 1]`)
273/// into clip-space `(x', y')` (origin centre, range `[-1, 1]`, Y-up).
274#[inline]
275pub fn preset_xy_to_clip(x: f64, y: f64) -> [f32; 2] {
276    [(x * 2.0 - 1.0) as f32, (1.0 - y * 2.0) as f32]
277}
278
279/// Expand one MD2 per-point output `(x, y, r, g, b, a)` into a small
280/// screen-space quad (two triangles, six vertices). Used by the dots
281/// pipeline. `radius_clip` is the half-side in clip space — pick
282/// `2.0 / height` for a single-pixel-equivalent dot at default sizing.
283pub fn point_to_dot_quad(
284    x: f64,
285    y: f64,
286    color: [f32; 4],
287    radius_clip: f32,
288) -> [CustomWaveVertex; 6] {
289    let [cx, cy] = preset_xy_to_clip(x, y);
290    let lo_x = cx - radius_clip;
291    let hi_x = cx + radius_clip;
292    let lo_y = cy - radius_clip;
293    let hi_y = cy + radius_clip;
294    let tl = CustomWaveVertex {
295        pos: [lo_x, hi_y],
296        color,
297    };
298    let tr = CustomWaveVertex {
299        pos: [hi_x, hi_y],
300        color,
301    };
302    let bl = CustomWaveVertex {
303        pos: [lo_x, lo_y],
304        color,
305    };
306    let br = CustomWaveVertex {
307        pos: [hi_x, lo_y],
308        color,
309    };
310    [tl, tr, bl, tr, br, bl]
311}
312
313/// Expand two consecutive per-point outputs into a thick-line segment
314/// quad — six clip-space vertices forming a screen-space rectangle of
315/// `thickness_clip` perpendicular to the segment direction. Mirrors
316/// the `line_vertex` shape used by `waveform_advanced.wgsl` for
317/// `b_wave_thick`, so MD2's "thick custom-wave trail" looks identical
318/// to the thick-static-wave path.
319///
320/// `color_a` / `color_b` come from the two source points so the segment
321/// gracefully interpolates colour along a fading per-point trail.
322/// Caller supplies `(x, y)` in MD2 preset space; we convert internally.
323pub fn segment_to_thick_quad(
324    ax: f64,
325    ay: f64,
326    color_a: [f32; 4],
327    bx: f64,
328    by: f64,
329    color_b: [f32; 4],
330    thickness_clip: f32,
331) -> [CustomWaveVertex; 6] {
332    let [ax_c, ay_c] = preset_xy_to_clip(ax, ay);
333    let [bx_c, by_c] = preset_xy_to_clip(bx, by);
334    let dx = bx_c - ax_c;
335    let dy = by_c - ay_c;
336    let len = (dx * dx + dy * dy).sqrt().max(1e-6);
337    // Perpendicular `(–dy, dx) / len` × half-thickness.
338    let half = thickness_clip * 0.5;
339    let nx = -dy / len * half;
340    let ny = dx / len * half;
341    let a_minus = CustomWaveVertex {
342        pos: [ax_c - nx, ay_c - ny],
343        color: color_a,
344    };
345    let a_plus = CustomWaveVertex {
346        pos: [ax_c + nx, ay_c + ny],
347        color: color_a,
348    };
349    let b_minus = CustomWaveVertex {
350        pos: [bx_c - nx, by_c - ny],
351        color: color_b,
352    };
353    let b_plus = CustomWaveVertex {
354        pos: [bx_c + nx, by_c + ny],
355        color: color_b,
356    };
357    [a_minus, b_minus, b_plus, a_minus, b_plus, a_plus]
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn preset_xy_to_clip_center_is_origin() {
366        let p = preset_xy_to_clip(0.5, 0.5);
367        assert!((p[0] - 0.0).abs() < 1e-5);
368        assert!((p[1] - 0.0).abs() < 1e-5);
369    }
370
371    #[test]
372    fn preset_xy_to_clip_topleft_is_neg1_pos1() {
373        let p = preset_xy_to_clip(0.0, 0.0);
374        assert!((p[0] - -1.0).abs() < 1e-5);
375        assert!((p[1] - 1.0).abs() < 1e-5);
376    }
377
378    #[test]
379    fn preset_xy_to_clip_bottomright_is_pos1_neg1() {
380        let p = preset_xy_to_clip(1.0, 1.0);
381        assert!((p[0] - 1.0).abs() < 1e-5);
382        assert!((p[1] - -1.0).abs() < 1e-5);
383    }
384
385    /// A horizontal segment `(0, 0) → (1, 0)` in preset space becomes
386    /// a screen-space rectangle whose perpendicular offset is
387    /// straight up/down (positive Y is the screen *up* direction in
388    /// clip-space; preset space flips Y so MD2 y=0 maps to clip y=1).
389    /// We only care that:
390    /// - the 6 vertices form a closed quad (no degenerate triangles),
391    /// - the half-width offset matches the requested thickness,
392    /// - the two endpoints' colours carry through to their respective
393    ///   pairs.
394    #[test]
395    fn segment_to_thick_quad_horizontal_yields_perpendicular_offset() {
396        let q = segment_to_thick_quad(
397            0.0,
398            0.5,
399            [1.0, 0.0, 0.0, 1.0],
400            1.0,
401            0.5,
402            [0.0, 1.0, 0.0, 1.0],
403            0.01,
404        );
405        // Endpoint A (preset (0, 0.5)) → clip (-1, 0).
406        // Endpoint B (preset (1, 0.5)) → clip ( 1, 0).
407        // Perpendicular to a horizontal segment is vertical, so x ≈
408        // endpoint x and y deviates by ±0.005 (half of 0.01).
409        let half = 0.005f32;
410        for v in q.iter() {
411            // Either near x=-1 or near x=1 (no horizontal drift).
412            let near_a = (v.pos[0] + 1.0).abs() < 1e-4;
413            let near_b = (v.pos[0] - 1.0).abs() < 1e-4;
414            assert!(near_a || near_b, "x off-axis: {v:?}");
415            assert!(
416                (v.pos[1].abs() - half).abs() < 1e-4,
417                "y not at ±half-thickness: {v:?}"
418            );
419            if near_a {
420                assert_eq!(v.color, [1.0, 0.0, 0.0, 1.0]);
421            }
422            if near_b {
423                assert_eq!(v.color, [0.0, 1.0, 0.0, 1.0]);
424            }
425        }
426    }
427
428    #[test]
429    fn point_to_dot_quad_centered_at_xy() {
430        let q = point_to_dot_quad(0.5, 0.5, [1.0, 0.0, 0.0, 1.0], 0.01);
431        // Two triangles share two vertices; all six should be near origin.
432        for v in q.iter() {
433            assert!(v.pos[0].abs() <= 0.011);
434            assert!(v.pos[1].abs() <= 0.011);
435            assert_eq!(v.color, [1.0, 0.0, 0.0, 1.0]);
436        }
437    }
438
439    #[test]
440    fn pipeline_index_table() {
441        assert_eq!(pipeline_index(false, false), 0); // lines alpha
442        assert_eq!(pipeline_index(false, true), 1); // lines additive
443        assert_eq!(pipeline_index(true, false), 2); // dots alpha
444        assert_eq!(pipeline_index(true, true), 3); // dots additive
445    }
446}