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::{ParamGroup, 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;
178const DEFAULT_STAR_SEED: f32 = marks::DEFAULT_STAR_SEED;
179const DEFAULT_STAR_WOBBLE: f32 = marks::DEFAULT_STAR_WOBBLE;
180const DEFAULT_STAR_WOBBLE_FREQ: f32 = marks::DEFAULT_STAR_WOBBLE_FREQ;
181
182/// The scene's own WGSL. The shared mark-silhouette chunk
183/// ([`marks::sdf_wgsl`]) is prepended at module creation, so `mark_distance` here
184/// is the same function the emitter evaluates.
185///
186/// **`shape` and `points` travel vertex -> fragment as flat varyings rather than
187/// being read from `misc` in the fragment stage**, and that is deliberate. The
188/// fragment stage cannot see this scene's uniform without widening the bind
189/// layout's visibility to `VERTEX_FRAGMENT` — which would make this descriptor
190/// byte-identical to the line renderer's (`{uniform, VERTEX_FRAGMENT,
191/// min_binding_size: None}`), the exact collision shape ADR-0058 records and the
192/// one the emitter's layout comment says not to tidy back in. A flat varying
193/// carries a per-draw value with no descriptor change at all.
194const SHADER: &str = r#"
195struct Misc {
196    // x: aspect, y: zoom, zw: pan (the shared ViewTransform, ADR-0018)
197    v: vec4<f32>,
198    // x: mark shape position, y: quantized point count (ADR-0084), z: the star
199    // arm's arrangement seed, w: its edge-wobble amplitude. Per draw, not per
200    // instance: the branch stays uniform across a warp and `Instance` does not
201    // grow.
202    m: vec4<f32>,
203    // xyz: the star arm's shape params (valley, curve, jitter), w: the edge
204    // wobble's frequency — all conditioned CPU-side (Plan 0091 Phase 5). Per
205    // draw, like `m`. Inert on every other shape, and at their defaults the arm
206    // takes its original closed form.
207    //
208    // The three hand-drawn controls sit in the padding these two rows already
209    // carried, which is what keeps this uniform and the bind layout over it the
210    // shapes they were (ADR-0058).
211    s: vec4<f32>,
212}
213
214@group(0) @binding(0) var<uniform> misc: Misc;
215
216struct VsOut {
217    @builtin(position) pos: vec4<f32>,
218    @location(0) local: vec2<f32>,
219    @location(1) color: vec3<f32>,
220    @location(2) @interpolate(flat) shape: f32,
221    @location(3) @interpolate(flat) points: f32,
222    @location(4) @interpolate(flat) star: vec3<f32>,
223    @location(5) @interpolate(flat) rough: vec3<f32>,
224}
225
226@vertex
227fn vs_main(
228    @builtin(vertex_index) vi: u32,
229    @location(0) center: vec2<f32>,
230    @location(1) size: f32,
231    @location(2) color: vec3<f32>,
232    @location(3) parallax: f32,
233) -> VsOut {
234    var corners = array<vec2<f32>, 6>(
235        vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
236        vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
237    );
238    let c = corners[vi] * 2.0 - vec2<f32>(1.0, 1.0);
239    // Shared ViewTransform (ADR-0018): zoom about the frame centre, then pan the
240    // particle position; the sprite quad (c * size) keeps its on-screen size.
241    //
242    // Depth parallax (Plan 0043 Phase 3): `parallax` is the per-particle strength
243    // the CPU derived from `z`, so a near particle takes more of the pan and more
244    // of the zoom deflection than a far one and the layers slide across each other
245    // as the camera moves. At the identity transform (zoom 1, pan 0) this reduces
246    // to `center` for every depth, so an unbound preset is untouched.
247    let zoom = misc.v.y;
248    let pan = misc.v.zw;
249    let center_v = center * (1.0 + (zoom - 1.0) * parallax) + pan * parallax;
250    let world = center_v + c * size;
251    var out: VsOut;
252    out.pos = vec4<f32>(world.x / misc.v.x, world.y, 0.0, 1.0);
253    out.local = c;
254    out.color = color;
255    out.shape = misc.m.x;
256    out.points = misc.m.y;
257    out.star = misc.s.xyz;
258    out.rough = vec3<f32>(misc.m.z, misc.m.w, misc.s.w);
259    return out;
260}
261
262@fragment
263fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
264    // The silhouette (ADR-0084). At the default `disc` this is `length(in.local)`
265    // and nothing else, so an unshaped swarm is the arithmetic it always was; the
266    // falloff below is untouched either way, so a visual change is attributable
267    // to the shape alone.
268    let d = mark_distance(in.local, in.shape, in.points, in.star, in.rough);
269    let falloff = max(0.0, 1.0 - d);
270    let g = falloff * falloff;
271    // Premultiplied: colour AND alpha carry the same coverage `g`, so the four
272    // corners outside the inscribed disc write nothing at all rather than
273    // opaque black (ADR-0056). See `gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE`.
274    return vec4<f32>(in.color * g, g);
275}
276"#;
277
278struct Particle {
279    /// Position on the torus in **normalized** domain coordinates, each axis in
280    /// `[-1, 1)`; world position is this times the current half-extents (Plan 0043
281    /// Phase 1).
282    ///
283    /// Normalized rather than world-space for one reason: the half-extents now
284    /// follow the render target, so they change on a resize, and a world-space
285    /// store would have to either re-wrap (teleporting every particle that fell
286    /// outside the new domain, all in one frame) or rescale every position by
287    /// hand. Here the resize *is* the rescale — each particle keeps its place on
288    /// the torus and the field stretches with the frame, which is the
289    /// discontinuity-free resize ADR-0044 requires. It also keeps the seeded
290    /// scatter aspect-independent, so the same seed gives the same field at any
291    /// target size (NFR §6).
292    pos: [f32; 2],
293    /// Velocity in **world** units per second — the flow field and the burst are
294    /// screen-space forces, so they must not change magnitude with the domain.
295    vel: [f32; 2],
296    /// Per-particle twinkle oscillator (Plan 0077 Phase 2): rate in Hz from
297    /// the `TWINKLE_FREQ_LO..HI` band and phase in cycles, both off the
298    /// particle's stable identity through [`unit`]. Fixed for the particle's
299    /// life; resolved into a brightness factor at draw only when `twinkle`
300    /// is bound.
301    twinkle_freq: f32,
302    twinkle_phase: f32,
303    /// The particle's unit draw for `size_spread`, resolved at draw time so an
304    /// eased spread moves the whole population continuously (the emitter's
305    /// reasoning for draw-time resolution, verbatim).
306    size_unit: f32,
307    /// Depth: 0 = far, 1 = near (Plan 0043 Phase 3). Drives sprite scale, an
308    /// atmospheric brightness fade, a parallax offset against the shared view
309    /// transform, and which current the particle rides — **never** sorting, since
310    /// the scene blends additively (ADR-0044).
311    ///
312    /// Fixed for the particle's life, like `hue` and `bright`: it comes off the
313    /// seeded scatter, so the same seed gives the same depth sequence every run
314    /// (NFR §6).
315    z: f32,
316    /// Per-particle palette offset and brightness, from the seeded scatter.
317    hue: f32,
318    bright: f32,
319    size: f32,
320}
321
322/// ~10k-particle CPU flow-field swarm, driven by named preset parameters.
323pub struct SwarmScene {
324    /// The instance buffer, the view/silhouette uniform, the bind group over the
325    /// layout declared below, and the instanced-quad pipeline (ADR-0007).
326    quads: marks::InstancedQuads,
327    particles: Vec<Particle>,
328    /// This frame's marks, rebuilt in place every `update` — the fourth attribute
329    /// is this scene's **depth parallax**, resolved from the particle's `z` on the
330    /// CPU so the shader needs no depth constants (Plan 0043 Phase 3).
331    instance_data: Vec<marks::QuadInstance>,
332    /// Shared scene clock (seconds), set by the renderer each frame.
333    time: f32,
334    /// The **render target's** aspect, recorded by `render` for the next `update`
335    /// to size the toroidal domain from (Plan 0043 Phase 1).
336    ///
337    /// Read off `render`'s argument and deliberately **not** off
338    /// [`Scene::set_target_size`](super::Scene::set_target_size), which carries the
339    /// post chain's internal grid — a quantized *resolution*, not a shape, whose
340    /// aspect is only approximately the target's (ADR-0037). Every swarm preset
341    /// composes `trails`, so that grid is exactly the quantized case; taking a
342    /// domain shape from it is the defect ADR-0037 was written for.
343    ///
344    /// One frame behind by construction: `update` runs before `render` in a frame,
345    /// so the domain follows the target with a single frame of lag. Harmless —
346    /// positions are normalized, so a change rescales the field continuously.
347    aspect: f32,
348    /// Real elapsed seconds for this frame's integration (Plan 0014 Phase 2),
349    /// injected via `advance` so the swarm moves at the same wall-clock rate on
350    /// any refresh. Seeded to the fallback step for the first frame before any
351    /// `advance` call.
352    dt: f32,
353    force: f32,
354    spin: f32,
355    /// The curl-noise field's own clock, integrated at `spin` ([`Phase`]).
356    ///
357    /// **Not `time * spin`** (ADR-0135). `spin` is the one rate here two shipped
358    /// worlds bind to a band, and under the multiply a binding that moved
359    /// rescaled every second already elapsed: at t = 100 s a 0.04 swing advanced
360    /// this clock by 4 s in a single frame against a nominal 0.019 s, and the
361    /// field re-rolled rather than flowing on. The particles steer by the field,
362    /// so it reads as the flow changing its mind, not as a teleport.
363    field_phase: Phase,
364    burst: f32,
365    /// The shared palette knobs (ADR-0021).
366    colour: common::PaletteParams,
367    /// The shared view transform (ADR-0018).
368    pan: common::PanParams,
369    size: f32,
370    field_freq: f32,
371    zoom: f32,
372    /// The active baked palette (ADR-0021), sampled per particle on the CPU. Set
373    /// by `set_palette` on a preset switch; default `spectrum` reproduces the
374    /// prior cosine.
375    palette: Palette,
376    /// Per-particle hue band + shared desaturation (ADR-0021).
377    hue_spread: f32,
378    hue_center: f32,
379    /// The mark silhouette and its point count, **as bound** (ADR-0084). Both
380    /// are conditioned on the way to the uniform rather than here, and the two
381    /// conditionings differ: `points` is quantized, so a `[smoothing]`-eased
382    /// binding steps at the midpoints (see [`marks::mark_points`]), while
383    /// `shape` is only clamped, so an eased binding travels between two arms
384    /// (ADR-0226, [`marks::mark_shape`]).
385    shape: f32,
386    points: f32,
387    /// The `star` arm's three shape params, raw as the preset bound them
388    /// (Plan 0091 Phase 5). `marks::star_*` condition them on the way to the
389    /// uniform. Inert on every other silhouette, and nothing warns —
390    /// `presets/README.md` carries that.
391    star_valley: f32,
392    star_curve: f32,
393    star_jitter: f32,
394    star_seed: f32,
395    star_wobble: f32,
396    star_wobble_freq: f32,
397    /// Per-mark individuation (Plan 0077 Phase 2): the twinkle depth and the
398    /// size-spread width, both resolved per particle at draw.
399    twinkle: f32,
400    size_spread: f32,
401    /// This frame's `reseed` level (bound to a beat/onset expression); its
402    /// rising edge past [`RESEED_THRESHOLD`] disturbs the population once
403    /// (Plan 0077 Phase 3, ADR-0066 semantics).
404    reseed: f32,
405    /// Previous frame's `reseed`, for rising-edge detection.
406    prev_reseed: f32,
407    /// How many reseeds have fired. Salts the per-particle kick draw so
408    /// successive reseeds scatter differently (the attractor's convention).
409    reseed_count: u32,
410}
411
412impl SwarmScene {
413    /// Build the pipeline, buffers, and seeded particle set on `device`.
414    /// `particles` is the active tier's
415    /// [`swarm_particles`](crate::render::TierConfig::swarm_particles). The count
416    /// is fixed for the life of the scene — the instance buffer and the CPU
417    /// mirror are both sized to it here, so the per-frame path never allocates —
418    /// and a tier change rebuilds the scene rather than resizing it.
419    pub fn new(
420        device: &wgpu::Device,
421        surface_format: wgpu::TextureFormat,
422        particles: usize,
423    ) -> Self {
424        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
425            label: Some("swarm-shader"),
426            // The shared silhouette chunk first, then this scene's own source —
427            // one `mark_distance`, two scenes (ADR-0084).
428            source: wgpu::ShaderSource::Wgsl(format!("{}{SHADER}", marks::sdf_wgsl()).into()),
429        });
430        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
431            label: Some("swarm-bind-layout"),
432            entries: &[wgpu::BindGroupLayoutEntry {
433                binding: 0,
434                visibility: wgpu::ShaderStages::VERTEX,
435                ty: wgpu::BindingType::Buffer {
436                    ty: wgpu::BufferBindingType::Uniform,
437                    has_dynamic_offset: false,
438                    min_binding_size: None,
439                },
440                count: None,
441            }],
442        });
443        let quads = marks::InstancedQuads::new(
444            device,
445            "swarm",
446            particles,
447            &shader,
448            &bind_layout,
449            surface_format,
450        );
451
452        let mut rng = SeededRng::new(SEED);
453        // The individuation draws come off the particle's index through `unit`,
454        // NOT off `rng`: an extra `SeededRng` draw per particle would shift the
455        // stream for every draw after it and re-scatter the whole field, and
456        // the defaults' byte-identity claim (Plan 0077 Phase 2) rests on the
457        // existing scatter being untouched.
458        let particle_state: Vec<Particle> = (0..particles)
459            .map(|i| {
460                let mut p = Self::spawn(&mut rng);
461                let seed = i as u32;
462                p.twinkle_freq = TWINKLE_FREQ_LO
463                    + unit(seed, channel::TWINKLE_FREQ) * (TWINKLE_FREQ_HI - TWINKLE_FREQ_LO);
464                p.twinkle_phase = unit(seed, channel::TWINKLE_PHASE);
465                p.size_unit = unit(seed, channel::SIZE);
466                p
467            })
468            .collect();
469
470        Self {
471            quads,
472            particles: particle_state,
473            instance_data: vec![
474                marks::QuadInstance {
475                    center: [0.0, 0.0],
476                    size: 0.0,
477                    color: [0.0, 0.0, 0.0],
478                    attr: 1.0,
479                };
480                particles
481            ],
482            time: 0.0,
483            aspect: FALLBACK_ASPECT,
484            dt: FALLBACK_DT,
485            force: DEFAULT_FORCE,
486            spin: DEFAULT_SPIN,
487            field_phase: Phase::default(),
488            burst: DEFAULT_BURST,
489            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
490            pan: common::PanParams::default(),
491            size: DEFAULT_SIZE,
492            field_freq: DEFAULT_FIELD_FREQ,
493            zoom: DEFAULT_ZOOM,
494            palette: Palette::default_spectrum(),
495            hue_spread: DEFAULT_HUE_SPREAD,
496            hue_center: DEFAULT_HUE_CENTER,
497            shape: DEFAULT_SHAPE,
498            points: DEFAULT_POINTS,
499            star_valley: DEFAULT_STAR_VALLEY,
500            star_curve: DEFAULT_STAR_CURVE,
501            star_jitter: DEFAULT_STAR_JITTER,
502            star_seed: DEFAULT_STAR_SEED,
503            star_wobble: DEFAULT_STAR_WOBBLE,
504            star_wobble_freq: DEFAULT_STAR_WOBBLE_FREQ,
505            twinkle: DEFAULT_TWINKLE,
506            size_spread: DEFAULT_SIZE_SPREAD,
507            reseed: 0.0,
508            prev_reseed: 0.0,
509            reseed_count: 0,
510        }
511    }
512
513    /// A particle scattered across the field with a random heading and tint.
514    ///
515    /// The scatter is in **normalized** domain coordinates, so it does not depend
516    /// on the render target — the same seed gives the same field at any size
517    /// (NFR §6).
518    #[allow(
519        clippy::indexing_slicing,
520        reason = "pos/vel index a fixed [f32; 2] at constant 0/1, always in-bounds"
521    )]
522    fn spawn(rng: &mut SeededRng) -> Particle {
523        let angle = rng.range(0.0, std::f32::consts::TAU);
524        Particle {
525            pos: [rng.range(-1.0, 1.0), rng.range(-1.0, 1.0)],
526            vel: [angle.cos() * 0.2, angle.sin() * 0.2],
527            z: rng.next_f32(),
528            hue: rng.next_f32(),
529            bright: rng.range(0.5, 1.0),
530            size: rng.range(0.004, 0.011),
531            // Neutral; `new` overwrites all three from the particle's index.
532            // Deliberately not drawn from `rng` — see the comment there.
533            twinkle_freq: 0.0,
534            twinkle_phase: 0.0,
535            size_unit: 0.5,
536        }
537    }
538}
539
540/// The toroidal world half-extents for a render target of this aspect (Plan 0043
541/// Phase 1).
542///
543/// The visible frame is `|world.y| <= 1` and `|world.x| <= aspect` — the shader
544/// divides x by the aspect on its way to NDC — so this is the visible rectangle
545/// scaled by [`MARGIN`], which is what puts the wrap seam off-screen. At
546/// `MARGIN = 1` and 16:9 it returns `(1.78, 1.0)`, i.e. the constants it replaces.
547fn bounds(aspect: f32) -> (f32, f32) {
548    (aspect * MARGIN, MARGIN)
549}
550
551/// The LUT sample coordinate for one particle (ADR-0021): its per-particle hue
552/// occupies the band `hue_center + (particle_hue - 0.5) * hue_spread`, plus the
553/// shared `hue` rotation. Defaults (`center = 0.5`, `spread = 1`, `hue = 0`)
554/// reduce to `particle_hue`, reproducing the prior full-wheel look.
555fn hue_coord(hue_center: f32, hue_spread: f32, particle_hue: f32, hue: f32) -> f32 {
556    hue_center + (particle_hue - 0.5) * hue_spread + hue
557}
558
559/// The individuation contract, mirrored from the emitter (`emitter.rs`'s
560/// `unit`, which is private to that scene): a per-particle quantity is a pure
561/// function of `(seed, channel)` — splitmix64's finalizer applied as a hash.
562/// The swarm's `seed` is the particle's index in the seeded pool, which is
563/// stable for the scene's life, and the hash runs at construction only.
564fn unit(seed: u32, k: u32) -> f32 {
565    let mut z = ((seed as u64) << 32 | k as u64).wrapping_add(0x9E37_79B9_7F4A_7C15);
566    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
567    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
568    z ^= z >> 31;
569    (z >> 40) as f32 / (1u64 << 24) as f32
570}
571
572/// Seed channels, named so a later quantity cannot silently reuse one and
573/// correlate itself with an existing draw (the emitter's convention).
574mod channel {
575    pub(super) const TWINKLE_FREQ: u32 = 0;
576    pub(super) const TWINKLE_PHASE: u32 = 1;
577    pub(super) const SIZE: u32 = 2;
578    pub(super) const RESEED_X: u32 = 3;
579    pub(super) const RESEED_Y: u32 = 4;
580}
581
582/// The particle's brightness multiplier under `twinkle` — the emitter's
583/// semantics on the emitter's frequency band, over the pre-resolved
584/// per-particle rate and phase. Exactly `1.0` at `twinkle <= 0`, which is what
585/// makes the defaults' byte-identity falsifiable in both directions; clamped
586/// at zero because `twinkle` is a preset expression and may exceed 1, and a
587/// negative multiplier would subtract light rather than removing it.
588fn twinkle_factor(freq: f32, phase: f32, time: f32, twinkle: f32) -> f32 {
589    if twinkle <= 0.0 {
590        return 1.0;
591    }
592    let wave = (std::f32::consts::TAU * (freq * time + phase)).sin();
593    (1.0 + twinkle * wave).max(0.0)
594}
595
596/// The particle's size multiplier within `size_spread` — the emitter's
597/// `size_factor`, over the pre-resolved unit draw. Exactly `1.0` at zero
598/// spread (the default), on top of the scatter's own seeded base size.
599fn size_factor(size_unit: f32, size_spread: f32) -> f32 {
600    (1.0 + (size_unit - 0.5) * size_spread).max(0.0)
601}
602
603/// Parameter vocabulary — see [`fragment_field::PARAMS`](super::fragment_field::PARAMS).
604/// **Keep in sync with `set_param` below.**
605pub const PARAMS: &[ParamSpec] = &[
606    ParamSpec {
607        name: "force",
608        default: 1.4,
609        range: Some([0.0, 4.0]),
610        doc: "How hard the flow field pushes each particle, so higher is faster and straighter.",
611        kind: ParamKind::Modal,
612        group: ParamGroup::Motion,
613        main: true,
614    },
615    ParamSpec {
616        name: "spin",
617        default: 0.3,
618        range: Some([-2.0, 2.0]),
619        doc: "Rotational bias added to the flow, curling the paths into vortices.",
620        kind: ParamKind::Modal,
621        group: ParamGroup::Motion,
622        main: true,
623    },
624    ParamSpec {
625        name: "burst",
626        default: 0.0,
627        range: Some([0.0, 2.0]),
628        doc: "An outward impulse from the centre, for a beat to throw the swarm apart.",
629        kind: ParamKind::Modal,
630        group: ParamGroup::Motion,
631        main: false,
632    },
633    crate::render::scenes::common::hue(DEFAULT_HUE),
634    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
635    ParamSpec {
636        name: "size",
637        default: 1.0,
638        range: Some([0.0, 4.0]),
639        doc: "Size of each particle's mark.",
640        kind: ParamKind::Modal,
641        group: ParamGroup::Shape,
642        main: true,
643    },
644    ParamSpec {
645        name: "field_freq",
646        default: 2.3,
647        range: Some([0.5, 8.0]),
648        doc: "Spatial frequency of the flow field; higher makes smaller, busier eddies.",
649        kind: ParamKind::Modal,
650        group: ParamGroup::Shape,
651        main: false,
652    },
653    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
654    crate::render::scenes::common::PAN_X,
655    crate::render::scenes::common::PAN_Y,
656    ParamSpec {
657        name: "hue_spread",
658        default: 1.0,
659        range: Some([0.0, 1.0]),
660        doc: "How far across the palette the particle band reaches.",
661        kind: ParamKind::Modal,
662        group: ParamGroup::Colour,
663        main: false,
664    },
665    ParamSpec {
666        name: "hue_center",
667        default: 0.5,
668        range: Some([0.0, 1.0]),
669        doc: "Where that band sits along the palette.",
670        kind: ParamKind::Modal,
671        group: ParamGroup::Colour,
672        main: false,
673    },
674    crate::render::scenes::common::SATURATION,
675    crate::render::scenes::common::PALETTE_MIX,
676    crate::render::scenes::common::PALETTE_STEPS,
677    crate::render::scenes::common::PALETTE_CONTOUR,
678    ParamSpec {
679        name: "twinkle",
680        default: 0.0,
681        range: Some([0.0, 1.0]),
682        doc: "Per-particle brightness flicker, seeded so it is reproducible.",
683        kind: ParamKind::Modal,
684        group: ParamGroup::Light,
685        main: false,
686    },
687    ParamSpec {
688        name: "size_spread",
689        default: 0.0,
690        range: Some([0.0, 1.0]),
691        doc: "How much particle sizes vary about `size`; 0 makes them uniform.",
692        kind: ParamKind::Modal,
693        group: ParamGroup::Shape,
694        main: false,
695    },
696    ParamSpec {
697        name: "reseed",
698        default: 0.0,
699        range: Some([0.0, 1.0]),
700        doc: "Crossing zero throws every particle back to a fresh start position.",
701        kind: ParamKind::Modal,
702        group: ParamGroup::Motion,
703        main: false,
704    },
705    crate::render::scenes::marks::SHAPE,
706    crate::render::scenes::marks::POINTS,
707    crate::render::scenes::marks::STAR_VALLEY,
708    crate::render::scenes::marks::STAR_CURVE,
709    crate::render::scenes::marks::STAR_JITTER,
710    crate::render::scenes::marks::STAR_SEED,
711    crate::render::scenes::marks::STAR_WOBBLE,
712    crate::render::scenes::marks::STAR_WOBBLE_FREQ,
713];
714
715impl Scene for SwarmScene {
716    fn name(&self) -> &'static str {
717        "swarm"
718    }
719
720    fn advance(&mut self, dt: f32) {
721        self.dt = dt;
722    }
723
724    fn set_time(&mut self, time: f32) {
725        self.time = time;
726    }
727
728    fn set_palette(&mut self, palette: &Palette) {
729        // CPU-sampled per particle in `update`; a cheap array copy, off the hot
730        // path (once per preset switch).
731        self.palette = palette.clone();
732    }
733
734    fn reset_params(&mut self) {
735        self.force = DEFAULT_FORCE;
736        self.spin = DEFAULT_SPIN;
737        self.burst = DEFAULT_BURST;
738        self.colour.reset();
739        self.pan.reset();
740        self.size = DEFAULT_SIZE;
741        self.field_freq = DEFAULT_FIELD_FREQ;
742        self.zoom = DEFAULT_ZOOM;
743        self.hue_spread = DEFAULT_HUE_SPREAD;
744        self.hue_center = DEFAULT_HUE_CENTER;
745        self.shape = DEFAULT_SHAPE;
746        self.points = DEFAULT_POINTS;
747        self.star_valley = DEFAULT_STAR_VALLEY;
748        self.star_curve = DEFAULT_STAR_CURVE;
749        self.star_jitter = DEFAULT_STAR_JITTER;
750        self.star_seed = DEFAULT_STAR_SEED;
751        self.star_wobble = DEFAULT_STAR_WOBBLE;
752        self.star_wobble_freq = DEFAULT_STAR_WOBBLE_FREQ;
753        self.twinkle = DEFAULT_TWINKLE;
754        self.size_spread = DEFAULT_SIZE_SPREAD;
755        // `prev_reseed` is deliberately NOT reset: this runs every frame
756        // before the bindings are routed, and resetting the previous level
757        // would turn a held gate into an edge per frame — a continuous
758        // disturbance in place of a percussive one (measured while building
759        // this: the population never re-gathered at all). The attractor's
760        // reset_params makes the same omission for the same reason.
761        self.reseed = 0.0;
762    }
763
764    fn set_param(&mut self, name: &str, value: f32) {
765        // The shared param blocks first, this scene's own names after
766        // (`scenes::common`).
767        if self.colour.set(name, value) || self.pan.set(name, value) {
768            return;
769        }
770        match name {
771            "force" => self.force = value,
772            "spin" => self.spin = value,
773            "burst" => self.burst = value,
774            "size" => self.size = value,
775            "field_freq" => self.field_freq = value,
776            "zoom" => self.zoom = value,
777            "hue_spread" => self.hue_spread = value,
778            "hue_center" => self.hue_center = value,
779            "shape" => self.shape = value,
780            "points" => self.points = value,
781            "star_valley" => self.star_valley = value,
782            "star_curve" => self.star_curve = value,
783            "star_jitter" => self.star_jitter = value,
784            "star_seed" => self.star_seed = value,
785            "star_wobble" => self.star_wobble = value,
786            "star_wobble_freq" => self.star_wobble_freq = value,
787            "twinkle" => self.twinkle = value,
788            "size_spread" => self.size_spread = value,
789            "reseed" => self.reseed = value,
790            _ => {}
791        }
792    }
793
794    #[allow(
795        clippy::indexing_slicing,
796        reason = "pos/vel index fixed [f32; 2] and base indexes a fixed [f32; 3], all at constant offsets, always in-bounds"
797    )]
798    fn update(&mut self, _frame: &AnalysisFrame) {
799        // Rising-edge detect on `reseed` (Plan 0077 Phase 3): **disturb** the
800        // existing population where it is, by a seeded, domain-relative kick —
801        // ADR-0066's semantics, not the box respawn it removed. The kick is a
802        // pure function of (particle index, reseed ordinal), so a capture
803        // remains reproducible; unbound, `reseed` and `prev_reseed` sit at 0
804        // and this path never touches a position.
805        if self.reseed >= RESEED_THRESHOLD && self.prev_reseed < RESEED_THRESHOLD {
806            self.reseed_count = self.reseed_count.wrapping_add(1);
807            let salt = self.reseed_count.wrapping_mul(0x9E37_79B9);
808            for (i, p) in self.particles.iter_mut().enumerate() {
809                let seed = (i as u32).wrapping_add(salt);
810                p.pos[0] += (unit(seed, channel::RESEED_X) - 0.5) * 2.0 * RESEED_KICK;
811                p.pos[1] += (unit(seed, channel::RESEED_Y) - 0.5) * 2.0 * RESEED_KICK;
812                // No wrap here: a kick of ±RESEED_KICK cannot overshoot the ±1
813                // seam by more than itself, and the integration loop below
814                // wraps every position this same frame.
815            }
816        }
817        self.prev_reseed = self.reseed;
818
819        // Field evolves at `spin`; `force` steers, `burst` shoves outward. The
820        // clock integrates here, after this frame's `set_param` calls have
821        // landed, so it advances at *this* frame's rate.
822        self.field_phase.step(self.spin, self.dt);
823        let field_t = self.field_phase.get();
824        let force = self.force;
825        let burst_kick = self.burst;
826        // Hoisted out of the loop: one read, 10 000 uses (Plan 0043 Phase 2).
827        let field_freq = self.field_freq;
828        // The individuation pair, resolved at draw like the emitter's: an
829        // eased width moves the whole population continuously instead of only
830        // particles spawned since the change (Plan 0077 Phase 2).
831        let twinkle = self.twinkle;
832        let size_spread = self.size_spread;
833        let time = self.time;
834
835        // Frame-rate-independent integration (Plan 0014 Phase 2): scale the
836        // acceleration/advection by real `dt`, and raise the per-frame damping to
837        // the `dt`-relative power so the velocity decays at the same wall-clock
838        // rate regardless of refresh (one `powf` per frame, not per particle).
839        // At `dt == FALLBACK_DT` (1/60) this reduces to the former fixed step, so
840        // the look is unchanged live and byte-identical under fixed-`dt` capture.
841        let dt = self.dt;
842        let damp = DAMPING.powf(dt * 60.0);
843
844        // The domain follows the render target (Plan 0043 Phase 1). Computed once
845        // per frame, outside the loop; positions are normalized, so a change in
846        // these rescales the whole field at once instead of wrapping particles
847        // individually — no resize teleport (ADR-0044).
848        let (bound_x, bound_y) = bounds(self.aspect);
849
850        for (p, inst) in self.particles.iter_mut().zip(self.instance_data.iter_mut()) {
851            // Normalized torus position -> world, which is what the field, the
852            // burst and the sprite all work in.
853            let world = [p.pos[0] * bound_x, p.pos[1] * bound_y];
854
855            // Scalar potential -> flow direction (cheap curl-ish field), sampled at
856            // a depth-dependent phase so each layer rides its own currents rather
857            // than the same streamlines at several sizes (Plan 0043 Phase 3). The
858            // two axes take different offsets, so layers decorrelate in both.
859            let zo = p.z * DEPTH_FIELD_OFFSET;
860            let a = (world[0] * field_freq + field_t + zo).sin()
861                + (world[1] * field_freq - field_t * 0.8 - zo * 0.7).cos();
862            let dir = [a.cos(), a.sin()];
863
864            p.vel[0] = p.vel[0] * damp + dir[0] * force * dt;
865            p.vel[1] = p.vel[1] * damp + dir[1] * force * dt;
866
867            // Beat burst pushes particles radially outward from center.
868            if burst_kick > 0.0 {
869                let r = (world[0] * world[0] + world[1] * world[1]).sqrt().max(1e-3);
870                p.vel[0] += world[0] / r * burst_kick * dt;
871                p.vel[1] += world[1] / r * burst_kick * dt;
872            }
873
874            // Integrate a world-space velocity into a normalized position.
875            p.pos[0] += p.vel[0] * dt / bound_x;
876            p.pos[1] += p.vel[1] * dt / bound_y;
877
878            // Toroidal wrap keeps the field populated (no respawns/hitches). In
879            // normalized space the seam is at +/-1 whatever the target is, and it
880            // is `MARGIN` past the visible frame — which is what stopped it from
881            // burning a bright bar into the feedback stage (ADR-0044).
882            if p.pos[0] > 1.0 {
883                p.pos[0] -= 2.0;
884            } else if p.pos[0] < -1.0 {
885                p.pos[0] += 2.0;
886            }
887            if p.pos[1] > 1.0 {
888                p.pos[1] -= 2.0;
889            } else if p.pos[1] < -1.0 {
890                p.pos[1] += 2.0;
891            }
892
893            let speed = (p.vel[0] * p.vel[0] + p.vel[1] * p.vel[1]).sqrt();
894            // Colour through the shared LUT (ADR-0021): the per-particle hue is
895            // mapped into the `hue_spread`/`hue_center` band, then desaturated by
896            // the shared `saturation`. Defaults reproduce the prior full-wheel look.
897            let coord = hue_coord(self.hue_center, self.hue_spread, p.hue, self.colour.hue);
898            // Hard bands on the palette coordinate (ADR-0078), the canonical
899            // `palette::band_coord` called rather than copied. `palette_steps <= 1`
900            // returns it untouched, so an unbound preset is byte-unchanged.
901            let base = palette::desaturate(
902                self.palette.sample(
903                    palette::band_coord(coord, self.colour.steps),
904                    self.colour.mix,
905                ),
906                self.colour.saturation,
907            );
908            // Depth, resolved into the three visual terms it drives (Plan 0043
909            // Phase 3). Three `mul_add`-shaped lerps on a value that never changes
910            // — the whole per-particle cost of the depth axis.
911            let depth_scale = DEPTH_SCALE_FAR + (DEPTH_SCALE_NEAR - DEPTH_SCALE_FAR) * p.z;
912            let depth_fade = DEPTH_FADE_FAR + (DEPTH_FADE_NEAR - DEPTH_FADE_FAR) * p.z;
913            let parallax = DEPTH_PARALLAX_FAR + (DEPTH_PARALLAX_NEAR - DEPTH_PARALLAX_FAR) * p.z;
914
915            // The speed cue predates depth and still earns its place: on a coherent
916            // field the fast channels read brighter than slack water. The
917            // atmospheric fade multiplies it rather than replacing it. The
918            // twinkle factor is exactly 1.0 when `twinkle` is unbound, and the
919            // size factor exactly 1.0 at zero spread — multiplying by either is
920            // bit-exact, which is what keeps the shipped captures byte-identical
921            // (Plan 0077 Phase 2).
922            let bright = ((0.25 + speed * 0.7) * p.bright).min(1.6)
923                * self.colour.brightness
924                * depth_fade
925                * twinkle_factor(p.twinkle_freq, p.twinkle_phase, time, twinkle);
926
927            *inst = marks::QuadInstance {
928                center: [p.pos[0] * bound_x, p.pos[1] * bound_y],
929                size: p.size * self.size * depth_scale * size_factor(p.size_unit, size_spread),
930                color: [base[0] * bright, base[1] * bright, base[2] * bright],
931                attr: parallax,
932            };
933        }
934    }
935
936    fn render(
937        &mut self,
938        queue: &wgpu::Queue,
939        encoder: &mut wgpu::CommandEncoder,
940        view: &wgpu::TextureView,
941        aspect: f32,
942    ) {
943        // The domain the *next* `update` wraps against (Plan 0043 Phase 1). This
944        // argument is the render target's aspect — the only correct source for a
945        // shape (ADR-0037); see the field's docs for why `set_target_size` is not.
946        self.aspect = aspect.max(0.1);
947        self.quads.write_instances(queue, &self.instance_data);
948        self.quads.write_uniform(
949            queue,
950            &marks::QuadUniform {
951                v: [self.aspect, self.zoom, self.pan.x, self.pan.y],
952                // Quantized here, on the way into the uniform, so the shader's
953                // precondition stays visible on the CPU side: the roster's
954                // bounds and the integer point count live in `marks`, and no
955                // fractional value ever reaches an angular fold (ADR-0084).
956                m: [
957                    marks::mark_shape(self.shape),
958                    marks::mark_points(self.points),
959                    marks::star_seed(self.star_seed),
960                    marks::star_wobble(self.star_wobble),
961                ],
962                s: [
963                    marks::star_valley(self.star_valley),
964                    marks::star_curve(self.star_curve),
965                    marks::star_jitter(self.star_jitter),
966                    marks::star_wobble_freq(self.star_wobble_freq),
967                ],
968            },
969        );
970
971        // Load over the engine backdrop (ADR-0018): the additive particles
972        // bloom over whatever the background pass painted, so the sparse gaps
973        // between them reveal it.
974        self.quads.draw(
975            encoder,
976            "swarm-pass",
977            view,
978            wgpu::LoadOp::Load,
979            self.particles.len() as u32,
980        );
981    }
982}
983
984#[cfg(test)]
985mod tests;