Skip to main content

rlx_core/render/scenes/particles/
resources.rs

1//! The attractor's GPU resources: the three uniform blocks, the three resource
2//! holders, and their bind-group helpers (Plan 0061 Phase 6).
3//!
4//! This is the `wgpu` half of `particles/` — buffers, layouts, pipelines and the
5//! bind groups that wire them together. The scene that drives them, its `Scene`
6//! impl and the `encode_*` passes stay in `mod.rs`; the ODE math it draws is in
7//! [`family`], which imports no `wgpu` at all.
8
9// Hot-path panic-denial pragma (Plan 0002 Phase 2; render/ is scanned by the
10// hygiene guard).
11#![deny(
12    clippy::unwrap_used,
13    clippy::expect_used,
14    clippy::indexing_slicing,
15    clippy::panic,
16    clippy::unreachable
17)]
18
19// A continuation of one module split across four files, so it needs the names
20// `particles/mod.rs` has in scope.
21use super::*;
22
23/// Compute step uniform (per frame): the attractor coefficients, the fixed
24/// sub-step `dt`, the selected family, and the active particle count.
25///
26/// The same layout drives the one-shot **jitter** dispatch (ADR-0066), where
27/// `family` is [`JITTER_MODE`], `coeffs.xyz` is the kick's half-extent and `salt`
28/// is the reseed counter. One struct and one pipeline rather than a second of
29/// each: the jitter reads and writes the same storage buffer through the same
30/// bind-group layout, so only the uniform's contents differ.
31///
32/// **192 bytes**, for every family including the four that ignore the trailing
33/// fields — negligible in
34/// bandwidth, and noted because it is a struct four families share. ADR-0075
35/// predicted 144 for the Plan 0062 shape; the extra 16 is the alignment padding
36/// [`step_index`](Self::step_index) forces, because the scalar block ahead of the
37/// `vec4` table has to round up to a multiple of 16 and it was already exactly
38/// full. **The bind-group layout gains no binding at either step**, so the
39/// collision surface ADR-0058 reasons about does not change shape.
40#[repr(C)]
41#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
42pub(super) struct StepUniform {
43    pub(super) coeffs: [f32; 4],
44    pub(super) dt: f32,
45    pub(super) family: u32,
46    pub(super) count: u32,
47    /// Which reseed this is, for the jitter dispatch. Zero (and unread) on a
48    /// stepping dispatch — it was the struct's explicit padding word.
49    pub(super) salt: u32,
50    /// The monotonic fixed-step counter the IFS draws its map choice from
51    /// (ADR-0075). Zero (and unread) on every other family, and on the jitter
52    /// dispatch — which keeps its own `salt` rather than sharing this.
53    pub(super) step_index: u32,
54    /// The reciprocal of the fixed-point set's floored diameter
55    /// ([`ifs::skeleton_scale`], ADR-0088) — the scale the step shader's IFS arm
56    /// normalises a raw nearest-point distance by. Zero (and unread) on the four
57    /// map families and on the jitter dispatch, exactly as the affine table is.
58    ///
59    /// **It costs no bytes.** It takes the first of the three explicit padding
60    /// words the `vec4` table's alignment had already paid for, so the struct
61    /// stays 192 and the bind-group layout gains no binding.
62    pub(super) root_recip: f32,
63    /// The rest of that padding. Explicit, because the `vec4` table below is
64    /// 16-byte aligned and the scalars above are five words. `bytemuck::Pod`
65    /// requires no implicit padding, so these words must be named.
66    pub(super) _pad: [u32; 2],
67    /// The IFS's resolved affine table — [`IfsPacked`] laid out flat. Zeroed for
68    /// the four map families, which never read it.
69    pub(super) linear: [[f32; 4]; ifs::MAPS],
70    pub(super) translate: [[f32; 4]; 2],
71    pub(super) cumulative_p: [f32; 4],
72    /// The four respawn targets (ADR-0087), two `(x, y)` per row exactly as
73    /// `translate` is packed.
74    pub(super) fixed: [[f32; 4]; 2],
75}
76
77impl StepUniform {
78    /// The IFS half of the uniform, as the four map families and the jitter
79    /// dispatch write it: all zeros, and unread.
80    pub(super) const NO_IFS: IfsPacked = IfsPacked::ZERO;
81
82    /// Assemble one slot. The IFS payload is spread across three fields, so a
83    /// constructor is what keeps the three call sites from disagreeing about it.
84    pub(super) fn new(
85        coeffs: [f32; 4],
86        family: u32,
87        count: u32,
88        salt: u32,
89        step_index: u32,
90        packed: IfsPacked,
91    ) -> Self {
92        Self {
93            coeffs,
94            dt: FIXED_STEP,
95            family,
96            count,
97            salt,
98            step_index,
99            root_recip: packed.root_recip,
100            _pad: [0; 2],
101            linear: packed.linear,
102            translate: packed.translate,
103            cumulative_p: packed.cumulative_p,
104            fixed: packed.fixed,
105        }
106    }
107}
108
109/// Draw uniform (per frame). `v`: x aspect, y point half-size, z hue offset, w
110/// spin. `w`: x world scale, y projection dim (2 or 3), z z-centre (3D),
111/// w [`deposit_scale`] (ADR-0065) times [`brightness_factor`] (ADR-0080).
112/// `u`: x hue_spread, y hue_center, z palette_mix, w saturation (ADR-0021).
113/// `x`: x zoom, yz pan (view transform, ADR-0018), w the streak flag
114/// (ADR-0069) — non-zero exactly when [`AttractorFamily::is_continuous`].
115/// `bh`/`bv`: the 3D projection basis's two axis selectors (ADR-0068) — the axis
116/// the spin rotates `x` against, and the vertical. Read only on the 3D branch.
117/// `d`: x unused, y `depth_fade`, z `depth_hue`, w the family's
118/// **inverse** depth half-extent (ADR-0076) — `0` for a 2D family, which is what
119/// makes every depth cue the identity there without a shader branch.
120/// `ctr`: xyz the world centre subtracted before projection (Plan 0062), w unused.
121/// `ch`: the two per-particle colour channels at two routes each — x `map_tint`,
122/// y `map_hue` (ADR-0087), z `root_tint`, w `root_hue` (ADR-0088). All four
123/// default to `0`, which is the arithmetic identity on every route.
124///
125/// **The row swapped rather than grew** at Plan 0074 Phase 3: `age_tint` and
126/// `age_hue` held z and w until the age channel was retired. The two halves are
127/// *not* the same shape — `map_*` is centred, `root_*` is anchored at `0` — for
128/// the reason in ADR-0088's Anchoring section.
129///
130/// `em`: the emergence ramp (ADR-0087) — x the per-step brightness increment, y
131/// the floor. `(1/emergence, 0)` on the IFS and `(0, 1)` everywhere else,
132/// because every other family's `age` is identically zero and a bare `age·rate`
133/// would black them out rather than leave them alone. **z and w are free** since
134/// the retirement: z carried `1/churn_max_lifetime()`, which only the age colour
135/// channel read.
136///
137/// `cam`: the shared camera (ADR-0260) a family with depth projects through,
138/// its `viewport.z` the orbit target's view depth, which sprite sizes are
139/// stated at. `mdl`: x the model transform's `1 / framed half-extent`, y the
140/// sprite radius in target pixels at that reference depth, z the model depth
141/// that is one unit of normalized depth, inverted, w the camera's `fog`
142/// (ADR-0263). Both are zero on a
143/// flat family, whose path never reads them.
144#[repr(C)]
145#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
146pub(super) struct DrawUniform {
147    pub(super) v: [f32; 4],
148    pub(super) w: [f32; 4],
149    pub(super) u: [f32; 4],
150    pub(super) x: [f32; 4],
151    pub(super) bh: [f32; 4],
152    pub(super) bv: [f32; 4],
153    pub(super) d: [f32; 4],
154    pub(super) ctr: [f32; 4],
155    pub(super) ch: [f32; 4],
156    pub(super) em: [f32; 4],
157    pub(super) cam: crate::render::camera::CameraUniform,
158    pub(super) mdl: [f32; 4],
159}
160
161/// Decay uniform (per frame): x is the per-frame trail retention factor.
162#[repr(C)]
163#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
164pub(super) struct DecayUniform {
165    pub(super) k: [f32; 4],
166    /// The ADR-0048 feedback transform, exactly as
167    /// [`feedback::Transform::pack`](crate::render::feedback::Transform::pack)
168    /// returns it. Read by the decay pass; the present pass declares only `k` over
169    /// the same buffer, which is legal — a uniform binding may be wider than the
170    /// struct a shader lays over it — and deliberate, because the present's
171    /// bind-group layout *shape* is what the WARP adapter is sensitive to (a
172    /// WARP reading, unverified on lavapipe as of 2026-10-06).
173    pub(super) xf: [f32; 4],
174    pub(super) tr: [f32; 4],
175    pub(super) wp: [f32; 4],
176}
177
178/// The GPU-side state, built lazily on first render (see the module docs), split
179/// along the axis that actually varies (Plan 0029 Phase 1): everything in
180/// [`PipelineResources`] is built once and survives every size change; only
181/// [`FieldResources`] is rebuilt when the accumulation grid changes.
182pub(super) struct Resources {
183    pub(super) pipelines: PipelineResources,
184    pub(super) grid: FieldResources,
185}
186
187/// The grid-**independent** GPU state: the four shader modules, every pipeline,
188/// the particle storage buffer, the uniform buffers, and the LUT textures. None
189/// of it references the accumulation field, so a size change must not touch it —
190/// recompiling four WGSL modules and rebuilding four pipelines inside `render` is
191/// a multi-hundred-millisecond stall, and the standalone forwards every
192/// `WindowEvent::Resized`, so a live drag paid it per frame (Plan 0029 Phase 1).
193pub(super) struct PipelineResources {
194    pub(super) compute_pipeline: wgpu::ComputePipeline,
195    pub(super) draw_pipeline: wgpu::RenderPipeline,
196    pub(super) decay_pipeline: wgpu::RenderPipeline,
197    pub(super) present_pipeline: wgpu::RenderPipeline,
198    pub(super) particles: wgpu::Buffer,
199    pub(super) step_uniform: wgpu::Buffer,
200    /// Byte stride between two slots of `step_uniform`, rounded up to the
201    /// adapter's dynamic-offset alignment.
202    ///
203    /// Separate slots rather than one written repeatedly, because a frame encodes
204    /// `pending_steps` step dispatches against one binding: folding the jitter into
205    /// the step slot would apply it once per sub-step, making the disturbance a
206    /// function of the frame's timing and breaking determinism. [`STEP_SLOTS`] has
207    /// the same argument for the sub-steps themselves.
208    pub(super) step_stride: u32,
209    pub(super) draw_uniform: wgpu::Buffer,
210    pub(super) decay_uniform: wgpu::Buffer,
211    pub(super) compute_bg: wgpu::BindGroup,
212    pub(super) draw_bg: wgpu::BindGroup,
213    /// The shared gradient LUT pair (A/B) the draw vertex shader samples +
214    /// crossfades (ADR-0021); uploaded from the scene's baked palette on the first
215    /// frame after a build and on a preset switch. It lives here rather than in
216    /// [`FieldResources`] because it outlives a grid change, so a resize does not
217    /// re-upload the palette.
218    pub(super) luts: palette::LutPair,
219    /// Kept so a grid change can rebuild [`FieldResources`]' four bind groups
220    /// without recreating a layout, a sampler, or any pipeline.
221    pub(super) decay_layout: wgpu::BindGroupLayout,
222    pub(super) present_layout: wgpu::BindGroupLayout,
223    pub(super) field_sampler: wgpu::Sampler,
224    /// How many particles the buffer above holds — the active tier's **ceiling**
225    /// ([`attractor_particles_live_ceiling`](crate::render::TierConfig::attractor_particles_live_ceiling),
226    /// or the offline one on a headless render path), fixed for the life of these
227    /// resources.
228    ///
229    /// The dispatch, the instance draw and the step uniform all take the
230    /// **active** count instead — `round(budget * density)` (ADR-0069) over a
231    /// budget that is itself a density against the render target (ADR-0140) — so
232    /// this is two clamps above what any frame actually draws. It survives as the
233    /// allocation bound: the draw clamps its instance range to it, so neither a
234    /// preset's `density` nor a resize can fetch a vertex past the end of the
235    /// buffer.
236    pub(super) count: u32,
237}
238
239/// The grid-**dependent** GPU state: the accumulation field and the four bind
240/// groups that reference its two texture views. The only block a size change
241/// rebuilds — a texture pair plus four bind groups, none of which compiles a
242/// shader (Plan 0029 Phase 1).
243pub(super) struct FieldResources {
244    /// Two-texture accumulation the trails ping-pong between (ADR-0012 reuse).
245    pub(super) field: PingPongField,
246    /// Decay/present bind groups reading texture A / texture B — selected by the
247    /// field's read side each frame so nothing is rebuilt on the hot path.
248    pub(super) decay_bg_a: wgpu::BindGroup,
249    pub(super) decay_bg_b: wgpu::BindGroup,
250    pub(super) present_bg_a: wgpu::BindGroup,
251    pub(super) present_bg_b: wgpu::BindGroup,
252    /// The accumulation grid this block was built for; `render` compares the
253    /// requested grid against it and rebuilds only this block on a difference.
254    pub(super) trail_w: u32,
255    pub(super) trail_h: u32,
256}
257
258impl Resources {
259    pub(super) fn build(
260        device: &wgpu::Device,
261        surface_format: wgpu::TextureFormat,
262        trail_w: u32,
263        trail_h: u32,
264        count: u32,
265    ) -> Self {
266        let pipelines = PipelineResources::build(device, surface_format, count);
267        let grid = FieldResources::build(device, &pipelines, trail_w, trail_h);
268        Self { pipelines, grid }
269    }
270
271    /// Re-allocate the accumulation field at a new grid, reusing every pipeline,
272    /// buffer, and texture that does not depend on it. The rebuilt field is
273    /// undefined, so the caller re-flags the clear (and the seed upload, which
274    /// keeps a capture reproducible from the same starting scatter).
275    pub(super) fn rebuild_grid(&mut self, device: &wgpu::Device, trail_w: u32, trail_h: u32) {
276        self.grid = FieldResources::build(device, &self.pipelines, trail_w, trail_h);
277    }
278}
279
280/// The draw pass's instance attributes, with **explicit byte offsets into
281/// [`Particle`]**.
282///
283/// **Spelled out rather than built by `vertex_attr_array!`, and that is the
284/// whole point of this constant.** That macro lays its attributes out
285/// *consecutively* — which was correct while the struct was `pos`, `seed`,
286/// `prev` and one trailing pad, and stopped being correct the moment ADR-0087
287/// put `age` and `map` past that pad. A fourth macro entry would have fetched
288/// the padding word at offset 28 and fed the draw someone else's bytes, silently
289/// and with no compile error. `the_particle_layout_carries_three_channels`
290/// measures these offsets against the struct so the two cannot drift.
291pub(super) const PARTICLE_ATTRIBUTES: &[wgpu::VertexAttribute] = &[
292    wgpu::VertexAttribute {
293        format: wgpu::VertexFormat::Float32x3,
294        offset: 0,
295        shader_location: 0, // pos (z = 0 for 2D families)
296    },
297    wgpu::VertexAttribute {
298        format: wgpu::VertexFormat::Float32,
299        offset: 12,
300        shader_location: 1, // seed
301    },
302    wgpu::VertexAttribute {
303        format: wgpu::VertexFormat::Float32x3,
304        offset: 16,
305        shader_location: 2, // prev (ADR-0069)
306    },
307    wgpu::VertexAttribute {
308        format: wgpu::VertexFormat::Float32,
309        offset: 36,
310        shader_location: 3, // map (ADR-0087) — 36, NOT 28, which is `_pad`
311    },
312    wgpu::VertexAttribute {
313        format: wgpu::VertexFormat::Float32,
314        offset: 32,
315        shader_location: 4, // age (ADR-0087)
316    },
317    wgpu::VertexAttribute {
318        format: wgpu::VertexFormat::Float32,
319        offset: 40,
320        shader_location: 5, // root (ADR-0088) — 40, the first spare word
321    },
322];
323
324impl PipelineResources {
325    pub(super) fn build(
326        device: &wgpu::Device,
327        surface_format: wgpu::TextureFormat,
328        count: u32,
329    ) -> Self {
330        // The shared bit-mixer, concatenated in — the same WGSL the tonemap's
331        // dither compiles (Plan 0082 Phase 1), so a particle's reseed kick and a
332        // display-write LSB cannot drift apart on what the hash is.
333        let step_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
334            label: Some("attractor-step-shader"),
335            source: wgpu::ShaderSource::Wgsl(format!("{}{STEP_SHADER}", gpu::HASH_WGSL).into()),
336        });
337        let draw_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
338            label: Some("attractor-draw-shader"),
339            source: wgpu::ShaderSource::Wgsl(
340                format!("{}\n{DRAW_SHADER}", crate::render::camera::CAMERA_WGSL).into(),
341            ),
342        });
343        // ADR-0048's transform, concatenated in: the same WGSL the engine trails
344        // stage compiles, so the two accumulation sinks cannot drift apart on what
345        // `fb_rotate` means.
346        let decay_shader = gpu::fullscreen_shader(
347            device,
348            "attractor-decay-shader",
349            gpu::FULLSCREEN_VS_UV_FLIPPED,
350            &format!("{}{DECAY_SHADER}", crate::render::feedback::TRANSFORM_WGSL),
351        );
352        let present_shader = gpu::fullscreen_shader(
353            device,
354            "attractor-present-shader",
355            gpu::FULLSCREEN_VS_UV_FLIPPED,
356            PRESENT_SHADER,
357        );
358
359        // Particle storage buffer: written by the compute step (STORAGE), read by
360        // the draw pass as an instance vertex buffer (VERTEX), seeded once from
361        // the CPU (COPY_DST). One buffer, two roles — no CPU round-trip.
362        //
363        // `COPY_SRC` is there for [`read_particles`], the reseed test's readback
364        // (Plan 0057 Phase 3). Carried unconditionally rather than behind
365        // `cfg(test)` so the test exercises the buffer the app actually allocates;
366        // a usage flag costs nothing that is not used, and a test running against a
367        // differently-configured resource is a test of something else.
368        let particles = device.create_buffer(&wgpu::BufferDescriptor {
369            label: Some("attractor-particles"),
370            size: (count as usize * std::mem::size_of::<Particle>()) as u64,
371            usage: wgpu::BufferUsages::STORAGE
372                | wgpu::BufferUsages::VERTEX
373                | wgpu::BufferUsages::COPY_DST
374                | wgpu::BufferUsages::COPY_SRC,
375            mapped_at_creation: false,
376        });
377        // [`STEP_SLOTS`] slots in ONE buffer, selected per dispatch by a dynamic
378        // offset.
379        //
380        // **Not two buffers behind two bind groups**, which is what this was first
381        // written as and which does not survive the software adapter: a second bind
382        // group sharing a live pipeline's layout gets aliased on WARP, so the step
383        // dispatch read the *jitter* slot — all zeros, so `count = 0`, so every
384        // invocation returned and the cloud never moved. It rendered a plausible
385        // static box, moved the golden baseline, and dropped three presets to
386        // ~0.000 in `animation` (WARP readings, unverified on lavapipe as of
387        // 2026-10-06). One layout and one bind group has no aliasing surface to
388        // get wrong.
389        let step_stride = uniform_stride(device);
390        let step_uniform = gpu::uniform_buffer(
391            device,
392            "attractor-step-uniform",
393            (step_stride * STEP_SLOTS) as usize,
394        );
395        let draw_uniform =
396            gpu::uniform_buffer(device, "attractor-draw-uniform", size_of::<DrawUniform>());
397        let decay_uniform =
398            gpu::uniform_buffer(device, "attractor-decay-uniform", size_of::<DecayUniform>());
399
400        // --- compute: read_write storage + step uniform ---
401        let compute_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
402            label: Some("attractor-compute-layout"),
403            entries: &[
404                storage_entry(0),
405                wgpu::BindGroupLayoutEntry {
406                    binding: 1,
407                    visibility: wgpu::ShaderStages::COMPUTE,
408                    ty: wgpu::BindingType::Buffer {
409                        ty: wgpu::BufferBindingType::Uniform,
410                        // The sub-step slots and the jitter slot, one dispatch each.
411                        has_dynamic_offset: true,
412                        min_binding_size: wgpu::BufferSize::new(size_of::<StepUniform>() as u64),
413                    },
414                    count: None,
415                },
416            ],
417        });
418        let compute_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
419            label: Some("attractor-compute-bg"),
420            layout: &compute_layout,
421            entries: &[
422                wgpu::BindGroupEntry {
423                    binding: 0,
424                    resource: particles.as_entire_binding(),
425                },
426                wgpu::BindGroupEntry {
427                    binding: 1,
428                    // A window the size of one `StepUniform`, not the whole
429                    // buffer: the dynamic offset slides it between the two slots.
430                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
431                        buffer: &step_uniform,
432                        offset: 0,
433                        size: wgpu::BufferSize::new(size_of::<StepUniform>() as u64),
434                    }),
435                },
436            ],
437        });
438        let compute_pipeline_layout =
439            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
440                label: Some("attractor-compute-pipeline-layout"),
441                bind_group_layouts: &[Some(&compute_layout)],
442                immediate_size: 0,
443            });
444        let compute_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
445            label: Some("attractor-compute-pipeline"),
446            layout: Some(&compute_pipeline_layout),
447            module: &step_shader,
448            entry_point: Some("main"),
449            compilation_options: Default::default(),
450            cache: None,
451        });
452
453        // Shared gradient LUTs (ADR-0021): two 256×1 textures (A/B) + a repeat
454        // sampler, bound to the draw pass and sampled per-particle in the vertex
455        // shader (so VERTEX visibility).
456        let luts = palette::LutPair::new(device, "attractor");
457
458        // --- draw: the particle buffer as an instance vertex buffer, additively
459        // into the trail field (float target so the accumulation has headroom) ---
460        let lut_vertex_texture = |binding: u32| wgpu::BindGroupLayoutEntry {
461            binding,
462            visibility: wgpu::ShaderStages::VERTEX,
463            ty: wgpu::BindingType::Texture {
464                sample_type: wgpu::TextureSampleType::Float { filterable: true },
465                view_dimension: wgpu::TextureViewDimension::D2,
466                multisampled: false,
467            },
468            count: None,
469        };
470        let draw_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
471            label: Some("attractor-draw-layout"),
472            entries: &[
473                gpu::uniform(0, wgpu::ShaderStages::VERTEX),
474                lut_vertex_texture(1),
475                lut_vertex_texture(2),
476                wgpu::BindGroupLayoutEntry {
477                    binding: 3,
478                    visibility: wgpu::ShaderStages::VERTEX,
479                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
480                    count: None,
481                },
482            ],
483        });
484        let draw_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
485            label: Some("attractor-draw-bg"),
486            layout: &draw_layout,
487            entries: &{
488                let [lut_a, lut_b, lut_sampler] = luts.bind_entries(1, 2, 3);
489                [
490                    wgpu::BindGroupEntry {
491                        binding: 0,
492                        resource: draw_uniform.as_entire_binding(),
493                    },
494                    lut_a,
495                    lut_b,
496                    lut_sampler,
497                ]
498            },
499        });
500        let draw_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
501            label: Some("attractor-draw-pipeline-layout"),
502            bind_group_layouts: &[Some(&draw_layout)],
503            immediate_size: 0,
504        });
505        let draw_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
506            label: Some("attractor-draw-pipeline"),
507            layout: Some(&draw_pipeline_layout),
508            vertex: wgpu::VertexState {
509                module: &draw_shader,
510                entry_point: Some("vs_main"),
511                compilation_options: Default::default(),
512                buffers: &[Some(wgpu::VertexBufferLayout {
513                    array_stride: std::mem::size_of::<Particle>() as u64,
514                    step_mode: wgpu::VertexStepMode::Instance,
515                    attributes: PARTICLE_ATTRIBUTES,
516                })],
517            },
518            fragment: Some(wgpu::FragmentState {
519                module: &draw_shader,
520                entry_point: Some("fs_main"),
521                compilation_options: Default::default(),
522                targets: &[Some(wgpu::ColorTargetState {
523                    format: PingPongField::FORMAT,
524                    // Additive: overlapping points bloom brighter (the dense look).
525                    blend: Some(wgpu::BlendState {
526                        color: wgpu::BlendComponent {
527                            src_factor: wgpu::BlendFactor::One,
528                            dst_factor: wgpu::BlendFactor::One,
529                            operation: wgpu::BlendOperation::Add,
530                        },
531                        alpha: wgpu::BlendComponent::OVER,
532                    }),
533                    write_mask: wgpu::ColorWrites::ALL,
534                })],
535            }),
536            primitive: wgpu::PrimitiveState::default(),
537            depth_stencil: None,
538            multisample: wgpu::MultisampleState::default(),
539            multiview_mask: None,
540            cache: None,
541        });
542
543        // --- decay + present: fullscreen samples of the accumulation field ---
544        // The layouts, the sampler and both pipelines are grid-independent; only
545        // the bind groups that name the field's views are not, and those live in
546        // `FieldResources`.
547        let field_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
548            label: Some("attractor-sampler"),
549            address_mode_u: wgpu::AddressMode::ClampToEdge,
550            address_mode_v: wgpu::AddressMode::ClampToEdge,
551            address_mode_w: wgpu::AddressMode::ClampToEdge,
552            mag_filter: wgpu::FilterMode::Linear,
553            min_filter: wgpu::FilterMode::Linear,
554            ..Default::default()
555        });
556
557        let decay_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
558            label: Some("attractor-decay-layout"),
559            entries: &[
560                gpu::texture(0, true),
561                gpu::sampler(1),
562                gpu::uniform(2, wgpu::ShaderStages::FRAGMENT),
563            ],
564        });
565        let decay_pipeline = gpu::fullscreen_pipeline(
566            device,
567            &decay_shader,
568            &[&decay_layout],
569            PingPongField::FORMAT,
570            // The decay pass overwrites the trail field with the faded previous frame.
571            wgpu::BlendState::REPLACE,
572            "attractor-decay",
573        );
574
575        // Texture, sampler, uniform, **sampler again** — the fourth entry is the
576        // same sampler a second time, and it is there to make this layout a shape
577        // nothing else in the crate has. `occlude` (ADR-0085) needed a uniform in a
578        // pass that had none; see `PRESENT_SHADER` for the measurement that says a
579        // colliding shape silently mis-renders on WARP (unverified on lavapipe as
580        // of 2026-10-06).
581        let present_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
582            label: Some("attractor-present-layout"),
583            entries: &[
584                gpu::texture(0, true),
585                gpu::sampler(1),
586                gpu::uniform(2, wgpu::ShaderStages::FRAGMENT),
587                gpu::sampler(3),
588            ],
589        });
590        let present_pipeline = gpu::fullscreen_pipeline(
591            device,
592            &present_shader,
593            &[&present_layout],
594            surface_format,
595            // Premultiplied-alpha OVER the backdrop (ADR-0026): the accumulation is
596            // emissive, so `c` adds over the atmosphere and the present's alpha
597            // (accumulated luminance) reveals bg_* in the cloud's empty space. Over
598            // the default black backdrop this equals the prior opaque present.
599            wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
600            "attractor-present",
601        );
602
603        Self {
604            compute_pipeline,
605            draw_pipeline,
606            decay_pipeline,
607            present_pipeline,
608            particles,
609            step_uniform,
610            step_stride,
611            draw_uniform,
612            decay_uniform,
613            compute_bg,
614            draw_bg,
615            luts,
616            decay_layout,
617            present_layout,
618            field_sampler,
619            count,
620        }
621    }
622}
623
624impl FieldResources {
625    /// Allocate the accumulation field at `trail_w`x`trail_h` and bind its two
626    /// views into the four decay/present groups, reusing `pipelines`' layouts,
627    /// sampler and decay uniform. No shader, pipeline, particle or LUT resource
628    /// is created here — that is the whole point of the split.
629    pub(super) fn build(
630        device: &wgpu::Device,
631        pipelines: &PipelineResources,
632        trail_w: u32,
633        trail_h: u32,
634    ) -> Self {
635        let field = PingPongField::new(device, trail_w, trail_h);
636        let decay_bg_a = blit_bind_group(
637            device,
638            &pipelines.decay_layout,
639            "attractor-decay-bg-a",
640            field.view_a(),
641            &pipelines.field_sampler,
642            Some(&pipelines.decay_uniform),
643            false,
644        );
645        let decay_bg_b = blit_bind_group(
646            device,
647            &pipelines.decay_layout,
648            "attractor-decay-bg-b",
649            field.view_b(),
650            &pipelines.field_sampler,
651            Some(&pipelines.decay_uniform),
652            false,
653        );
654        let present_bg_a = blit_bind_group(
655            device,
656            &pipelines.present_layout,
657            "attractor-present-bg-a",
658            field.view_a(),
659            &pipelines.field_sampler,
660            Some(&pipelines.decay_uniform),
661            true,
662        );
663        let present_bg_b = blit_bind_group(
664            device,
665            &pipelines.present_layout,
666            "attractor-present-bg-b",
667            field.view_b(),
668            &pipelines.field_sampler,
669            Some(&pipelines.decay_uniform),
670            true,
671        );
672        Self {
673            field,
674            decay_bg_a,
675            decay_bg_b,
676            present_bg_a,
677            present_bg_b,
678            trail_w,
679            trail_h,
680        }
681    }
682
683    /// Clear both accumulation textures to black — run once after a (re)build so
684    /// the first decay pass reads a defined (empty) trail rather than garbage.
685    pub(super) fn clear_field(&self, encoder: &mut wgpu::CommandEncoder) {
686        for view in [self.field.view_a(), self.field.view_b()] {
687            gpu::color_pass(
688                encoder,
689                "attractor-clear-pass",
690                view,
691                wgpu::LoadOp::Clear(wgpu::Color::BLACK),
692            );
693        }
694    }
695}
696
697/// Byte stride between two dynamically-offset slots of a `StepUniform`, rounded
698/// up to the adapter's `min_uniform_buffer_offset_alignment` (256 on the default
699/// limits). Read from the device rather than hardcoded: a dynamic offset that is
700/// not a multiple of it is a validation error, and the limit is the adapter's to
701/// state.
702pub(super) fn uniform_stride(device: &wgpu::Device) -> u32 {
703    let align = device.limits().min_uniform_buffer_offset_alignment.max(1);
704    size_of::<StepUniform>().next_multiple_of(align as usize) as u32
705}
706
707pub(super) fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
708    wgpu::BindGroupLayoutEntry {
709        binding,
710        visibility: wgpu::ShaderStages::COMPUTE,
711        ty: wgpu::BindingType::Buffer {
712            ty: wgpu::BufferBindingType::Storage { read_only: false },
713            has_dynamic_offset: false,
714            min_binding_size: None,
715        },
716        count: None,
717    }
718}
719
720/// A texture(+sampler)[+uniform][+sampler] bind group for the decay/present
721/// fullscreen passes.
722///
723/// `uniform` is the decay buffer for both — the retention factor for decay, and
724/// `occlude` out of the same buffer's second component for present (ADR-0085).
725/// `repeat_sampler` binds the sampler a second time at binding 3 and is the
726/// **present** pass only: it is what makes that layout a fourth shape rather than
727/// a copy of `attractor-decay-layout`'s. See `PRESENT_SHADER`.
728pub(super) fn blit_bind_group(
729    device: &wgpu::Device,
730    layout: &wgpu::BindGroupLayout,
731    label: &str,
732    input: &wgpu::TextureView,
733    sampler: &wgpu::Sampler,
734    uniform: Option<&wgpu::Buffer>,
735    repeat_sampler: bool,
736) -> wgpu::BindGroup {
737    let mut entries = vec![
738        wgpu::BindGroupEntry {
739            binding: 0,
740            resource: wgpu::BindingResource::TextureView(input),
741        },
742        wgpu::BindGroupEntry {
743            binding: 1,
744            resource: wgpu::BindingResource::Sampler(sampler),
745        },
746    ];
747    if let Some(buf) = uniform {
748        entries.push(wgpu::BindGroupEntry {
749            binding: 2,
750            resource: buf.as_entire_binding(),
751        });
752    }
753    if repeat_sampler {
754        entries.push(wgpu::BindGroupEntry {
755            binding: 3,
756            resource: wgpu::BindingResource::Sampler(sampler),
757        });
758    }
759    device.create_bind_group(&wgpu::BindGroupDescriptor {
760        label: Some(label),
761        layout,
762        entries: &entries,
763    })
764}