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 nothing here, and that is the contract** (Plan 0109
183/// Phase 2): every figure this layer builds is a pure function of the trace and
184/// the frame outputs. The parameter stays in the signature because the scene has
185/// the value and because time-independence is a claim worth being able to *test*
186/// — `draw_layer.rs` calls this twice at well-separated times and compares the
187/// geometry. A future mode that legitimately animates would rename it back, and
188/// would owe that test a reason.
189pub fn build(
190 geometry: &mut DrawGeometry,
191 runtime: Option<&mut MilkRuntime>,
192 out: &FrameOutputs,
193 waveform: &[f32; WAVE_SAMPLES],
194 _time: f32,
195 dt: f32,
196 aspect: f32,
197) {
198 geometry.clear();
199 let Some(runtime) = runtime else {
200 return;
201 };
202 let exposure = Exposure::new(dt);
203 waveform_figure(geometry, out, waveform, exposure, aspect);
204 custom_waves(geometry, runtime, waveform, exposure, aspect);
205 custom_shapes(geometry, runtime, exposure, aspect);
206 // The two borders and the motion-vector grid are **always** alpha-blended in
207 // the reference — neither has an additive flag to read — so they go to the
208 // over half unconditionally.
209 borders(geometry, out, exposure, aspect);
210 motion_vectors(geometry, out, exposure, aspect);
211}
212
213/// **What one frame of the draw layer is worth**, which depends on how the
214/// producer that drew it blends. Both cases are a rate rather than a constant,
215/// and for the same reason.
216///
217/// MilkDrop deposits once per rendered frame into a buffer that decays once per
218/// rendered frame, so the two are in step by construction. Here the field decays
219/// **per second** (`decay` is rate-converted like every other MilkDrop factor),
220/// which means a 60 Hz display would deposit twice the light per second that a
221/// 30 Hz one does into a buffer that fades at the same rate — and the picture
222/// would differ with the refresh, which ADR-0019 exists to prevent. `rate` is how
223/// many nominal frames of wall clock this frame is, and both branches of
224/// `scale` convert through it.
225#[derive(Clone, Copy)]
226pub struct Exposure {
227 /// `dt * NOMINAL_FPS`: how many nominal frames of wall clock this frame is.
228 rate: f32,
229}
230
231impl Exposure {
232 /// From this frame's `dt`, capped at **four** nominal frames: a long frame
233 /// deposits proportionally more light, but a stall cannot deposit an
234 /// unbounded amount in one go.
235 pub fn new(dt: f32) -> Self {
236 Self {
237 rate: (dt * crate::milk::NOMINAL_FPS).clamp(0.0, 4.0),
238 }
239 }
240
241 /// The producer's effective alpha this frame — its colour is premultiplied
242 /// by this, and it is the coverage the fragment writes.
243 ///
244 /// # The two conversions
245 ///
246 /// **Additive** light composes by *addition* across frames, so `n` frames
247 /// deposit `n * a` and the rate conversion is the plain product `a * rate`.
248 ///
249 /// **Alpha-over** composes by *repeated interpolation*: `n` frames of
250 /// `dst = src*a + dst*(1-a)` leave `1 - (1-a)^n` of the way travelled, so the
251 /// conversion is `1 - (1-a)^rate`. At `rate = 1` it is exactly `a`, which is
252 /// what makes 60 Hz the reference's own cadence rather than an approximation
253 /// of it; at `rate = 2` a 30 Hz frame travels as far as two 60 Hz ones, which
254 /// is the property ADR-0019 asks for.
255 ///
256 /// Note that the alpha-over branch is **bounded by 1** for every `rate`,
257 /// which is the whole reason this is worth two pipelines: the sum of N
258 /// over-blended producers is still ≤ 1, where N additive ones is N.
259 fn scale(self, alpha: f32, additive: bool) -> f32 {
260 let a = alpha.clamp(0.0, 1.0);
261 if additive {
262 a * self.rate
263 } else {
264 1.0 - (1.0 - a).powf(self.rate)
265 }
266 }
267}
268
269/// A producer's premultiplied colour and its coverage, both already scaled by
270/// [`Exposure::scale`].
271///
272/// The two are returned together because both seams need coverage to equal the
273/// light's own footprint: additively so a dim deposit does not occlude a lit
274/// backdrop (ADR-0056), and over-blended because the coverage *is* the blend's
275/// alpha.
276#[derive(Clone, Copy)]
277struct Light {
278 /// Premultiplied colour: the producer's RGB times its effective alpha.
279 rgb: [f32; 3],
280 /// **The alpha the fragment writes**, which is not the same number in the two
281 /// seams and that difference is ADR-0056's rule rather than an inconsistency.
282 ///
283 /// Over-blended, coverage *is* the blend's alpha — a `wave_a = 0.1` stroke
284 /// must replace a tenth of what is under it, so it is the effective alpha.
285 ///
286 /// Additive, it is **`1.0`**: "a dimmed stroke still covers its own
287 /// footprint", so brightness lives in [`rgb`](Self::rgb) and the geometry's
288 /// own falloff is the whole footprint. Passing the effective alpha here
289 /// instead would make a dim additive stroke *narrower* rather than darker,
290 /// and could exceed 1 at a long `dt`.
291 coverage: f32,
292}
293
294impl Light {
295 /// Whether this producer writes anything worth a draw call.
296 fn is_dark(&self) -> bool {
297 self.rgb.iter().all(|c| *c <= 0.0001)
298 }
299}
300
301/// One point of a stroked figure: where it is, and what it deposits there.
302type Point = ([f32; 2], Light);
303
304/// Colour and coverage for one producer. `wave_brighten` normalizes to the
305/// brightest channel first, which is what the reference's `bMaximizeWaveColor`
306/// does.
307fn light(
308 r: f32,
309 g: f32,
310 b: f32,
311 a: f32,
312 brighten: bool,
313 exposure: Exposure,
314 additive: bool,
315) -> Light {
316 let (mut r, mut g, mut b) = (r, g, b);
317 if brighten {
318 let peak = r.max(g).max(b);
319 if peak > 0.0001 {
320 let k = 1.0 / peak;
321 r *= k;
322 g *= k;
323 b *= k;
324 }
325 }
326 let a = exposure.scale(a, additive);
327 Light {
328 rgb: [r * a, g * a, b * a],
329 coverage: if additive { 1.0 } else { a },
330 }
331}
332
333/// Push a polyline, flagging the interior joins so the strokes meet cleanly
334/// (ADR-0041).
335fn polyline(
336 geometry: &mut DrawGeometry,
337 points: &[Point],
338 width: f32,
339 closed: bool,
340 additive: bool,
341) {
342 let n = points.len();
343 if n < 2 {
344 return;
345 }
346 let last = if closed { n } else { n - 1 };
347 for i in 0..last {
348 let Some((a, light)) = points.get(i) else {
349 continue;
350 };
351 let Some((b, _)) = points.get((i + 1) % n) else {
352 continue;
353 };
354 let ext_a = if closed || i > 0 { width } else { 0.0 };
355 let ext_b = if closed || i + 2 < n { width } else { 0.0 };
356 geometry.push_segment(
357 SegmentInstance {
358 a: *a,
359 b: *b,
360 color: light.rgb,
361 width,
362 alpha: light.coverage,
363 ext_a,
364 ext_b,
365 },
366 additive,
367 );
368 }
369}
370
371/// Emit one built trace the way the mode asked for it: separated marks when
372/// `wave_usedots` is set, a continuous stroke otherwise.
373///
374/// **This exists because there were four call sites and one of them forgot**
375/// (Plan 0108 Phase 4). `wave_mode 5` draws its figure in two passes and its
376/// first pass called [`polyline`] unconditionally, so a preset asking for dots
377/// got a continuous stroke above `wave_y` and beads below it. Nothing failed and
378/// nothing warned; the trace was simply half wrong. The defect was found by
379/// measurement — mode 5's dotted geometry held segments **13.6x longer than
380/// [`DOT_LENGTH`]** where every other mode's held none — and the repair is to
381/// leave one place where the choice is made.
382fn emit_trace(
383 geometry: &mut DrawGeometry,
384 points: &[Point],
385 width: f32,
386 closed: bool,
387 additive: bool,
388 use_dots: bool,
389) {
390 if use_dots {
391 dots(geometry, points, width, additive);
392 } else {
393 polyline(geometry, points, width, closed, additive);
394 }
395}
396
397/// Push each point as its own dot.
398///
399/// **Both ends are extended by a half-width, and that is what makes a dot a
400/// dot** (Plan 0108 Phase 4). The line renderer's falloff runs *across* the
401/// stroke only; the quad simply ends at each endpoint unless `ext_a`/`ext_b`
402/// push it past (ADR-0158). Without that extension a mark is a hard-edged
403/// [`DOT_LENGTH`] x `2 * width` rectangle — **3.3x wider than it is long**, a
404/// sub-pixel dash lying across the trace rather than the round dot this module's
405/// header describes. Measured at 1080p on one drawn frame, that cost 300 pixels
406/// above half brightness against a continuous stroke's 5 008, and at 320x180 it
407/// left **2**, which is design-backlog 0107's "the `wave_usedots` beads never
408/// appear".
409///
410/// With both ends extended the quad grows by the half-width at each cap, so the
411/// mark is `DOT_LENGTH + 2 * width` long against `2 * width` across — round
412/// enough that the falloff reads as a bead at any resolution, through the
413/// mechanism that already exists rather than a new constant.
414///
415/// The mark is also **centred** on its point rather than growing forward from
416/// it. Half of [`DOT_LENGTH`] is well under a pixel, so this moves nothing
417/// visible; it is here because a dot that is offset from the sample it stands
418/// for is wrong in a way nobody would ever see and everybody would inherit.
419fn dots(geometry: &mut DrawGeometry, points: &[Point], width: f32, additive: bool) {
420 for (p, light) in points {
421 geometry.push_segment(
422 SegmentInstance {
423 a: [p[0] - DOT_LENGTH * 0.5, p[1]],
424 b: [p[0] + DOT_LENGTH * 0.5, p[1]],
425 color: light.rgb,
426 width,
427 alpha: light.coverage,
428 // A cap, not a miter: this is the half-width that rounds the
429 // mark, and a zero-length segment has no interior angle to
430 // compute one from.
431 ext_a: width,
432 ext_b: width,
433 },
434 additive,
435 );
436 }
437}
438
439/// How many `wave_mode` figures there are — MilkDrop's own eight.
440pub const WAVE_MODES: u32 = 8;
441
442/// The built-in waveform: MilkDrop's eight `wave_mode` figures over the audio
443/// trace.
444///
445/// Each mode is the reference's own construction, and the eight are **pairwise
446/// distinct figures** — a circle, a pair of rings, a scope, a Lissajous, a
447/// mirrored pair, an angled line and its double — which is what Phase 4's
448/// done-when asks and what
449/// [`every_wave_mode_builds_a_different_figure`](super::tests) holds them to.
450///
451/// Two pairs had to be separated to get there, and both for the same reason: the
452/// reference tells 0 from 1, and 6 from 7, using the **second audio channel**,
453/// and this engine's analysis is mono by construction. Where the reference would
454/// draw two diverging traces, this draws the one trace at the separation the
455/// reference's own parameters name — the same figure with the channel difference
456/// removed rather than an invented eighth mode. See each arm.
457fn waveform_figure(
458 geometry: &mut DrawGeometry,
459 out: &FrameOutputs,
460 waveform: &[f32; WAVE_SAMPLES],
461 exposure: Exposure,
462 aspect: f32,
463) {
464 let additive = out.wave_additive >= 0.5;
465 let colour = light(
466 out.wave_r,
467 out.wave_g,
468 out.wave_b,
469 out.wave_a,
470 out.wave_brighten >= 0.5,
471 exposure,
472 additive,
473 );
474 if colour.is_dark() {
475 return;
476 }
477 let width = if out.wave_thick >= 0.5 { THICK } else { THIN };
478 // Read once and passed to every [`emit_trace`] below: the modes that draw in
479 // two passes have to make the same choice in both, and reading the flag at
480 // each site is how one of them came to be a stroke where the other was beads.
481 let use_dots = out.wave_usedots >= 0.5;
482 let scale = out.wave_scale;
483 let mystery = out.wave_mystery;
484 let (cx, cy) = (out.wave_x, out.wave_y);
485 // `wave_smoothing` is a running average along the trace, exactly as the
486 // reference's `fWaveSmoothing` is: 0 is the raw samples and 1 is a straight
487 // line.
488 let smooth = out.wave_smoothing.clamp(0.0, 0.99);
489
490 // How many points this mode draws, and the sampled trace it draws them from.
491 let mode = (out.wave_mode.max(0.0) as u32) % WAVE_MODES;
492 let count: usize = match mode {
493 // The two "spectrum"-ish modes draw a coarser figure, as the reference
494 // does — a 512-point circle at 480 px is denser than the frame.
495 3 | 4 => 128,
496 _ => 256,
497 };
498
499 let mut trace = [0.0f32; 256];
500 let mut held = 0.0f32;
501 for (i, slot) in trace.iter_mut().enumerate().take(count) {
502 let source = waveform
503 .get(i * WAVE_SAMPLES / count.max(1))
504 .copied()
505 .unwrap_or(0.0);
506 held = held * smooth + source * (1.0 - smooth);
507 *slot = held * scale;
508 }
509 let sample = |i: usize| trace.get(i).copied().unwrap_or(0.0);
510
511 let mut points: [Point; 256] = [([0.0; 2], colour); 256];
512 let mut used = 0usize;
513 let mut closed = false;
514 let push = |uv: (f32, f32), points: &mut [Point; 256], used: &mut usize| {
515 if let Some(slot) = points.get_mut(*used) {
516 slot.0 = uv_to_world(uv.0, uv.1, aspect);
517 *used += 1;
518 }
519 };
520
521 match mode {
522 // 0 — a circle whose radius breathes with the trace. MilkDrop's first
523 // mode and the one most presets use.
524 0 => {
525 closed = true;
526 let base = 0.2 + 0.1 * mystery;
527 for i in 0..count {
528 let t = i as f32 / count as f32 * std::f32::consts::TAU;
529 let r = base + sample(i) * 0.1;
530 push(
531 (cx + r * t.cos() / aspect.max(0.1), cy + r * t.sin()),
532 &mut points,
533 &mut used,
534 );
535 }
536 }
537 // 1 — the reference's **second** circular mode, which draws the left and
538 // right channels as two rings whose separation is `wave_mystery`. This
539 // engine's analysis is mono (see `MilkRuntime::run_wave_point`), so the
540 // two rings carry the same trace and only the separation tells them
541 // apart — which is the reference's own figure with the channel
542 // difference removed, and is what keeps mode 1 from being mode 0.
543 1 => {
544 closed = true;
545 let base = 0.2 + 0.1 * mystery;
546 let separation = 0.04 + 0.06 * mystery.abs();
547 for ring in [separation, -separation] {
548 used = 0;
549 for i in 0..count {
550 let t = i as f32 / count as f32 * std::f32::consts::TAU;
551 let r = base + ring + sample(i) * 0.1;
552 push(
553 (cx + r * t.cos() / aspect.max(0.1), cy + r * t.sin()),
554 &mut points,
555 &mut used,
556 );
557 }
558 // The outer ring closes here; the inner one falls through to the
559 // shared emit below, so both are stroked exactly once.
560 if ring > 0.0 {
561 let built = points.get(..used).unwrap_or(&[]);
562 emit_trace(geometry, built, width, true, additive, use_dots);
563 }
564 }
565 }
566 // 2 — a horizontal line across the frame, the classic scope.
567 2 => {
568 for i in 0..count {
569 let u = i as f32 / (count - 1).max(1) as f32;
570 push((u, cy + sample(i) * 0.15), &mut points, &mut used);
571 }
572 }
573 // 3 — the same line, vertical.
574 3 => {
575 for i in 0..count {
576 let v = i as f32 / (count - 1).max(1) as f32;
577 push((cx + sample(i) * 0.15, v), &mut points, &mut used);
578 }
579 }
580 // 4 — a Lissajous-style figure: the trace against itself, offset. In
581 // MilkDrop this is the left channel against the right; this engine's
582 // analysis is mono, so the offset stands in for the channel difference
583 // and the figure is a leaning loop rather than a blob (see
584 // `MilkRuntime::run_wave_point`).
585 4 => {
586 let lag = 8usize;
587 for i in 0..count {
588 push(
589 (
590 cx + sample(i) * 0.2 / aspect.max(0.1),
591 cy + sample((i + lag) % count) * 0.2,
592 ),
593 &mut points,
594 &mut used,
595 );
596 }
597 }
598 // 5 — a double horizontal line, mirrored about `wave_y`.
599 5 => {
600 for i in 0..count {
601 let u = i as f32 / (count - 1).max(1) as f32;
602 let s = sample(i).abs() * 0.15;
603 push((u, cy + s), &mut points, &mut used);
604 }
605 emit_trace(
606 geometry,
607 points.get(..used).unwrap_or(&[]),
608 width,
609 false,
610 additive,
611 use_dots,
612 );
613 used = 0;
614 for i in 0..count {
615 let u = i as f32 / (count - 1).max(1) as f32;
616 let s = sample(i).abs() * 0.15;
617 push((u, cy - s), &mut points, &mut used);
618 }
619 }
620 // 6 — a line at an angle set by `wave_mystery`, which is what the
621 // reference uses it for here, and 7 — the reference's **double** line, the
622 // same figure offset to both sides along its own normal. That is exactly
623 // the relationship mode 5 has to mode 2, so the pair is consistent with
624 // the pair above it rather than being two names for one figure.
625 6 | 7 => {
626 // **No `time` term here, deliberately** (Plan 0109 Phase 2,
627 // design-backlog 0115). A `time * 0.05` addend turns the figure a
628 // full turn every ~126 s, so a trace authored horizontal would be
629 // horizontal only at the instants
630 // `mystery * PI + time * 0.05` happened to be a multiple of `pi`.
631 // Plan 0108 Phase 4 named it a suspect and deliberately left it in,
632 // because removing it moves every mode-6 and mode-7 preset and
633 // because whether the reference's line drifts is a question about
634 // the reference. Plan 0108 Phase 6 asked it: *Blur Mix 3*'s traces
635 // stay horizontal in `foo_vis_milk2` and drew one steep diagonal
636 // here. So the angle is what the sentence above always said it was —
637 // `wave_mystery` alone — and this file is now a pure function of the
638 // trace and the outputs, with no use of `time` anywhere in it.
639 let angle = mystery * std::f32::consts::PI;
640 let (s, c) = angle.sin_cos();
641 let offsets: &[f32] = if mode == 7 { &[0.03, -0.03] } else { &[0.0] };
642 for (index, offset) in offsets.iter().enumerate() {
643 used = 0;
644 for i in 0..count {
645 let t = i as f32 / (count - 1).max(1) as f32 - 0.5;
646 let n = sample(i) * 0.15 + offset;
647 // Built in uv and stretched by the target on the way out, so
648 // `t` spans the frame's **width**: `uv_to_world` is the only
649 // aspect term, exactly as this module's one-conversion rule
650 // says. Dividing x by the aspect here would cancel that
651 // multiply and normalize the trace to the frame's *height*
652 // instead, which is 56 % of the width at 16:9 and full width
653 // only on a square target (design-backlog 0122).
654 //
655 // A rotated trace therefore picks up the target's shape in
656 // its amplitude — a uv-space construction is stretched, which
657 // is what the reference does and not a defect to correct. At
658 // `wave_mystery = 0` the amplitude is pure y and aspect-free.
659 push(
660 (cx + t * c - n * s, cy + t * s + n * c),
661 &mut points,
662 &mut used,
663 );
664 }
665 // All but the last pass emit here; the last falls through to the
666 // shared emit below.
667 if index + 1 < offsets.len() {
668 let built = points.get(..used).unwrap_or(&[]);
669 emit_trace(geometry, built, width, false, additive, use_dots);
670 }
671 }
672 }
673 _ => {}
674 }
675
676 let built = points.get(..used).unwrap_or(&[]);
677 emit_trace(geometry, built, width, closed, additive, use_dots);
678}
679
680/// The preset's custom waves, each a polyline or a scatter from its own
681/// per-point program.
682fn custom_waves(
683 geometry: &mut DrawGeometry,
684 runtime: &mut MilkRuntime,
685 waveform: &[f32; WAVE_SAMPLES],
686 exposure: Exposure,
687 aspect: f32,
688) {
689 for index in 0..runtime.wave_count() {
690 let Some(spec) = runtime.wave_spec(index) else {
691 continue;
692 };
693 if runtime.run_wave_frame(index).is_none() {
694 continue;
695 }
696 let count = spec.count.max(2) as usize;
697 let mut points: Vec<Point> = Vec::with_capacity(count);
698 for i in 0..count {
699 let t = i as f32 / (count - 1).max(1) as f32;
700 // The audio at this point along the wave — MilkDrop's `value1`.
701 let value = waveform
702 .get(((t * (WAVE_SAMPLES - 1) as f32) as usize).min(WAVE_SAMPLES - 1))
703 .copied()
704 .unwrap_or(0.0);
705 let Some(point) = runtime.run_wave_point(index, t, value) else {
706 break;
707 };
708 points.push((
709 uv_to_world(point.x, point.y, aspect),
710 light(
711 point.r,
712 point.g,
713 point.b,
714 point.a,
715 false,
716 exposure,
717 spec.additive,
718 ),
719 ));
720 }
721 let width = if spec.thick { THICK } else { THIN };
722 if spec.use_dots {
723 dots(geometry, &points, width, spec.additive);
724 } else {
725 polyline(geometry, &points, width, false, spec.additive);
726 }
727 }
728}
729
730/// The preset's custom shapes: filled polygons with an optional outline.
731fn custom_shapes(
732 geometry: &mut DrawGeometry,
733 runtime: &mut MilkRuntime,
734 exposure: Exposure,
735 aspect: f32,
736) {
737 for index in 0..runtime.shape_count() {
738 let Some(spec) = runtime.shape_spec(index) else {
739 continue;
740 };
741 for instance in 0..spec.instances {
742 let Some(shape) = runtime.run_shape_instance(index, instance) else {
743 break;
744 };
745 // Per **instance**, not per element: `additive` is one of the
746 // registers the shape's own per-frame program may write, so one
747 // shape's copies can blend differently from each other.
748 let additive = shape.additive >= 0.5;
749 let sides = (shape.sides.max(3.0) as u32).clamp(3, MAX_SHAPE_SIDES);
750 let centre = uv_to_world(shape.x, shape.y, aspect);
751 let inner = light(
752 shape.r, shape.g, shape.b, shape.a, false, exposure, additive,
753 );
754 let outer = light(
755 shape.r2, shape.g2, shape.b2, shape.a2, false, exposure, additive,
756 );
757 // The perimeter, in world space. `rad` is in frame-heights, so the
758 // aspect only enters through the centre — which is what keeps a
759 // shape round rather than stretched.
760 let point = |i: u32| -> [f32; 2] {
761 let t = shape.ang + i as f32 / sides as f32 * std::f32::consts::TAU;
762 [
763 centre[0] + shape.rad * t.cos() * aspect,
764 centre[1] + shape.rad * t.sin(),
765 ]
766 };
767 // A triangle fan, emitted as a plain list so the whole draw layer is
768 // one buffer, in the blend mode this instance asked for.
769 for i in 0..sides {
770 geometry.push_triangle(
771 [
772 ShapeVertex {
773 pos: centre,
774 color: inner.rgb,
775 alpha: inner.coverage,
776 },
777 ShapeVertex {
778 pos: point(i),
779 color: outer.rgb,
780 alpha: outer.coverage,
781 },
782 ShapeVertex {
783 pos: point((i + 1) % sides),
784 color: outer.rgb,
785 alpha: outer.coverage,
786 },
787 ],
788 additive,
789 );
790 }
791 // ...and the outline, through the same line batch as everything else.
792 if shape.border_a > 0.0001 {
793 let colour = light(
794 shape.border_r,
795 shape.border_g,
796 shape.border_b,
797 shape.border_a,
798 false,
799 exposure,
800 additive,
801 );
802 let outline: Vec<Point> = (0..sides).map(|i| (point(i), colour)).collect();
803 let width = if shape.thick_outline >= 0.5 || spec.thick {
804 THICK
805 } else {
806 THIN
807 };
808 polyline(geometry, &outline, width, true, additive);
809 }
810 }
811 }
812}
813
814/// The inner and outer borders: two rectangles inset from the frame edge.
815fn borders(geometry: &mut DrawGeometry, out: &FrameOutputs, exposure: Exposure, aspect: f32) {
816 for (size, r, g, b, a, inset) in [
817 (out.ob_size, out.ob_r, out.ob_g, out.ob_b, out.ob_a, 0.0),
818 (
819 out.ib_size,
820 out.ib_r,
821 out.ib_g,
822 out.ib_b,
823 out.ib_a,
824 out.ob_size,
825 ),
826 ] {
827 if a <= 0.0001 || size <= 0.0 {
828 continue;
829 }
830 let colour = light(r, g, b, a, false, exposure, false);
831 // The rectangle sits at the middle of its own band, and the stroke is the
832 // band's whole width — which is how a border of `size` reads as a band of
833 // `size` rather than as a hairline.
834 let half = (size * 0.5).clamp(0.0005, 0.4);
835 let edge = inset + half;
836 let corners = [
837 (edge, edge),
838 (1.0 - edge, edge),
839 (1.0 - edge, 1.0 - edge),
840 (edge, 1.0 - edge),
841 ];
842 let points: Vec<Point> = corners
843 .iter()
844 .map(|(u, v)| (uv_to_world(*u, *v, aspect), colour))
845 .collect();
846 polyline(geometry, &points, half * 2.0, true, false);
847 }
848}
849
850/// The motion-vector grid: a lattice of short strokes showing where the warp is
851/// taking the frame.
852///
853/// MilkDrop samples its own warp mesh to draw these. Here the grid is drawn from
854/// the same `mv_*` vocabulary at the positions the preset names, with `mv_l`
855/// setting the length — the figure a preset asks for, without a second
856/// evaluation of the per-vertex program per grid point.
857fn motion_vectors(
858 geometry: &mut DrawGeometry,
859 out: &FrameOutputs,
860 exposure: Exposure,
861 aspect: f32,
862) {
863 if out.mv_a <= 0.0001 {
864 return;
865 }
866 let nx = (out.mv_x.max(0.0) as u32).min(64);
867 let ny = (out.mv_y.max(0.0) as u32).min(48);
868 if nx == 0 || ny == 0 {
869 return;
870 }
871 let colour = light(
872 out.mv_r, out.mv_g, out.mv_b, out.mv_a, false, exposure, false,
873 );
874 let len = out.mv_l * 0.02;
875 for iy in 0..ny {
876 for ix in 0..nx {
877 let u = (ix as f32 + 0.5 + out.mv_dx) / nx as f32;
878 let v = (iy as f32 + 0.5 + out.mv_dy) / ny as f32;
879 let a = uv_to_world(u, v, aspect);
880 geometry.push_segment(
881 SegmentInstance {
882 a,
883 b: [a[0] + len * aspect, a[1] + len],
884 color: colour.rgb,
885 width: THIN,
886 alpha: colour.coverage,
887 ext_a: 0.0,
888 ext_b: 0.0,
889 },
890 false,
891 );
892 }
893 }
894}