rlx_core/render/scenes/warp_mesh/draw.rs
1//! What MilkDrop draws between the warp and the composite (Plan 0100 Phase 4):
2//! the waveform, the custom waves and shapes, the two borders, and the
3//! motion-vector grid.
4//!
5//! # It is CPU geometry, deliberately
6//!
7//! Every figure here is a handful of hundreds of points produced by a program the
8//! preset wrote, so it is built on the render thread into two reused buffers and
9//! handed to the GPU as one line batch and one triangle batch. **Nothing here
10//! allocates per frame**: both buffers are sized once at preset load from the
11//! bundle's own counts, which are bounded by
12//! [`MAX_WAVE_POINTS`](crate::milk::MAX_WAVE_POINTS) and its siblings.
13//!
14//! # Two blend modes, and why the geometry is ordered
15//!
16//! MilkDrop chooses per element: `bAdditiveWaves` and a custom element's own
17//! `additive` pick between `dst = src + dst` and `dst = src*a + dst*(1-a)`. Both
18//! are honoured here, by **partitioning each buffer** — additive producers first,
19//! over-blended ones after — and handing the split index to the two-pipeline
20//! draw ([`LineRenderer::draw_split`](crate::render::scenes::lines::LineRenderer::draw_split)).
21//!
22//! Reading both as additive is what saturated the frame to flat colour inside
23//! half a second, and it is not a small error: an additive seam **sums** where
24//! alpha-over **replaces**, so N overlapping producers land at N rather than at
25//! ≤ 1. That bites hardest on the **28.5 % of the corpus that sets
26//! `fDecay >= 1.0`** (2 949 of 10 347, measured 2026-08-16), where the field is a
27//! perfect integrator and nothing brings the sum back down.
28//!
29//! The order within each half is the order MilkDrop draws in — waveform, custom
30//! waves, custom shapes, borders, motion vectors — because an over-blended stroke
31//! must land on top of what it covers.
32//!
33//! # The coordinate space, once
34//!
35//! MilkDrop places everything in **uv**: `0..1` across the frame with `y = 0` at
36//! the *top*. The shared line renderer takes **world** space: `y` in `-1..1`
37//! bottom-to-top, `x` in `-aspect..aspect` (its shader divides x by the aspect).
38//! [`uv_to_world`] is the one conversion, and every producer below goes through
39//! it — which is what makes a figure round on any display and is the ADR-0037
40//! rule applied to geometry rather than to a grid.
41//!
42//! # What is approximated, stated
43//!
44//! - **A dot is a very short segment with both caps extended.** `wave_usedots`
45//! draws points; the line renderer draws quads between endpoints, and its
46//! falloff runs across the stroke only — so a short segment is round *because*
47//! both endpoint extensions push the quad past it by the half-width
48//! (ADR-0158), not because it is short. Without them it is a sub-pixel dash;
49//! see `dots`.
50//! - **`wave_mystery` means something different in every mode**, which is the
51//! reference's own design rather than a simplification here.
52//! - **Mode 6 and 7's line does not drift.** Its angle is `wave_mystery` alone;
53//! a `time * 0.05` term rotates it a full turn every ~126 s, which a Plan 0109
54//! Phase 2 look gate rejected against *Blur Mix 3*'s horizontal reference
55//! traces. Listed here because the reference's own mode 6 is documented only
56//! as "a line", and "which line" is a reading of it — see the arm's comment.
57
58// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
59// 0003 Phase 0). This builds geometry every displayed frame.
60#![deny(
61 clippy::unwrap_used,
62 clippy::expect_used,
63 clippy::indexing_slicing,
64 clippy::panic,
65 clippy::unreachable
66)]
67
68use crate::dsp::WAVE_SAMPLES;
69use crate::milk::outputs::FrameOutputs;
70use crate::milk::{MAX_SHAPE_SIDES, MilkRuntime};
71use crate::render::scenes::lines::SegmentInstance;
72
73/// One vertex of a filled custom shape.
74#[repr(C)]
75#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
76pub struct ShapeVertex {
77 /// World-space position — see the module docs.
78 pub pos: [f32; 2],
79 /// Premultiplied RGB, already scaled by the instance's alpha.
80 pub color: [f32; 3],
81 /// Coverage, which the additive seam needs to equal the light's own
82 /// footprint (ADR-0056).
83 pub alpha: f32,
84}
85
86/// The two buffers a frame's draw layer fills, each **partitioned by blend
87/// mode**. Sized once at preset load.
88///
89/// Additive geometry occupies `..n_additive` and over-blended geometry the rest,
90/// which is what lets one buffer and one render pass serve two pipelines — see
91/// the module docs. Producers are appended through `push_segment`
92/// and `push_triangle` rather than to the vectors
93/// directly, so the invariant is maintained in one place.
94#[derive(Default)]
95pub struct DrawGeometry {
96 /// Every line: the waveform, custom waves, shape outlines, borders, motion
97 /// vectors.
98 pub segments: Vec<SegmentInstance>,
99 /// How many leading entries of [`segments`](Self::segments) blend additively.
100 pub segments_additive: usize,
101 /// Every filled shape's triangles, as a plain list.
102 pub triangles: Vec<ShapeVertex>,
103 /// How many leading entries of [`triangles`](Self::triangles) blend
104 /// additively. Always a multiple of 3 — a triangle's three vertices share one
105 /// blend mode.
106 pub triangles_additive: usize,
107}
108
109impl DrawGeometry {
110 /// Discard last frame's geometry without giving back its capacity — the
111 /// reuse that keeps the per-frame path allocation-free.
112 pub fn clear(&mut self) {
113 self.segments.clear();
114 self.segments_additive = 0;
115 self.triangles.clear();
116 self.triangles_additive = 0;
117 }
118
119 /// Whether anything was built this frame.
120 pub fn is_empty(&self) -> bool {
121 self.segments.is_empty() && self.triangles.is_empty()
122 }
123
124 /// Append one segment into its blend mode's half.
125 ///
126 /// An additive one is **inserted** at the partition rather than pushed, which
127 /// is `O(n)` in the over-blended tail. That is deliberate and it is cheap:
128 /// the whole layer is a few hundred segments of CPU geometry (see the module
129 /// docs), and the alternative — two vectors concatenated per frame — would
130 /// either allocate or need a third buffer. Neither is worth it at this size,
131 /// and this keeps the draw order MilkDrop's within each half.
132 fn push_segment(&mut self, segment: SegmentInstance, additive: bool) {
133 if additive {
134 self.segments.insert(self.segments_additive, segment);
135 self.segments_additive += 1;
136 } else {
137 self.segments.push(segment);
138 }
139 }
140
141 /// Append one triangle — three vertices, one blend mode. See
142 /// [`push_segment`](Self::push_segment) for why the additive case inserts.
143 fn push_triangle(&mut self, vertices: [ShapeVertex; 3], additive: bool) {
144 if additive {
145 for (offset, vertex) in vertices.into_iter().enumerate() {
146 self.triangles
147 .insert(self.triangles_additive + offset, vertex);
148 }
149 self.triangles_additive += 3;
150 } else {
151 self.triangles.extend_from_slice(&vertices);
152 }
153 }
154}
155
156/// uv (`y` down, `0..1`) to the line renderer's world space (`y` up, `x` scaled
157/// by the aspect).
158///
159/// **The one conversion**, and the only place the aspect enters the draw layer.
160/// `aspect` is the **render target's** (ADR-0037).
161pub fn uv_to_world(x: f32, y: f32, aspect: f32) -> [f32; 2] {
162 [(x * 2.0 - 1.0) * aspect, 1.0 - y * 2.0]
163}
164
165/// The stroke half-width a `thick` flag selects, in NDC-y units.
166///
167/// MilkDrop draws a thick line as two or four passes offset by a pixel; here it
168/// is one stroke of twice the width, which is the same gesture through this
169/// engine's soft-falloff primitive.
170const THIN: f32 = 0.0025;
171/// See [`THIN`].
172const THICK: f32 = 0.006;
173
174/// How long a `wave_usedots` dot's segment is, in world units. Short enough that
175/// the falloff reads as round and long enough that the quad is not degenerate.
176const DOT_LENGTH: f32 = 0.0015;
177
178/// Build the whole draw layer for this frame.
179///
180/// `runtime` is `None` for a hand-authored `warp_mesh` preset, which draws no
181/// MilkDrop layer at all — so this whole file costs a native preset one branch.
182/// **`time` is read by exactly three of the eight `wave_mode` figures**, and by
183/// nothing else in this file: mode 0 turns at `0.2` rad/s (`milkdropfs.cpp`
184/// l.2886-2925), mode 1 at `2.3` (l.2942) and mode 5 at `0.3` (l.3085-3086).
185/// Every other figure is a pure function of the trace and the frame outputs, and
186/// that narrowed claim is what `draw_layer.rs` tests — by calling this twice at
187/// well-separated times for a mode with no time term, and by asserting each
188/// turning mode comes back after exactly one turn at its own rate.
189#[allow(clippy::too_many_arguments)]
190pub fn build(
191 geometry: &mut DrawGeometry,
192 runtime: Option<&mut MilkRuntime>,
193 out: &FrameOutputs,
194 pair: &[[f32; WAVE_SAMPLES]; 2],
195 time: f32,
196 dt: f32,
197 aspect: f32,
198 target_width: u32,
199) {
200 geometry.clear();
201 let Some(runtime) = runtime else {
202 return;
203 };
204 let exposure = Exposure::new(dt);
205 // The two traces, smoothed and scaled **once**, the way the source builds
206 // `fWaveL`/`fWaveR` before any figure reads them (`milkdropfs.cpp`
207 // l.933-942): a one-pole run along the trace at `wave_smoothing`, then
208 // `wave_scale`. Every mode and every custom wave indexes these.
209 let smooth = out.wave_smoothing.clamp(0.0, 0.99);
210 let scale = out.wave_scale;
211 let mut traces = [[0.0f32; WAVE_SAMPLES]; 2];
212 for (dst, src) in traces.iter_mut().zip(pair.iter()) {
213 let mut held = 0.0f32;
214 for (slot, raw) in dst.iter_mut().zip(src.iter()) {
215 held = held * smooth + raw * (1.0 - smooth);
216 *slot = held * scale;
217 }
218 }
219 let [left, right] = &traces;
220 waveform_figure(
221 geometry,
222 out,
223 left,
224 right,
225 time,
226 exposure,
227 aspect,
228 target_width,
229 );
230 custom_waves(geometry, runtime, left, right, exposure, aspect);
231 custom_shapes(geometry, runtime, exposure, aspect);
232 // The two borders and the motion-vector grid are **always** alpha-blended in
233 // the reference — neither has an additive flag to read — so they go to the
234 // over half unconditionally.
235 borders(geometry, out, exposure, aspect);
236 motion_vectors(geometry, out, exposure, aspect);
237}
238
239/// **What one frame of the draw layer is worth**, which depends on how the
240/// producer that drew it blends. Both cases are a rate rather than a constant,
241/// and for the same reason.
242///
243/// MilkDrop deposits once per rendered frame into a buffer that decays once per
244/// rendered frame, so the two are in step by construction. Here the field decays
245/// **per second** (`decay` is rate-converted like every other MilkDrop factor),
246/// which means a 60 Hz display would deposit twice the light per second that a
247/// 30 Hz one does into a buffer that fades at the same rate — and the picture
248/// would differ with the refresh, which ADR-0019 exists to prevent. `rate` is how
249/// many nominal frames of wall clock this frame is, and both branches of
250/// `scale` convert through it.
251#[derive(Clone, Copy)]
252pub struct Exposure {
253 /// `dt * NOMINAL_FPS`: how many nominal frames of wall clock this frame is.
254 rate: f32,
255}
256
257impl Exposure {
258 /// From this frame's `dt`, capped at **four** nominal frames: a long frame
259 /// deposits proportionally more light, but a stall cannot deposit an
260 /// unbounded amount in one go.
261 pub fn new(dt: f32) -> Self {
262 Self {
263 rate: (dt * crate::milk::NOMINAL_FPS).clamp(0.0, 4.0),
264 }
265 }
266
267 /// The producer's effective alpha this frame — its colour is premultiplied
268 /// by this, and it is the coverage the fragment writes.
269 ///
270 /// # The two conversions
271 ///
272 /// **Additive** light composes by *addition* across frames, so `n` frames
273 /// deposit `n * a` and the rate conversion is the plain product `a * rate`.
274 ///
275 /// **Alpha-over** composes by *repeated interpolation*: `n` frames of
276 /// `dst = src*a + dst*(1-a)` leave `1 - (1-a)^n` of the way travelled, so the
277 /// conversion is `1 - (1-a)^rate`. At `rate = 1` it is exactly `a`, which is
278 /// what makes 60 Hz the reference's own cadence rather than an approximation
279 /// of it; at `rate = 2` a 30 Hz frame travels as far as two 60 Hz ones, which
280 /// is the property ADR-0019 asks for.
281 ///
282 /// Note that the alpha-over branch is **bounded by 1** for every `rate`,
283 /// which is the whole reason this is worth two pipelines: the sum of N
284 /// over-blended producers is still ≤ 1, where N additive ones is N.
285 fn scale(self, alpha: f32, additive: bool) -> f32 {
286 let a = alpha.clamp(0.0, 1.0);
287 if additive {
288 a * self.rate
289 } else {
290 1.0 - (1.0 - a).powf(self.rate)
291 }
292 }
293}
294
295/// A producer's premultiplied colour and its coverage, both already scaled by
296/// [`Exposure::scale`].
297///
298/// The two are returned together because both seams need coverage to equal the
299/// light's own footprint: additively so a dim deposit does not occlude a lit
300/// backdrop (ADR-0056), and over-blended because the coverage *is* the blend's
301/// alpha.
302#[derive(Clone, Copy)]
303struct Light {
304 /// Premultiplied colour: the producer's RGB times its effective alpha.
305 rgb: [f32; 3],
306 /// **The alpha the fragment writes**, which is not the same number in the two
307 /// seams and that difference is ADR-0056's rule rather than an inconsistency.
308 ///
309 /// Over-blended, coverage *is* the blend's alpha — a `wave_a = 0.1` stroke
310 /// must replace a tenth of what is under it, so it is the effective alpha.
311 ///
312 /// Additive, it is **`1.0`**: "a dimmed stroke still covers its own
313 /// footprint", so brightness lives in [`rgb`](Self::rgb) and the geometry's
314 /// own falloff is the whole footprint. Passing the effective alpha here
315 /// instead would make a dim additive stroke *narrower* rather than darker,
316 /// and could exceed 1 at a long `dt`.
317 coverage: f32,
318}
319
320impl Light {
321 /// Whether this producer writes anything worth a draw call.
322 fn is_dark(&self) -> bool {
323 self.rgb.iter().all(|c| *c <= 0.0001)
324 }
325}
326
327/// One point of a stroked figure: where it is, and what it deposits there.
328type Point = ([f32; 2], Light);
329
330/// Colour and coverage for one producer. `wave_brighten` normalizes to the
331/// brightest channel first, which is what the reference's `bMaximizeWaveColor`
332/// does.
333fn light(
334 r: f32,
335 g: f32,
336 b: f32,
337 a: f32,
338 brighten: bool,
339 exposure: Exposure,
340 additive: bool,
341) -> Light {
342 let (mut r, mut g, mut b) = (r, g, b);
343 if brighten {
344 let peak = r.max(g).max(b);
345 if peak > 0.0001 {
346 let k = 1.0 / peak;
347 r *= k;
348 g *= k;
349 b *= k;
350 }
351 }
352 let a = exposure.scale(a, additive);
353 Light {
354 rgb: [r * a, g * a, b * a],
355 coverage: if additive { 1.0 } else { a },
356 }
357}
358
359/// Push a polyline, flagging the interior joins so the strokes meet cleanly
360/// (ADR-0041).
361fn polyline(
362 geometry: &mut DrawGeometry,
363 points: &[Point],
364 width: f32,
365 closed: bool,
366 additive: bool,
367) {
368 let n = points.len();
369 if n < 2 {
370 return;
371 }
372 let last = if closed { n } else { n - 1 };
373 for i in 0..last {
374 let Some((a, light)) = points.get(i) else {
375 continue;
376 };
377 let Some((b, _)) = points.get((i + 1) % n) else {
378 continue;
379 };
380 let ext_a = if closed || i > 0 { width } else { 0.0 };
381 let ext_b = if closed || i + 2 < n { width } else { 0.0 };
382 geometry.push_segment(
383 SegmentInstance {
384 a: *a,
385 b: *b,
386 color: light.rgb,
387 width,
388 alpha: light.coverage,
389 ext_a,
390 ext_b,
391 },
392 additive,
393 );
394 }
395}
396
397/// Emit one built trace the way the mode asked for it: separated marks when
398/// `wave_usedots` is set, a continuous stroke otherwise.
399///
400/// **This exists because there were four call sites and one of them forgot**
401/// (Plan 0108 Phase 4). `wave_mode 5` draws its figure in two passes and its
402/// first pass called [`polyline`] unconditionally, so a preset asking for dots
403/// got a continuous stroke above `wave_y` and beads below it. Nothing failed and
404/// nothing warned; the trace was simply half wrong. The defect was found by
405/// measurement — mode 5's dotted geometry held segments **13.6x longer than
406/// [`DOT_LENGTH`]** where every other mode's held none — and the repair is to
407/// leave one place where the choice is made.
408fn emit_trace(
409 geometry: &mut DrawGeometry,
410 points: &[Point],
411 width: f32,
412 closed: bool,
413 additive: bool,
414 use_dots: bool,
415) {
416 if use_dots {
417 dots(geometry, points, width, additive);
418 } else {
419 polyline(geometry, points, width, closed, additive);
420 }
421}
422
423/// Push each point as its own dot.
424///
425/// **Both ends are extended by a half-width, and that is what makes a dot a
426/// dot** (Plan 0108 Phase 4). The line renderer's falloff runs *across* the
427/// stroke only; the quad simply ends at each endpoint unless `ext_a`/`ext_b`
428/// push it past (ADR-0158). Without that extension a mark is a hard-edged
429/// [`DOT_LENGTH`] x `2 * width` rectangle — **3.3x wider than it is long**, a
430/// sub-pixel dash lying across the trace rather than the round dot this module's
431/// header describes. Measured at 1080p on one drawn frame, that cost 300 pixels
432/// above half brightness against a continuous stroke's 5 008, and at 320x180 it
433/// left **2**, which is design-backlog 0107's "the `wave_usedots` beads never
434/// appear".
435///
436/// With both ends extended the quad grows by the half-width at each cap, so the
437/// mark is `DOT_LENGTH + 2 * width` long against `2 * width` across — round
438/// enough that the falloff reads as a bead at any resolution, through the
439/// mechanism that already exists rather than a new constant.
440///
441/// The mark is also **centred** on its point rather than growing forward from
442/// it. Half of [`DOT_LENGTH`] is well under a pixel, so this moves nothing
443/// visible; it is here because a dot that is offset from the sample it stands
444/// for is wrong in a way nobody would ever see and everybody would inherit.
445fn dots(geometry: &mut DrawGeometry, points: &[Point], width: f32, additive: bool) {
446 for (p, light) in points {
447 geometry.push_segment(
448 SegmentInstance {
449 a: [p[0] - DOT_LENGTH * 0.5, p[1]],
450 b: [p[0] + DOT_LENGTH * 0.5, p[1]],
451 color: light.rgb,
452 width,
453 alpha: light.coverage,
454 // A cap, not a miter: this is the half-width that rounds the
455 // mark, and a zero-length segment has no interior angle to
456 // compute one from.
457 ext_a: width,
458 ext_b: width,
459 },
460 additive,
461 );
462 }
463}
464
465/// How many `wave_mode` figures there are — MilkDrop's own eight.
466pub const WAVE_MODES: u32 = 8;
467
468/// The factor between the source's sample term and what the host people run
469/// actually draws.
470///
471/// `foo_vis_milk2` 0.2.0.0, `wave_mode = 6`, a full-scale 200 Hz sine at
472/// `fWaveScale = 1`, captured by Plan 0127: the trace measured **0.316 frame
473/// heights peak to peak**, over a stroke whose own width read `0.0019`. The
474/// source's mode-6 coefficient is `0.25` clip along the line's normal, which is
475/// `0.125` frame heights per unit sample, so the factor is
476/// `((0.316 - 0.0019) / 2) / 0.125`.
477///
478/// **It multiplies the sample term and nothing else** — never a base radius, a
479/// separation or an extent (ADR-0199). It is measured on one mode and inferred
480/// for the other seven **and for a custom wave's `value1`/`value2`**
481/// (ADR-0223), on the argument that the host's gap is in the sample path every
482/// figure shares; the confirmation is a unit-scale mode-0 capture that
483/// Plan 0142's rig session owes.
484pub(crate) const HOST_SAMPLE_FACTOR: f32 = 1.256;
485
486/// A whole turn, **as the source writes it** (`milkdropfs.cpp` l.2886-2948):
487/// `6.28`, not `TAU`.
488///
489/// The difference is 0.05 % of a turn over the 239 steps modes 0 and 1 lay down,
490/// so the last point falls an eighth of a step short of the first. Mode 0's
491/// cosine blend is what closes that join, and writing `TAU` here would move every
492/// converted circle by that eighth of a step for no reason but tidiness.
493#[allow(
494 clippy::approx_constant,
495 reason = "the source writes 6.28 and the truncation is the behaviour, not a typo for TAU"
496)]
497const SOURCE_TURN: f32 = 6.28;
498
499/// The largest figure the source builds, in constructed points: modes 2, 3, 4
500/// and 5 at 480. [`smooth_wave`] turns `n` of them into `2n - 1`.
501const MAX_WAVE_POINTS: usize = 480;
502/// See [`MAX_WAVE_POINTS`].
503const MAX_SMOOTHED_POINTS: usize = 2 * MAX_WAVE_POINTS - 1;
504
505/// `CPlugin::DrawWave`'s `SmoothWave` (`milkdropfs.cpp` l.2549-2577), applied to
506/// every built-in figure once it is constructed (l.3319-3335) and to each side of
507/// mode 7's break separately.
508///
509/// It inserts one point between each consecutive pair, at
510/// `(-0.15 v[i-1] + 1.15 v[i] + 1.15 v[i+1] - 0.15 v[i+2]) / 2` with the indices
511/// clamped at the ends, so `n` points become `2n - 1` and **every original point
512/// keeps its position, at an even index**. That last property is what lets a test
513/// read the figure the mode built rather than the curve through it.
514///
515/// The interpolation is affine in the points, so running it in uv rather than in
516/// the line renderer's world space is the same arithmetic — `uv_to_world` is
517/// itself affine.
518fn smooth_wave(src: &[[f32; 2]], dst: &mut [[f32; 2]; MAX_SMOOTHED_POINTS]) -> usize {
519 if src.len() < 2 {
520 for (slot, point) in dst.iter_mut().zip(src) {
521 *slot = *point;
522 }
523 return src.len();
524 }
525 let at = |i: isize| -> [f32; 2] {
526 let clamped = i.clamp(0, src.len() as isize - 1) as usize;
527 src.get(clamped).copied().unwrap_or([0.0; 2])
528 };
529 let mut used = 0usize;
530 for i in 0..src.len() {
531 if let Some(slot) = dst.get_mut(used) {
532 *slot = at(i as isize);
533 }
534 used += 1;
535 if i + 1 == src.len() {
536 break;
537 }
538 let (a, b, c, d) = (
539 at(i as isize - 1),
540 at(i as isize),
541 at(i as isize + 1),
542 at(i as isize + 2),
543 );
544 if let Some(slot) = dst.get_mut(used) {
545 *slot = inserted_point(a, b, c, d);
546 }
547 used += 1;
548 }
549 used.min(MAX_SMOOTHED_POINTS)
550}
551
552/// The one point `SmoothWave` inserts between `b` and `c`, given the points
553/// either side of that pair — `a` before `b`, `d` after `c`, each clamped to the
554/// figure's ends by the caller.
555///
556/// **The kernel, in one place**, because two callers run it: the built-in
557/// figures through [`smooth_wave`] and a custom wave through [`smooth_points`].
558/// The four coefficients sum to `2`, so the `* 0.5` is the kernel's own
559/// normalization rather than an average of the middle pair.
560fn inserted_point(a: [f32; 2], b: [f32; 2], c: [f32; 2], d: [f32; 2]) -> [f32; 2] {
561 [
562 (-0.15 * a[0] + 1.15 * b[0] + 1.15 * c[0] - 0.15 * d[0]) * 0.5,
563 (-0.15 * a[1] + 1.15 * b[1] + 1.15 * c[1] - 0.15 * d[1]) * 0.5,
564 ]
565}
566
567/// [`smooth_wave`] over points that carry a light, which is the form a custom
568/// wave's per-point program produces (ADR-0223 — the source smooths a custom
569/// wave too, unless it draws dots).
570///
571/// The positions are the same insertion, so `n` points become `2n - 1` and every
572/// original keeps its place at an even index. **The inserted point takes the
573/// light of the point before it** rather than a blend of the pair: a custom
574/// wave's colour is its own program's output at a point the program ran at, and
575/// there is no run between two points to take a colour from.
576///
577/// Writes into `dst` rather than returning, so one buffer serves every wave of a
578/// frame.
579fn smooth_points(src: &[Point], dst: &mut Vec<Point>) {
580 dst.clear();
581 if src.len() < 2 {
582 dst.extend_from_slice(src);
583 return;
584 }
585 let at = |i: isize| -> Option<Point> {
586 let clamped = i.clamp(0, src.len() as isize - 1) as usize;
587 src.get(clamped).copied()
588 };
589 for i in 0..src.len() {
590 let i = i as isize;
591 let Some(b) = at(i) else {
592 continue;
593 };
594 dst.push(b);
595 if i + 1 == src.len() as isize {
596 break;
597 }
598 let (Some(a), Some(c), Some(d)) = (at(i - 1), at(i + 1), at(i + 2)) else {
599 continue;
600 };
601 dst.push((inserted_point(a.0, b.0, c.0, d.0), b.1));
602 }
603}
604
605/// The source's `wave_mystery` fold, `milkdropfs.cpp` l.2869-2877 — **modes 0, 1
606/// and 4 only**, which is why it is a function rather than applied to the output
607/// on the way in. Modes 6 and 7 read the value unfolded, and the rest ignore it.
608fn fold_mystery(m: f32) -> f32 {
609 if !m.is_finite() {
610 return 0.0;
611 }
612 m - 2.0 * ((m + 1.0) * 0.5).floor()
613}
614
615/// The built-in waveform: MilkDrop's eight `wave_mode` figures, each built the
616/// way `CPlugin::DrawWave` builds it (`milkdropfs.cpp` l.2765-3259).
617///
618/// # The two coordinate conventions, and which modes use which
619///
620/// The source works in a clip space where `-1..1` is the whole frame on **both**
621/// axes. Modes 0, 1, 2, 3 and 5 multiply their x offset by `m_fAspectY` and their
622/// y offset by `m_fAspectX` before placing it, which puts those figures in
623/// shorter-axis units and makes a circle round; modes 4, 6 and 7 apply no aspect
624/// term at all, so their extents are the frame's own. Both are reproduced here,
625/// and [`uv_to_world`] supplies the one conversion out (ADR-0037).
626///
627/// # What the eight figures are
628///
629/// Modes 2 and 3 are **the same geometry, line for line** (l.2950-2976 against
630/// l.2977-3004) and differ only in the alpha they draw at (l.2982-2991), which
631/// this file does not carry. That is the source's own design and not a gap here.
632///
633/// Modes 1, 2, 3, 5 and 7 read **both** channels of the analyzer's pair; modes 0
634/// and 6 read one.
635#[allow(clippy::too_many_arguments)]
636fn waveform_figure(
637 geometry: &mut DrawGeometry,
638 out: &FrameOutputs,
639 left: &[f32; WAVE_SAMPLES],
640 right: &[f32; WAVE_SAMPLES],
641 time: f32,
642 exposure: Exposure,
643 aspect: f32,
644 target_width: u32,
645) {
646 let additive = out.wave_additive >= 0.5;
647 let colour = light(
648 out.wave_r,
649 out.wave_g,
650 out.wave_b,
651 out.wave_a,
652 out.wave_brighten >= 0.5,
653 exposure,
654 additive,
655 );
656 if colour.is_dark() {
657 return;
658 }
659 let stroke = if out.wave_thick >= 0.5 { THICK } else { THIN };
660 // Read once and passed to every emit below: the mode that draws in two passes
661 // has to make the same choice in both, and reading the flag at each site is
662 // how one of them came to be a stroke where the other was beads.
663 let use_dots = out.wave_usedots >= 0.5;
664 let (cx, cy) = (out.wave_x, out.wave_y);
665 let mystery = out.wave_mystery;
666 let folded = fold_mystery(mystery);
667 let mode = (out.wave_mode.max(0.0) as u32) % WAVE_MODES;
668
669 // The sample term, with the host factor on it and nothing else (ADR-0199).
670 let l = |i: usize| left.get(i).copied().unwrap_or(0.0) * HOST_SAMPLE_FACTOR;
671 let r = |i: usize| right.get(i).copied().unwrap_or(0.0) * HOST_SAMPLE_FACTOR;
672
673 // Clip to uv. `centred` carries the aspect pair and the preset's centre;
674 // `plain` is the frame's own clip space, which modes 4, 6 and 7 place in.
675 let (ax, ay) = if aspect >= 1.0 {
676 (1.0, 1.0 / aspect)
677 } else {
678 (aspect, 1.0)
679 };
680 let centred = |px: f32, py: f32| [cx + 0.5 * px * ay, cy - 0.5 * py * ax];
681 let plain = |px: f32, py: f32| [0.5 + 0.5 * px, 0.5 - 0.5 * py];
682
683 let mut pts = [[0.0f32; 2]; MAX_WAVE_POINTS];
684 let mut n = 0usize;
685 let push = |p: [f32; 2], pts: &mut [[f32; 2]; MAX_WAVE_POINTS], n: &mut usize| {
686 if let Some(slot) = pts.get_mut(*n) {
687 *slot = p;
688 *n += 1;
689 }
690 };
691 // `min(480, texW/3)` and `min(240, texW/3)`: the source refuses to lay more
692 // points than a third of the frame's pixels across (l.3005-3065, l.3100-3244).
693 let thirds = (target_width / 3).max(2) as usize;
694
695 match mode {
696 // 0 — a circle of radius 0.5 clip, breathing with the right channel, and
697 // turning at 0.2 rad/s (l.2886-2925). The first 24 points cosine-blend to
698 // the sample 240 further along, which is what closes the loop without a
699 // step across the join.
700 0 => {
701 let count = 240usize;
702 for i in 0..count {
703 let ang = i as f32 / (count - 1) as f32 * SOURCE_TURN + time * 0.2;
704 let blend = 24usize;
705 let s = if i < blend {
706 let mix = 0.5 - 0.5 * (i as f32 / blend as f32 * std::f32::consts::PI).cos();
707 r(i + 360) * (1.0 - mix) + r(i + 120) * mix
708 } else {
709 r(i + 120)
710 };
711 let rad = 0.5 + 0.4 * s + folded;
712 push(centred(rad * ang.cos(), rad * ang.sin()), &mut pts, &mut n);
713 }
714 }
715 // 1 — a polar figure: the right channel drives the radius and the left
716 // the angle, turning at 2.3 rad/s (l.2927-2948). Open, unlike mode 0.
717 1 => {
718 let count = 240usize;
719 for i in 0..count {
720 let rad = 0.53 + 0.43 * r(i) + folded;
721 let ang =
722 i as f32 / (count - 1) as f32 * SOURCE_TURN + 1.57 * l(i + 32) + time * 2.3;
723 push(centred(rad * ang.cos(), rad * ang.sin()), &mut pts, &mut n);
724 }
725 }
726 // 2 and 3 — the x-y scope, right against left at a 32-sample offset
727 // (l.2950-3004). One construction: the two modes differ in alpha alone.
728 2 | 3 => {
729 let count = 480usize;
730 for i in 0..count {
731 push(centred(r(i), l(i + 32)), &mut pts, &mut n);
732 }
733 }
734 // 4 — a horizontal sweep whose y comes from the left channel and whose x
735 // is nudged by the right, then run through the source's momentum filter
736 // (l.3005-3065). `wave_mystery` sets how much of each point's step
737 // carries into the next.
738 4 => {
739 let count = 480usize.min(thirds);
740 let offset = (480 - count) / 2;
741 let w1 = 0.45 + 0.5 * (folded * 0.5 + 0.5);
742 let w2 = 1.0 - w1;
743 let mut dx = [0.0f32; MAX_WAVE_POINTS];
744 let mut dy = [0.0f32; MAX_WAVE_POINTS];
745 for i in 0..count {
746 if let (Some(sx), Some(sy)) = (dx.get_mut(i), dy.get_mut(i)) {
747 *sx = 0.44 * r(i + 25 + offset);
748 *sy = 0.47 * l(i + offset);
749 }
750 }
751 for series in [&mut dx, &mut dy] {
752 for i in 2..count {
753 let (a, b) = (
754 series.get(i - 1).copied().unwrap_or(0.0),
755 series.get(i - 2).copied().unwrap_or(0.0),
756 );
757 if let Some(slot) = series.get_mut(i) {
758 *slot = *slot * w2 + w1 * (2.0 * a - b);
759 }
760 }
761 }
762 for i in 0..count {
763 let along = -1.0 + 2.0 * i as f32 / count.max(2) as f32;
764 let px = along + (cx * 2.0 - 1.0) + dx.get(i).copied().unwrap_or(0.0);
765 let py = (cy * 2.0 - 1.0) + dy.get(i).copied().unwrap_or(0.0);
766 push(plain(px, py), &mut pts, &mut n);
767 }
768 }
769 // 5 — the source's product figure, the two channels multiplied into each
770 // other and the whole thing turned at 0.3 rad/s (l.3067-3098).
771 5 => {
772 let count = 480usize;
773 let (s, c) = (time * 0.3).sin_cos();
774 for i in 0..count {
775 let x0 = r(i) * l(i + 32) + l(i) * r(i + 32);
776 let y0 = r(i) * r(i) - l(i + 32) * l(i + 32);
777 push(centred(x0 * c - y0 * s, x0 * s + y0 * c), &mut pts, &mut n);
778 }
779 }
780 // 6 and 7 — a line at an angle `wave_mystery` sets, run across the frame
781 // and clipped to just outside it, with the left channel displacing it
782 // along its own normal (l.3100-3244). Mode 7 draws the same line twice,
783 // one channel each, pushed apart by `wave_y` squared; the two sides are a
784 // break in the figure rather than one polyline, so they are emitted and
785 // smoothed separately.
786 //
787 // **`wave_mystery` is read unfolded here** and `wave_x` slides the line
788 // along its NORMAL rather than along itself, which is the source's own
789 // use of the two and not a transcription slip.
790 6 | 7 => {
791 let count = 240usize.min(thirds);
792 let ang = 1.57 * mystery;
793 let (sa, ca) = ang.sin_cos();
794 let dir = [ca, sa];
795 let nrm = [-sa, ca];
796 let slide = cx * 2.0 - 1.0;
797 let (t0, t1) = clip_to_frame(dir, [nrm[0] * slide, nrm[1] * slide]);
798 let sep = cy * cy;
799 let sides: &[(f32, bool)] = if mode == 7 {
800 &[(sep, true), (-sep, false)]
801 } else {
802 &[(0.0, true)]
803 };
804 let mut smoothed = [[0.0f32; 2]; MAX_SMOOTHED_POINTS];
805 for (offset, from_left) in sides {
806 n = 0;
807 for i in 0..count {
808 let t = t0 + (t1 - t0) * i as f32 / (count - 1).max(1) as f32;
809 let sample = if *from_left { l(i + 120) } else { r(i + 120) };
810 let d = 0.25 * sample + offset;
811 let px = dir[0] * t + nrm[0] * (slide + d);
812 let py = dir[1] * t + nrm[1] * (slide + d);
813 push(plain(px, py), &mut pts, &mut n);
814 }
815 let built = smooth_wave(pts.get(..n).unwrap_or(&[]), &mut smoothed);
816 emit_uv(
817 geometry,
818 smoothed.get(..built).unwrap_or(&[]),
819 colour,
820 stroke,
821 false,
822 additive,
823 use_dots,
824 aspect,
825 );
826 }
827 return;
828 }
829 _ => {}
830 }
831
832 let mut smoothed = [[0.0f32; 2]; MAX_SMOOTHED_POINTS];
833 let built = smooth_wave(pts.get(..n).unwrap_or(&[]), &mut smoothed);
834 emit_uv(
835 geometry,
836 smoothed.get(..built).unwrap_or(&[]),
837 colour,
838 stroke,
839 false,
840 additive,
841 use_dots,
842 aspect,
843 );
844}
845
846/// Where a line through `offset` in direction `dir` enters and leaves the source's
847/// `+/-1.1` clip box (`milkdropfs.cpp` l.3100-3244) — slightly outside the frame,
848/// so a rotated trace runs past the corners rather than stopping short of them.
849///
850/// Returns the parameter range along `dir`, clamped to the source's own
851/// `-3..3` extent when the line is axis-parallel and one axis never bounds it.
852fn clip_to_frame(dir: [f32; 2], offset: [f32; 2]) -> (f32, f32) {
853 const EDGE: f32 = 1.1;
854 const REACH: f32 = 3.0;
855 let (mut lo, mut hi) = (-REACH, REACH);
856 for axis in 0..2 {
857 let (d, o) = (
858 dir.get(axis).copied().unwrap_or(0.0),
859 offset.get(axis).copied().unwrap_or(0.0),
860 );
861 if d.abs() < 1e-6 {
862 continue;
863 }
864 let (a, b) = ((-EDGE - o) / d, (EDGE - o) / d);
865 lo = lo.max(a.min(b));
866 hi = hi.min(a.max(b));
867 }
868 if hi <= lo { (-REACH, REACH) } else { (lo, hi) }
869}
870
871/// Convert a figure built in uv into the line renderer's world space and emit it.
872#[allow(clippy::too_many_arguments)]
873fn emit_uv(
874 geometry: &mut DrawGeometry,
875 uv: &[[f32; 2]],
876 colour: Light,
877 width: f32,
878 closed: bool,
879 additive: bool,
880 use_dots: bool,
881 aspect: f32,
882) {
883 let mut points: Vec<Point> = Vec::with_capacity(uv.len());
884 for p in uv {
885 points.push((uv_to_world(p[0], p[1], aspect), colour));
886 }
887 emit_trace(geometry, &points, width, closed, additive, use_dots);
888}
889
890/// The preset's custom waves, each a polyline or a scatter from its own
891/// per-point program.
892///
893/// **A custom wave draws through the same figure contract as the eight built-in
894/// modes** (ADR-0223): its sample term carries [`HOST_SAMPLE_FACTOR`], and the
895/// figure its program placed passes through [`smooth_points`] unless the wave
896/// draws dots — which is the source's own exception (`milkdropfs.cpp` l.2722)
897/// rather than one taken here.
898fn custom_waves(
899 geometry: &mut DrawGeometry,
900 runtime: &mut MilkRuntime,
901 left: &[f32; WAVE_SAMPLES],
902 right: &[f32; WAVE_SAMPLES],
903 exposure: Exposure,
904 aspect: f32,
905) {
906 // Reused by every wave of this frame, in the shape the built-in figure's own
907 // smoothing buffer takes.
908 let mut smoothed: Vec<Point> = Vec::new();
909 for index in 0..runtime.wave_count() {
910 let Some(spec) = runtime.wave_spec(index) else {
911 continue;
912 };
913 if runtime.run_wave_frame(index).is_none() {
914 continue;
915 }
916 let count = spec.count.max(2) as usize;
917 let mut points: Vec<Point> = Vec::with_capacity(count);
918 for i in 0..count {
919 let t = i as f32 / (count - 1).max(1) as f32;
920 // The audio at this point along the wave — MilkDrop's `value1` and
921 // `value2`, which are its left and right channels and are two
922 // different numbers on a stereo stream (ADR-0199). A per-point
923 // program that plots one against the other draws the figure its
924 // author saw rather than a diagonal line.
925 // The sample term, with the host factor on it and nothing else —
926 // the same term the built-in figures read (ADR-0223).
927 let at = ((t * (WAVE_SAMPLES - 1) as f32) as usize).min(WAVE_SAMPLES - 1);
928 let value1 = left.get(at).copied().unwrap_or(0.0) * HOST_SAMPLE_FACTOR;
929 let value2 = right.get(at).copied().unwrap_or(0.0) * HOST_SAMPLE_FACTOR;
930 let Some(point) = runtime.run_wave_point(index, t, value1, value2) else {
931 break;
932 };
933 points.push((
934 uv_to_world(point.x, point.y, aspect),
935 light(
936 point.r,
937 point.g,
938 point.b,
939 point.a,
940 false,
941 exposure,
942 spec.additive,
943 ),
944 ));
945 }
946 let width = if spec.thick { THICK } else { THIN };
947 if spec.use_dots {
948 dots(geometry, &points, width, spec.additive);
949 } else {
950 smooth_points(&points, &mut smoothed);
951 polyline(geometry, &smoothed, width, false, spec.additive);
952 }
953 }
954}
955
956/// The preset's custom shapes: filled polygons with an optional outline.
957fn custom_shapes(
958 geometry: &mut DrawGeometry,
959 runtime: &mut MilkRuntime,
960 exposure: Exposure,
961 aspect: f32,
962) {
963 for index in 0..runtime.shape_count() {
964 let Some(spec) = runtime.shape_spec(index) else {
965 continue;
966 };
967 for instance in 0..spec.instances {
968 let Some(shape) = runtime.run_shape_instance(index, instance) else {
969 break;
970 };
971 // Per **instance**, not per element: `additive` is one of the
972 // registers the shape's own per-frame program may write, so one
973 // shape's copies can blend differently from each other.
974 let additive = shape.additive >= 0.5;
975 let sides = (shape.sides.max(3.0) as u32).clamp(3, MAX_SHAPE_SIDES);
976 let centre = uv_to_world(shape.x, shape.y, aspect);
977 let inner = light(
978 shape.r, shape.g, shape.b, shape.a, false, exposure, additive,
979 );
980 let outer = light(
981 shape.r2, shape.g2, shape.b2, shape.a2, false, exposure, additive,
982 );
983 // The perimeter, in world space. `rad` is in frame-heights, so the
984 // aspect only enters through the centre — which is what keeps a
985 // shape round rather than stretched.
986 let point = |i: u32| -> [f32; 2] {
987 let t = shape.ang + i as f32 / sides as f32 * std::f32::consts::TAU;
988 [
989 centre[0] + shape.rad * t.cos() * aspect,
990 centre[1] + shape.rad * t.sin(),
991 ]
992 };
993 // A triangle fan, emitted as a plain list so the whole draw layer is
994 // one buffer, in the blend mode this instance asked for.
995 for i in 0..sides {
996 geometry.push_triangle(
997 [
998 ShapeVertex {
999 pos: centre,
1000 color: inner.rgb,
1001 alpha: inner.coverage,
1002 },
1003 ShapeVertex {
1004 pos: point(i),
1005 color: outer.rgb,
1006 alpha: outer.coverage,
1007 },
1008 ShapeVertex {
1009 pos: point((i + 1) % sides),
1010 color: outer.rgb,
1011 alpha: outer.coverage,
1012 },
1013 ],
1014 additive,
1015 );
1016 }
1017 // ...and the outline, through the same line batch as everything else.
1018 if shape.border_a > 0.0001 {
1019 let colour = light(
1020 shape.border_r,
1021 shape.border_g,
1022 shape.border_b,
1023 shape.border_a,
1024 false,
1025 exposure,
1026 additive,
1027 );
1028 let outline: Vec<Point> = (0..sides).map(|i| (point(i), colour)).collect();
1029 let width = if shape.thick_outline >= 0.5 || spec.thick {
1030 THICK
1031 } else {
1032 THIN
1033 };
1034 polyline(geometry, &outline, width, true, additive);
1035 }
1036 }
1037 }
1038}
1039
1040/// The inner and outer borders: two rectangles inset from the frame edge.
1041fn borders(geometry: &mut DrawGeometry, out: &FrameOutputs, exposure: Exposure, aspect: f32) {
1042 for (size, r, g, b, a, inset) in [
1043 (out.ob_size, out.ob_r, out.ob_g, out.ob_b, out.ob_a, 0.0),
1044 (
1045 out.ib_size,
1046 out.ib_r,
1047 out.ib_g,
1048 out.ib_b,
1049 out.ib_a,
1050 out.ob_size,
1051 ),
1052 ] {
1053 if a <= 0.0001 || size <= 0.0 {
1054 continue;
1055 }
1056 let colour = light(r, g, b, a, false, exposure, false);
1057 // The rectangle sits at the middle of its own band, and the stroke is the
1058 // band's whole width — which is how a border of `size` reads as a band of
1059 // `size` rather than as a hairline.
1060 let half = (size * 0.5).clamp(0.0005, 0.4);
1061 let edge = inset + half;
1062 let corners = [
1063 (edge, edge),
1064 (1.0 - edge, edge),
1065 (1.0 - edge, 1.0 - edge),
1066 (edge, 1.0 - edge),
1067 ];
1068 let points: Vec<Point> = corners
1069 .iter()
1070 .map(|(u, v)| (uv_to_world(*u, *v, aspect), colour))
1071 .collect();
1072 polyline(geometry, &points, half * 2.0, true, false);
1073 }
1074}
1075
1076/// The motion-vector grid: a lattice of short strokes showing where the warp is
1077/// taking the frame.
1078///
1079/// MilkDrop samples its own warp mesh to draw these. Here the grid is drawn from
1080/// the same `mv_*` vocabulary at the positions the preset names, with `mv_l`
1081/// setting the length — the figure a preset asks for, without a second
1082/// evaluation of the per-vertex program per grid point.
1083fn motion_vectors(
1084 geometry: &mut DrawGeometry,
1085 out: &FrameOutputs,
1086 exposure: Exposure,
1087 aspect: f32,
1088) {
1089 if out.mv_a <= 0.0001 {
1090 return;
1091 }
1092 let nx = (out.mv_x.max(0.0) as u32).min(64);
1093 let ny = (out.mv_y.max(0.0) as u32).min(48);
1094 if nx == 0 || ny == 0 {
1095 return;
1096 }
1097 let colour = light(
1098 out.mv_r, out.mv_g, out.mv_b, out.mv_a, false, exposure, false,
1099 );
1100 let len = out.mv_l * 0.02;
1101 for iy in 0..ny {
1102 for ix in 0..nx {
1103 let u = (ix as f32 + 0.5 + out.mv_dx) / nx as f32;
1104 let v = (iy as f32 + 0.5 + out.mv_dy) / ny as f32;
1105 let a = uv_to_world(u, v, aspect);
1106 geometry.push_segment(
1107 SegmentInstance {
1108 a,
1109 b: [a[0] + len * aspect, a[1] + len],
1110 color: colour.rgb,
1111 width: THIN,
1112 alpha: colour.coverage,
1113 ext_a: 0.0,
1114 ext_b: 0.0,
1115 },
1116 false,
1117 );
1118 }
1119 }
1120}