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`] 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::{Camera3d, CameraUniform, Lens};
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
121/// How far past the frame an edge may lie before it is culled, in normalized
122/// device units — room for the stroke's own width, so a line whose centre has
123/// left the frame does not take its visible edge with it.
124const CULL_MARGIN: f32 = 0.05;
125
126const DEFAULT_LINK_DISTANCE: f32 = default_of(PARAMS, "link_distance");
127const DEFAULT_LINK_ALPHA: f32 = default_of(PARAMS, "link_alpha");
128const DEFAULT_LINE_WIDTH: f32 = default_of(PARAMS, "line_width");
129const DEFAULT_DRIFT: f32 = default_of(PARAMS, "drift");
130const DEFAULT_WAVE: f32 = default_of(PARAMS, "wave");
131const DEFAULT_WAVE_SCALE: f32 = default_of(PARAMS, "wave_scale");
132const DEFAULT_YAW: f32 = default_of(PARAMS, "yaw");
133const DEFAULT_PITCH: f32 = default_of(PARAMS, "pitch");
134const DEFAULT_DISTANCE: f32 = default_of(PARAMS, "distance");
135const DEFAULT_FOV: f32 = default_of(PARAMS, "fov");
136const DEFAULT_NODE_SIZE: f32 = default_of(PARAMS, "node_size");
137const DEFAULT_NODE_GLOW: f32 = default_of(PARAMS, "node_glow");
138const DEFAULT_FOCUS: f32 = default_of(PARAMS, "focus");
139const DEFAULT_APERTURE: f32 = default_of(PARAMS, "aperture");
140const DEFAULT_BRIGHTNESS: f32 = default_of(PARAMS, "brightness");
141const DEFAULT_HUE_CENTER: f32 = default_of(PARAMS, "hue_center");
142const DEFAULT_HUE_SPREAD: f32 = default_of(PARAMS, "hue_spread");
143const DEFAULT_ZOOM: f32 = default_of(PARAMS, "zoom");
144
145/// Parameter vocabulary — see [`fragment_field::PARAMS`](super::fragment_field::PARAMS).
146/// **Keep in sync with `set_param` below.**
147pub const PARAMS: &[ParamSpec] = &[
148    ParamSpec {
149        name: "link_distance",
150        default: 0.35,
151        range: Some([0.05, 1.0]),
152        doc: "How close two points must be to be joined, in the layout's own units; the cube is 2 across.",
153        kind: ParamKind::Modal,
154        group: ParamGroup::Shape,
155        main: true,
156    },
157    ParamSpec {
158        name: "link_alpha",
159        default: 0.7,
160        range: Some([0.0, 1.0]),
161        doc: "How strongly a link at its closest is drawn; a link always fades to nothing at link_distance.",
162        kind: ParamKind::Modal,
163        group: ParamGroup::Shape,
164        main: false,
165    },
166    ParamSpec {
167        name: "line_width",
168        default: 1.5,
169        range: Some([0.5, 8.0]),
170        doc: "Line width in pixels at the focal plane; nearer lines are wider and farther ones thinner.",
171        kind: ParamKind::Modal,
172        group: ParamGroup::Shape,
173        main: true,
174    },
175    ParamSpec {
176        name: "node_size",
177        default: 2.5,
178        range: Some([0.0, 12.0]),
179        doc: "Radius of the dot at every point, in pixels at the focal plane; 0 draws no dots.",
180        kind: ParamKind::Modal,
181        group: ParamGroup::Shape,
182        main: true,
183    },
184    ParamSpec {
185        name: "node_glow",
186        default: 1.0,
187        range: Some([0.0, 4.0]),
188        doc: "Brightness of the dots relative to the lines.",
189        kind: ParamKind::Modal,
190        group: ParamGroup::Light,
191        main: false,
192    },
193    ParamSpec {
194        name: "drift",
195        default: 0.15,
196        range: Some([0.0, 1.0]),
197        doc: "How fast the points drift on their flow; 0 holds the network still.",
198        kind: ParamKind::Modal,
199        group: ParamGroup::Motion,
200        main: true,
201    },
202    ParamSpec {
203        name: "wave",
204        default: 0.15,
205        range: Some([0.0, 0.6]),
206        doc: "How far a sheet ripples above and below its plane, in the layout's own units; 0 lies flat.",
207        kind: ParamKind::Modal,
208        group: ParamGroup::Shape,
209        main: true,
210    },
211    ParamSpec {
212        name: "wave_scale",
213        default: 1.0,
214        range: Some([0.3, 3.0]),
215        doc: "How broad a sheet's ripples are; larger is a slower swell, smaller a fine chop.",
216        kind: ParamKind::Modal,
217        group: ParamGroup::Shape,
218        main: false,
219    },
220    ParamSpec {
221        name: "yaw",
222        default: 0.0,
223        range: Some([-std::f32::consts::PI, std::f32::consts::PI]),
224        doc: "Turns the camera around the network, in radians; bind it to a slow clock to orbit.",
225        kind: ParamKind::Modal,
226        group: ParamGroup::Motion,
227        main: false,
228    },
229    ParamSpec {
230        name: "pitch",
231        default: 0.25,
232        range: Some([-1.55, 1.55]),
233        doc: "Raises the camera above the network, in radians; negative looks up from below.",
234        kind: ParamKind::Modal,
235        group: ParamGroup::Motion,
236        main: false,
237    },
238    ParamSpec {
239        name: "distance",
240        default: 3.5,
241        range: Some([1.5, 8.0]),
242        doc: "How far the camera sits from the network's centre; nearer exaggerates the perspective.",
243        kind: ParamKind::Modal,
244        group: ParamGroup::Motion,
245        main: false,
246    },
247    ParamSpec {
248        name: "fov",
249        default: 0.8,
250        range: Some([0.2, 2.0]),
251        doc: "The camera's vertical field of view in radians; zoom divides it.",
252        kind: ParamKind::Modal,
253        group: ParamGroup::Motion,
254        main: false,
255    },
256    ParamSpec {
257        name: "focus",
258        default: 0.5,
259        range: Some([0.0, 1.0]),
260        doc: "Where the focal plane sits in the network's depth: 0 at its nearest point, 1 at its farthest.",
261        kind: ParamKind::Modal,
262        group: ParamGroup::Light,
263        main: true,
264    },
265    ParamSpec {
266        name: "aperture",
267        default: 0.0,
268        range: Some([0.0, crate::render::TierConfig::RICH.max_coc_px as f32]),
269        doc: "The blur of the far background, in pixels; lines nearer than the focal plane blur more, up to the tier's cap. 0 keeps every line sharp, and wider costs fill.",
270        kind: ParamKind::Modal,
271        group: ParamGroup::Light,
272        main: true,
273    },
274    crate::render::scenes::common::brightness(1.0),
275    ParamSpec {
276        name: "hue_center",
277        default: 0.5,
278        range: Some([0.0, 1.0]),
279        doc: "Where along the palette the middle of the volume's depth is coloured.",
280        kind: ParamKind::Modal,
281        group: ParamGroup::Colour,
282        main: false,
283    },
284    ParamSpec {
285        name: "hue_spread",
286        default: 0.5,
287        range: Some([0.0, 1.0]),
288        doc: "How far along the palette the colour travels from the nearest part of the volume to the farthest.",
289        kind: ParamKind::Modal,
290        group: ParamGroup::Colour,
291        main: false,
292    },
293    crate::render::scenes::common::SATURATION,
294    crate::render::scenes::common::PALETTE_MIX,
295    crate::render::scenes::common::PALETTE_STEPS,
296    crate::render::scenes::common::zoom(1.0),
297    crate::render::scenes::common::PAN_X,
298    crate::render::scenes::common::PAN_Y,
299];
300
301/// Every parameter only some layouts read (ADR-0180 rule 4), with the range
302/// that reads there. A parameter missing from here reads the same on both.
303pub const FAMILY_PARAMS: &[FamilyParam] = &[
304    FamilyParam {
305        name: "wave",
306        ranges: &[
307            FamilyRange {
308                family: "cloud",
309                range: None,
310            },
311            FamilyRange {
312                family: "sheet",
313                range: Some([0.0, 0.6]),
314            },
315        ],
316    },
317    FamilyParam {
318        name: "wave_scale",
319        ranges: &[
320            FamilyRange {
321                family: "cloud",
322                range: None,
323            },
324            FamilyRange {
325                family: "sheet",
326                range: Some([0.3, 3.0]),
327            },
328        ],
329    },
330];
331
332/// The plexus scene: the point set, its graph, and the 3D line renderer it
333/// draws through.
334pub struct PlexusScene {
335    lines: LineRenderer,
336    nodes: InstancedQuads3d,
337    /// The tier's point cap, the most points this scene will ever hold.
338    points_cap: usize,
339    /// The tier's edge cap.
340    edges_cap: usize,
341    /// The tier's cap on the circle of confusion, in pixels.
342    max_coc: f32,
343    /// This frame's per-frame clamp — the edge cap or the blur cap — if one
344    /// bit, for the renderer to announce (ADR-0007: a cap is never silent).
345    clamp: Option<super::CapOverflow>,
346    points: sim::Points,
347    edges: Vec<sim::Edge>,
348    instances: Vec<Segment3dInstance>,
349    node_instances: Vec<Quad3dInstance>,
350    palette: Palette,
351    dt: f32,
352    /// The render target's size in pixels, handed in every frame.
353    target: (u32, u32),
354
355    link_distance: f32,
356    link_alpha: f32,
357    line_width: f32,
358    node_size: f32,
359    node_glow: f32,
360    drift: f32,
361    wave: f32,
362    wave_scale: f32,
363    yaw: f32,
364    pitch: f32,
365    distance: f32,
366    fov: f32,
367    focus: f32,
368    aperture: f32,
369    hue_center: f32,
370    hue_spread: f32,
371    zoom: f32,
372    colour: PaletteParams,
373    pan: PanParams,
374}
375
376impl PlexusScene {
377    /// Build the scene with buffers for `points_cap` points and `edges_cap`
378    /// edges, and blur held to `max_coc` pixels — the tier's caps.
379    pub(crate) fn new(
380        device: &wgpu::Device,
381        surface_format: wgpu::TextureFormat,
382        points_cap: usize,
383        edges_cap: usize,
384        max_coc: f32,
385    ) -> Self {
386        let config = PlexusConfig::default();
387        let count = (config.points as usize).min(points_cap);
388        Self {
389            lines: LineRenderer::new_3d(device, surface_format, edges_cap, "plexus"),
390            nodes: InstancedQuads3d::new(device, "plexus", points_cap, surface_format),
391            points_cap,
392            edges_cap,
393            max_coc,
394            clamp: None,
395            points: sim::Points::seeded(config.layout, config.seed, count, points_cap),
396            edges: Vec::with_capacity(edges_cap),
397            instances: Vec::with_capacity(edges_cap),
398            node_instances: Vec::with_capacity(points_cap),
399            palette: Palette::default_spectrum(),
400            dt: super::FALLBACK_DT,
401            target: (1, 1),
402            link_distance: DEFAULT_LINK_DISTANCE,
403            link_alpha: DEFAULT_LINK_ALPHA,
404            line_width: DEFAULT_LINE_WIDTH,
405            node_size: DEFAULT_NODE_SIZE,
406            node_glow: DEFAULT_NODE_GLOW,
407            drift: DEFAULT_DRIFT,
408            wave: DEFAULT_WAVE,
409            wave_scale: DEFAULT_WAVE_SCALE,
410            yaw: DEFAULT_YAW,
411            pitch: DEFAULT_PITCH,
412            distance: DEFAULT_DISTANCE,
413            fov: DEFAULT_FOV,
414            focus: DEFAULT_FOCUS,
415            aperture: DEFAULT_APERTURE,
416            hue_center: DEFAULT_HUE_CENTER,
417            hue_spread: DEFAULT_HUE_SPREAD,
418            zoom: DEFAULT_ZOOM,
419            colour: PaletteParams::new(0.0, DEFAULT_BRIGHTNESS),
420            pan: PanParams::default(),
421        }
422    }
423
424    /// The camera this frame's parameters describe.
425    fn camera(&self) -> Camera3d {
426        Camera3d {
427            yaw: self.yaw,
428            pitch: self.pitch,
429            distance: self.distance,
430            fov: self.fov,
431            focus: self.focus,
432            aperture: self.aperture,
433        }
434    }
435
436    /// The palette coordinate at normalized depth `depth01`, `0` at the
437    /// volume's nearest extent and `1` at its farthest (ADR-0059: a scene
438    /// colours along its own generator's axis, and depth is this one's).
439    fn colour_at(&self, depth01: f32) -> [f32; 3] {
440        depth_colour(
441            &self.palette,
442            &self.colour,
443            self.hue_center,
444            self.hue_spread,
445            depth01,
446        )
447    }
448}
449
450impl Scene for PlexusScene {
451    fn name(&self) -> &'static str {
452        "plexus"
453    }
454
455    fn advance(&mut self, dt: f32) {
456        self.dt = dt;
457    }
458
459    fn set_target_size(&mut self, width: u32, height: u32) {
460        self.target = (width, height);
461    }
462
463    fn set_palette(&mut self, palette: &Palette) {
464        self.palette = palette.clone();
465    }
466
467    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<super::CapOverflow> {
468        let GeneratorConfig::Plexus(config) = cfg else {
469            return None;
470        };
471        // A switch starts the incoming preset from its own seed, with the
472        // point count held to the tier and the clamp returned for the renderer
473        // to announce with the preset.
474        let (count, overflow) = points_clamp(config.points, self.points_cap);
475        self.points = sim::Points::seeded(config.layout, config.seed, count, self.points_cap);
476        self.edges.clear();
477        // The outgoing preset's per-frame clamp is not this one's to report.
478        self.clamp = None;
479        overflow
480    }
481
482    fn mirror_overflow(&self) -> Option<&super::CapOverflow> {
483        self.clamp.as_ref()
484    }
485
486    fn reset_params(&mut self) {
487        self.link_distance = DEFAULT_LINK_DISTANCE;
488        self.link_alpha = DEFAULT_LINK_ALPHA;
489        self.line_width = DEFAULT_LINE_WIDTH;
490        self.node_size = DEFAULT_NODE_SIZE;
491        self.node_glow = DEFAULT_NODE_GLOW;
492        self.drift = DEFAULT_DRIFT;
493        self.wave = DEFAULT_WAVE;
494        self.wave_scale = DEFAULT_WAVE_SCALE;
495        self.yaw = DEFAULT_YAW;
496        self.pitch = DEFAULT_PITCH;
497        self.distance = DEFAULT_DISTANCE;
498        self.fov = DEFAULT_FOV;
499        self.focus = DEFAULT_FOCUS;
500        self.aperture = DEFAULT_APERTURE;
501        self.hue_center = DEFAULT_HUE_CENTER;
502        self.hue_spread = DEFAULT_HUE_SPREAD;
503        self.zoom = DEFAULT_ZOOM;
504        self.colour.reset();
505        self.pan.reset();
506    }
507
508    fn set_param(&mut self, name: &str, value: f32) {
509        // The shared param blocks first, this scene's own names after
510        // (`scenes::common`).
511        if self.colour.set(name, value) || self.pan.set(name, value) {
512            return;
513        }
514        match name {
515            "link_distance" => self.link_distance = value,
516            "link_alpha" => self.link_alpha = value,
517            "line_width" => self.line_width = value,
518            "node_size" => self.node_size = value,
519            "node_glow" => self.node_glow = value,
520            "drift" => self.drift = value,
521            "wave" => self.wave = value,
522            "wave_scale" => self.wave_scale = value,
523            "yaw" => self.yaw = value,
524            "pitch" => self.pitch = value,
525            "distance" => self.distance = value,
526            "fov" => self.fov = value,
527            "focus" => self.focus = value,
528            "aperture" => self.aperture = value,
529            "hue_center" => self.hue_center = value,
530            "hue_spread" => self.hue_spread = value,
531            "zoom" => self.zoom = value,
532            _ => {}
533        }
534    }
535
536    fn update(&mut self, _frame: &AnalysisFrame) {
537        self.points
538            .step(self.dt, self.drift, self.wave, self.wave_scale);
539        let linked = sim::link(
540            self.points.pos(),
541            self.points.fade(),
542            self.link_distance,
543            self.edges_cap,
544            &mut self.edges,
545        );
546        self.clamp = (linked > self.edges.len()).then(|| super::CapOverflow {
547            dropped: linked - self.edges.len(),
548            context: super::OverflowContext::Edges(linked as u32),
549            cap: self.edges_cap,
550        });
551    }
552
553    fn render(
554        &mut self,
555        queue: &wgpu::Queue,
556        encoder: &mut wgpu::CommandEncoder,
557        view: &wgpu::TextureView,
558        aspect: f32,
559    ) {
560        // The aspect is the render target's, handed in here (ADR-0037).
561        let camera = self.camera();
562        let cam = camera.view(aspect, self.zoom, [self.pan.x, self.pan.y]);
563        let radius = self.points.bounding_radius();
564        let near_extent = cam.distance - radius;
565        let far_extent = cam.distance + radius;
566        let span = far_extent - near_extent;
567        let lens = Lens::new(
568            camera.aperture,
569            cam.focal_depth(camera.focus, radius),
570            self.max_coc,
571        );
572        // The aperture is the far field's blur, the one the lens approaches
573        // and never passes behind focus; past the cap that is what draws
574        // shallower, and it is announced unless the graph's own cap already
575        // is. The near side is unbounded and saturates at the cap by design,
576        // so it is never judged (ADR-0257).
577        if self.clamp.is_none() && lens.aperture > lens.max_coc {
578            self.clamp = Some(super::CapOverflow {
579                dropped: 0,
580                context: super::OverflowContext::Blur(lens.aperture.ceil() as u32),
581                cap: lens.max_coc as usize,
582            });
583        }
584        // A blurred stroke reaches past its centreline by up to the cap, so
585        // the cull keeps that much more of the frame's surround.
586        let margin = if lens.aperture > 0.0 {
587            CULL_MARGIN + 2.0 * lens.max_coc / self.target.1.max(1) as f32
588        } else {
589            CULL_MARGIN
590        };
591        let link_alpha = self.link_alpha.clamp(0.0, 1.0);
592        let width = if self.line_width.is_finite() {
593            self.line_width.max(0.0)
594        } else {
595            DEFAULT_LINE_WIDTH
596        };
597
598        let mut instances = std::mem::take(&mut self.instances);
599        instances.clear();
600        for edge in &self.edges {
601            let (Some(&pa), Some(&pb)) = (
602                self.points.pos().get(edge.a as usize),
603                self.points.pos().get(edge.b as usize),
604            ) else {
605                continue;
606            };
607            let alpha = edge.presence * link_alpha;
608            if alpha <= 0.0 {
609                continue;
610            }
611            let Some((a, b)) = cam.clip_near(pa, pb) else {
612                continue;
613            };
614            if cam.outside(a, b, margin) {
615                continue;
616            }
617            let mid = [
618                0.5 * (pa[0] + pb[0]),
619                0.5 * (pa[1] + pb[1]),
620                0.5 * (pa[2] + pb[2]),
621            ];
622            let depth01 = ((cam.depth(mid) - near_extent) / span).clamp(0.0, 1.0);
623            instances.push(Segment3dInstance {
624                a,
625                b,
626                color: self.colour_at(depth01),
627                width,
628                alpha,
629            });
630        }
631
632        let uniform = CameraUniform::new(&cam, self.target.0, self.target.1, lens);
633        self.lines
634            .draw_3d(queue, encoder, view, &uniform, 1.0, SOFTNESS, &instances);
635        self.instances = instances;
636
637        let mut nodes = std::mem::take(&mut self.node_instances);
638        node_instances(
639            &mut nodes,
640            self.points.pos(),
641            self.points.fade(),
642            &cam,
643            margin,
644            self.node_size,
645            |depth| self.colour_at(((depth - near_extent) / span).clamp(0.0, 1.0)),
646        );
647        self.nodes.draw(
648            queue,
649            encoder,
650            view,
651            &uniform,
652            if self.node_glow.is_finite() {
653                self.node_glow.max(0.0)
654            } else {
655                DEFAULT_NODE_GLOW
656            },
657            &nodes,
658        );
659        self.node_instances = nodes;
660    }
661}
662
663/// `asked` points held to the tier's `cap`, and the overflow to announce when
664/// the cap bit.
665pub(crate) fn points_clamp(asked: u32, cap: usize) -> (usize, Option<super::CapOverflow>) {
666    let asked_n = asked as usize;
667    if asked_n <= cap {
668        return (asked_n, None);
669    }
670    (
671        cap,
672        Some(super::CapOverflow {
673            dropped: asked_n - cap,
674            context: super::OverflowContext::Points(asked),
675            cap,
676        }),
677    )
678}
679
680/// The colour at normalized depth `depth01`, `0` at the volume's nearest extent
681/// and `1` at its farthest (ADR-0059: a scene colours along its own generator's
682/// axis, and depth is this one's). Edges and nodes both take it, so a node is
683/// the colour of the lines that meet at it.
684///
685/// The palette coordinate is `hue_center + (depth01 - 0.5) * hue_spread`, then
686/// banded by `palette_steps` and crossfaded by `palette_mix` through the shared
687/// `palette::band_coord` and `Palette::sample` — the arithmetic every
688/// CPU-coloured scene uses.
689pub(crate) fn depth_colour(
690    palette: &Palette,
691    colour: &PaletteParams,
692    hue_center: f32,
693    hue_spread: f32,
694    depth01: f32,
695) -> [f32; 3] {
696    let coord = hue_center + (depth01 - 0.5) * hue_spread;
697    let rgb = palette::desaturate(
698        palette.sample(palette::band_coord(coord, colour.steps), colour.mix),
699        colour.saturation,
700    );
701    let b = colour.brightness;
702    [rgb[0] * b, rgb[1] * b, rgb[2] * b]
703}
704
705/// A node at every visible point into `out` (cleared first): each point in
706/// front of the near plane and not outside the frame by more than `margin`, of
707/// radius `node_size` pixels at the focal plane and coloured by `colour` of its
708/// view depth, dimmed by its face fade.
709///
710/// **`node_size <= 0` leaves `out` empty**, so a preset without dots issues no
711/// sprites at all rather than drawing invisible ones.
712pub(crate) fn node_instances(
713    out: &mut Vec<Quad3dInstance>,
714    pos: &[[f32; 3]],
715    fade: &[f32],
716    cam: &crate::render::camera::CameraView,
717    margin: f32,
718    node_size: f32,
719    colour: impl Fn(f32) -> [f32; 3],
720) {
721    out.clear();
722    if !node_size.is_finite() || node_size <= 0.0 {
723        return;
724    }
725    for (p, f) in pos.iter().zip(fade) {
726        if *f <= 0.0 {
727            continue;
728        }
729        let depth = cam.depth(*p);
730        if depth < crate::render::camera::NEAR || cam.outside(*p, *p, margin) {
731            continue;
732        }
733        let [r, g, b] = colour(depth);
734        out.push(Quad3dInstance {
735            center: *p,
736            radius: node_size,
737            color: [r * f, g * f, b * f],
738        });
739    }
740}
741
742#[cfg(test)]
743mod tests;