Skip to main content

rlx_core/render/scenes/
swarm.rs

1//! Particle-swarm scene: ~10k CPU-simulated particles drifting through a flow
2//! field, drawn as instanced additive sprites (the starfield's approach,
3//! scaled up). One of the two preset-driven systems (ADR-0002 layers 1-2).
4//!
5//! Its behavior is a set of named parameters — `force`, `spin`, `burst`, `hue`,
6//! `brightness`, `size` — that a preset binds to expressions over the audio
7//! analysis (Plan 0003 Phase 5). All per-particle math is CPU-side; no compute
8//! shader. Motion is deterministic; the only randomness is the seeded initial
9//! scatter (NFR 6).
10
11// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
12// 0003 Phase 0). Runs every displayed frame.
13#![deny(
14    clippy::unwrap_used,
15    clippy::expect_used,
16    clippy::indexing_slicing,
17    clippy::panic,
18    clippy::unreachable
19)]
20
21use super::common;
22use super::marks;
23use super::{FALLBACK_DT, Phase, Scene, SeededRng};
24use crate::dsp::AnalysisFrame;
25use crate::render::palette::{self, Palette};
26use crate::render::scenes::{ParamKind, ParamSpec, default_of};
27
28// The ASCII bytes of "LMV_SWRM" read as a number. Re-spelling them to
29// match a renamed prefix changes every particle's start state and moves
30// this scene's goldens, so the value is opaque and stays as it is.
31const SEED: u64 = 0x4C4D_565F_5357_524D;
32
33/// How far the toroidal domain extends past the visible frame (Plan 0043 Phase 1,
34/// ADR-0044).
35///
36/// Half-extents of `BOUND_X = 1.8` / `BOUND_Y = 1.0` put the wrap seam
37/// on the NDC frame edge, which `1.0` **is**. The wrap is toroidal, so
38/// that line is the one place on screen every wrapping particle is
39/// guaranteed to paint, and the feedback stage integrates it into a
40/// saturated bar across the top and bottom of every swarm preset within
41/// a few hundred frames.
42///
43/// The bounds now follow the render target (below) and carry this margin so the
44/// seam sits *outside* the frame. Chosen by measurement, not by rounding: the
45/// family works at `zoom` 1.0–1.3 with `pan_*` to about 0.16, and a particle at
46/// world `y = BOUND_Y` lands on the frame edge when `BOUND_Y * zoom - |pan_y| ==
47/// 1`. At the worst case in that range (`zoom = 1.0`) the seam clears the frame
48/// for `|pan| <= MARGIN - 1`, so 1.25 buys 0.25 of pan headroom on both axes —
49/// comfortably past what the family uses, and it also puts the *domain rectangle*
50/// off-screen down to `zoom = 0.8`, which is the inset-edge wall that pinned the
51/// family at or above 1.0.
52///
53/// The cost is visible density: the visible fraction of the domain is `1 /
54/// MARGIN^2`, so a quarter of the 10 000 particles are off-screen at any moment.
55/// That is the tradeoff Phase 4's re-authoring absorbs.
56const MARGIN: f32 = 1.25;
57/// Domain aspect before the first [`Scene::render`] hands one over. Only reached
58/// on the very first `update` of a fresh scene; because positions are stored
59/// normalized (see [`Particle::pos`]) an aspect change rescales the field rather
60/// than teleporting it, so this fallback is continuous with whatever follows.
61const FALLBACK_ASPECT: f32 = 16.0 / 9.0;
62
63/// Velocity retained per frame (the rest is re-steered by the flow field).
64const DAMPING: f32 = 0.86;
65
66// --- The depth axis (Plan 0043 Phase 3, ADR-0044) -------------------------------
67//
68// Each particle carries a `z` in `0..1` — 0 far, 1 near — seeded with the rest of
69// the scatter. It drives four things and **never** a sort: the scene blends
70// additively, and addition is commutative, so draw order is irrelevant. That one
71// fact is what makes a depth axis nearly free here; the per-frame sort a 3D
72// particle system normally pays buys occlusion an additive scene does not have.
73//
74// It is an honest fake. There is no occlusion and no perspective divide — two
75// particles at different depths that overlap simply sum — so the illusion flattens
76// as density rises. That is the known limit of the 2.5D choice, not a defect.
77/// Sprite scale at `z = 0` and `z = 1`. The mean is ~1, so the family's `size`
78/// bindings keep roughly their old meaning.
79const DEPTH_SCALE_FAR: f32 = 0.55;
80const DEPTH_SCALE_NEAR: f32 = 1.50;
81/// Atmospheric fade: brightness multiplier at `z = 0` and `z = 1`. Distance
82/// washing out contrast is the oldest depth cue there is, and it is what keeps a
83/// far particle from reading as merely a small near one.
84const DEPTH_FADE_FAR: f32 = 0.45;
85const DEPTH_FADE_NEAR: f32 = 1.05;
86/// Parallax strength against the shared view transform at `z = 0` and `z = 1`.
87///
88/// A near particle traverses the frame ~1.9x faster than a far one under the same
89/// `pan_*`, which is the difference between a depth axis and a sprite sheet at two
90/// scales. Both ends are deliberately kept near 1 rather than spread wide: the
91/// near layer is the binding case for the [`MARGIN`] seam clearance (it is the one
92/// pan pushes furthest toward the frame), and at `zoom = 1` with the family's
93/// `pan` of 0.16 this still leaves the seam off-screen.
94const DEPTH_PARALLAX_FAR: f32 = 0.65;
95const DEPTH_PARALLAX_NEAR: f32 = 1.25;
96/// Phase offset, in radians, applied to the flow-field sample per unit of `z`.
97///
98/// **This is the term that makes it read as volume.** Without it every depth layer
99/// rides identical streamlines and the result is one flock drawn at several sizes;
100/// with it the near and far layers follow genuinely different currents, so they
101/// cross and separate the way real depth does. Sized as a large fraction of the
102/// field's `TAU` period — enough to decorrelate the layers, short of wrapping them
103/// back onto each other.
104const DEPTH_FIELD_OFFSET: f32 = 2.6;
105
106/// Parameter defaults — a calm idle drift when nothing is bound.
107const DEFAULT_FORCE: f32 = default_of(PARAMS, "force");
108const DEFAULT_SPIN: f32 = default_of(PARAMS, "spin");
109const DEFAULT_BURST: f32 = default_of(PARAMS, "burst");
110const DEFAULT_HUE: f32 = 0.0;
111const DEFAULT_BRIGHTNESS: f32 = 0.8;
112const DEFAULT_SIZE: f32 = default_of(PARAMS, "size");
113/// Spatial frequency of the flow field — how many vortices fit across the world,
114/// and so how many distinct streams a frame can hold (Plan 0043 Phase 2).
115///
116/// Was a bare `const FIELD_FREQ`; it is now the bindable `field_freq`, and this
117/// default is **exactly** the constant it replaced, so a preset that does not bind
118/// it renders unchanged.
119///
120/// It is this scene's first structural lever. Low values give a few broad
121/// currents that many particles share — which is where the family's apparent
122/// flocking comes from, since neighbours on one streamline travel together — and
123/// high values give many tight swirls. `spin` says how fast the field is rewritten;
124/// this says how finely it is divided.
125const DEFAULT_FIELD_FREQ: f32 = default_of(PARAMS, "field_freq");
126// Per-mark individuation (Plan 0077 Phase 2, backlog 0068). Both default OFF —
127// unlike the emitter's spreads, which default non-zero, the swarm's scatter
128// already ships a seeded per-particle size and brightness, so these *widen*
129// what is there and their defaults must leave every shipped capture
130// byte-identical.
131const DEFAULT_TWINKLE: f32 = default_of(PARAMS, "twinkle");
132const DEFAULT_SIZE_SPREAD: f32 = default_of(PARAMS, "size_spread");
133/// The per-particle twinkle rate band, Hz — the emitter's values
134/// (`emitter.rs`), kept equal so `twinkle` means one thing across the two
135/// particle scenes. The spread across particles is the point, not the values:
136/// a field of oscillators sharing one rate flashes as one sheet however their
137/// phases scatter, so the **rate is drawn per particle as well as the phase**
138/// — that is what keeps the whole-frame mean steady while every member of it
139/// swings (backlog 0068's measurement).
140const TWINKLE_FREQ_LO: f32 = 0.35;
141const TWINKLE_FREQ_HI: f32 = 1.6;
142/// `reseed` rises past this to disturb the population once — edge-triggered,
143/// the attractor's constant and its reason (`particles/mod.rs`): a sustained
144/// beat flag must not disturb every frame.
145const RESEED_THRESHOLD: f32 = 0.5;
146/// Fraction of the domain's normalized half-extent one `reseed` kick spans per
147/// axis (Plan 0077 Phase 3, ADR-0066 semantics): the kick disturbs the
148/// population **where it is**, sized from the swarm's own domain the way
149/// `AttractorFamily::jitter_extent` derives from `seed_box` — *not* a respawn
150/// into a uniform box, which is the artifact class ADR-0066 removed and
151/// backlog 0064 caught returning once already. Positions are normalized, so a
152/// fraction here is domain-relative on any target and any aspect.
153///
154/// The value is the attractor's measured `JITTER_FRACTION`, adopted as the
155/// starting magnitude for the same figure-relative kick. ADR-0066 records the
156/// magnitude as the lever if the disturbance reads too subtle; returning to a
157/// box re-fill is not.
158const RESEED_KICK: f32 = 0.06;
159// Shared palette color knobs (ADR-0021). Each particle's hue occupies the band
160// `hue_center + (particle_hue - 0.5) * hue_spread`; the defaults (`center = 0.5`,
161// `spread = 1`) reproduce the prior full-wheel look (`particle_hue`), and
162// `saturation = 1` leaves color untouched — so an unbound swarm is unchanged.
163const DEFAULT_HUE_SPREAD: f32 = default_of(PARAMS, "hue_spread");
164const DEFAULT_HUE_CENTER: f32 = default_of(PARAMS, "hue_center");
165// Shared view transform (ADR-0018): identity by default, so an unbound preset is
166// unchanged. `zoom` multiplies particle positions about the frame centre; `pan_*`
167// offset them — matching the line scenes' semantics (zoom > 1 = zoomed in).
168const DEFAULT_ZOOM: f32 = 1.0;
169// The mark silhouette (ADR-0084). `disc` is exactly the arithmetic the sprite
170// drew before the roster existed, so an unbound swarm is unchanged.
171const DEFAULT_SHAPE: f32 = marks::DEFAULT_SHAPE;
172const DEFAULT_POINTS: f32 = marks::DEFAULT_POINTS;
173/// The `star` arm's three shape params (Plan 0091 Phase 5), aliased beside the
174/// other two mark defaults so this scene states its whole vocabulary locally.
175const DEFAULT_STAR_VALLEY: f32 = marks::DEFAULT_STAR_VALLEY;
176const DEFAULT_STAR_CURVE: f32 = marks::DEFAULT_STAR_CURVE;
177const DEFAULT_STAR_JITTER: f32 = marks::DEFAULT_STAR_JITTER;
178
179/// The scene's own WGSL. The shared mark-silhouette chunk
180/// ([`marks::sdf_wgsl`]) is prepended at module creation, so `mark_distance` here
181/// is the same function the emitter evaluates.
182///
183/// **`shape` and `points` travel vertex -> fragment as flat varyings rather than
184/// being read from `misc` in the fragment stage**, and that is deliberate. The
185/// fragment stage cannot see this scene's uniform without widening the bind
186/// layout's visibility to `VERTEX_FRAGMENT` — which would make this descriptor
187/// byte-identical to the line renderer's (`{uniform, VERTEX_FRAGMENT,
188/// min_binding_size: None}`), the exact collision shape ADR-0058 records and the
189/// one the emitter's layout comment says not to tidy back in. A flat varying
190/// carries a per-draw value with no descriptor change at all.
191const SHADER: &str = r#"
192struct Misc {
193    // x: aspect, y: zoom, zw: pan (the shared ViewTransform, ADR-0018)
194    v: vec4<f32>,
195    // x: mark shape index, y: quantized point count (ADR-0084). Per draw, not
196    // per instance: the branch stays uniform across a warp and `Instance` does
197    // not grow.
198    m: vec4<f32>,
199    // xyz: the star arm's shape params (valley, curve, jitter), conditioned
200    // CPU-side (Plan 0091 Phase 5). Per draw, like `m`. Inert on every other
201    // shape, and at their defaults the arm takes its original closed form.
202    s: vec4<f32>,
203}
204
205@group(0) @binding(0) var<uniform> misc: Misc;
206
207struct VsOut {
208    @builtin(position) pos: vec4<f32>,
209    @location(0) local: vec2<f32>,
210    @location(1) color: vec3<f32>,
211    @location(2) @interpolate(flat) shape: f32,
212    @location(3) @interpolate(flat) points: f32,
213    @location(4) @interpolate(flat) star: vec3<f32>,
214}
215
216@vertex
217fn vs_main(
218    @builtin(vertex_index) vi: u32,
219    @location(0) center: vec2<f32>,
220    @location(1) size: f32,
221    @location(2) color: vec3<f32>,
222    @location(3) parallax: f32,
223) -> VsOut {
224    var corners = array<vec2<f32>, 6>(
225        vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
226        vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
227    );
228    let c = corners[vi] * 2.0 - vec2<f32>(1.0, 1.0);
229    // Shared ViewTransform (ADR-0018): zoom about the frame centre, then pan the
230    // particle position; the sprite quad (c * size) keeps its on-screen size.
231    //
232    // Depth parallax (Plan 0043 Phase 3): `parallax` is the per-particle strength
233    // the CPU derived from `z`, so a near particle takes more of the pan and more
234    // of the zoom deflection than a far one and the layers slide across each other
235    // as the camera moves. At the identity transform (zoom 1, pan 0) this reduces
236    // to `center` for every depth, so an unbound preset is untouched.
237    let zoom = misc.v.y;
238    let pan = misc.v.zw;
239    let center_v = center * (1.0 + (zoom - 1.0) * parallax) + pan * parallax;
240    let world = center_v + c * size;
241    var out: VsOut;
242    out.pos = vec4<f32>(world.x / misc.v.x, world.y, 0.0, 1.0);
243    out.local = c;
244    out.color = color;
245    out.shape = misc.m.x;
246    out.points = misc.m.y;
247    out.star = misc.s.xyz;
248    return out;
249}
250
251@fragment
252fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
253    // The silhouette (ADR-0084). At the default `disc` this is `length(in.local)`
254    // and nothing else, so an unshaped swarm is the arithmetic it always was; the
255    // falloff below is untouched either way, so a visual change is attributable
256    // to the shape alone.
257    let d = mark_distance(in.local, in.shape, in.points, in.star);
258    let falloff = max(0.0, 1.0 - d);
259    let g = falloff * falloff;
260    // Premultiplied: colour AND alpha carry the same coverage `g`, so the four
261    // corners outside the inscribed disc write nothing at all rather than
262    // opaque black (ADR-0056). See `gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE`.
263    return vec4<f32>(in.color * g, g);
264}
265"#;
266
267struct Particle {
268    /// Position on the torus in **normalized** domain coordinates, each axis in
269    /// `[-1, 1)`; world position is this times the current half-extents (Plan 0043
270    /// Phase 1).
271    ///
272    /// Normalized rather than world-space for one reason: the half-extents now
273    /// follow the render target, so they change on a resize, and a world-space
274    /// store would have to either re-wrap (teleporting every particle that fell
275    /// outside the new domain, all in one frame) or rescale every position by
276    /// hand. Here the resize *is* the rescale — each particle keeps its place on
277    /// the torus and the field stretches with the frame, which is the
278    /// discontinuity-free resize ADR-0044 requires. It also keeps the seeded
279    /// scatter aspect-independent, so the same seed gives the same field at any
280    /// target size (NFR §6).
281    pos: [f32; 2],
282    /// Velocity in **world** units per second — the flow field and the burst are
283    /// screen-space forces, so they must not change magnitude with the domain.
284    vel: [f32; 2],
285    /// Per-particle twinkle oscillator (Plan 0077 Phase 2): rate in Hz from
286    /// the `TWINKLE_FREQ_LO..HI` band and phase in cycles, both off the
287    /// particle's stable identity through [`unit`]. Fixed for the particle's
288    /// life; resolved into a brightness factor at draw only when `twinkle`
289    /// is bound.
290    twinkle_freq: f32,
291    twinkle_phase: f32,
292    /// The particle's unit draw for `size_spread`, resolved at draw time so an
293    /// eased spread moves the whole population continuously (the emitter's
294    /// reasoning for draw-time resolution, verbatim).
295    size_unit: f32,
296    /// Depth: 0 = far, 1 = near (Plan 0043 Phase 3). Drives sprite scale, an
297    /// atmospheric brightness fade, a parallax offset against the shared view
298    /// transform, and which current the particle rides — **never** sorting, since
299    /// the scene blends additively (ADR-0044).
300    ///
301    /// Fixed for the particle's life, like `hue` and `bright`: it comes off the
302    /// seeded scatter, so the same seed gives the same depth sequence every run
303    /// (NFR §6).
304    z: f32,
305    /// Per-particle palette offset and brightness, from the seeded scatter.
306    hue: f32,
307    bright: f32,
308    size: f32,
309}
310
311/// ~10k-particle CPU flow-field swarm, driven by named preset parameters.
312pub struct SwarmScene {
313    /// The instance buffer, the view/silhouette uniform, the bind group over the
314    /// layout declared below, and the instanced-quad pipeline (ADR-0007).
315    quads: marks::InstancedQuads,
316    particles: Vec<Particle>,
317    /// This frame's marks, rebuilt in place every `update` — the fourth attribute
318    /// is this scene's **depth parallax**, resolved from the particle's `z` on the
319    /// CPU so the shader needs no depth constants (Plan 0043 Phase 3).
320    instance_data: Vec<marks::QuadInstance>,
321    /// Shared scene clock (seconds), set by the renderer each frame.
322    time: f32,
323    /// The **render target's** aspect, recorded by `render` for the next `update`
324    /// to size the toroidal domain from (Plan 0043 Phase 1).
325    ///
326    /// Read off `render`'s argument and deliberately **not** off
327    /// [`Scene::set_target_size`](super::Scene::set_target_size), which carries the
328    /// post chain's internal grid — a quantized *resolution*, not a shape, whose
329    /// aspect is only approximately the target's (ADR-0037). Every swarm preset
330    /// composes `trails`, so that grid is exactly the quantized case; taking a
331    /// domain shape from it is the defect ADR-0037 was written for.
332    ///
333    /// One frame behind by construction: `update` runs before `render` in a frame,
334    /// so the domain follows the target with a single frame of lag. Harmless —
335    /// positions are normalized, so a change rescales the field continuously.
336    aspect: f32,
337    /// Real elapsed seconds for this frame's integration (Plan 0014 Phase 2),
338    /// injected via `advance` so the swarm moves at the same wall-clock rate on
339    /// any refresh. Seeded to the fallback step for the first frame before any
340    /// `advance` call.
341    dt: f32,
342    force: f32,
343    spin: f32,
344    /// The curl-noise field's own clock, integrated at `spin` ([`Phase`]).
345    ///
346    /// **Not `time * spin`** (ADR-0135). `spin` is the one rate here two shipped
347    /// worlds bind to a band, and under the multiply a binding that moved
348    /// rescaled every second already elapsed: at t = 100 s a 0.04 swing advanced
349    /// this clock by 4 s in a single frame against a nominal 0.019 s, and the
350    /// field re-rolled rather than flowing on. The particles steer by the field,
351    /// so it reads as the flow changing its mind, not as a teleport.
352    field_phase: Phase,
353    burst: f32,
354    /// The shared palette knobs (ADR-0021).
355    colour: common::PaletteParams,
356    /// The shared view transform (ADR-0018).
357    pan: common::PanParams,
358    size: f32,
359    field_freq: f32,
360    zoom: f32,
361    /// The active baked palette (ADR-0021), sampled per particle on the CPU. Set
362    /// by `set_palette` on a preset switch; default `spectrum` reproduces the
363    /// prior cosine.
364    palette: Palette,
365    /// Per-particle hue band + shared desaturation (ADR-0021).
366    hue_spread: f32,
367    hue_center: f32,
368    /// The mark silhouette and its point count, **as bound** (ADR-0084). Both
369    /// are quantized on the way to the uniform rather than here, so a
370    /// `[smoothing]`-eased binding still eases — it just steps at the midpoints
371    /// (see [`marks::mark_points`]).
372    shape: f32,
373    points: f32,
374    /// The `star` arm's three shape params, raw as the preset bound them
375    /// (Plan 0091 Phase 5). `marks::star_*` condition them on the way to the
376    /// uniform. Inert on every other silhouette, and nothing warns —
377    /// `presets/README.md` carries that.
378    star_valley: f32,
379    star_curve: f32,
380    star_jitter: f32,
381    /// Per-mark individuation (Plan 0077 Phase 2): the twinkle depth and the
382    /// size-spread width, both resolved per particle at draw.
383    twinkle: f32,
384    size_spread: f32,
385    /// This frame's `reseed` level (bound to a beat/onset expression); its
386    /// rising edge past [`RESEED_THRESHOLD`] disturbs the population once
387    /// (Plan 0077 Phase 3, ADR-0066 semantics).
388    reseed: f32,
389    /// Previous frame's `reseed`, for rising-edge detection.
390    prev_reseed: f32,
391    /// How many reseeds have fired. Salts the per-particle kick draw so
392    /// successive reseeds scatter differently (the attractor's convention).
393    reseed_count: u32,
394}
395
396impl SwarmScene {
397    /// Build the pipeline, buffers, and seeded particle set on `device`.
398    /// `particles` is the active tier's
399    /// [`swarm_particles`](crate::render::TierConfig::swarm_particles). The count
400    /// is fixed for the life of the scene — the instance buffer and the CPU
401    /// mirror are both sized to it here, so the per-frame path never allocates —
402    /// and a tier change rebuilds the scene rather than resizing it.
403    pub fn new(
404        device: &wgpu::Device,
405        surface_format: wgpu::TextureFormat,
406        particles: usize,
407    ) -> Self {
408        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
409            label: Some("swarm-shader"),
410            // The shared silhouette chunk first, then this scene's own source —
411            // one `mark_distance`, two scenes (ADR-0084).
412            source: wgpu::ShaderSource::Wgsl(format!("{}{SHADER}", marks::sdf_wgsl()).into()),
413        });
414        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
415            label: Some("swarm-bind-layout"),
416            entries: &[wgpu::BindGroupLayoutEntry {
417                binding: 0,
418                visibility: wgpu::ShaderStages::VERTEX,
419                ty: wgpu::BindingType::Buffer {
420                    ty: wgpu::BufferBindingType::Uniform,
421                    has_dynamic_offset: false,
422                    min_binding_size: None,
423                },
424                count: None,
425            }],
426        });
427        let quads = marks::InstancedQuads::new(
428            device,
429            "swarm",
430            particles,
431            &shader,
432            &bind_layout,
433            surface_format,
434        );
435
436        let mut rng = SeededRng::new(SEED);
437        // The individuation draws come off the particle's index through `unit`,
438        // NOT off `rng`: an extra `SeededRng` draw per particle would shift the
439        // stream for every draw after it and re-scatter the whole field, and
440        // the defaults' byte-identity claim (Plan 0077 Phase 2) rests on the
441        // existing scatter being untouched.
442        let particle_state: Vec<Particle> = (0..particles)
443            .map(|i| {
444                let mut p = Self::spawn(&mut rng);
445                let seed = i as u32;
446                p.twinkle_freq = TWINKLE_FREQ_LO
447                    + unit(seed, channel::TWINKLE_FREQ) * (TWINKLE_FREQ_HI - TWINKLE_FREQ_LO);
448                p.twinkle_phase = unit(seed, channel::TWINKLE_PHASE);
449                p.size_unit = unit(seed, channel::SIZE);
450                p
451            })
452            .collect();
453
454        Self {
455            quads,
456            particles: particle_state,
457            instance_data: vec![
458                marks::QuadInstance {
459                    center: [0.0, 0.0],
460                    size: 0.0,
461                    color: [0.0, 0.0, 0.0],
462                    attr: 1.0,
463                };
464                particles
465            ],
466            time: 0.0,
467            aspect: FALLBACK_ASPECT,
468            dt: FALLBACK_DT,
469            force: DEFAULT_FORCE,
470            spin: DEFAULT_SPIN,
471            field_phase: Phase::default(),
472            burst: DEFAULT_BURST,
473            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
474            pan: common::PanParams::default(),
475            size: DEFAULT_SIZE,
476            field_freq: DEFAULT_FIELD_FREQ,
477            zoom: DEFAULT_ZOOM,
478            palette: Palette::default_spectrum(),
479            hue_spread: DEFAULT_HUE_SPREAD,
480            hue_center: DEFAULT_HUE_CENTER,
481            shape: DEFAULT_SHAPE,
482            points: DEFAULT_POINTS,
483            star_valley: DEFAULT_STAR_VALLEY,
484            star_curve: DEFAULT_STAR_CURVE,
485            star_jitter: DEFAULT_STAR_JITTER,
486            twinkle: DEFAULT_TWINKLE,
487            size_spread: DEFAULT_SIZE_SPREAD,
488            reseed: 0.0,
489            prev_reseed: 0.0,
490            reseed_count: 0,
491        }
492    }
493
494    /// A particle scattered across the field with a random heading and tint.
495    ///
496    /// The scatter is in **normalized** domain coordinates, so it does not depend
497    /// on the render target — the same seed gives the same field at any size
498    /// (NFR §6).
499    #[allow(
500        clippy::indexing_slicing,
501        reason = "pos/vel index a fixed [f32; 2] at constant 0/1, always in-bounds"
502    )]
503    fn spawn(rng: &mut SeededRng) -> Particle {
504        let angle = rng.range(0.0, std::f32::consts::TAU);
505        Particle {
506            pos: [rng.range(-1.0, 1.0), rng.range(-1.0, 1.0)],
507            vel: [angle.cos() * 0.2, angle.sin() * 0.2],
508            z: rng.next_f32(),
509            hue: rng.next_f32(),
510            bright: rng.range(0.5, 1.0),
511            size: rng.range(0.004, 0.011),
512            // Neutral; `new` overwrites all three from the particle's index.
513            // Deliberately not drawn from `rng` — see the comment there.
514            twinkle_freq: 0.0,
515            twinkle_phase: 0.0,
516            size_unit: 0.5,
517        }
518    }
519}
520
521/// The toroidal world half-extents for a render target of this aspect (Plan 0043
522/// Phase 1).
523///
524/// The visible frame is `|world.y| <= 1` and `|world.x| <= aspect` — the shader
525/// divides x by the aspect on its way to NDC — so this is the visible rectangle
526/// scaled by [`MARGIN`], which is what puts the wrap seam off-screen. At
527/// `MARGIN = 1` and 16:9 it returns `(1.78, 1.0)`, i.e. the constants it replaces.
528fn bounds(aspect: f32) -> (f32, f32) {
529    (aspect * MARGIN, MARGIN)
530}
531
532/// The LUT sample coordinate for one particle (ADR-0021): its per-particle hue
533/// occupies the band `hue_center + (particle_hue - 0.5) * hue_spread`, plus the
534/// shared `hue` rotation. Defaults (`center = 0.5`, `spread = 1`, `hue = 0`)
535/// reduce to `particle_hue`, reproducing the prior full-wheel look.
536fn hue_coord(hue_center: f32, hue_spread: f32, particle_hue: f32, hue: f32) -> f32 {
537    hue_center + (particle_hue - 0.5) * hue_spread + hue
538}
539
540/// The individuation contract, mirrored from the emitter (`emitter.rs`'s
541/// `unit`, which is private to that scene): a per-particle quantity is a pure
542/// function of `(seed, channel)` — splitmix64's finalizer applied as a hash.
543/// The swarm's `seed` is the particle's index in the seeded pool, which is
544/// stable for the scene's life, and the hash runs at construction only.
545fn unit(seed: u32, k: u32) -> f32 {
546    let mut z = ((seed as u64) << 32 | k as u64).wrapping_add(0x9E37_79B9_7F4A_7C15);
547    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
548    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
549    z ^= z >> 31;
550    (z >> 40) as f32 / (1u64 << 24) as f32
551}
552
553/// Seed channels, named so a later quantity cannot silently reuse one and
554/// correlate itself with an existing draw (the emitter's convention).
555mod channel {
556    pub(super) const TWINKLE_FREQ: u32 = 0;
557    pub(super) const TWINKLE_PHASE: u32 = 1;
558    pub(super) const SIZE: u32 = 2;
559    pub(super) const RESEED_X: u32 = 3;
560    pub(super) const RESEED_Y: u32 = 4;
561}
562
563/// The particle's brightness multiplier under `twinkle` — the emitter's
564/// semantics on the emitter's frequency band, over the pre-resolved
565/// per-particle rate and phase. Exactly `1.0` at `twinkle <= 0`, which is what
566/// makes the defaults' byte-identity falsifiable in both directions; clamped
567/// at zero because `twinkle` is a preset expression and may exceed 1, and a
568/// negative multiplier would subtract light rather than removing it.
569fn twinkle_factor(freq: f32, phase: f32, time: f32, twinkle: f32) -> f32 {
570    if twinkle <= 0.0 {
571        return 1.0;
572    }
573    let wave = (std::f32::consts::TAU * (freq * time + phase)).sin();
574    (1.0 + twinkle * wave).max(0.0)
575}
576
577/// The particle's size multiplier within `size_spread` — the emitter's
578/// `size_factor`, over the pre-resolved unit draw. Exactly `1.0` at zero
579/// spread (the default), on top of the scatter's own seeded base size.
580fn size_factor(size_unit: f32, size_spread: f32) -> f32 {
581    (1.0 + (size_unit - 0.5) * size_spread).max(0.0)
582}
583
584/// Parameter vocabulary — see [`fragment_field::PARAMS`](super::fragment_field::PARAMS).
585/// **Keep in sync with `set_param` below.**
586pub const PARAMS: &[ParamSpec] = &[
587    ParamSpec {
588        name: "force",
589        default: 1.4,
590        range: Some([0.0, 4.0]),
591        doc: "How hard the flow field pushes each particle, so higher is faster and straighter.",
592        kind: ParamKind::Modal,
593    },
594    ParamSpec {
595        name: "spin",
596        default: 0.3,
597        range: Some([-2.0, 2.0]),
598        doc: "Rotational bias added to the flow, curling the paths into vortices.",
599        kind: ParamKind::Modal,
600    },
601    ParamSpec {
602        name: "burst",
603        default: 0.0,
604        range: Some([0.0, 2.0]),
605        doc: "An outward impulse from the centre, for a beat to throw the swarm apart.",
606        kind: ParamKind::Modal,
607    },
608    crate::render::scenes::common::hue(DEFAULT_HUE),
609    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
610    ParamSpec {
611        name: "size",
612        default: 1.0,
613        range: Some([0.0, 4.0]),
614        doc: "Size of each particle's mark.",
615        kind: ParamKind::Modal,
616    },
617    ParamSpec {
618        name: "field_freq",
619        default: 2.3,
620        range: Some([0.5, 8.0]),
621        doc: "Spatial frequency of the flow field; higher makes smaller, busier eddies.",
622        kind: ParamKind::Modal,
623    },
624    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
625    crate::render::scenes::common::PAN_X,
626    crate::render::scenes::common::PAN_Y,
627    ParamSpec {
628        name: "hue_spread",
629        default: 1.0,
630        range: Some([0.0, 1.0]),
631        doc: "How far across the palette the particle band reaches.",
632        kind: ParamKind::Modal,
633    },
634    ParamSpec {
635        name: "hue_center",
636        default: 0.5,
637        range: Some([0.0, 1.0]),
638        doc: "Where that band sits along the palette.",
639        kind: ParamKind::Modal,
640    },
641    crate::render::scenes::common::SATURATION,
642    crate::render::scenes::common::PALETTE_MIX,
643    crate::render::scenes::common::PALETTE_STEPS,
644    crate::render::scenes::common::PALETTE_CONTOUR,
645    ParamSpec {
646        name: "twinkle",
647        default: 0.0,
648        range: Some([0.0, 1.0]),
649        doc: "Per-particle brightness flicker, seeded so it is reproducible.",
650        kind: ParamKind::Modal,
651    },
652    ParamSpec {
653        name: "size_spread",
654        default: 0.0,
655        range: Some([0.0, 1.0]),
656        doc: "How much particle sizes vary about `size`; 0 makes them uniform.",
657        kind: ParamKind::Modal,
658    },
659    ParamSpec {
660        name: "reseed",
661        default: 0.0,
662        range: Some([0.0, 1.0]),
663        doc: "Crossing zero throws every particle back to a fresh start position.",
664        kind: ParamKind::Modal,
665    },
666    crate::render::scenes::marks::SHAPE,
667    crate::render::scenes::marks::POINTS,
668    crate::render::scenes::marks::STAR_VALLEY,
669    crate::render::scenes::marks::STAR_CURVE,
670    crate::render::scenes::marks::STAR_JITTER,
671];
672
673impl Scene for SwarmScene {
674    fn name(&self) -> &'static str {
675        "swarm"
676    }
677
678    fn advance(&mut self, dt: f32) {
679        self.dt = dt;
680    }
681
682    fn set_time(&mut self, time: f32) {
683        self.time = time;
684    }
685
686    fn set_palette(&mut self, palette: &Palette) {
687        // CPU-sampled per particle in `update`; a cheap array copy, off the hot
688        // path (once per preset switch).
689        self.palette = palette.clone();
690    }
691
692    fn reset_params(&mut self) {
693        self.force = DEFAULT_FORCE;
694        self.spin = DEFAULT_SPIN;
695        self.burst = DEFAULT_BURST;
696        self.colour.reset();
697        self.pan.reset();
698        self.size = DEFAULT_SIZE;
699        self.field_freq = DEFAULT_FIELD_FREQ;
700        self.zoom = DEFAULT_ZOOM;
701        self.hue_spread = DEFAULT_HUE_SPREAD;
702        self.hue_center = DEFAULT_HUE_CENTER;
703        self.shape = DEFAULT_SHAPE;
704        self.points = DEFAULT_POINTS;
705        self.star_valley = DEFAULT_STAR_VALLEY;
706        self.star_curve = DEFAULT_STAR_CURVE;
707        self.star_jitter = DEFAULT_STAR_JITTER;
708        self.twinkle = DEFAULT_TWINKLE;
709        self.size_spread = DEFAULT_SIZE_SPREAD;
710        // `prev_reseed` is deliberately NOT reset: this runs every frame
711        // before the bindings are routed, and resetting the previous level
712        // would turn a held gate into an edge per frame — a continuous
713        // disturbance in place of a percussive one (measured while building
714        // this: the population never re-gathered at all). The attractor's
715        // reset_params makes the same omission for the same reason.
716        self.reseed = 0.0;
717    }
718
719    fn set_param(&mut self, name: &str, value: f32) {
720        // The shared param blocks first, this scene's own names after
721        // (`scenes::common`).
722        if self.colour.set(name, value) || self.pan.set(name, value) {
723            return;
724        }
725        match name {
726            "force" => self.force = value,
727            "spin" => self.spin = value,
728            "burst" => self.burst = value,
729            "size" => self.size = value,
730            "field_freq" => self.field_freq = value,
731            "zoom" => self.zoom = value,
732            "hue_spread" => self.hue_spread = value,
733            "hue_center" => self.hue_center = value,
734            "shape" => self.shape = value,
735            "points" => self.points = value,
736            "star_valley" => self.star_valley = value,
737            "star_curve" => self.star_curve = value,
738            "star_jitter" => self.star_jitter = value,
739            "twinkle" => self.twinkle = value,
740            "size_spread" => self.size_spread = value,
741            "reseed" => self.reseed = value,
742            _ => {}
743        }
744    }
745
746    #[allow(
747        clippy::indexing_slicing,
748        reason = "pos/vel index fixed [f32; 2] and base indexes a fixed [f32; 3], all at constant offsets, always in-bounds"
749    )]
750    fn update(&mut self, _frame: &AnalysisFrame) {
751        // Rising-edge detect on `reseed` (Plan 0077 Phase 3): **disturb** the
752        // existing population where it is, by a seeded, domain-relative kick —
753        // ADR-0066's semantics, not the box respawn it removed. The kick is a
754        // pure function of (particle index, reseed ordinal), so a capture
755        // remains reproducible; unbound, `reseed` and `prev_reseed` sit at 0
756        // and this path never touches a position.
757        if self.reseed >= RESEED_THRESHOLD && self.prev_reseed < RESEED_THRESHOLD {
758            self.reseed_count = self.reseed_count.wrapping_add(1);
759            let salt = self.reseed_count.wrapping_mul(0x9E37_79B9);
760            for (i, p) in self.particles.iter_mut().enumerate() {
761                let seed = (i as u32).wrapping_add(salt);
762                p.pos[0] += (unit(seed, channel::RESEED_X) - 0.5) * 2.0 * RESEED_KICK;
763                p.pos[1] += (unit(seed, channel::RESEED_Y) - 0.5) * 2.0 * RESEED_KICK;
764                // No wrap here: a kick of ±RESEED_KICK cannot overshoot the ±1
765                // seam by more than itself, and the integration loop below
766                // wraps every position this same frame.
767            }
768        }
769        self.prev_reseed = self.reseed;
770
771        // Field evolves at `spin`; `force` steers, `burst` shoves outward. The
772        // clock integrates here, after this frame's `set_param` calls have
773        // landed, so it advances at *this* frame's rate.
774        self.field_phase.step(self.spin, self.dt);
775        let field_t = self.field_phase.get();
776        let force = self.force;
777        let burst_kick = self.burst;
778        // Hoisted out of the loop: one read, 10 000 uses (Plan 0043 Phase 2).
779        let field_freq = self.field_freq;
780        // The individuation pair, resolved at draw like the emitter's: an
781        // eased width moves the whole population continuously instead of only
782        // particles spawned since the change (Plan 0077 Phase 2).
783        let twinkle = self.twinkle;
784        let size_spread = self.size_spread;
785        let time = self.time;
786
787        // Frame-rate-independent integration (Plan 0014 Phase 2): scale the
788        // acceleration/advection by real `dt`, and raise the per-frame damping to
789        // the `dt`-relative power so the velocity decays at the same wall-clock
790        // rate regardless of refresh (one `powf` per frame, not per particle).
791        // At `dt == FALLBACK_DT` (1/60) this reduces to the former fixed step, so
792        // the look is unchanged live and byte-identical under fixed-`dt` capture.
793        let dt = self.dt;
794        let damp = DAMPING.powf(dt * 60.0);
795
796        // The domain follows the render target (Plan 0043 Phase 1). Computed once
797        // per frame, outside the loop; positions are normalized, so a change in
798        // these rescales the whole field at once instead of wrapping particles
799        // individually — no resize teleport (ADR-0044).
800        let (bound_x, bound_y) = bounds(self.aspect);
801
802        for (p, inst) in self.particles.iter_mut().zip(self.instance_data.iter_mut()) {
803            // Normalized torus position -> world, which is what the field, the
804            // burst and the sprite all work in.
805            let world = [p.pos[0] * bound_x, p.pos[1] * bound_y];
806
807            // Scalar potential -> flow direction (cheap curl-ish field), sampled at
808            // a depth-dependent phase so each layer rides its own currents rather
809            // than the same streamlines at several sizes (Plan 0043 Phase 3). The
810            // two axes take different offsets, so layers decorrelate in both.
811            let zo = p.z * DEPTH_FIELD_OFFSET;
812            let a = (world[0] * field_freq + field_t + zo).sin()
813                + (world[1] * field_freq - field_t * 0.8 - zo * 0.7).cos();
814            let dir = [a.cos(), a.sin()];
815
816            p.vel[0] = p.vel[0] * damp + dir[0] * force * dt;
817            p.vel[1] = p.vel[1] * damp + dir[1] * force * dt;
818
819            // Beat burst pushes particles radially outward from center.
820            if burst_kick > 0.0 {
821                let r = (world[0] * world[0] + world[1] * world[1]).sqrt().max(1e-3);
822                p.vel[0] += world[0] / r * burst_kick * dt;
823                p.vel[1] += world[1] / r * burst_kick * dt;
824            }
825
826            // Integrate a world-space velocity into a normalized position.
827            p.pos[0] += p.vel[0] * dt / bound_x;
828            p.pos[1] += p.vel[1] * dt / bound_y;
829
830            // Toroidal wrap keeps the field populated (no respawns/hitches). In
831            // normalized space the seam is at +/-1 whatever the target is, and it
832            // is `MARGIN` past the visible frame — which is what stopped it from
833            // burning a bright bar into the feedback stage (ADR-0044).
834            if p.pos[0] > 1.0 {
835                p.pos[0] -= 2.0;
836            } else if p.pos[0] < -1.0 {
837                p.pos[0] += 2.0;
838            }
839            if p.pos[1] > 1.0 {
840                p.pos[1] -= 2.0;
841            } else if p.pos[1] < -1.0 {
842                p.pos[1] += 2.0;
843            }
844
845            let speed = (p.vel[0] * p.vel[0] + p.vel[1] * p.vel[1]).sqrt();
846            // Colour through the shared LUT (ADR-0021): the per-particle hue is
847            // mapped into the `hue_spread`/`hue_center` band, then desaturated by
848            // the shared `saturation`. Defaults reproduce the prior full-wheel look.
849            let coord = hue_coord(self.hue_center, self.hue_spread, p.hue, self.colour.hue);
850            // Hard bands on the palette coordinate (ADR-0078), the canonical
851            // `palette::band_coord` called rather than copied. `palette_steps <= 1`
852            // returns it untouched, so an unbound preset is byte-unchanged.
853            let base = palette::desaturate(
854                self.palette.sample(
855                    palette::band_coord(coord, self.colour.steps),
856                    self.colour.mix,
857                ),
858                self.colour.saturation,
859            );
860            // Depth, resolved into the three visual terms it drives (Plan 0043
861            // Phase 3). Three `mul_add`-shaped lerps on a value that never changes
862            // — the whole per-particle cost of the depth axis.
863            let depth_scale = DEPTH_SCALE_FAR + (DEPTH_SCALE_NEAR - DEPTH_SCALE_FAR) * p.z;
864            let depth_fade = DEPTH_FADE_FAR + (DEPTH_FADE_NEAR - DEPTH_FADE_FAR) * p.z;
865            let parallax = DEPTH_PARALLAX_FAR + (DEPTH_PARALLAX_NEAR - DEPTH_PARALLAX_FAR) * p.z;
866
867            // The speed cue predates depth and still earns its place: on a coherent
868            // field the fast channels read brighter than slack water. The
869            // atmospheric fade multiplies it rather than replacing it. The
870            // twinkle factor is exactly 1.0 when `twinkle` is unbound, and the
871            // size factor exactly 1.0 at zero spread — multiplying by either is
872            // bit-exact, which is what keeps the shipped captures byte-identical
873            // (Plan 0077 Phase 2).
874            let bright = ((0.25 + speed * 0.7) * p.bright).min(1.6)
875                * self.colour.brightness
876                * depth_fade
877                * twinkle_factor(p.twinkle_freq, p.twinkle_phase, time, twinkle);
878
879            *inst = marks::QuadInstance {
880                center: [p.pos[0] * bound_x, p.pos[1] * bound_y],
881                size: p.size * self.size * depth_scale * size_factor(p.size_unit, size_spread),
882                color: [base[0] * bright, base[1] * bright, base[2] * bright],
883                attr: parallax,
884            };
885        }
886    }
887
888    fn render(
889        &mut self,
890        queue: &wgpu::Queue,
891        encoder: &mut wgpu::CommandEncoder,
892        view: &wgpu::TextureView,
893        aspect: f32,
894    ) {
895        // The domain the *next* `update` wraps against (Plan 0043 Phase 1). This
896        // argument is the render target's aspect — the only correct source for a
897        // shape (ADR-0037); see the field's docs for why `set_target_size` is not.
898        self.aspect = aspect.max(0.1);
899        self.quads.write_instances(queue, &self.instance_data);
900        self.quads.write_uniform(
901            queue,
902            &marks::QuadUniform {
903                v: [self.aspect, self.zoom, self.pan.x, self.pan.y],
904                // Quantized here, on the way into the uniform, so the shader's
905                // precondition stays visible on the CPU side: the roster's
906                // bounds and the integer point count live in `marks`, and no
907                // fractional value ever reaches an angular fold (ADR-0084).
908                m: [
909                    marks::mark_shape(self.shape),
910                    marks::mark_points(self.points),
911                    0.0,
912                    0.0,
913                ],
914                s: [
915                    marks::star_valley(self.star_valley),
916                    marks::star_curve(self.star_curve),
917                    marks::star_jitter(self.star_jitter),
918                    0.0,
919                ],
920            },
921        );
922
923        // Load over the engine backdrop (ADR-0018): the additive particles
924        // bloom over whatever the background pass painted, so the sparse gaps
925        // between them reveal it.
926        self.quads.draw(
927            encoder,
928            "swarm-pass",
929            view,
930            wgpu::LoadOp::Load,
931            self.particles.len() as u32,
932        );
933    }
934}
935
936#[cfg(test)]
937mod tests;