Skip to main content

rlx_core/render/scenes/
swarm.rs

1//! Particle-swarm scene: ~10k CPU-simulated particles drifting through a flow
2//! field in a perspective volume, drawn as instanced additive sprites projected
3//! through the shared camera (ADR-0259). One of the two preset-driven systems
4//! (ADR-0002 layers 1-2).
5//!
6//! Its behavior is a set of named parameters — `force`, `spin`, `burst`, `hue`,
7//! `brightness`, `size` — that a preset binds to expressions over the audio
8//! analysis (Plan 0003 Phase 5), plus a subset of the shared camera block. All
9//! per-particle math is CPU-side; no compute shader. Motion is deterministic;
10//! the only randomness is the seeded initial scatter (NFR 6).
11//!
12//! # The world is a torus in frustum coordinates
13//!
14//! A particle holds `(u, v, z)`: `z` is a view depth inside the slab
15//! `Z_NEAR..Z_FAR`, and `u`, `v` in `[-1, 1)` are its place across the
16//! frustum's cross-section **at its own depth**, times `MARGIN`. Its world
17//! position is `(u * hx(z), v * hy(z))` with `hx`, `hy` the rest frustum's
18//! half-extents at `z` scaled by the margin (`half_extent`). The torus wraps
19//! in `u` and `v`, so the seam sits the same margin outside the frame at every
20//! depth — a box of fixed world bounds would show its seam at one end of the
21//! slab or waste its population at the other (ADR-0259).
22//!
23//! The flow is evaluated in world space and a world velocity is converted back
24//! by the half-extent at the particle's depth, so a far particle crosses the
25//! screen more slowly than a near one under the same current: motion parallax
26//! out of the simulation itself. `z` rides a slow component of the same field,
27//! wraps across the slab, and a particle's light fades to zero in a band at
28//! either slab bound, so the depth wrap never pops.
29//!
30//! # The camera is a subset of the shared block
31//!
32//! The eye orbits the slab's centre at `PIVOT` and looks down `-z`. `yaw` and
33//! `pitch` are held to a sway the margin covers (`sway_bound`), and there is
34//! no `distance`: the slab is defined relative to the camera, so an orbit would
35//! show the edge of the world. `zoom` divides the field of view and `pan_*`
36//! shift after the projection (ADR-0257); neither re-maps the simulation, and a
37//! `zoom` below the margin shows the seam.
38
39// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
40// 0003 Phase 0). Runs every displayed frame.
41#![deny(
42    clippy::unwrap_used,
43    clippy::expect_used,
44    clippy::indexing_slicing,
45    clippy::panic,
46    clippy::unreachable
47)]
48
49use super::common;
50use super::marks;
51use super::{FALLBACK_DT, Phase, Scene, SeededRng};
52use crate::dsp::AnalysisFrame;
53use crate::render::camera::{self, CameraFrame, CameraParams, CameraUniform};
54use crate::render::gpu;
55use crate::render::palette::{self, Palette};
56use crate::render::scenes::{ParamGroup, ParamKind, ParamSpec, default_of};
57
58// The ASCII bytes of "LMV_SWRM" read as a number. Re-spelling them to
59// match a renamed prefix changes every particle's start state and moves
60// this scene's goldens, so the value is opaque and stays as it is.
61const SEED: u64 = 0x4C4D_565F_5357_524D;
62
63/// How far the torus extends past the visible frame, as a multiple of the rest
64/// frustum's half-extent at every depth (ADR-0044, ADR-0259).
65///
66/// At `1.0` the wrap seam would sit on the frame edge, the one line on screen
67/// every wrapping particle is guaranteed to paint, and the feedback stage
68/// integrates that into a saturated bar within a few hundred frames. At 1.25 the
69/// seam projects to normalized-device `±1.25` at rest, which leaves a quarter of
70/// the frame's half-extent for `pan_*` and the camera sway to share
71/// ([`sway_bound`]).
72///
73/// The cost is visible density: the visible fraction of the cross-section is
74/// `1 / MARGIN^2`, so a third of the population is off-screen at any moment.
75const MARGIN: f32 = 1.25;
76/// Domain aspect before the first [`Scene::render`] hands one over. Only reached
77/// on the very first `update` of a fresh scene; because positions are stored in
78/// frustum coordinates an aspect change rescales the field rather than
79/// teleporting it, so this fallback is continuous with whatever follows.
80const FALLBACK_ASPECT: f32 = 16.0 / 9.0;
81
82/// Velocity retained per frame (the rest is re-steered by the flow field).
83const DAMPING: f32 = 0.86;
84
85// --- The slab ----------------------------------------------------------------
86//
87// Depth is a view depth in camera units, and never a sort: the scene blends
88// additively, and addition is commutative, so draw order is irrelevant and no
89// depth buffer is needed (ADR-0044, ADR-0259).
90
91/// The nearest view depth a particle reaches.
92const Z_NEAR: f32 = 1.4;
93/// The farthest. `Z_FAR / Z_NEAR` is the near-to-far ratio of on-screen sprite
94/// size and of screen speed under the same world current.
95const Z_FAR: f32 = 3.8;
96/// The slab's depth.
97const Z_SPAN: f32 = Z_FAR - Z_NEAR;
98/// The eye's distance from the orbit target, which is the slab's centre: a sway
99/// turns about the middle of the volume, so the near and far layers slide past
100/// each other in opposite directions.
101const PIVOT: f32 = 0.5 * (Z_NEAR + Z_FAR);
102/// The view depth at which a sprite's `size` is stated: nearer draws larger and
103/// farther smaller, by `SIZE_DEPTH / depth`. It is where `ln(Z_FAR / Z_NEAR) /
104/// Z_SPAN`, the slab's mean of `1 / depth`, puts the mean sprite at its stated
105/// size, and where the rest frame's half-height is one world unit to within
106/// 2 %, so `field_freq` keeps the scale it is documented at.
107const SIZE_DEPTH: f32 = 2.4;
108/// The field of view the torus is sized against: the shared camera's resting
109/// `fov`. A wider bound `fov` shows more of the margin, and past
110/// `2 * atan(MARGIN * tan(REST_FOV / 2))` the seam.
111const REST_FOV: f32 = camera::FOV.default;
112/// The share of a slab bound's depth over which a particle's light fades to
113/// zero before it wraps to the other bound.
114const FADE_BAND: f32 = 0.12;
115/// The flow's phase per unit of world depth, in radians. **This is the term
116/// that makes it read as volume**: layers at different depths ride genuinely
117/// different currents, so they cross and separate the way real depth does.
118/// Sized so the whole slab spans 2.6 radians, a large fraction of the field's
119/// `TAU` period — enough to decorrelate the layers, short of wrapping them back
120/// onto each other.
121const DEPTH_FIELD_FREQ: f32 = 2.6 / Z_SPAN;
122/// The depth current's share of `force`. Small, so a particle takes tens of
123/// seconds to cross the slab and depth reads as layering, not as rushing.
124const Z_FLOW: f32 = 0.3;
125/// The share of [`sway_bound`]'s exact seam-corner bound the sway may take. The
126/// bound covers sprites up to [`SPRITE_SIZE_MAX`] at the default `size`; a bound
127/// `size` or `size_spread` can draw larger ones, which this share is not derived
128/// to cover.
129const SWAY_SHARE: f32 = 0.8;
130/// The largest seeded sprite radius, in normalized-device height at
131/// [`SIZE_DEPTH`] under the default `size`. [`sway_bound`] insets the seam by
132/// it, so a sprite whose centre is just inside the seam stays off screen too.
133const SPRITE_SIZE_MAX: f32 = 0.011;
134/// The light of a particle at the near slab bound, under `depth_fade`. With the
135/// default fade it falls to 0.45 at the far bound: the ramp ADR-0044 tuned.
136const DEPTH_LIGHT_NEAR: f32 = 1.05;
137
138/// Parameter defaults — a calm idle drift when nothing is bound.
139const DEFAULT_FORCE: f32 = default_of(PARAMS, "force");
140const DEFAULT_SPIN: f32 = default_of(PARAMS, "spin");
141const DEFAULT_BURST: f32 = default_of(PARAMS, "burst");
142const DEFAULT_HUE: f32 = 0.0;
143const DEFAULT_BRIGHTNESS: f32 = 0.8;
144const DEFAULT_SIZE: f32 = default_of(PARAMS, "size");
145/// Spatial frequency of the flow field — how many vortices fit across a world
146/// unit, and so how many distinct streams a frame can hold (Plan 0043 Phase 2).
147///
148/// It is this scene's first structural lever. Low values give a few broad
149/// currents that many particles share — which is where the family's apparent
150/// flocking comes from, since neighbours on one streamline travel together — and
151/// high values give many tight swirls. `spin` says how fast the field is rewritten;
152/// this says how finely it is divided.
153const DEFAULT_FIELD_FREQ: f32 = default_of(PARAMS, "field_freq");
154// Per-mark individuation (Plan 0077 Phase 2, backlog 0068). Both default OFF —
155// unlike the emitter's spreads, which default non-zero, the swarm's scatter
156// already ships a seeded per-particle size and brightness, so these *widen*
157// what is there and their defaults leave every capture byte-identical.
158const DEFAULT_TWINKLE: f32 = default_of(PARAMS, "twinkle");
159const DEFAULT_SIZE_SPREAD: f32 = default_of(PARAMS, "size_spread");
160/// `depth_fade` at rest: the share of [`DEPTH_LIGHT_NEAR`] a particle loses
161/// from the near slab bound to the far one, chosen so the far bound keeps 0.45.
162const DEFAULT_DEPTH_FADE: f32 = default_of(PARAMS, "depth_fade");
163/// The per-particle twinkle rate band, Hz — the emitter's values
164/// (`emitter.rs`), kept equal so `twinkle` means one thing across the two
165/// particle scenes. The spread across particles is the point, not the values:
166/// a field of oscillators sharing one rate flashes as one sheet however their
167/// phases scatter, so the **rate is drawn per particle as well as the phase**
168/// — that is what keeps the whole-frame mean steady while every member of it
169/// swings (backlog 0068's measurement).
170const TWINKLE_FREQ_LO: f32 = 0.35;
171const TWINKLE_FREQ_HI: f32 = 1.6;
172/// `reseed` rises past this to disturb the population once — edge-triggered,
173/// the attractor's constant and its reason (`particles/mod.rs`): a sustained
174/// beat flag must not disturb every frame.
175const RESEED_THRESHOLD: f32 = 0.5;
176/// Fraction of the cross-section's normalized half-extent one `reseed` kick
177/// spans per axis (Plan 0077 Phase 3, ADR-0066 semantics): the kick disturbs the
178/// population **where it is**, sized from the swarm's own domain the way
179/// `AttractorFamily::jitter_extent` derives from `seed_box` — *not* a respawn
180/// into a uniform box, which is the artifact class ADR-0066 removed and
181/// backlog 0064 caught returning once already. Positions are frustum
182/// coordinates, so a fraction here is domain-relative on any target, at any
183/// depth.
184///
185/// The value is the attractor's measured `JITTER_FRACTION`, adopted as the
186/// starting magnitude for the same figure-relative kick. ADR-0066 records the
187/// magnitude as the lever if the disturbance reads too subtle; returning to a
188/// box re-fill is not.
189const RESEED_KICK: f32 = 0.06;
190// Shared palette color knobs (ADR-0021). Each particle's hue occupies the band
191// `hue_center + (particle_hue - 0.5) * hue_spread`; the defaults (`center = 0.5`,
192// `spread = 1`) reproduce the full-wheel look (`particle_hue`), and
193// `saturation = 1` leaves color untouched — so an unbound swarm is unchanged.
194const DEFAULT_HUE_SPREAD: f32 = default_of(PARAMS, "hue_spread");
195const DEFAULT_HUE_CENTER: f32 = default_of(PARAMS, "hue_center");
196// Shared view transform (ADR-0018): identity by default. `zoom` divides the
197// camera's field of view and `pan_*` shift after the projection (ADR-0257).
198const DEFAULT_ZOOM: f32 = 1.0;
199// The mark silhouette (ADR-0084). `disc` is exactly the arithmetic the sprite
200// drew before the roster existed, so an unbound swarm is unchanged.
201const DEFAULT_SHAPE: f32 = marks::DEFAULT_SHAPE;
202const DEFAULT_POINTS: f32 = marks::DEFAULT_POINTS;
203/// The `star` arm's three shape params (Plan 0091 Phase 5), aliased beside the
204/// other two mark defaults so this scene states its whole vocabulary locally.
205const DEFAULT_STAR_VALLEY: f32 = marks::DEFAULT_STAR_VALLEY;
206const DEFAULT_STAR_CURVE: f32 = marks::DEFAULT_STAR_CURVE;
207const DEFAULT_STAR_JITTER: f32 = marks::DEFAULT_STAR_JITTER;
208const DEFAULT_STAR_SEED: f32 = marks::DEFAULT_STAR_SEED;
209const DEFAULT_STAR_WOBBLE: f32 = marks::DEFAULT_STAR_WOBBLE;
210const DEFAULT_STAR_WOBBLE_FREQ: f32 = marks::DEFAULT_STAR_WOBBLE_FREQ;
211
212/// The scene's own WGSL. The shared camera ([`camera::CAMERA_WGSL`]) and the
213/// shared mark-silhouette chunk ([`marks::sdf_wgsl`]) are prepended at module
214/// creation, so `project()` here is the function every 3D pipeline projects
215/// through and `mark_distance` is the same function the emitter evaluates.
216///
217/// **`shape` and `points` travel vertex -> fragment as flat varyings rather than
218/// being read from `misc` in the fragment stage.** The fragment stage cannot see
219/// this scene's uniforms without widening the bind layout's visibility, and a
220/// flat varying carries a per-draw value with no descriptor change at all
221/// (ADR-0058).
222const SHADER: &str = r#"
223struct Misc {
224    // x: the render target's aspect, y: the view depth a sprite's size is
225    // stated at, zw: unused.
226    v: vec4<f32>,
227    // x: mark shape position, y: quantized point count (ADR-0084), z: the star
228    // arm's arrangement seed, w: its edge-wobble amplitude. Per draw, not per
229    // instance: the branch stays uniform across a warp and the instance does
230    // not grow.
231    m: vec4<f32>,
232    // xyz: the star arm's shape params (valley, curve, jitter), w: the edge
233    // wobble's frequency — all conditioned CPU-side (Plan 0091 Phase 5). Per
234    // draw, like `m`. Inert on every other shape, and at their defaults the arm
235    // takes its original closed form.
236    s: vec4<f32>,
237}
238
239@group(0) @binding(0) var<uniform> misc: Misc;
240@group(0) @binding(1) var<uniform> cam: Camera;
241
242struct VsOut {
243    @builtin(position) pos: vec4<f32>,
244    @location(0) local: vec2<f32>,
245    @location(1) color: vec3<f32>,
246    @location(2) @interpolate(flat) shape: f32,
247    @location(3) @interpolate(flat) points: f32,
248    @location(4) @interpolate(flat) star: vec3<f32>,
249    @location(5) @interpolate(flat) rough: vec3<f32>,
250    @location(6) @interpolate(flat) presence: f32,
251    // How far the drawn quad reaches past the sharp sprite: `(r + coc) / r`,
252    // exactly 1 where the sprite is in focus.
253    @location(7) @interpolate(flat) reach: f32,
254}
255
256@vertex
257fn vs_main(
258    @builtin(vertex_index) vi: u32,
259    @location(0) center: vec3<f32>,
260    @location(1) radius: f32,
261    @location(2) color: vec3<f32>,
262    @location(3) presence: f32,
263) -> VsOut {
264    var corners = array<vec2<f32>, 6>(
265        vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
266        vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
267    );
268    let c = corners[vi] * 2.0 - vec2<f32>(1.0, 1.0);
269    // Every particle sits inside the slab, far in front of the near plane, so
270    // the divide by `w` is safe without a CPU clip.
271    let clip = project(cam, center);
272    let depth = clip.w;
273    // `radius` is the sprite's half-extent in normalized-device height at the
274    // reference depth; perspective carries it to this one. The x half-extent
275    // is divided by the target's aspect so the sprite is round on screen.
276    let r = radius * misc.v.y / depth;
277    // Depth of field (ADR-0257): the circle of confusion at this depth, in
278    // pixels of the texture drawn into, grows the sprite by `(r + coc) / r` and
279    // dims its light by the area factor, so a blurred sprite spreads its light
280    // over the larger disc rather than adding to it. At `coc = 0` both factors
281    // are exactly 1 and the sprite is the sharp one, bit for bit.
282    let r_px = r * 0.5 * cam.viewport.y;
283    let blur = coc(cam, depth);
284    let reach = select(1.0, (r_px + blur) / r_px, r_px > 0.0);
285    let keep = 1.0 / (reach * reach);
286    let ndc = clip.xy / depth + c * (r * reach) * vec2<f32>(1.0 / misc.v.x, 1.0);
287    var out: VsOut;
288    out.pos = vec4<f32>(ndc, 0.0, 1.0);
289    // The silhouette is sampled out to the grown radius, in the sharp sprite's
290    // own units, so its shape is unchanged and only its edge widens.
291    out.local = c * reach;
292    out.reach = reach;
293    out.color = color * keep;
294    out.shape = misc.m.x;
295    out.points = misc.m.y;
296    out.star = misc.s.xyz;
297    out.rough = vec3<f32>(misc.m.z, misc.m.w, misc.s.w);
298    out.presence = presence;
299    return out;
300}
301
302@fragment
303fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
304    // The silhouette (ADR-0084). At the default `disc` this is `length(in.local)`
305    // and nothing else; the falloff below is untouched either way, so a visual
306    // change is attributable to the shape alone.
307    let d = mark_distance(in.local, in.shape, in.points, in.star, in.rough);
308    // The falloff stretched to reach zero at the grown radius rather than the
309    // sharp one: `d` scales with radius on every arm, so its iso-lines keep the
310    // silhouette's shape and a blurred polygon reads as a soft polygon. At
311    // `reach = 1` this is `1 - d`.
312    let falloff = max(0.0, 1.0 - d / in.reach);
313    let g = falloff * falloff;
314    // Premultiplied: colour AND alpha carry the same coverage `g`, so the four
315    // corners outside the inscribed disc write nothing at all rather than
316    // opaque black (ADR-0056). See `gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE`.
317    // The colour already carries `presence`; the coverage takes it here.
318    return vec4<f32>(in.color * g, g * in.presence);
319}
320"#;
321
322/// One sprite as the GPU reads it.
323///
324/// **Field order is shader-location order**: `vertex_attr_array!` assigns
325/// locations by declaration order and offsets by field order, so a field
326/// inserted anywhere but the end re-points every attribute after it.
327#[repr(C)]
328#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
329struct SwarmInstance {
330    /// Centre, world space.
331    center: [f32; 3],
332    /// Half-extent in normalized-device height at [`SIZE_DEPTH`].
333    radius: f32,
334    /// Premultiplied light (ADR-0056): the colour already scaled by brightness
335    /// and by `presence`.
336    color: [f32; 3],
337    /// How present the particle is, `0..1`: its [`slab_fade`]. It scales the
338    /// sprite's coverage as the CPU scaled its light, so a particle fading out
339    /// at a slab bound stops holding the backdrop out as it stops lighting it.
340    presence: f32,
341}
342
343/// The per-draw uniform at binding 0: `v` is `[aspect, SIZE_DEPTH, 0, 0]`, `m`
344/// is `[shape, points, star_seed, star_wobble]` (ADR-0084) and `s` is
345/// `[star_valley, star_curve, star_jitter, star_wobble_freq]`, quantized and
346/// conditioned on the way in.
347#[repr(C)]
348#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
349struct SwarmUniform {
350    v: [f32; 4],
351    m: [f32; 4],
352    s: [f32; 4],
353}
354
355/// The swarm's sprite pipeline: its instance buffer, its two uniforms and the
356/// draw.
357///
358/// **The layout is this scene's own shape** — the silhouette uniform with no
359/// declared size, then the camera with its size — which no other layout in
360/// `core/src` shares (ADR-0058).
361struct Sprites {
362    pipeline: wgpu::RenderPipeline,
363    instances: wgpu::Buffer,
364    misc: wgpu::Buffer,
365    camera: wgpu::Buffer,
366    bind_group: wgpu::BindGroup,
367}
368
369impl Sprites {
370    fn new(device: &wgpu::Device, capacity: usize, target_format: wgpu::TextureFormat) -> Self {
371        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
372            label: Some("swarm-shader"),
373            // The shared camera, then the shared silhouette chunk, then this
374            // scene's own source — one `project()` for every 3D pipeline, one
375            // `mark_distance` for both mark scenes (ADR-0257, ADR-0084).
376            source: wgpu::ShaderSource::Wgsl(
377                format!("{}\n{}{SHADER}", camera::CAMERA_WGSL, marks::sdf_wgsl()).into(),
378            ),
379        });
380        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
381            label: Some("swarm-bind-layout"),
382            entries: &[
383                wgpu::BindGroupLayoutEntry {
384                    binding: 0,
385                    visibility: wgpu::ShaderStages::VERTEX,
386                    ty: wgpu::BindingType::Buffer {
387                        ty: wgpu::BufferBindingType::Uniform,
388                        has_dynamic_offset: false,
389                        min_binding_size: None,
390                    },
391                    count: None,
392                },
393                wgpu::BindGroupLayoutEntry {
394                    binding: 1,
395                    visibility: wgpu::ShaderStages::VERTEX,
396                    ty: wgpu::BindingType::Buffer {
397                        ty: wgpu::BufferBindingType::Uniform,
398                        has_dynamic_offset: false,
399                        min_binding_size: wgpu::BufferSize::new(
400                            std::mem::size_of::<CameraUniform>() as u64,
401                        ),
402                    },
403                    count: None,
404                },
405            ],
406        });
407        let instances = device.create_buffer(&wgpu::BufferDescriptor {
408            label: Some("swarm-instances"),
409            size: (capacity.max(1) * std::mem::size_of::<SwarmInstance>()) as u64,
410            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
411            mapped_at_creation: false,
412        });
413        let misc = gpu::uniform_buffer(device, "swarm-misc", std::mem::size_of::<SwarmUniform>());
414        let camera =
415            gpu::uniform_buffer(device, "swarm-camera", std::mem::size_of::<CameraUniform>());
416        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
417            label: Some("swarm-bind-group"),
418            layout: &bind_layout,
419            entries: &[
420                wgpu::BindGroupEntry {
421                    binding: 0,
422                    resource: misc.as_entire_binding(),
423                },
424                wgpu::BindGroupEntry {
425                    binding: 1,
426                    resource: camera.as_entire_binding(),
427                },
428            ],
429        });
430        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
431            label: Some("swarm-pipeline-layout"),
432            bind_group_layouts: &[Some(&bind_layout)],
433            immediate_size: 0,
434        });
435        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
436            label: Some("swarm-pipeline"),
437            layout: Some(&pipeline_layout),
438            vertex: wgpu::VertexState {
439                module: &shader,
440                entry_point: Some("vs_main"),
441                compilation_options: Default::default(),
442                buffers: &[Some(wgpu::VertexBufferLayout {
443                    array_stride: std::mem::size_of::<SwarmInstance>() as u64,
444                    step_mode: wgpu::VertexStepMode::Instance,
445                    attributes: &wgpu::vertex_attr_array![
446                        0 => Float32x3,
447                        1 => Float32,
448                        2 => Float32x3,
449                        3 => Float32,
450                    ],
451                })],
452            },
453            fragment: Some(wgpu::FragmentState {
454                module: &shader,
455                entry_point: Some("fs_main"),
456                compilation_options: Default::default(),
457                targets: &[Some(wgpu::ColorTargetState {
458                    format: target_format,
459                    // The seam every additive mark draws through (ADR-0056).
460                    blend: Some(gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE),
461                    write_mask: wgpu::ColorWrites::ALL,
462                })],
463            }),
464            primitive: wgpu::PrimitiveState::default(),
465            depth_stencil: None,
466            multisample: wgpu::MultisampleState::default(),
467            multiview_mask: None,
468            cache: None,
469        });
470        Self {
471            pipeline,
472            instances,
473            misc,
474            camera,
475            bind_group,
476        }
477    }
478
479    /// Write this frame's uniforms and sprites and encode the draw, **loading**
480    /// over what is already in `view`. The pass is begun even when there is
481    /// nothing to draw, so an idle frame encodes the same load/store pair.
482    fn draw(
483        &self,
484        queue: &wgpu::Queue,
485        encoder: &mut wgpu::CommandEncoder,
486        view: &wgpu::TextureView,
487        misc: &SwarmUniform,
488        camera: &CameraUniform,
489        sprites: &[SwarmInstance],
490    ) {
491        let mut pass = gpu::color_pass(encoder, "swarm-pass", view, wgpu::LoadOp::Load);
492        if sprites.is_empty() {
493            return;
494        }
495        queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(sprites));
496        queue.write_buffer(&self.misc, 0, bytemuck::bytes_of(misc));
497        queue.write_buffer(&self.camera, 0, bytemuck::bytes_of(camera));
498        pass.set_pipeline(&self.pipeline);
499        pass.set_bind_group(0, &self.bind_group, &[]);
500        pass.set_vertex_buffer(0, self.instances.slice(..));
501        pass.draw(0..6, 0..sprites.len() as u32);
502    }
503}
504
505struct Particle {
506    /// Position across the frustum's cross-section **at the particle's own
507    /// depth**, each axis in `[-1, 1)`; world position is this times
508    /// [`half_extent`] at `z`.
509    ///
510    /// Frustum coordinates rather than world space for two reasons. The
511    /// half-extents follow the render target, so a resize rescales the field
512    /// continuously instead of re-wrapping it (ADR-0044); and the torus wraps
513    /// at `±1` here whatever the depth, which is what keeps the seam outside
514    /// the frame at every depth (ADR-0259). It also keeps the seeded scatter
515    /// aspect-independent, so the same seed gives the same field at any target
516    /// size (NFR §6).
517    pos: [f32; 2],
518    /// View depth at rest, in `Z_NEAR..Z_FAR`.
519    z: f32,
520    /// Velocity in **world** units per second — the flow field and the burst are
521    /// world-space forces, so they must not change magnitude with the domain.
522    vel: [f32; 3],
523    /// Per-particle twinkle oscillator (Plan 0077 Phase 2): rate in Hz from
524    /// the `TWINKLE_FREQ_LO..HI` band and phase in cycles, both off the
525    /// particle's stable identity through [`unit`]. Fixed for the particle's
526    /// life; resolved into a brightness factor at draw only when `twinkle`
527    /// is bound.
528    twinkle_freq: f32,
529    twinkle_phase: f32,
530    /// The particle's unit draw for `size_spread`, resolved at draw time so an
531    /// eased spread moves the whole population continuously (the emitter's
532    /// reasoning for draw-time resolution, verbatim).
533    size_unit: f32,
534    /// Per-particle palette offset and brightness, from the seeded scatter.
535    hue: f32,
536    bright: f32,
537    size: f32,
538}
539
540/// ~10k-particle CPU flow-field swarm, driven by named preset parameters.
541pub struct SwarmScene {
542    sprites: Sprites,
543    particles: Vec<Particle>,
544    /// This frame's sprites, rebuilt in place every `update`, so the per-frame
545    /// path never allocates.
546    instance_data: Vec<SwarmInstance>,
547    /// Shared scene clock (seconds), set by the renderer each frame.
548    time: f32,
549    /// The **render target's** aspect, recorded by `render` for the next `update`
550    /// to size the frustum torus from.
551    ///
552    /// Read off `render`'s argument and deliberately **not** off
553    /// [`Scene::set_target_size`](super::Scene::set_target_size), which carries the
554    /// post chain's internal grid — a quantized *resolution*, not a shape, whose
555    /// aspect is only approximately the target's (ADR-0037). Every swarm preset
556    /// composes `trails`, so that grid is exactly the quantized case; taking a
557    /// domain shape from it is the defect ADR-0037 was written for.
558    ///
559    /// One frame behind by construction: `update` runs before `render` in a frame,
560    /// so the domain follows the target with a single frame of lag. Harmless —
561    /// positions are frustum coordinates, so a change rescales the field
562    /// continuously.
563    aspect: f32,
564    /// The pixel size of the texture this scene draws into, for the camera
565    /// uniform's viewport. A resolution only: no shape is taken from it.
566    target: (u32, u32),
567    /// Real elapsed seconds for this frame's integration (Plan 0014 Phase 2),
568    /// injected via `advance` so the swarm moves at the same wall-clock rate on
569    /// any refresh. Seeded to the fallback step for the first frame before any
570    /// `advance` call.
571    dt: f32,
572    force: f32,
573    spin: f32,
574    /// The curl-noise field's own clock, integrated at `spin` ([`Phase`]).
575    ///
576    /// **Not `time * spin`** (ADR-0135). `spin` is a rate shipped worlds bind to
577    /// a band, and under the multiply a binding that moved rescaled every second
578    /// already elapsed: at t = 100 s a 0.04 swing advanced this clock by 4 s in a
579    /// single frame against a nominal 0.019 s, and the field re-rolled rather
580    /// than flowing on.
581    field_phase: Phase,
582    burst: f32,
583    /// The shared palette knobs (ADR-0021).
584    colour: common::PaletteParams,
585    /// The shared view transform (ADR-0018).
586    pan: common::PanParams,
587    /// The camera subset this scene splices: `yaw`, `pitch`, `fov`, `focus`
588    /// and `aperture`, raw as bound. `distance`, `fog` and `solid` stay at
589    /// their resting values and are replaced when the frame is built.
590    camera: CameraParams,
591    size: f32,
592    field_freq: f32,
593    zoom: f32,
594    depth_fade: f32,
595    /// The active baked palette (ADR-0021), sampled per particle on the CPU. Set
596    /// by `set_palette` on a preset switch; default `spectrum`.
597    palette: Palette,
598    /// Per-particle hue band + shared desaturation (ADR-0021).
599    hue_spread: f32,
600    hue_center: f32,
601    /// The mark silhouette and its point count, **as bound** (ADR-0084). Both
602    /// are conditioned on the way to the uniform rather than here, and the two
603    /// conditionings differ: `points` is quantized, so a `[smoothing]`-eased
604    /// binding steps at the midpoints (see [`marks::mark_points`]), while
605    /// `shape` is only clamped, so an eased binding travels between two arms
606    /// (ADR-0226, [`marks::mark_shape`]).
607    shape: f32,
608    points: f32,
609    /// The `star` arm's three shape params, raw as the preset bound them
610    /// (Plan 0091 Phase 5). `marks::star_*` condition them on the way to the
611    /// uniform. Inert on every other silhouette, and nothing warns —
612    /// `presets/README.md` carries that.
613    star_valley: f32,
614    star_curve: f32,
615    star_jitter: f32,
616    star_seed: f32,
617    star_wobble: f32,
618    star_wobble_freq: f32,
619    /// Per-mark individuation (Plan 0077 Phase 2): the twinkle depth and the
620    /// size-spread width, both resolved per particle at draw.
621    twinkle: f32,
622    size_spread: f32,
623    /// This frame's `reseed` level (bound to a beat/onset expression); its
624    /// rising edge past [`RESEED_THRESHOLD`] disturbs the population once
625    /// (Plan 0077 Phase 3, ADR-0066 semantics).
626    reseed: f32,
627    /// Previous frame's `reseed`, for rising-edge detection.
628    prev_reseed: f32,
629    /// How many reseeds have fired. Salts the per-particle kick draw so
630    /// successive reseeds scatter differently (the attractor's convention).
631    reseed_count: u32,
632    /// The tier's cap on a sprite's circle of confusion, in pixels.
633    max_coc: f32,
634    /// This frame's blur clamp, when `aperture` is past `max_coc`, for the
635    /// renderer to announce (ADR-0007: a cap is never silent).
636    clamp: Option<super::CapOverflow>,
637}
638
639impl SwarmScene {
640    /// Build the pipeline, buffers, and seeded particle set on `device`.
641    /// `particles` is the active tier's
642    /// [`swarm_particles`](crate::render::TierConfig::swarm_particles). The count
643    /// is fixed for the life of the scene — the instance buffer and the CPU
644    /// mirror are both sized to it here, so the per-frame path never allocates —
645    /// and a tier change rebuilds the scene rather than resizing it. Blur is
646    /// held to `max_coc` pixels, the tier's cap.
647    pub fn new(
648        device: &wgpu::Device,
649        surface_format: wgpu::TextureFormat,
650        particles: usize,
651        max_coc: f32,
652    ) -> Self {
653        let sprites = Sprites::new(device, particles, surface_format);
654
655        let mut rng = SeededRng::new(SEED);
656        // The individuation draws come off the particle's index through `unit`,
657        // NOT off `rng`: an extra `SeededRng` draw per particle would shift the
658        // stream for every draw after it and re-scatter the whole field.
659        let particle_state: Vec<Particle> = (0..particles)
660            .map(|i| {
661                let mut p = Self::spawn(&mut rng);
662                let seed = i as u32;
663                p.twinkle_freq = TWINKLE_FREQ_LO
664                    + unit(seed, channel::TWINKLE_FREQ) * (TWINKLE_FREQ_HI - TWINKLE_FREQ_LO);
665                p.twinkle_phase = unit(seed, channel::TWINKLE_PHASE);
666                p.size_unit = unit(seed, channel::SIZE);
667                p
668            })
669            .collect();
670
671        Self {
672            sprites,
673            particles: particle_state,
674            instance_data: vec![
675                SwarmInstance {
676                    center: [0.0, 0.0, 0.0],
677                    radius: 0.0,
678                    color: [0.0, 0.0, 0.0],
679                    presence: 0.0,
680                };
681                particles
682            ],
683            time: 0.0,
684            aspect: FALLBACK_ASPECT,
685            target: (1, 1),
686            dt: FALLBACK_DT,
687            force: DEFAULT_FORCE,
688            spin: DEFAULT_SPIN,
689            field_phase: Phase::default(),
690            burst: DEFAULT_BURST,
691            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
692            pan: common::PanParams::default(),
693            camera: rest_camera(),
694            size: DEFAULT_SIZE,
695            field_freq: DEFAULT_FIELD_FREQ,
696            zoom: DEFAULT_ZOOM,
697            depth_fade: DEFAULT_DEPTH_FADE,
698            palette: Palette::default_spectrum(),
699            hue_spread: DEFAULT_HUE_SPREAD,
700            hue_center: DEFAULT_HUE_CENTER,
701            shape: DEFAULT_SHAPE,
702            points: DEFAULT_POINTS,
703            star_valley: DEFAULT_STAR_VALLEY,
704            star_curve: DEFAULT_STAR_CURVE,
705            star_jitter: DEFAULT_STAR_JITTER,
706            star_seed: DEFAULT_STAR_SEED,
707            star_wobble: DEFAULT_STAR_WOBBLE,
708            star_wobble_freq: DEFAULT_STAR_WOBBLE_FREQ,
709            twinkle: DEFAULT_TWINKLE,
710            size_spread: DEFAULT_SIZE_SPREAD,
711            reseed: 0.0,
712            prev_reseed: 0.0,
713            reseed_count: 0,
714            max_coc,
715            clamp: None,
716        }
717    }
718
719    /// A particle scattered across the slab with a random heading and tint.
720    ///
721    /// The scatter is in **frustum** coordinates, so it does not depend on the
722    /// render target — the same seed gives the same field at any size (NFR §6).
723    fn spawn(rng: &mut SeededRng) -> Particle {
724        let angle = rng.range(0.0, std::f32::consts::TAU);
725        Particle {
726            pos: [rng.range(-1.0, 1.0), rng.range(-1.0, 1.0)],
727            vel: [angle.cos() * 0.2, angle.sin() * 0.2, 0.0],
728            z: Z_NEAR + rng.next_f32() * Z_SPAN,
729            hue: rng.next_f32(),
730            bright: rng.range(0.5, 1.0),
731            size: rng.range(0.004, SPRITE_SIZE_MAX),
732            // Neutral; `new` overwrites all three from the particle's index.
733            // Deliberately not drawn from `rng` — see the comment there.
734            twinkle_freq: 0.0,
735            twinkle_phase: 0.0,
736            size_unit: 0.5,
737        }
738    }
739
740    /// This frame's camera onto a render target of `aspect`: the bound subset
741    /// with `yaw` and `pitch` held to [`sway_bound`], the eye at [`PIVOT`], and
742    /// the slab as the volume `focus` is stated across.
743    fn camera_frame(&self, aspect: f32, max_coc: f32) -> CameraFrame {
744        let pan = [self.pan.x, self.pan.y];
745        let [yaw_max, pitch_max] = sway_bound(self.camera.fov, self.zoom, aspect, pan);
746        let finite = |v: f32| if v.is_finite() { v } else { 0.0 };
747        let params = CameraParams {
748            yaw: finite(self.camera.yaw).clamp(-yaw_max, yaw_max),
749            pitch: finite(self.camera.pitch).clamp(-pitch_max, pitch_max),
750            ..rest_camera_with(self.camera)
751        };
752        params.frame(aspect, self.zoom, pan, self.target, 0.5 * Z_SPAN, max_coc)
753    }
754}
755
756/// The shared camera block at this scene's rest: eye on the slab's axis with no
757/// sway, at [`PIVOT`], every other value at the shared default.
758fn rest_camera() -> CameraParams {
759    CameraParams {
760        yaw: 0.0,
761        pitch: 0.0,
762        ..rest_camera_with(CameraParams::default())
763    }
764}
765
766/// `bound` with the values this scene does not offer put back: `distance` at
767/// [`PIVOT`], and neither `fog` nor `solid`.
768fn rest_camera_with(bound: CameraParams) -> CameraParams {
769    CameraParams {
770        distance: PIVOT,
771        fog: 0.0,
772        solid: 0.0,
773        ..bound
774    }
775}
776
777/// The torus's half-extents at view depth `z` for a render target of `aspect`:
778/// the rest frustum's cross-section at that depth, times [`MARGIN`].
779///
780/// The visible frame at rest is exactly `[MARGIN; 2]` smaller on each axis, so
781/// the seam sits outside it by the same share at every depth.
782fn half_extent(z: f32, aspect: f32) -> (f32, f32) {
783    let h = MARGIN * z * (0.5 * REST_FOV).tan();
784    (h * aspect, h)
785}
786
787/// The largest `|yaw|` and `|pitch|`, in radians, that keep the wrap seam
788/// outside the frame under a bound `fov`, `zoom`, target `aspect` and `pan`.
789///
790/// At rest the seam projects to normalized-device `MARGIN * tan(REST_FOV / 2)
791/// / tan(fov / 2)` on both axes; what is left past the frame edge and the pan
792/// is that axis's headroom `h`. Zero where either axis has none, so a pan or a
793/// `fov` that already shows the seam is not made worse by a sway.
794///
795/// The bound's **shape** is first-order: turning the eye by `θ` about
796/// [`PIVOT`] moves a seam point at depth `z` along the turn's own axis by
797/// `θ * (1 + s^2 - PIVOT / z)` in tangent units, where `s` is the seam's rest
798/// tangent on that axis, so an axis alone may turn by about `h` over the worst
799/// of that drift across the slab. Its **size** is exact: one scale `c` shared
800/// by both axes is bisected against the real view ([`seam_clear`]), the largest
801/// that keeps every seam face, inset by the largest sprite's radius, outside
802/// the frame with the turn at any sign combination of the two bounds, either
803/// axis alone included. Each inset face is a planar quad between the slab's
804/// near and far depths, and a perspective projection maps it to a convex quad,
805/// so its four corners carry the face's extremes. The result is then scaled by
806/// [`SWAY_SHARE`]. Zero, too, where the inset seam already shows at rest.
807fn sway_bound(fov: f32, zoom: f32, aspect: f32, pan: [f32; 2]) -> [f32; 2] {
808    /// The largest share of the first-order bound searched, and the bisection
809    /// steps; 24 halvings put the scale within `4 / 2^24` of the limit.
810    const SCALE_MAX: f32 = 4.0;
811    const STEPS: u32 = 24;
812    let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback };
813    let zoom = finite(zoom, 1.0).max(1e-3);
814    let bound_fov = finite(fov, REST_FOV);
815    let fov = (bound_fov / zoom).clamp(camera::MIN_FOV, camera::MAX_FOV);
816    let aspect = finite(aspect, 1.0).max(0.1);
817    let pan = [finite(pan[0], 0.0), finite(pan[1], 0.0)];
818    let rest = (0.5 * REST_FOV).tan();
819    let open = (0.5 * fov).tan();
820    let seam = MARGIN * rest / open;
821    // Each axis's pan in normalized-device units, the frame edge's tangent and
822    // the seam's rest tangent.
823    let (pan_x, pan_y) = (pan[0].abs() / aspect, pan[1].abs());
824    let (frame_x, frame_y) = (open * aspect, open);
825    let (slope_x, slope_y) = (MARGIN * rest * aspect, MARGIN * rest);
826    let (h_x, h_y) = (seam - 1.0 - pan_x, seam - 1.0 - pan_y);
827    if h_x <= 0.0 || h_y <= 0.0 {
828        return [0.0, 0.0];
829    }
830    let drift = |slope: f32| {
831        [Z_NEAR, Z_FAR]
832            .into_iter()
833            .map(|z| (1.0 + slope * slope - PIVOT / z).abs())
834            .fold(f32::EPSILON, f32::max)
835    };
836    let (alone_yaw, alone_pitch) = (
837        h_x * frame_x / drift(slope_x),
838        h_y * frame_y / drift(slope_y),
839    );
840    let clear = |c: f32| {
841        seam_clear(
842            [c * alone_yaw, c * alone_pitch],
843            bound_fov,
844            zoom,
845            aspect,
846            pan,
847        )
848    };
849    let c = if clear(SCALE_MAX) {
850        SCALE_MAX
851    } else {
852        // `hi` never clears; `lo` does, or stays at zero when even the rest
853        // view does not.
854        let (mut lo, mut hi) = (0.0f32, SCALE_MAX);
855        for _ in 0..STEPS {
856            let mid = 0.5 * (lo + hi);
857            if clear(mid) {
858                lo = mid;
859            } else {
860                hi = mid;
861            }
862        }
863        lo
864    };
865    [SWAY_SHARE * c * alone_yaw, SWAY_SHARE * c * alone_pitch]
866}
867
868/// Whether every face of the wrap seam projects outside the frame with the eye
869/// turned to each of `±bound[0]` yaw and `±bound[1]` pitch, and to each alone.
870///
871/// The `u = ±1` faces must project wholly past `x = ±1` and the `v = ±1` faces
872/// past `y = ±1`, through the view [`camera_frame`](SwarmScene::camera_frame)
873/// builds; a corner nearer the eye than [`camera::NEAR`] fails.
874///
875/// Each face is inset by [`SPRITE_SIZE_MAX`]'s radius at the corner's depth,
876/// `SPRITE_SIZE_MAX * SIZE_DEPTH / z` in normalized-device height, which in
877/// world units is the depth-independent `SPRITE_SIZE_MAX * SIZE_DEPTH *
878/// tan(REST_FOV / 2)` on both axes: the inset face stays planar.
879fn seam_clear(bound: [f32; 2], fov: f32, zoom: f32, aspect: f32, pan: [f32; 2]) -> bool {
880    let unit = half_extent(1.0, aspect);
881    let inset = SPRITE_SIZE_MAX * SIZE_DEPTH * (0.5 * REST_FOV).tan();
882    let turns = [-1.0f32, 0.0, 1.0];
883    turns.into_iter().all(|sy| {
884        turns.into_iter().all(|sp| {
885            let view = camera::Camera3d {
886                yaw: sy * bound[0],
887                pitch: sp * bound[1],
888                distance: PIVOT,
889                fov,
890                focus: 0.5,
891                aperture: 0.0,
892            }
893            .view(aspect, zoom, pan);
894            [Z_NEAR, Z_FAR].into_iter().all(|z| {
895                let (hx, hy) = (unit.0 * z - inset, unit.1 * z - inset);
896                [-1.0f32, 1.0].into_iter().all(|a| {
897                    [-1.0f32, 1.0].into_iter().all(|b| {
898                        // Corner `(a, b)` lies on the `u = a` face and the
899                        // `v = b` face both.
900                        let c = view.clip([a * hx, b * hy, PIVOT - z]);
901                        c[3] >= camera::NEAR && a * c[0] / c[3] >= 1.0 && b * c[1] / c[3] >= 1.0
902                    })
903                })
904            })
905        })
906    })
907}
908
909/// The light a particle keeps at view depth `z` under `depth_fade`:
910/// [`DEPTH_LIGHT_NEAR`] at the near slab bound, falling linearly by the
911/// `depth_fade` share of it to the far bound. Clamped at zero, since a bound
912/// fade past 1 would otherwise subtract light.
913fn depth_light(z: f32, depth_fade: f32) -> f32 {
914    let across = ((z - Z_NEAR) / Z_SPAN).clamp(0.0, 1.0);
915    (DEPTH_LIGHT_NEAR * (1.0 - depth_fade * across)).max(0.0)
916}
917
918/// How much of its light a particle at view depth `z` shows as it nears a slab
919/// bound: 1 in the slab's interior, easing to 0 across [`FADE_BAND`] of the
920/// slab at either bound, so a particle is dark at the moment it wraps in depth.
921fn slab_fade(z: f32) -> f32 {
922    let band = FADE_BAND * Z_SPAN;
923    let e = ((z - Z_NEAR).min(Z_FAR - z) / band).clamp(0.0, 1.0);
924    e * e * (3.0 - 2.0 * e)
925}
926
927/// The LUT sample coordinate for one particle (ADR-0021): its per-particle hue
928/// occupies the band `hue_center + (particle_hue - 0.5) * hue_spread`, plus the
929/// shared `hue` rotation. Defaults (`center = 0.5`, `spread = 1`, `hue = 0`)
930/// reduce to `particle_hue`, the full-wheel look.
931fn hue_coord(hue_center: f32, hue_spread: f32, particle_hue: f32, hue: f32) -> f32 {
932    hue_center + (particle_hue - 0.5) * hue_spread + hue
933}
934
935/// The individuation contract, mirrored from the emitter (`emitter.rs`'s
936/// `unit`, which is private to that scene): a per-particle quantity is a pure
937/// function of `(seed, channel)` — splitmix64's finalizer applied as a hash.
938/// The swarm's `seed` is the particle's index in the seeded pool, which is
939/// stable for the scene's life, and the hash runs at construction only.
940fn unit(seed: u32, k: u32) -> f32 {
941    let mut z = ((seed as u64) << 32 | k as u64).wrapping_add(0x9E37_79B9_7F4A_7C15);
942    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
943    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
944    z ^= z >> 31;
945    (z >> 40) as f32 / (1u64 << 24) as f32
946}
947
948/// Seed channels, named so a later quantity cannot silently reuse one and
949/// correlate itself with an existing draw (the emitter's convention).
950mod channel {
951    pub(super) const TWINKLE_FREQ: u32 = 0;
952    pub(super) const TWINKLE_PHASE: u32 = 1;
953    pub(super) const SIZE: u32 = 2;
954    pub(super) const RESEED_X: u32 = 3;
955    pub(super) const RESEED_Y: u32 = 4;
956}
957
958/// The particle's brightness multiplier under `twinkle` — the emitter's
959/// semantics on the emitter's frequency band, over the pre-resolved
960/// per-particle rate and phase. Exactly `1.0` at `twinkle <= 0`, which is what
961/// makes the defaults' byte-identity falsifiable in both directions; clamped
962/// at zero because `twinkle` is a preset expression and may exceed 1, and a
963/// negative multiplier would subtract light rather than removing it.
964fn twinkle_factor(freq: f32, phase: f32, time: f32, twinkle: f32) -> f32 {
965    if twinkle <= 0.0 {
966        return 1.0;
967    }
968    let wave = (std::f32::consts::TAU * (freq * time + phase)).sin();
969    (1.0 + twinkle * wave).max(0.0)
970}
971
972/// The particle's size multiplier within `size_spread` — the emitter's
973/// `size_factor`, over the pre-resolved unit draw. Exactly `1.0` at zero
974/// spread (the default), on top of the scatter's own seeded base size.
975fn size_factor(size_unit: f32, size_spread: f32) -> f32 {
976    (1.0 + (size_unit - 0.5) * size_spread).max(0.0)
977}
978
979/// Parameter vocabulary — see [`fragment_field::PARAMS`](super::fragment_field::PARAMS).
980/// **Keep in sync with `set_param` below.**
981pub const PARAMS: &[ParamSpec] = &[
982    ParamSpec {
983        name: "force",
984        default: 1.4,
985        range: Some([0.0, 4.0]),
986        doc: "How hard the flow field pushes each particle, so higher is faster and straighter.",
987        kind: ParamKind::Modal,
988        group: ParamGroup::Motion,
989        main: true,
990    },
991    ParamSpec {
992        name: "spin",
993        default: 0.3,
994        range: Some([-2.0, 2.0]),
995        doc: "Rotational bias added to the flow, curling the paths into vortices.",
996        kind: ParamKind::Modal,
997        group: ParamGroup::Motion,
998        main: true,
999    },
1000    ParamSpec {
1001        name: "burst",
1002        default: 0.0,
1003        range: Some([0.0, 2.0]),
1004        doc: "An outward impulse from the centre, for a beat to throw the swarm apart.",
1005        kind: ParamKind::Modal,
1006        group: ParamGroup::Motion,
1007        main: false,
1008    },
1009    crate::render::scenes::common::hue(DEFAULT_HUE),
1010    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
1011    ParamSpec {
1012        name: "size",
1013        default: 1.0,
1014        range: Some([0.0, 4.0]),
1015        doc: "Size of each particle's mark at the middle of the swarm's depth; nearer marks draw larger and farther ones smaller.",
1016        kind: ParamKind::Modal,
1017        group: ParamGroup::Shape,
1018        main: true,
1019    },
1020    ParamSpec {
1021        name: "field_freq",
1022        default: 2.3,
1023        range: Some([0.5, 8.0]),
1024        doc: "Spatial frequency of the flow field; higher makes smaller, busier eddies.",
1025        kind: ParamKind::Modal,
1026        group: ParamGroup::Shape,
1027        main: false,
1028    },
1029    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
1030    crate::render::scenes::common::PAN_X,
1031    crate::render::scenes::common::PAN_Y,
1032    ParamSpec {
1033        name: "hue_spread",
1034        default: 1.0,
1035        range: Some([0.0, 1.0]),
1036        doc: "How far across the palette the particle band reaches.",
1037        kind: ParamKind::Modal,
1038        group: ParamGroup::Colour,
1039        main: false,
1040    },
1041    ParamSpec {
1042        name: "hue_center",
1043        default: 0.5,
1044        range: Some([0.0, 1.0]),
1045        doc: "Where that band sits along the palette.",
1046        kind: ParamKind::Modal,
1047        group: ParamGroup::Colour,
1048        main: false,
1049    },
1050    crate::render::scenes::common::SATURATION,
1051    crate::render::scenes::common::PALETTE_MIX,
1052    crate::render::scenes::common::PALETTE_STEPS,
1053    crate::render::scenes::common::PALETTE_CONTOUR,
1054    ParamSpec {
1055        name: "twinkle",
1056        default: 0.0,
1057        range: Some([0.0, 1.0]),
1058        doc: "Per-particle brightness flicker, seeded so it is reproducible.",
1059        kind: ParamKind::Modal,
1060        group: ParamGroup::Light,
1061        main: false,
1062    },
1063    ParamSpec {
1064        name: "size_spread",
1065        default: 0.0,
1066        range: Some([0.0, 1.0]),
1067        doc: "How much particle sizes vary about `size`; 0 makes them uniform.",
1068        kind: ParamKind::Modal,
1069        group: ParamGroup::Shape,
1070        main: false,
1071    },
1072    ParamSpec {
1073        name: "reseed",
1074        default: 0.0,
1075        range: Some([0.0, 1.0]),
1076        doc: "Crossing zero throws every particle back to a fresh start position.",
1077        kind: ParamKind::Modal,
1078        group: ParamGroup::Motion,
1079        main: false,
1080    },
1081    ParamSpec {
1082        name: "depth_fade",
1083        default: 1.0 - 0.45 / DEPTH_LIGHT_NEAR,
1084        range: Some([0.0, 1.0]),
1085        doc: "How much light a particle loses from the front of the swarm to the back; 0 lights every depth alike.",
1086        kind: ParamKind::Modal,
1087        group: ParamGroup::Light,
1088        main: false,
1089    },
1090    ParamSpec {
1091        name: "yaw",
1092        default: 0.0,
1093        range: Some([-0.2, 0.2]),
1094        doc: "Turns the camera sideways within the swarm, in radians; held to the sway the margin past the frame covers, so bind it to a slow wave.",
1095        ..camera::YAW
1096    },
1097    ParamSpec {
1098        name: "pitch",
1099        default: 0.0,
1100        range: Some([-0.2, 0.2]),
1101        doc: "Tilts the camera up or down within the swarm, in radians; held to the sway the margin past the frame covers.",
1102        ..camera::PITCH
1103    },
1104    ParamSpec {
1105        range: Some([0.2, 0.9]),
1106        doc: "The camera's vertical field of view in radians; zoom divides it. Wider than the resting 0.8 uses up the margin past the frame, and much wider shows the wrap seam.",
1107        ..camera::FOV
1108    },
1109    camera::FOCUS,
1110    camera::APERTURE,
1111    crate::render::scenes::marks::SHAPE,
1112    crate::render::scenes::marks::POINTS,
1113    crate::render::scenes::marks::STAR_VALLEY,
1114    crate::render::scenes::marks::STAR_CURVE,
1115    crate::render::scenes::marks::STAR_JITTER,
1116    crate::render::scenes::marks::STAR_SEED,
1117    crate::render::scenes::marks::STAR_WOBBLE,
1118    crate::render::scenes::marks::STAR_WOBBLE_FREQ,
1119];
1120
1121impl Scene for SwarmScene {
1122    fn name(&self) -> &'static str {
1123        "swarm"
1124    }
1125
1126    fn advance(&mut self, dt: f32) {
1127        self.dt = dt;
1128    }
1129
1130    fn set_time(&mut self, time: f32) {
1131        self.time = time;
1132    }
1133
1134    fn set_target_size(&mut self, width: u32, height: u32) {
1135        self.target = (width, height);
1136    }
1137
1138    fn mirror_overflow(&self) -> Option<&super::CapOverflow> {
1139        self.clamp.as_ref()
1140    }
1141
1142    fn set_palette(&mut self, palette: &Palette) {
1143        // CPU-sampled per particle in `update`; a cheap array copy, off the hot
1144        // path (once per preset switch).
1145        self.palette = palette.clone();
1146    }
1147
1148    fn reset_params(&mut self) {
1149        self.force = DEFAULT_FORCE;
1150        self.spin = DEFAULT_SPIN;
1151        self.burst = DEFAULT_BURST;
1152        self.colour.reset();
1153        self.pan.reset();
1154        self.camera = rest_camera();
1155        self.size = DEFAULT_SIZE;
1156        self.field_freq = DEFAULT_FIELD_FREQ;
1157        self.zoom = DEFAULT_ZOOM;
1158        self.depth_fade = DEFAULT_DEPTH_FADE;
1159        self.hue_spread = DEFAULT_HUE_SPREAD;
1160        self.hue_center = DEFAULT_HUE_CENTER;
1161        self.shape = DEFAULT_SHAPE;
1162        self.points = DEFAULT_POINTS;
1163        self.star_valley = DEFAULT_STAR_VALLEY;
1164        self.star_curve = DEFAULT_STAR_CURVE;
1165        self.star_jitter = DEFAULT_STAR_JITTER;
1166        self.star_seed = DEFAULT_STAR_SEED;
1167        self.star_wobble = DEFAULT_STAR_WOBBLE;
1168        self.star_wobble_freq = DEFAULT_STAR_WOBBLE_FREQ;
1169        self.twinkle = DEFAULT_TWINKLE;
1170        self.size_spread = DEFAULT_SIZE_SPREAD;
1171        // `prev_reseed` is deliberately NOT reset: this runs every frame
1172        // before the bindings are routed, and resetting the previous level
1173        // would turn a held gate into an edge per frame — a continuous
1174        // disturbance in place of a percussive one. The attractor's
1175        // reset_params makes the same omission for the same reason.
1176        self.reseed = 0.0;
1177    }
1178
1179    fn set_param(&mut self, name: &str, value: f32) {
1180        // The shared param blocks first, this scene's own names after
1181        // (`scenes::common`). The camera is a subset of the shared block, so
1182        // its five names are matched here rather than delegated: the block's
1183        // `distance`, `fog` and `solid` are not this scene's.
1184        if self.colour.set(name, value) || self.pan.set(name, value) {
1185            return;
1186        }
1187        match name {
1188            "yaw" => self.camera.yaw = value,
1189            "pitch" => self.camera.pitch = value,
1190            "fov" => self.camera.fov = value,
1191            "focus" => self.camera.focus = value,
1192            "aperture" => self.camera.aperture = value,
1193            "force" => self.force = value,
1194            "spin" => self.spin = value,
1195            "burst" => self.burst = value,
1196            "size" => self.size = value,
1197            "field_freq" => self.field_freq = value,
1198            "zoom" => self.zoom = value,
1199            "depth_fade" => self.depth_fade = value,
1200            "hue_spread" => self.hue_spread = value,
1201            "hue_center" => self.hue_center = value,
1202            "shape" => self.shape = value,
1203            "points" => self.points = value,
1204            "star_valley" => self.star_valley = value,
1205            "star_curve" => self.star_curve = value,
1206            "star_jitter" => self.star_jitter = value,
1207            "star_seed" => self.star_seed = value,
1208            "star_wobble" => self.star_wobble = value,
1209            "star_wobble_freq" => self.star_wobble_freq = value,
1210            "twinkle" => self.twinkle = value,
1211            "size_spread" => self.size_spread = value,
1212            "reseed" => self.reseed = value,
1213            _ => {}
1214        }
1215    }
1216
1217    #[allow(
1218        clippy::indexing_slicing,
1219        reason = "pos/vel/base index fixed-size arrays at constant offsets, always in-bounds"
1220    )]
1221    fn update(&mut self, _frame: &AnalysisFrame) {
1222        // Rising-edge detect on `reseed` (Plan 0077 Phase 3): **disturb** the
1223        // existing population where it is, by a seeded, domain-relative kick —
1224        // ADR-0066's semantics, not the box respawn it removed. The kick is a
1225        // pure function of (particle index, reseed ordinal), so a capture
1226        // remains reproducible; unbound, `reseed` and `prev_reseed` sit at 0
1227        // and this path never touches a position.
1228        if self.reseed >= RESEED_THRESHOLD && self.prev_reseed < RESEED_THRESHOLD {
1229            self.reseed_count = self.reseed_count.wrapping_add(1);
1230            let salt = self.reseed_count.wrapping_mul(0x9E37_79B9);
1231            for (i, p) in self.particles.iter_mut().enumerate() {
1232                let seed = (i as u32).wrapping_add(salt);
1233                p.pos[0] += (unit(seed, channel::RESEED_X) - 0.5) * 2.0 * RESEED_KICK;
1234                p.pos[1] += (unit(seed, channel::RESEED_Y) - 0.5) * 2.0 * RESEED_KICK;
1235                // No wrap here: a kick of ±RESEED_KICK cannot overshoot the ±1
1236                // seam by more than itself, and the integration loop below
1237                // wraps every position this same frame.
1238            }
1239        }
1240        self.prev_reseed = self.reseed;
1241
1242        // Field evolves at `spin`; `force` steers, `burst` shoves outward. The
1243        // clock integrates here, after this frame's `set_param` calls have
1244        // landed, so it advances at *this* frame's rate.
1245        self.field_phase.step(self.spin, self.dt);
1246        let field_t = self.field_phase.get();
1247        let force = self.force;
1248        let burst_kick = self.burst;
1249        let field_freq = self.field_freq;
1250        // The individuation pair, resolved at draw like the emitter's: an
1251        // eased width moves the whole population continuously instead of only
1252        // particles spawned since the change (Plan 0077 Phase 2).
1253        let twinkle = self.twinkle;
1254        let size_spread = self.size_spread;
1255        let depth_fade = self.depth_fade;
1256        let time = self.time;
1257
1258        // Frame-rate-independent integration (Plan 0014 Phase 2): scale the
1259        // acceleration/advection by real `dt`, and raise the per-frame damping to
1260        // the `dt`-relative power so the velocity decays at the same wall-clock
1261        // rate regardless of refresh (one `powf` per frame, not per particle).
1262        let dt = self.dt;
1263        let damp = DAMPING.powf(dt * 60.0);
1264
1265        // The cross-section follows the render target. Its half-extents are
1266        // linear in depth, so the per-depth scale is one multiply per particle
1267        // off these two per-frame constants.
1268        let (unit_x, unit_y) = half_extent(1.0, self.aspect);
1269
1270        for (p, inst) in self.particles.iter_mut().zip(self.instance_data.iter_mut()) {
1271            let (hx, hy) = (unit_x * p.z, unit_y * p.z);
1272            // Frustum coordinates -> world, which is what the field, the burst
1273            // and the sprite all work in.
1274            let world = [p.pos[0] * hx, p.pos[1] * hy];
1275
1276            // Scalar potential -> flow direction (cheap curl-ish field), with
1277            // depth in its phase so each layer rides its own currents; the two
1278            // axes take different offsets, so layers decorrelate in both.
1279            let zo = p.z * DEPTH_FIELD_FREQ;
1280            let a = (world[0] * field_freq + field_t + zo).sin()
1281                + (world[1] * field_freq - field_t * 0.8 - zo * 0.7).cos();
1282            let dir = [a.cos(), a.sin()];
1283            // The slow depth current, off the same field at half its
1284            // frequency, so neighbours on a streamline drift in depth together.
1285            let lift = ((world[0] - world[1]) * field_freq * 0.5 + field_t * 0.6 + zo * 1.3).sin();
1286
1287            p.vel[0] = p.vel[0] * damp + dir[0] * force * dt;
1288            p.vel[1] = p.vel[1] * damp + dir[1] * force * dt;
1289            p.vel[2] = p.vel[2] * damp + lift * force * Z_FLOW * dt;
1290
1291            // Beat burst pushes particles radially outward from the view axis.
1292            if burst_kick > 0.0 {
1293                let r = (world[0] * world[0] + world[1] * world[1]).sqrt().max(1e-3);
1294                p.vel[0] += world[0] / r * burst_kick * dt;
1295                p.vel[1] += world[1] / r * burst_kick * dt;
1296            }
1297
1298            // A world velocity becomes a frustum-coordinate one through the
1299            // half-extent at this depth: the same current moves a far particle
1300            // across less of the frame.
1301            p.pos[0] += p.vel[0] * dt / hx;
1302            p.pos[1] += p.vel[1] * dt / hy;
1303            p.z += p.vel[2] * dt;
1304
1305            // Toroidal wrap keeps the field populated (no respawns/hitches). The
1306            // seam is at ±1 whatever the target and the depth, `MARGIN` past
1307            // the visible frame (ADR-0044, ADR-0259).
1308            if p.pos[0] > 1.0 {
1309                p.pos[0] -= 2.0;
1310            } else if p.pos[0] < -1.0 {
1311                p.pos[0] += 2.0;
1312            }
1313            if p.pos[1] > 1.0 {
1314                p.pos[1] -= 2.0;
1315            } else if p.pos[1] < -1.0 {
1316                p.pos[1] += 2.0;
1317            }
1318            // The depth wrap jumps a particle across the slab; `slab_fade` has
1319            // it dark on both sides of the jump.
1320            if p.z > Z_FAR {
1321                p.z -= Z_SPAN;
1322            } else if p.z < Z_NEAR {
1323                p.z += Z_SPAN;
1324            }
1325
1326            let speed = (p.vel[0] * p.vel[0] + p.vel[1] * p.vel[1]).sqrt();
1327            // Colour through the shared LUT (ADR-0021): the per-particle hue is
1328            // mapped into the `hue_spread`/`hue_center` band, then desaturated by
1329            // the shared `saturation`.
1330            let coord = hue_coord(self.hue_center, self.hue_spread, p.hue, self.colour.hue);
1331            // Hard bands on the palette coordinate (ADR-0078), the canonical
1332            // `palette::band_coord` called rather than copied. `palette_steps <= 1`
1333            // returns it untouched.
1334            let base = palette::desaturate(
1335                self.palette.sample(
1336                    palette::band_coord(coord, self.colour.steps),
1337                    self.colour.mix,
1338                ),
1339                self.colour.saturation,
1340            );
1341
1342            // The speed cue: on a coherent field the fast channels read brighter
1343            // than slack water. Depth multiplies it rather than replacing it.
1344            // The twinkle factor is exactly 1.0 when `twinkle` is unbound, and
1345            // the size factor exactly 1.0 at zero spread, so multiplying by
1346            // either is bit-exact.
1347            let presence = slab_fade(p.z);
1348            let bright = ((0.25 + speed * 0.7) * p.bright).min(1.6)
1349                * self.colour.brightness
1350                * depth_light(p.z, depth_fade)
1351                * presence
1352                * twinkle_factor(p.twinkle_freq, p.twinkle_phase, time, twinkle);
1353
1354            let (hx, hy) = (unit_x * p.z, unit_y * p.z);
1355            *inst = SwarmInstance {
1356                // The eye sits at `+PIVOT` on world z looking down `-z`, so a
1357                // view depth `z` at rest is world `PIVOT - z`.
1358                center: [p.pos[0] * hx, p.pos[1] * hy, PIVOT - p.z],
1359                radius: p.size * self.size * size_factor(p.size_unit, size_spread),
1360                color: [base[0] * bright, base[1] * bright, base[2] * bright],
1361                presence,
1362            };
1363        }
1364    }
1365
1366    fn render(
1367        &mut self,
1368        queue: &wgpu::Queue,
1369        encoder: &mut wgpu::CommandEncoder,
1370        view: &wgpu::TextureView,
1371        aspect: f32,
1372    ) {
1373        // The domain the *next* `update` wraps against, and this frame's
1374        // projection. This argument is the render target's aspect — the only
1375        // correct source for a shape (ADR-0037); see the field's docs for why
1376        // `set_target_size` is not.
1377        self.aspect = aspect.max(0.1);
1378        let frame = self.camera_frame(self.aspect, self.max_coc);
1379        self.clamp = frame.blur;
1380        let misc = SwarmUniform {
1381            v: [self.aspect, SIZE_DEPTH, 0.0, 0.0],
1382            // Quantized here, on the way into the uniform, so the shader's
1383            // precondition stays visible on the CPU side: the roster's
1384            // bounds and the integer point count live in `marks`, and no
1385            // fractional value ever reaches an angular fold (ADR-0084).
1386            m: [
1387                marks::mark_shape(self.shape),
1388                marks::mark_points(self.points),
1389                marks::star_seed(self.star_seed),
1390                marks::star_wobble(self.star_wobble),
1391            ],
1392            s: [
1393                marks::star_valley(self.star_valley),
1394                marks::star_curve(self.star_curve),
1395                marks::star_jitter(self.star_jitter),
1396                marks::star_wobble_freq(self.star_wobble_freq),
1397            ],
1398        };
1399
1400        // Load over the engine backdrop (ADR-0018): the additive particles
1401        // bloom over whatever the background pass painted, so the sparse gaps
1402        // between them reveal it.
1403        self.sprites.draw(
1404            queue,
1405            encoder,
1406            view,
1407            &misc,
1408            &frame.uniform,
1409            &self.instance_data,
1410        );
1411    }
1412}
1413
1414#[cfg(test)]
1415mod tests;