Skip to main content

rlx_core/render/scenes/plexus/
mod.rs

1//! The plexus system (ADR-0257): a few hundred points in 3D, joined by a line
2//! wherever two lie within a bindable link distance, seen through the shared
3//! perspective camera.
4//!
5//! # One frame
6//!
7//! `update` advances the point set by the injected `dt` and rebuilds the
8//! proximity graph from it (`sim`): every pair closer than `link_distance` is
9//! an edge whose presence is `smoothstep(1 - d / link_distance)`, so a link
10//! fades in as its pair nears and out as it parts. `render` builds this frame's
11//! [`Camera3d`](crate::render::camera::Camera3d) view from the render target's aspect (ADR-0037), clips each edge
12//! against the near plane, drops the ones wholly off one edge of the frame,
13//! colours each by its depth, and hands the rest to the shared line renderer's
14//! `seg3d` pipeline. A node is drawn at every point through the 3D sprite
15//! pipeline beside the marks (`marks::InstancedQuads3d`), coloured the same way
16//! and blurred by the same `coc()`.
17//!
18//! # What is structural and what is bound
19//!
20//! The `[plexus]` table — the layout, the point count and the seed — is fixed
21//! for as long as the preset is loaded, because it decides how many points
22//! exist and where they start. Everything that moves is a bindable parameter.
23//!
24//! # Buffers are sized once
25//!
26//! The point set, the edge list and the instance scratch are reserved from the
27//! tier's caps when the scene is built, and a preset asking for more is held to
28//! them at load, so no frame allocates.
29
30// Hot-path panic-denial pragma (Plan 0002 Phase 2; `render/` scan set).
31// `update` and `render` run every displayed frame.
32#![deny(
33    clippy::unwrap_used,
34    clippy::expect_used,
35    clippy::indexing_slicing,
36    clippy::panic,
37    clippy::unreachable
38)]
39
40pub(crate) mod sim;
41
42use crate::dsp::AnalysisFrame;
43use crate::render::camera::{self, CameraParams};
44use crate::render::palette::{self, Palette};
45use crate::render::scenes::common::{PaletteParams, PanParams};
46use crate::render::scenes::lines::{GeneratorConfig, LineRenderer, Segment3dInstance};
47use crate::render::scenes::marks::{InstancedQuads3d, Quad3dInstance};
48use crate::render::scenes::{
49    FamilyParam, FamilyRange, ParamGroup, ParamKind, ParamSpec, Scene, default_of,
50};
51
52/// Which arrangement the points take — the `[plexus] layout` family.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum PlexusLayout {
55    /// Points scattered through a cube, drifting on a seeded flow.
56    #[default]
57    Cloud,
58    /// A jittered grid on a plane, rippled along its normal by a seeded height
59    /// field.
60    Sheet,
61}
62
63impl PlexusLayout {
64    /// Every layout, in roster order — the closed set, and the list the schema
65    /// export renders rather than restating.
66    pub const ALL: [PlexusLayout; 2] = [PlexusLayout::Cloud, PlexusLayout::Sheet];
67
68    /// Parse a `[plexus] layout` name, or `None` if unknown.
69    pub fn from_name(name: &str) -> Option<Self> {
70        Self::ALL.into_iter().find(|layout| layout.as_str() == name)
71    }
72
73    /// The `[plexus] layout` name this parses from.
74    pub fn as_str(self) -> &'static str {
75        match self {
76            PlexusLayout::Cloud => "cloud",
77            PlexusLayout::Sheet => "sheet",
78        }
79    }
80}
81
82/// The fewest points a preset may ask for. Below this the graph is a handful of
83/// strokes rather than a network.
84pub const MIN_POINTS: u32 = 16;
85
86/// The most points a preset may ask for at load, before the tier's own cap
87/// holds it further. The pairing is quadratic, so this bounds the worst frame
88/// on any tier.
89pub const MAX_POINTS: u32 = 4096;
90
91/// The point count a preset that names none draws.
92pub const DEFAULT_POINTS: u32 = 300;
93
94/// The `[plexus]` table, validated (ADR-0180 rule 1).
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub struct PlexusConfig {
97    /// Which arrangement the points take.
98    pub layout: PlexusLayout,
99    /// How many points, in [`MIN_POINTS`]`..=`[`MAX_POINTS`]; the tier may hold
100    /// it lower.
101    pub points: u32,
102    /// What every point's start and the flow are drawn from.
103    pub seed: u64,
104}
105
106impl Default for PlexusConfig {
107    fn default() -> Self {
108        Self {
109            layout: PlexusLayout::default(),
110            points: DEFAULT_POINTS,
111            seed: 0,
112        }
113    }
114}
115
116/// The stroke profile every plexus edge is drawn with: a solid core over the
117/// inner half and a ramp across the outer (ADR-0124). Fixed rather than bound,
118/// since the look this system is for is a fine luminous line.
119const SOFTNESS: f32 = 0.5;
120
121const DEFAULT_LINK_DISTANCE: f32 = default_of(PARAMS, "link_distance");
122const DEFAULT_LINK_ALPHA: f32 = default_of(PARAMS, "link_alpha");
123const DEFAULT_LINE_WIDTH: f32 = default_of(PARAMS, "line_width");
124const DEFAULT_DRIFT: f32 = default_of(PARAMS, "drift");
125const DEFAULT_WAVE: f32 = default_of(PARAMS, "wave");
126const DEFAULT_WAVE_SCALE: f32 = default_of(PARAMS, "wave_scale");
127const DEFAULT_NODE_SIZE: f32 = default_of(PARAMS, "node_size");
128const DEFAULT_NODE_GLOW: f32 = default_of(PARAMS, "node_glow");
129const DEFAULT_BRIGHTNESS: f32 = default_of(PARAMS, "brightness");
130const DEFAULT_HUE_CENTER: f32 = default_of(PARAMS, "hue_center");
131const DEFAULT_HUE_SPREAD: f32 = default_of(PARAMS, "hue_spread");
132const DEFAULT_ZOOM: f32 = default_of(PARAMS, "zoom");
133
134/// Parameter vocabulary — see [`fragment_field::PARAMS`](super::fragment_field::PARAMS).
135/// **Keep in sync with `set_param` below.**
136pub const PARAMS: &[ParamSpec] = &[
137    ParamSpec {
138        name: "link_distance",
139        default: 0.35,
140        range: Some([0.05, 1.0]),
141        doc: "How close two points must be to be joined, in the layout's own units; the cube is 2 across.",
142        kind: ParamKind::Modal,
143        group: ParamGroup::Shape,
144        main: true,
145    },
146    ParamSpec {
147        name: "link_alpha",
148        default: 0.7,
149        range: Some([0.0, 1.0]),
150        doc: "How strongly a link at its closest is drawn; a link always fades to nothing at link_distance.",
151        kind: ParamKind::Modal,
152        group: ParamGroup::Shape,
153        main: false,
154    },
155    ParamSpec {
156        name: "line_width",
157        default: 1.5,
158        range: Some([0.5, 8.0]),
159        doc: "Line width in pixels at the focal plane; nearer lines are wider and farther ones thinner.",
160        kind: ParamKind::Modal,
161        group: ParamGroup::Shape,
162        main: true,
163    },
164    ParamSpec {
165        name: "node_size",
166        default: 2.5,
167        range: Some([0.0, 12.0]),
168        doc: "Radius of the dot at every point, in pixels at the focal plane; 0 draws no dots.",
169        kind: ParamKind::Modal,
170        group: ParamGroup::Shape,
171        main: true,
172    },
173    ParamSpec {
174        name: "node_glow",
175        default: 1.0,
176        range: Some([0.0, 4.0]),
177        doc: "Brightness of the dots relative to the lines.",
178        kind: ParamKind::Modal,
179        group: ParamGroup::Light,
180        main: false,
181    },
182    ParamSpec {
183        name: "drift",
184        default: 0.15,
185        range: Some([0.0, 1.0]),
186        doc: "How fast the points drift on their flow; 0 holds the network still.",
187        kind: ParamKind::Modal,
188        group: ParamGroup::Motion,
189        main: true,
190    },
191    ParamSpec {
192        name: "wave",
193        default: 0.15,
194        range: Some([0.0, 0.6]),
195        doc: "How far a sheet ripples above and below its plane, in the layout's own units; 0 lies flat.",
196        kind: ParamKind::Modal,
197        group: ParamGroup::Shape,
198        main: true,
199    },
200    ParamSpec {
201        name: "wave_scale",
202        default: 1.0,
203        range: Some([0.3, 3.0]),
204        doc: "How broad a sheet's ripples are; larger is a slower swell, smaller a fine chop.",
205        kind: ParamKind::Modal,
206        group: ParamGroup::Shape,
207        main: false,
208    },
209    camera::YAW,
210    camera::PITCH,
211    camera::DISTANCE,
212    camera::FOV,
213    camera::FOCUS,
214    camera::APERTURE,
215    camera::FOG,
216    camera::SOLID,
217    crate::render::scenes::common::brightness(1.0),
218    ParamSpec {
219        name: "hue_center",
220        default: 0.5,
221        range: Some([0.0, 1.0]),
222        doc: "Where along the palette the middle of the volume's depth is coloured.",
223        kind: ParamKind::Modal,
224        group: ParamGroup::Colour,
225        main: false,
226    },
227    ParamSpec {
228        name: "hue_spread",
229        default: 0.5,
230        range: Some([0.0, 1.0]),
231        doc: "How far along the palette the colour travels from the nearest part of the volume to the farthest.",
232        kind: ParamKind::Modal,
233        group: ParamGroup::Colour,
234        main: false,
235    },
236    crate::render::scenes::common::SATURATION,
237    crate::render::scenes::common::PALETTE_MIX,
238    crate::render::scenes::common::PALETTE_STEPS,
239    crate::render::scenes::common::zoom(1.0),
240    crate::render::scenes::common::PAN_X,
241    crate::render::scenes::common::PAN_Y,
242];
243
244/// Every parameter only some layouts read (ADR-0180 rule 4), with the range
245/// that reads there. A parameter missing from here reads the same on both.
246pub const FAMILY_PARAMS: &[FamilyParam] = &[
247    FamilyParam {
248        name: "wave",
249        ranges: &[
250            FamilyRange {
251                family: "cloud",
252                range: None,
253            },
254            FamilyRange {
255                family: "sheet",
256                range: Some([0.0, 0.6]),
257            },
258        ],
259    },
260    FamilyParam {
261        name: "wave_scale",
262        ranges: &[
263            FamilyRange {
264                family: "cloud",
265                range: None,
266            },
267            FamilyRange {
268                family: "sheet",
269                range: Some([0.3, 3.0]),
270            },
271        ],
272    },
273];
274
275/// The plexus scene: the point set, its graph, and the 3D line renderer it
276/// draws through.
277pub struct PlexusScene {
278    lines: LineRenderer,
279    nodes: InstancedQuads3d,
280    /// The tier's point cap, the most points this scene will ever hold.
281    points_cap: usize,
282    /// The tier's edge cap.
283    edges_cap: usize,
284    /// The tier's cap on the circle of confusion, in pixels.
285    max_coc: f32,
286    /// This frame's per-frame clamp — the edge cap or the blur cap — if one
287    /// bit, for the renderer to announce (ADR-0007: a cap is never silent).
288    clamp: Option<super::CapOverflow>,
289    points: sim::Points,
290    edges: Vec<sim::Edge>,
291    instances: Vec<Segment3dInstance>,
292    node_instances: Vec<Quad3dInstance>,
293    palette: Palette,
294    dt: f32,
295    /// The render target's size in pixels, handed in every frame.
296    target: (u32, u32),
297
298    link_distance: f32,
299    link_alpha: f32,
300    line_width: f32,
301    node_size: f32,
302    node_glow: f32,
303    drift: f32,
304    wave: f32,
305    wave_scale: f32,
306    hue_center: f32,
307    hue_spread: f32,
308    zoom: f32,
309    colour: PaletteParams,
310    pan: PanParams,
311    camera: CameraParams,
312}
313
314impl PlexusScene {
315    /// Build the scene with buffers for `points_cap` points and `edges_cap`
316    /// edges, and blur held to `max_coc` pixels — the tier's caps.
317    pub(crate) fn new(
318        device: &wgpu::Device,
319        surface_format: wgpu::TextureFormat,
320        points_cap: usize,
321        edges_cap: usize,
322        max_coc: f32,
323    ) -> Self {
324        let config = PlexusConfig::default();
325        let count = (config.points as usize).min(points_cap);
326        Self {
327            lines: LineRenderer::new_3d(device, surface_format, edges_cap, "plexus"),
328            nodes: InstancedQuads3d::new(device, "plexus", points_cap, surface_format),
329            points_cap,
330            edges_cap,
331            max_coc,
332            clamp: None,
333            points: sim::Points::seeded(config.layout, config.seed, count, points_cap),
334            edges: Vec::with_capacity(edges_cap),
335            instances: Vec::with_capacity(edges_cap),
336            node_instances: Vec::with_capacity(points_cap),
337            palette: Palette::default_spectrum(),
338            dt: super::FALLBACK_DT,
339            target: (1, 1),
340            link_distance: DEFAULT_LINK_DISTANCE,
341            link_alpha: DEFAULT_LINK_ALPHA,
342            line_width: DEFAULT_LINE_WIDTH,
343            node_size: DEFAULT_NODE_SIZE,
344            node_glow: DEFAULT_NODE_GLOW,
345            drift: DEFAULT_DRIFT,
346            wave: DEFAULT_WAVE,
347            wave_scale: DEFAULT_WAVE_SCALE,
348            hue_center: DEFAULT_HUE_CENTER,
349            hue_spread: DEFAULT_HUE_SPREAD,
350            zoom: DEFAULT_ZOOM,
351            colour: PaletteParams::new(0.0, DEFAULT_BRIGHTNESS),
352            pan: PanParams::default(),
353            camera: CameraParams::default(),
354        }
355    }
356
357    /// The palette coordinate at normalized depth `depth01`, `0` at the
358    /// volume's nearest extent and `1` at its farthest (ADR-0059: a scene
359    /// colours along its own generator's axis, and depth is this one's).
360    fn colour_at(&self, depth01: f32) -> [f32; 3] {
361        depth_colour(
362            &self.palette,
363            &self.colour,
364            self.hue_center,
365            self.hue_spread,
366            depth01,
367        )
368    }
369}
370
371impl Scene for PlexusScene {
372    fn name(&self) -> &'static str {
373        "plexus"
374    }
375
376    fn advance(&mut self, dt: f32) {
377        self.dt = dt;
378    }
379
380    fn set_target_size(&mut self, width: u32, height: u32) {
381        self.target = (width, height);
382    }
383
384    fn set_palette(&mut self, palette: &Palette) {
385        self.palette = palette.clone();
386    }
387
388    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<super::CapOverflow> {
389        let GeneratorConfig::Plexus(config) = cfg else {
390            return None;
391        };
392        // A switch starts the incoming preset from its own seed, with the
393        // point count held to the tier and the clamp returned for the renderer
394        // to announce with the preset.
395        let (count, overflow) = points_clamp(config.points, self.points_cap);
396        self.points = sim::Points::seeded(config.layout, config.seed, count, self.points_cap);
397        self.edges.clear();
398        // The outgoing preset's per-frame clamp is not this one's to report.
399        self.clamp = None;
400        overflow
401    }
402
403    fn mirror_overflow(&self) -> Option<&super::CapOverflow> {
404        self.clamp.as_ref()
405    }
406
407    fn reset_params(&mut self) {
408        self.link_distance = DEFAULT_LINK_DISTANCE;
409        self.link_alpha = DEFAULT_LINK_ALPHA;
410        self.line_width = DEFAULT_LINE_WIDTH;
411        self.node_size = DEFAULT_NODE_SIZE;
412        self.node_glow = DEFAULT_NODE_GLOW;
413        self.drift = DEFAULT_DRIFT;
414        self.wave = DEFAULT_WAVE;
415        self.wave_scale = DEFAULT_WAVE_SCALE;
416        self.hue_center = DEFAULT_HUE_CENTER;
417        self.hue_spread = DEFAULT_HUE_SPREAD;
418        self.zoom = DEFAULT_ZOOM;
419        self.colour.reset();
420        self.pan.reset();
421        self.camera.reset();
422    }
423
424    fn set_param(&mut self, name: &str, value: f32) {
425        // The shared param blocks first, this scene's own names after
426        // (`scenes::common`, `render::camera`).
427        if self.colour.set(name, value) || self.pan.set(name, value) || self.camera.set(name, value)
428        {
429            return;
430        }
431        match name {
432            "link_distance" => self.link_distance = value,
433            "link_alpha" => self.link_alpha = value,
434            "line_width" => self.line_width = value,
435            "node_size" => self.node_size = value,
436            "node_glow" => self.node_glow = value,
437            "drift" => self.drift = value,
438            "wave" => self.wave = value,
439            "wave_scale" => self.wave_scale = value,
440            "hue_center" => self.hue_center = value,
441            "hue_spread" => self.hue_spread = value,
442            "zoom" => self.zoom = value,
443            _ => {}
444        }
445    }
446
447    fn update(&mut self, _frame: &AnalysisFrame) {
448        self.points
449            .step(self.dt, self.drift, self.wave, self.wave_scale);
450        let linked = sim::link(
451            self.points.pos(),
452            self.points.fade(),
453            self.link_distance,
454            self.edges_cap,
455            &mut self.edges,
456        );
457        self.clamp = (linked > self.edges.len()).then(|| super::CapOverflow {
458            dropped: linked - self.edges.len(),
459            context: super::OverflowContext::Edges(linked as u32),
460            cap: self.edges_cap,
461        });
462    }
463
464    fn render(
465        &mut self,
466        queue: &wgpu::Queue,
467        encoder: &mut wgpu::CommandEncoder,
468        view: &wgpu::TextureView,
469        aspect: f32,
470    ) {
471        // The aspect is the render target's, handed in here (ADR-0037).
472        let frame = self.camera.frame(
473            aspect,
474            self.zoom,
475            [self.pan.x, self.pan.y],
476            self.target,
477            self.points.bounding_radius(),
478            self.max_coc,
479        );
480        let (cam, margin) = (frame.view, frame.margin);
481        let (near_extent, span) = (frame.near_extent, frame.span);
482        // The blur clamp is announced unless the graph's own cap already is.
483        if self.clamp.is_none() {
484            self.clamp = frame.blur;
485        }
486        let link_alpha = self.link_alpha.clamp(0.0, 1.0);
487        let width = if self.line_width.is_finite() {
488            self.line_width.max(0.0)
489        } else {
490            DEFAULT_LINE_WIDTH
491        };
492
493        let mut instances = std::mem::take(&mut self.instances);
494        instances.clear();
495        for edge in &self.edges {
496            let (Some(&pa), Some(&pb)) = (
497                self.points.pos().get(edge.a as usize),
498                self.points.pos().get(edge.b as usize),
499            ) else {
500                continue;
501            };
502            let alpha = edge.presence * link_alpha;
503            if alpha <= 0.0 {
504                continue;
505            }
506            let Some((a, b)) = cam.clip_near(pa, pb) else {
507                continue;
508            };
509            if cam.outside(a, b, margin) {
510                continue;
511            }
512            let mid = [
513                0.5 * (pa[0] + pb[0]),
514                0.5 * (pa[1] + pb[1]),
515                0.5 * (pa[2] + pb[2]),
516            ];
517            let depth01 = ((cam.depth(mid) - near_extent) / span).clamp(0.0, 1.0);
518            instances.push(Segment3dInstance {
519                a,
520                b,
521                color: self.colour_at(depth01),
522                width,
523                alpha,
524                // A link is its own stroke: both ends free (ADR-0263).
525                prev: a,
526                next: b,
527                skirt: 0.0,
528            });
529        }
530
531        let uniform = frame.uniform;
532        self.lines
533            .draw_3d(queue, encoder, view, &frame, 1.0, SOFTNESS, &instances);
534        self.instances = instances;
535
536        let mut nodes = std::mem::take(&mut self.node_instances);
537        node_instances(
538            &mut nodes,
539            self.points.pos(),
540            self.points.fade(),
541            &cam,
542            margin,
543            self.node_size,
544            |depth| {
545                let colour = self.colour_at(((depth - near_extent) / span).clamp(0.0, 1.0));
546                // The links fog in the `seg3d` shader; the dots fog here, on
547                // the same scale (ADR-0263). Skipped at fog 0, so an unfogged
548                // dot keeps its bytes.
549                if frame.fog > 0.0 {
550                    let light = frame.fog_light(depth);
551                    colour.map(|c| c * light)
552                } else {
553                    colour
554                }
555            },
556        );
557        self.nodes.draw(
558            queue,
559            encoder,
560            view,
561            &uniform,
562            if self.node_glow.is_finite() {
563                self.node_glow.max(0.0)
564            } else {
565                DEFAULT_NODE_GLOW
566            },
567            &nodes,
568        );
569        self.node_instances = nodes;
570    }
571}
572
573/// `asked` points held to the tier's `cap`, and the overflow to announce when
574/// the cap bit.
575pub(crate) fn points_clamp(asked: u32, cap: usize) -> (usize, Option<super::CapOverflow>) {
576    let asked_n = asked as usize;
577    if asked_n <= cap {
578        return (asked_n, None);
579    }
580    (
581        cap,
582        Some(super::CapOverflow {
583            dropped: asked_n - cap,
584            context: super::OverflowContext::Points(asked),
585            cap,
586        }),
587    )
588}
589
590/// The colour at normalized depth `depth01`, `0` at the volume's nearest extent
591/// and `1` at its farthest (ADR-0059: a scene colours along its own generator's
592/// axis, and depth is this one's). Edges and nodes both take it, so a node is
593/// the colour of the lines that meet at it.
594///
595/// The palette coordinate is `hue_center + (depth01 - 0.5) * hue_spread`, then
596/// banded by `palette_steps` and crossfaded by `palette_mix` through the shared
597/// `palette::band_coord` and `Palette::sample` — the arithmetic every
598/// CPU-coloured scene uses.
599pub(crate) fn depth_colour(
600    palette: &Palette,
601    colour: &PaletteParams,
602    hue_center: f32,
603    hue_spread: f32,
604    depth01: f32,
605) -> [f32; 3] {
606    let coord = hue_center + (depth01 - 0.5) * hue_spread;
607    let rgb = palette::desaturate(
608        palette.sample(palette::band_coord(coord, colour.steps), colour.mix),
609        colour.saturation,
610    );
611    let b = colour.brightness;
612    [rgb[0] * b, rgb[1] * b, rgb[2] * b]
613}
614
615/// A node at every visible point into `out` (cleared first): each point in
616/// front of the near plane and not outside the frame by more than `margin`, of
617/// radius `node_size` pixels at the focal plane and coloured by `colour` of its
618/// view depth, dimmed by its face fade.
619///
620/// **`node_size <= 0` leaves `out` empty**, so a preset without dots issues no
621/// sprites at all rather than drawing invisible ones.
622pub(crate) fn node_instances(
623    out: &mut Vec<Quad3dInstance>,
624    pos: &[[f32; 3]],
625    fade: &[f32],
626    cam: &crate::render::camera::CameraView,
627    margin: f32,
628    node_size: f32,
629    colour: impl Fn(f32) -> [f32; 3],
630) {
631    out.clear();
632    if !node_size.is_finite() || node_size <= 0.0 {
633        return;
634    }
635    for (p, f) in pos.iter().zip(fade) {
636        if *f <= 0.0 {
637            continue;
638        }
639        let depth = cam.depth(*p);
640        if depth < crate::render::camera::NEAR || cam.outside(*p, *p, margin) {
641            continue;
642        }
643        let [r, g, b] = colour(depth);
644        out.push(Quad3dInstance {
645            center: *p,
646            radius: node_size,
647            color: [r * f, g * f, b * f],
648        });
649    }
650}
651
652#[cfg(test)]
653mod tests;