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;