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;