Skip to main content

rlx_core/render/scenes/lines/
waterfall.rs

1//! The waterfall system: the recent spectrum laid out as a landscape and seen
2//! through the shared perspective camera (ADR-0258).
3//!
4//! # One frame
5//!
6//! `update` reduces the analysis frame's band array to `elements` levels, shapes
7//! and eases each one through the same helpers the `spectrum` readout uses
8//! (`downsample`, `shape_and_ease`), and hands the eased levels to the
9//! `Landscape`. That keeps a ring of past rows and pushes one every
10//! `row_period` seconds of injected `dt`, so the rows are spaced in **time**,
11//! not in frames, and the landscape scrolls at the same speed at any display
12//! rate (ADR-0019).
13//!
14//! `render` lays the rows out on the ground plane: row `k` of the ring sits at
15//! depth `(k + frac) * row_spacing` behind the front edge, where `frac` is the
16//! fraction of a period since the last push, so every row slides back
17//! continuously and a push moves nothing. Each row is a polyline of
18//! `elements - 1` segments across the frequency axis, `height * level` tall,
19//! clipped and culled through the camera block and drawn by this scene's own
20//! `seg3d` renderer.
21//!
22//! # The front edge is live
23//!
24//! The nearest row is the **live** eased level, drawn at depth 0 every frame,
25//! not the last pushed row: the front edge answers the music on the frame it
26//! arrives, however long `row_period` is. A pushed row is born on top of it and
27//! fades in over its first period as it slides away (`alpha = frac`), so a push
28//! adds no line at once and the front edge never pops. The oldest row fades out
29//! over its last period the same way.
30//!
31//! # Colour
32//!
33//! The palette runs along the frequency axis, as on `spectrum` (ADR-0059):
34//! `hue` places the lowest band and `hue_spread` says how far the palette
35//! travels to the highest. Age dims a row by `fade`: the farthest row keeps
36//! `1 - fade` of the front edge's light.
37//!
38//! # Solid
39//!
40//! When the camera block's `solid` is on, every row segment also lays a
41//! **skirt** under itself: a black band down to the ground plane, drawn through
42//! the same `seg3d` pipeline and sorted with its row by where the row stands,
43//! so a near row hides what lies behind it — hidden-line removal for a
44//! ridgeline (ADR-0263). A skirted row costs twice the segments, and the rows
45//! drawn are held to that cost each solid frame, announced as the load-time
46//! row clamp is.
47
48// Hot-path panic-denial pragma (Plan 0002 Phase 2; `render/` scan set).
49// `update` and `render` run every displayed frame.
50#![deny(
51    clippy::unwrap_used,
52    clippy::expect_used,
53    clippy::indexing_slicing,
54    clippy::panic,
55    clippy::unreachable
56)]
57
58use super::renderer::{LineRenderer, Segment3dInstance, joined_chord};
59use super::spectrum::{CURVE_MAX, CURVE_MIN, downsample, shape_and_ease};
60use super::{CapOverflow, ColorRamp, GeneratorConfig, OverflowContext};
61use crate::dsp::AnalysisFrame;
62use crate::preset::Easing;
63use crate::render::camera::{self, CameraParams};
64use crate::render::palette::Palette;
65use crate::render::scenes::common::{PaletteParams, PanParams};
66use crate::render::scenes::{FALLBACK_DT, ParamGroup, ParamKind, ParamSpec, Scene, default_of};
67
68/// The fewest rows a landscape may ask for: the live front row and one pushed
69/// row behind it.
70pub const MIN_ROWS: u32 = 2;
71
72/// The most rows a preset may ask for at load, before the tier's
73/// `seg3d_segments` holds it further.
74pub const MAX_ROWS: u32 = 1024;
75
76/// The rows a preset that names none draws.
77pub const DEFAULT_ROWS: u32 = 64;
78
79/// The bands across a row a preset that names none draws.
80pub const DEFAULT_ELEMENTS: usize = 48;
81
82/// The shortest `row_period` a preset may ask for, in seconds. Below a frame
83/// at 100 Hz every frame would push more than one copy of the same row.
84pub const MIN_ROW_PERIOD: f32 = 0.01;
85
86/// The longest `row_period` a preset may ask for, in seconds.
87pub const MAX_ROW_PERIOD: f32 = 2.0;
88
89/// The `row_period` a preset that names none pushes at, in seconds.
90pub const DEFAULT_ROW_PERIOD: f32 = 0.04;
91
92/// How far short of a whole `row_period` the accumulator may fall and still
93/// push, in seconds.
94///
95/// A period that is a whole number of frames at some rate — 1/12 s is five
96/// frames at 60 Hz and twelve at 144 Hz — is reached by a sum of rounded `dt`s
97/// that can land a few nanoseconds short. Without this slack that push slips a
98/// whole frame later at one rate and not at the other.
99const PUSH_SLACK: f64 = 1e-6;
100
101/// The world z of the front edge. The camera orbits the world origin, so the
102/// landscape starts one unit in front of what the camera looks at and recedes
103/// past it.
104const FRONT_Z: f32 = 1.0;
105
106/// The world half-width of a row: the frequency axis spans `x` in `[-1, 1]`.
107const HALF_WIDTH: f32 = 1.0;
108
109/// The world y of the ground the rows stand on: a band at level `0` lies on
110/// it, and a solid row's skirts reach down to it (ADR-0263).
111const GROUND_Y: f32 = 0.0;
112
113/// The stroke profile every row is drawn with: a solid core over the inner half
114/// and a ramp across the outer (ADR-0124), as on the plexus.
115const SOFTNESS: f32 = 0.5;
116
117/// The `[waterfall]` table, validated (ADR-0180 rule 1).
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct WaterfallConfig {
120    /// Bands across a row, `2..=`[`SPECTRUM_BINS`](crate::dsp::SPECTRUM_BINS).
121    pub elements: usize,
122    /// Rows drawn, the live front row included, in
123    /// [`MIN_ROWS`]`..=`[`MAX_ROWS`]; the tier may hold it lower.
124    pub rows: u32,
125    /// Seconds between two pushed rows, in
126    /// [`MIN_ROW_PERIOD`]`..=`[`MAX_ROW_PERIOD`].
127    pub row_period: f32,
128    /// Per-band easing, in the `[smoothing]` vocabulary (ADR-0035).
129    pub easing: Easing,
130}
131
132impl Default for WaterfallConfig {
133    fn default() -> Self {
134        Self {
135            elements: DEFAULT_ELEMENTS,
136            rows: DEFAULT_ROWS,
137            row_period: DEFAULT_ROW_PERIOD,
138            easing: Easing::INSTANT,
139        }
140    }
141}
142
143const DEFAULT_HEIGHT: f32 = default_of(PARAMS, "height");
144const DEFAULT_ROW_SPACING: f32 = default_of(PARAMS, "row_spacing");
145const DEFAULT_CURVE: f32 = default_of(PARAMS, "curve");
146const DEFAULT_FADE: f32 = default_of(PARAMS, "fade");
147const DEFAULT_LINE_WIDTH: f32 = default_of(PARAMS, "line_width");
148const DEFAULT_GLOW: f32 = default_of(PARAMS, "glow");
149const DEFAULT_HUE: f32 = default_of(PARAMS, "hue");
150const DEFAULT_HUE_SPREAD: f32 = default_of(PARAMS, "hue_spread");
151const DEFAULT_BRIGHTNESS: f32 = default_of(PARAMS, "brightness");
152const DEFAULT_PITCH: f32 = default_of(PARAMS, "pitch");
153const DEFAULT_ZOOM: f32 = default_of(PARAMS, "zoom");
154
155/// Parameter vocabulary — see [`fragment_field::PARAMS`](crate::render::scenes::fragment_field::PARAMS).
156/// **Keep in sync with `set_param` below.**
157pub const PARAMS: &[ParamSpec] = &[
158    ParamSpec {
159        name: "height",
160        default: 0.6,
161        range: Some([0.0, 2.0]),
162        doc: "How tall a full band stands above the ground, in world units; a row is 2 across.",
163        kind: ParamKind::Modal,
164        group: ParamGroup::Shape,
165        main: true,
166    },
167    ParamSpec {
168        name: "row_spacing",
169        default: 0.06,
170        range: Some([0.01, 0.3]),
171        doc: "How far behind each row the next one lies, in world units; with rows, how deep \
172              the landscape reaches.",
173        kind: ParamKind::Modal,
174        group: ParamGroup::Shape,
175        main: true,
176    },
177    ParamSpec {
178        name: "curve",
179        default: 1.0,
180        range: Some([CURVE_MIN, CURVE_MAX]),
181        doc: "Exponent on each band's level: 1 is linear, below 1 lifts quiet detail, above 1 \
182              pushes it down.",
183        kind: ParamKind::Modal,
184        group: ParamGroup::Shape,
185        main: false,
186    },
187    ParamSpec {
188        name: "fade",
189        default: 0.8,
190        range: Some([0.0, 1.0]),
191        doc: "How much light the farthest row has lost against the front edge; 0 keeps every \
192              row as bright as the newest.",
193        kind: ParamKind::Modal,
194        group: ParamGroup::Light,
195        main: true,
196    },
197    ParamSpec {
198        name: "line_width",
199        default: 1.5,
200        range: Some([0.5, 8.0]),
201        doc: "Line width in pixels at the focal plane; nearer rows are wider and farther ones \
202              thinner.",
203        kind: ParamKind::Modal,
204        group: ParamGroup::Shape,
205        main: true,
206    },
207    crate::render::scenes::lines::GLOW,
208    crate::render::scenes::common::brightness(1.0),
209    crate::render::scenes::common::hue(0.55),
210    ParamSpec {
211        name: "hue_spread",
212        default: 0.6,
213        range: Some([0.0, 1.0]),
214        doc: "How far along the palette the colour travels from the lowest band to the highest.",
215        kind: ParamKind::Modal,
216        group: ParamGroup::Colour,
217        main: false,
218    },
219    crate::render::scenes::common::SATURATION,
220    crate::render::scenes::common::PALETTE_MIX,
221    crate::render::scenes::common::PALETTE_STEPS,
222    camera::YAW,
223    PITCH,
224    camera::DISTANCE,
225    camera::FOV,
226    camera::FOCUS,
227    camera::APERTURE,
228    camera::FOG,
229    camera::SOLID,
230    crate::render::scenes::common::zoom(1.0),
231    crate::render::scenes::common::PAN_X,
232    crate::render::scenes::common::PAN_Y,
233];
234
235/// The camera's `pitch`, raised so an unbound preset looks down the landscape
236/// rather than along it. Range, kind and wording stay the shared block's.
237const PITCH: ParamSpec = ParamSpec {
238    default: 0.35,
239    ..camera::PITCH
240};
241
242/// The rows of a landscape, before they are laid out: the live levels, a ring
243/// of past rows, and the time since the last push. Arithmetic only — no device
244/// in it — so the push cadence is testable on the CPU.
245#[derive(Debug, Clone, Default)]
246pub(crate) struct Landscape {
247    /// This frame's downsampled levels, before shaping and easing.
248    raw: Vec<f32>,
249    /// The eased levels: the live front row, and the per-band envelope state.
250    live: Vec<f32>,
251    /// `capacity` past rows of `elements` levels each, row-major.
252    ring: Vec<f32>,
253    /// Bands across a row.
254    elements: usize,
255    /// How many past rows the ring holds: the drawn rows less the live one.
256    capacity: usize,
257    /// The ring slot of the newest pushed row.
258    head: usize,
259    /// How many slots hold a pushed row.
260    len: usize,
261    /// Seconds since the last push, accumulated in `f64` so a long run does not
262    /// drift the cadence.
263    since_push: f64,
264    /// Seconds between two pushes.
265    period: f32,
266    /// Per-band easing.
267    easing: Easing,
268}
269
270impl Landscape {
271    /// Size every buffer for `rows` drawn rows of `elements` bands and empty
272    /// them. Off the hot path: the only place this type allocates.
273    pub(crate) fn resize(&mut self, elements: usize, rows: usize, period: f32, easing: Easing) {
274        self.elements = elements;
275        self.capacity = rows.saturating_sub(1);
276        self.period = period;
277        self.easing = easing;
278        self.raw.clear();
279        self.raw.resize(elements, 0.0);
280        self.live.clear();
281        self.live.resize(elements, 0.0);
282        self.ring.clear();
283        self.ring.resize(elements * self.capacity, 0.0);
284        self.head = 0;
285        self.len = 0;
286        self.since_push = 0.0;
287    }
288
289    /// Advance one frame of `dt` seconds on the band array `spectrum`: ease the
290    /// live row toward it, then push the live row once per whole `row_period`
291    /// the frame completed. Allocation-free.
292    pub(crate) fn step(&mut self, spectrum: &[f32], curve: f32, dt: f32) {
293        downsample(spectrum, &mut self.raw);
294        for (held, &raw) in self.live.iter_mut().zip(&self.raw) {
295            *held = shape_and_ease(*held, raw, curve, self.easing, dt);
296        }
297        let period = f64::from(self.period);
298        if period <= 0.0 || period.is_nan() || self.capacity == 0 {
299            return;
300        }
301        self.since_push += f64::from(dt);
302        // A frame longer than the whole ring pushes the ring full and no more:
303        // the rows it would push past that are the same live row again.
304        let mut pushes = 0;
305        while self.since_push + PUSH_SLACK >= period && pushes < self.capacity {
306            self.push();
307            self.since_push -= period;
308            pushes += 1;
309        }
310        if self.since_push >= period {
311            self.since_push %= period;
312        }
313        self.since_push = self.since_push.max(0.0);
314    }
315
316    /// Copy the live row into the ring as its newest row, over the oldest when
317    /// the ring is full.
318    fn push(&mut self) {
319        let n = self.elements;
320        self.head = (self.head + 1) % self.capacity.max(1);
321        let start = self.head * n;
322        if let (Some(dst), Some(src)) = (self.ring.get_mut(start..start + n), self.live.get(..n)) {
323            dst.copy_from_slice(src);
324        }
325        self.len = (self.len + 1).min(self.capacity);
326    }
327
328    /// The fraction of a period since the last push, in `[0, 1)`: how far every
329    /// pushed row has slid back from its whole-row depth.
330    pub(crate) fn frac(&self) -> f32 {
331        if self.period > 0.0 {
332            ((self.since_push / f64::from(self.period)) as f32).clamp(0.0, 1.0)
333        } else {
334            0.0
335        }
336    }
337
338    /// The live front row.
339    pub(crate) fn live(&self) -> &[f32] {
340        &self.live
341    }
342
343    /// Pushed row `k`, `0` the newest, or `None` past the rows pushed so far.
344    pub(crate) fn row(&self, k: usize) -> Option<&[f32]> {
345        if k >= self.len || self.capacity == 0 {
346            return None;
347        }
348        let slot = (self.head + self.capacity - k) % self.capacity;
349        let n = self.elements;
350        self.ring.get(slot * n..slot * n + n)
351    }
352
353    /// How many rows have been pushed and are still held.
354    pub(crate) fn len(&self) -> usize {
355        self.len
356    }
357
358    /// How many past rows the ring holds when full.
359    pub(crate) fn capacity(&self) -> usize {
360        self.capacity
361    }
362
363    /// Bands across a row.
364    pub(crate) fn elements(&self) -> usize {
365        self.elements
366    }
367}
368
369/// The waterfall scene: the landscape's rows and the `seg3d` renderer they are
370/// drawn through.
371pub struct WaterfallScene {
372    /// This scene's own renderer (ADR-0258): the `seg3d` pipeline and an
373    /// instance buffer of the tier's `seg3d_segments`.
374    lines: LineRenderer,
375    /// The tier's `seg3d_segments`: the most row segments one frame draws.
376    seg3d_cap: usize,
377    /// The tier's cap on the circle of confusion, in pixels.
378    max_coc: f32,
379    landscape: Landscape,
380    /// Reused instance buffer, preallocated to `seg3d_cap`.
381    instances: Vec<Segment3dInstance>,
382    /// The blur clamp, when `aperture` passes the tier's cap this frame.
383    clamp: Option<CapOverflow>,
384    palette: Palette,
385    dt: f32,
386    /// The render target's size in pixels, handed in every frame.
387    target: (u32, u32),
388
389    height: f32,
390    row_spacing: f32,
391    curve: f32,
392    fade: f32,
393    line_width: f32,
394    glow: f32,
395    hue_spread: f32,
396    zoom: f32,
397    colour: PaletteParams,
398    pan: PanParams,
399    camera: CameraParams,
400}
401
402impl WaterfallScene {
403    /// Build the scene with a `seg3d` buffer of `seg3d_cap` segments and blur
404    /// held to `max_coc` pixels — the tier's caps. The ring is sized by
405    /// `configure`, which the renderer runs on every preset switch.
406    pub(crate) fn new(
407        device: &wgpu::Device,
408        surface_format: wgpu::TextureFormat,
409        seg3d_cap: usize,
410        max_coc: f32,
411    ) -> Self {
412        let mut scene = Self {
413            lines: LineRenderer::new_3d(device, surface_format, seg3d_cap, "waterfall"),
414            seg3d_cap,
415            max_coc,
416            landscape: Landscape::default(),
417            instances: Vec::with_capacity(seg3d_cap),
418            clamp: None,
419            palette: Palette::default_spectrum(),
420            dt: FALLBACK_DT,
421            target: (1, 1),
422            height: DEFAULT_HEIGHT,
423            row_spacing: DEFAULT_ROW_SPACING,
424            curve: DEFAULT_CURVE,
425            fade: DEFAULT_FADE,
426            line_width: DEFAULT_LINE_WIDTH,
427            glow: DEFAULT_GLOW,
428            hue_spread: DEFAULT_HUE_SPREAD,
429            zoom: DEFAULT_ZOOM,
430            colour: PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
431            pan: PanParams::default(),
432            camera: CameraParams::default(),
433        };
434        scene.camera.pitch = DEFAULT_PITCH;
435        // The default table fits every tier's buffer, so there is no clamp to
436        // report before a preset is configured.
437        let _ = scene.resize(&WaterfallConfig::default());
438        scene
439    }
440
441    /// Size and empty the ring for `config`, with the rows held to what the
442    /// `seg3d` buffer can draw, and return that clamp when it bit. Off the hot
443    /// path: preset load only.
444    fn resize(&mut self, config: &WaterfallConfig) -> Option<CapOverflow> {
445        let (rows, overflow) = rows_clamp(config.rows, config.elements, self.seg3d_cap, false);
446        self.landscape
447            .resize(config.elements, rows, config.row_period, config.easing);
448        overflow
449    }
450
451    /// The colour ramp this frame's palette knobs describe.
452    fn ramp(&self) -> ColorRamp {
453        ColorRamp {
454            hue: self.colour.hue,
455            hue_spread: self.hue_spread,
456            palette_mix: self.colour.mix,
457            palette_steps: self.colour.steps,
458            saturation: self.colour.saturation,
459            brightness: self.colour.brightness,
460        }
461    }
462}
463
464/// `rows` held to the most whole rows of `elements` bands a buffer of `cap`
465/// segments draws — a row is `elements - 1` segments, and twice that when
466/// `skirted`, since a solid row lays a skirt under each segment (ADR-0263) —
467/// and the overflow to announce when the cap bit (ADR-0045), its `dropped`
468/// counted in segments.
469pub(crate) fn rows_clamp(
470    rows: u32,
471    elements: usize,
472    cap: usize,
473    skirted: bool,
474) -> (usize, Option<CapOverflow>) {
475    let per_row = elements.saturating_sub(1).max(1) * if skirted { 2 } else { 1 };
476    let fits = cap / per_row;
477    let asked = rows as usize;
478    if asked <= fits {
479        return (asked, None);
480    }
481    (
482        fits,
483        Some(CapOverflow {
484            dropped: (asked - fits) * per_row,
485            context: OverflowContext::Rows(rows, u32::try_from(per_row).unwrap_or(u32::MAX)),
486            cap,
487        }),
488    )
489}
490
491impl Scene for WaterfallScene {
492    fn name(&self) -> &'static str {
493        "waterfall"
494    }
495
496    fn advance(&mut self, dt: f32) {
497        self.dt = dt;
498    }
499
500    fn set_target_size(&mut self, width: u32, height: u32) {
501        self.target = (width, height);
502    }
503
504    fn set_palette(&mut self, palette: &Palette) {
505        self.palette = palette.clone();
506    }
507
508    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<CapOverflow> {
509        let GeneratorConfig::Waterfall(config) = cfg else {
510            return None;
511        };
512        // A switch starts the incoming preset from an empty ring, with its rows
513        // held to the tier and the clamp returned for the renderer to announce
514        // with the preset.
515        let overflow = self.resize(config);
516        // The outgoing preset's per-frame clamp is not this one's to report.
517        self.clamp = None;
518        overflow
519    }
520
521    fn mirror_overflow(&self) -> Option<&CapOverflow> {
522        self.clamp.as_ref()
523    }
524
525    fn reset_params(&mut self) {
526        self.height = DEFAULT_HEIGHT;
527        self.row_spacing = DEFAULT_ROW_SPACING;
528        self.curve = DEFAULT_CURVE;
529        self.fade = DEFAULT_FADE;
530        self.line_width = DEFAULT_LINE_WIDTH;
531        self.glow = DEFAULT_GLOW;
532        self.hue_spread = DEFAULT_HUE_SPREAD;
533        self.zoom = DEFAULT_ZOOM;
534        self.colour.reset();
535        self.pan.reset();
536        self.camera.reset();
537        // The shared block resets to its own pitch; this system rests on its own.
538        self.camera.pitch = DEFAULT_PITCH;
539    }
540
541    fn set_param(&mut self, name: &str, value: f32) {
542        // The shared param blocks first, this scene's own names after
543        // (`scenes::common`, `render::camera`).
544        if self.colour.set(name, value) || self.pan.set(name, value) || self.camera.set(name, value)
545        {
546            return;
547        }
548        match name {
549            "height" => self.height = value,
550            "row_spacing" => self.row_spacing = value,
551            "curve" => self.curve = value,
552            "fade" => self.fade = value,
553            "line_width" => self.line_width = value,
554            "glow" => self.glow = value,
555            "hue_spread" => self.hue_spread = value,
556            "zoom" => self.zoom = value,
557            _ => {}
558        }
559    }
560
561    fn update(&mut self, frame: &AnalysisFrame) {
562        self.landscape.step(&frame.spectrum, self.curve, self.dt);
563    }
564
565    fn render(
566        &mut self,
567        queue: &wgpu::Queue,
568        encoder: &mut wgpu::CommandEncoder,
569        view: &wgpu::TextureView,
570        aspect: f32,
571    ) {
572        let geometry = RowGeometry::new(
573            self.height,
574            self.row_spacing,
575            self.fade,
576            self.landscape.capacity() + 1,
577        );
578        // The aspect is the render target's, handed in here (ADR-0037).
579        let frame = self.camera.frame(
580            aspect,
581            self.zoom,
582            [self.pan.x, self.pan.y],
583            self.target,
584            geometry.radius(),
585            self.max_coc,
586        );
587        // A solid row costs its skirts too, so the rows the ring holds are held
588        // again to what the buffer draws at twice the cost, nearest first, and
589        // that clamp is announced ahead of the blur's (ADR-0263).
590        let held = self.landscape.capacity() + 1;
591        let (row_budget, row_clamp) = if frame.solid {
592            rows_clamp(
593                u32::try_from(held).unwrap_or(u32::MAX),
594                self.landscape.elements(),
595                self.seg3d_cap,
596                true,
597            )
598        } else {
599            (held, None)
600        };
601        self.clamp = row_clamp.or(frame.blur);
602        let width = if self.line_width.is_finite() {
603            self.line_width.max(0.0)
604        } else {
605            DEFAULT_LINE_WIDTH
606        };
607        let ramp = self.ramp();
608        let frac = self.landscape.frac();
609        let full = self.landscape.len() == self.landscape.capacity();
610
611        let mut instances = std::mem::take(&mut self.instances);
612        instances.clear();
613        // The live front row, then every pushed row from the newest back.
614        let rows = std::iter::once((0.0, 1.0, self.landscape.live())).chain(
615            (0..self.landscape.len()).filter_map(|k| {
616                let row = self.landscape.row(k)?;
617                // A row is born on the live one and fades in over its first
618                // period, and the oldest of a full ring fades out over its last.
619                let mut alpha = 1.0;
620                if k == 0 {
621                    alpha *= frac;
622                }
623                if full && k + 1 == self.landscape.capacity() {
624                    alpha *= 1.0 - frac;
625                }
626                Some((k as f32 + frac, alpha, row))
627            }),
628        );
629        for (depth, alpha, row) in rows.take(row_budget) {
630            if alpha <= 0.0 {
631                continue;
632            }
633            let light = geometry.light(depth);
634            let z = geometry.z(depth);
635            let n = row.len();
636            let span = n.saturating_sub(1).max(1) as f32;
637            // A row is one open polyline (ADR-0263): each segment joins its
638            // neighbours across the row, and the two outer bands are free.
639            let point = |j: usize| row.get(j).map(|&l| [geometry.x(j, n), geometry.y(l), z]);
640            let foot = |p: [f32; 3]| [p[0], GROUND_Y, p[2]];
641            for (i, pair) in row.windows(2).enumerate() {
642                let (Some(&l0), Some(&l1)) = (pair.first(), pair.get(1)) else {
643                    continue;
644                };
645                let pa = [geometry.x(i, n), geometry.y(l0), z];
646                let pb = [geometry.x(i + 1, n), geometry.y(l1), z];
647                let before = i.checked_sub(1).and_then(point);
648                let Some([prev, a, b, next]) =
649                    joined_chord(&frame.view, before, pa, pb, point(i + 2))
650                else {
651                    continue;
652                };
653                let line_in = !frame.view.outside(a, b, frame.margin);
654                // A skirt reaches the ground, so it can be on screen while
655                // its line is above the frame; a foot behind the near plane
656                // is kept rather than projected.
657                let skirt_in = frame.solid && {
658                    let (fa, fb) = (foot(a), foot(b));
659                    let in_front = frame.view.depth(fa).min(frame.view.depth(fb)) >= camera::NEAR;
660                    line_in || !in_front || !frame.view.outside(fa, fb, frame.margin)
661                };
662                if !line_in && !skirt_in {
663                    continue;
664                }
665                if instances.len() + usize::from(line_in) + usize::from(skirt_in) > self.seg3d_cap {
666                    break;
667                }
668                if skirt_in {
669                    instances.push(Segment3dInstance {
670                        a,
671                        b,
672                        color: [0.0; 3],
673                        width,
674                        alpha,
675                        prev: a,
676                        next: b,
677                        skirt: 1.0,
678                    });
679                }
680                if line_in {
681                    let [r, g, bl] = ramp.at(&self.palette, (i as f32 + 0.5) / span);
682                    instances.push(Segment3dInstance {
683                        a,
684                        b,
685                        color: [r * light, g * light, bl * light],
686                        width,
687                        alpha,
688                        prev,
689                        next,
690                        skirt: 0.0,
691                    });
692                }
693            }
694        }
695        let glow = if self.glow.is_finite() {
696            self.glow.max(0.0)
697        } else {
698            DEFAULT_GLOW
699        };
700        // The rows stand on the ground plane: a solid frame orders them by
701        // their feet and fills each skirt down to it.
702        self.lines.draw_3d_terrain(
703            queue, encoder, view, &frame, glow, SOFTNESS, GROUND_Y, &instances,
704        );
705        self.instances = instances;
706    }
707}
708
709/// Where a landscape's rows lie in the world, for one frame's bound params.
710#[derive(Debug, Clone, Copy)]
711pub(crate) struct RowGeometry {
712    height: f32,
713    spacing: f32,
714    fade: f32,
715    /// The deepest a drawn row reaches, in rows: the drawn count less one.
716    deepest: f32,
717}
718
719impl RowGeometry {
720    /// The geometry of `rows` drawn rows under the bound `height`,
721    /// `row_spacing` and `fade`, each made safe here once.
722    pub(crate) fn new(height: f32, spacing: f32, fade: f32, rows: usize) -> Self {
723        let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback };
724        Self {
725            height: finite(height, DEFAULT_HEIGHT),
726            spacing: finite(spacing, DEFAULT_ROW_SPACING).max(0.0),
727            fade: finite(fade, DEFAULT_FADE).clamp(0.0, 1.0),
728            deepest: rows.saturating_sub(1).max(1) as f32,
729        }
730    }
731
732    /// The world x of band `i` of a row of `n`.
733    pub(crate) fn x(&self, i: usize, n: usize) -> f32 {
734        let span = n.saturating_sub(1).max(1) as f32;
735        -HALF_WIDTH + 2.0 * HALF_WIDTH * i as f32 / span
736    }
737
738    /// The world y of a band at `level`.
739    pub(crate) fn y(&self, level: f32) -> f32 {
740        self.height * level
741    }
742
743    /// The world z of a row `depth` rows behind the front edge.
744    pub(crate) fn z(&self, depth: f32) -> f32 {
745        FRONT_Z - depth * self.spacing
746    }
747
748    /// The share of the front edge's light a row `depth` rows back keeps:
749    /// `1` at the front, `1 - fade` at the deepest row.
750    pub(crate) fn light(&self, depth: f32) -> f32 {
751        (1.0 - self.fade * (depth / self.deepest).clamp(0.0, 1.0)).max(0.0)
752    }
753
754    /// The radius about the orbit target that holds the whole landscape — what
755    /// `focus` is normalized across.
756    pub(crate) fn radius(&self) -> f32 {
757        let far = (FRONT_Z - self.deepest * self.spacing).abs().max(FRONT_Z);
758        (HALF_WIDTH * HALF_WIDTH + far * far + self.height * self.height).sqrt()
759    }
760}
761
762#[cfg(test)]
763mod tests {
764    #![allow(clippy::indexing_slicing, clippy::panic)]
765
766    use super::*;
767    use crate::dsp::SPECTRUM_BINS;
768
769    /// A band array that changes over time: band `b` at window `w` reads a
770    /// value no other `(b, w)` pair repeats.
771    fn spectrum_at(window: u64) -> [f32; SPECTRUM_BINS] {
772        let mut out = [0.0; SPECTRUM_BINS];
773        for (b, band) in out.iter_mut().enumerate() {
774            let phase = window as f32 * 0.37 + b as f32 * 0.11;
775            *band = 0.5 + 0.45 * phase.sin();
776        }
777        out
778    }
779
780    /// Run `frames` frames at `rate` Hz. The band array is held over windows of
781    /// 1/12 s, the shortest span both 60 Hz and 144 Hz frames tile exactly, and
782    /// a frame reads the window its own interval lies in — so both rates ease
783    /// toward the same value over the same stretch of time.
784    fn run(rate: u64, frames: u64, period: f32, easing: Easing) -> Landscape {
785        let mut landscape = Landscape::default();
786        landscape.resize(16, 8, period, easing);
787        let dt = 1.0 / rate as f32;
788        for n in 1..=frames {
789            let window = (n - 1) * 12 / rate;
790            landscape.step(&spectrum_at(window), 1.0, dt);
791        }
792        landscape
793    }
794
795    /// **The rows are spaced in time, not in frames** (ADR-0019): two seconds
796    /// of the same input at 60 Hz and at 144 Hz leave the same rows in the
797    /// ring, at the same depth offset.
798    ///
799    /// The period is 5/12 s, a whole number of frames at both rates, so both
800    /// push at the same instants and sample the same eased level; and two
801    /// seconds is not a whole number of periods, so the offset compared is not
802    /// the trivial zero.
803    #[test]
804    fn the_same_input_at_60_and_144_hz_leaves_the_same_rows() {
805        let period = 5.0 / 12.0;
806        let easing = Easing::symmetric(0.05);
807        let a = run(60, 120, period, easing);
808        let b = run(144, 288, period, easing);
809
810        assert_eq!(a.len(), 4, "two seconds at 5/12 s a row pushes four rows");
811        assert_eq!(
812            a.len(),
813            b.len(),
814            "both rates pushed the same number of rows"
815        );
816        for k in 0..a.len() {
817            let (ra, rb) = (a.row(k).unwrap_or(&[]), b.row(k).unwrap_or(&[]));
818            assert_eq!(ra.len(), 16);
819            for (i, (x, y)) in ra.iter().zip(rb).enumerate() {
820                assert!(
821                    (x - y).abs() < 1e-4,
822                    "row {k} band {i}: {x} at 60 Hz against {y} at 144 Hz"
823                );
824            }
825        }
826        // Non-vacuity: the rows hold different levels, so a ring that pushed
827        // one row four times would fail above.
828        let (r0, r1) = (a.row(0).unwrap_or(&[]), a.row(1).unwrap_or(&[]));
829        assert!(r0.iter().zip(r1).any(|(x, y)| (x - y).abs() > 1e-2));
830
831        let row_spacing = DEFAULT_ROW_SPACING;
832        let (fa, fb) = (a.frac() * row_spacing, b.frac() * row_spacing);
833        assert!(
834            (a.frac() - 0.8).abs() < 1e-3,
835            "2 s is 4.8 periods, so the rows sit 0.8 of a row back, got {}",
836            a.frac()
837        );
838        assert!(
839            (fa - fb).abs() <= row_spacing * 1e-3,
840            "depth offset {fa} at 60 Hz against {fb} at 144 Hz"
841        );
842    }
843
844    /// A full ring keeps its newest rows: the oldest is overwritten, and the
845    /// rows read back newest first.
846    #[test]
847    fn a_full_ring_drops_its_oldest_row() {
848        let mut landscape = Landscape::default();
849        landscape.resize(4, 3, 0.1, Easing::INSTANT);
850        assert_eq!(landscape.capacity(), 2);
851        for level in [0.1f32, 0.2, 0.3] {
852            let spectrum = [level; SPECTRUM_BINS];
853            landscape.step(&spectrum, 1.0, 0.1);
854        }
855        assert_eq!(landscape.len(), 2);
856        assert!((landscape.row(0).unwrap_or(&[])[0] - 0.3).abs() < 1e-6);
857        assert!((landscape.row(1).unwrap_or(&[])[0] - 0.2).abs() < 1e-6);
858        assert!(landscape.row(2).is_none());
859    }
860
861    /// A frame much longer than the ring pushes it full and no further.
862    #[test]
863    fn a_long_frame_pushes_at_most_the_ring() {
864        let mut landscape = Landscape::default();
865        landscape.resize(4, 5, 0.05, Easing::INSTANT);
866        landscape.step(&[0.5; SPECTRUM_BINS], 1.0, 10.0);
867        assert_eq!(landscape.len(), 4);
868        assert!(landscape.frac() < 1.0);
869    }
870
871    /// Rows past what the buffer draws are held to it, and the clamp is
872    /// announced with what was asked, in the buffer's own unit.
873    #[test]
874    fn rows_are_held_to_whole_rows_of_the_buffer() {
875        assert_eq!(
876            rows_clamp(126, 64, 8000, false),
877            (126, None),
878            "exactly at the cap"
879        );
880        let (rows, overflow) = rows_clamp(200, 64, 8000, false);
881        assert_eq!(rows, 126, "8000 segments hold 126 rows of 63");
882        let overflow = overflow.unwrap_or_else(|| panic!("a clamp that bit is announced"));
883        assert_eq!(overflow.context, OverflowContext::Rows(200, 63));
884        assert_eq!(overflow.cap, 8000);
885        assert_eq!(overflow.dropped, 74 * 63);
886        let text = overflow.to_string();
887        assert!(
888            text.contains("rows 200") && text.contains("keeps 126 rows"),
889            "{text}"
890        );
891    }
892
893    /// **A solid row costs its skirts** (ADR-0263): the same rows that fit a
894    /// buffer as lines alone are held to half as many once each segment
895    /// carries a skirt, and the clamp says so in the same words, counting
896    /// a row at its skirted cost.
897    #[test]
898    fn a_skirted_row_costs_twice_its_segments() {
899        assert_eq!(
900            rows_clamp(63, 64, 8000, true),
901            (63, None),
902            "63 skirted rows of 126 fit"
903        );
904        let (rows, overflow) = rows_clamp(126, 64, 8000, true);
905        assert_eq!(rows, 63, "8000 segments hold 63 skirted rows of 63 bands");
906        let overflow = overflow.unwrap_or_else(|| panic!("a clamp that bit is announced"));
907        assert_eq!(overflow.context, OverflowContext::Rows(126, 126));
908        assert_eq!(overflow.dropped, 63 * 126);
909        let text = overflow.to_string();
910        assert!(text.contains("keeps 63 rows"), "{text}");
911    }
912
913    /// **The same seed and analysis frames give the same ring after 600
914    /// frames**: two landscapes stepped through one varying sequence hold
915    /// bit-identical rows and offsets.
916    #[test]
917    fn the_same_frames_give_the_same_ring_after_600_frames() {
918        let run = || {
919            let mut landscape = Landscape::default();
920            landscape.resize(32, 40, 0.04, Easing::symmetric(0.08));
921            for n in 0..600u64 {
922                landscape.step(&spectrum_at(n), 0.8, 1.0 / 60.0);
923            }
924            landscape
925        };
926        let (a, b) = (run(), run());
927        assert_eq!(a.len(), 39, "600 frames at 0.04 s a row fill a 39-row ring");
928        assert_eq!(a.frac().to_bits(), b.frac().to_bits());
929        for k in 0..a.len() {
930            let (ra, rb) = (a.row(k).unwrap_or(&[]), b.row(k).unwrap_or(&[]));
931            assert!(!ra.is_empty());
932            assert!(
933                ra.iter().zip(rb).all(|(x, y)| x.to_bits() == y.to_bits()),
934                "row {k} differs between two runs of the same frames"
935            );
936        }
937        assert!(
938            a.live()
939                .iter()
940                .zip(b.live())
941                .all(|(x, y)| x.to_bits() == y.to_bits()),
942            "the live row differs"
943        );
944    }
945
946    /// The light runs from the front edge's to `1 - fade` at the deepest row.
947    #[test]
948    fn fade_dims_the_deepest_row_by_its_value() {
949        let g = RowGeometry::new(0.6, 0.05, 0.8, 11);
950        assert!((g.light(0.0) - 1.0).abs() < 1e-6);
951        assert!((g.light(10.0) - 0.2).abs() < 1e-6);
952        assert!((g.light(5.0) - 0.6).abs() < 1e-6);
953        assert!(
954            g.z(10.0) < g.z(0.0),
955            "deeper rows lie farther from the camera"
956        );
957    }
958}