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 `perspective`, 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#[repr(C)]
137#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
138pub(super) struct DrawUniform {
139 pub(super) v: [f32; 4],
140 pub(super) w: [f32; 4],
141 pub(super) u: [f32; 4],
142 pub(super) x: [f32; 4],
143 pub(super) bh: [f32; 4],
144 pub(super) bv: [f32; 4],
145 pub(super) d: [f32; 4],
146 pub(super) ctr: [f32; 4],
147 pub(super) ch: [f32; 4],
148 pub(super) em: [f32; 4],
149}
150
151/// Decay uniform (per frame): x is the per-frame trail retention factor.
152#[repr(C)]
153#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
154pub(super) struct DecayUniform {
155 pub(super) k: [f32; 4],
156 /// The ADR-0048 feedback transform, exactly as
157 /// [`feedback::Transform::pack`](crate::render::feedback::Transform::pack)
158 /// returns it. Read by the decay pass; the present pass declares only `k` over
159 /// the same buffer, which is legal — a uniform binding may be wider than the
160 /// struct a shader lays over it — and deliberate, because the present's
161 /// bind-group layout *shape* is what the WARP adapter is sensitive to.
162 pub(super) xf: [f32; 4],
163 pub(super) tr: [f32; 4],
164 pub(super) wp: [f32; 4],
165}
166
167/// The GPU-side state, built lazily on first render (see the module docs), split
168/// along the axis that actually varies (Plan 0029 Phase 1): everything in
169/// [`PipelineResources`] is built once and survives every size change; only
170/// [`FieldResources`] is rebuilt when the accumulation grid changes.
171pub(super) struct Resources {
172 pub(super) pipelines: PipelineResources,
173 pub(super) grid: FieldResources,
174}
175
176/// The grid-**independent** GPU state: the four shader modules, every pipeline,
177/// the particle storage buffer, the uniform buffers, and the LUT textures. None
178/// of it references the accumulation field, so a size change must not touch it —
179/// recompiling four WGSL modules and rebuilding four pipelines inside `render` is
180/// a multi-hundred-millisecond stall, and the standalone forwards every
181/// `WindowEvent::Resized`, so a live drag paid it per frame (Plan 0029 Phase 1).
182pub(super) struct PipelineResources {
183 pub(super) compute_pipeline: wgpu::ComputePipeline,
184 pub(super) draw_pipeline: wgpu::RenderPipeline,
185 pub(super) decay_pipeline: wgpu::RenderPipeline,
186 pub(super) present_pipeline: wgpu::RenderPipeline,
187 pub(super) particles: wgpu::Buffer,
188 pub(super) step_uniform: wgpu::Buffer,
189 /// Byte stride between two slots of `step_uniform`, rounded up to the
190 /// adapter's dynamic-offset alignment.
191 ///
192 /// Separate slots rather than one written repeatedly, because a frame encodes
193 /// `pending_steps` step dispatches against one binding: folding the jitter into
194 /// the step slot would apply it once per sub-step, making the disturbance a
195 /// function of the frame's timing and breaking determinism. [`STEP_SLOTS`] has
196 /// the same argument for the sub-steps themselves.
197 pub(super) step_stride: u32,
198 pub(super) draw_uniform: wgpu::Buffer,
199 pub(super) decay_uniform: wgpu::Buffer,
200 pub(super) compute_bg: wgpu::BindGroup,
201 pub(super) draw_bg: wgpu::BindGroup,
202 /// The shared gradient LUT pair (A/B) the draw vertex shader samples +
203 /// crossfades (ADR-0021); uploaded from the scene's baked palette on the first
204 /// frame after a build and on a preset switch. It lives here rather than in
205 /// [`FieldResources`] because it outlives a grid change, so a resize does not
206 /// re-upload the palette.
207 pub(super) luts: palette::LutPair,
208 /// Kept so a grid change can rebuild [`FieldResources`]' four bind groups
209 /// without recreating a layout, a sampler, or any pipeline.
210 pub(super) decay_layout: wgpu::BindGroupLayout,
211 pub(super) present_layout: wgpu::BindGroupLayout,
212 pub(super) field_sampler: wgpu::Sampler,
213 /// How many particles the buffer above holds — the active tier's **ceiling**
214 /// ([`attractor_particles_live_ceiling`](crate::render::TierConfig::attractor_particles_live_ceiling),
215 /// or the offline one on a headless render path), fixed for the life of these
216 /// resources.
217 ///
218 /// The dispatch, the instance draw and the step uniform all take the
219 /// **active** count instead — `round(budget * density)` (ADR-0069) over a
220 /// budget that is itself a density against the render target (ADR-0140) — so
221 /// this is two clamps above what any frame actually draws. It survives as the
222 /// allocation bound: the draw clamps its instance range to it, so neither a
223 /// preset's `density` nor a resize can fetch a vertex past the end of the
224 /// buffer.
225 pub(super) count: u32,
226}
227
228/// The grid-**dependent** GPU state: the accumulation field and the four bind
229/// groups that reference its two texture views. The only block a size change
230/// rebuilds — a texture pair plus four bind groups, none of which compiles a
231/// shader (Plan 0029 Phase 1).
232pub(super) struct FieldResources {
233 /// Two-texture accumulation the trails ping-pong between (ADR-0012 reuse).
234 pub(super) field: PingPongField,
235 /// Decay/present bind groups reading texture A / texture B — selected by the
236 /// field's read side each frame so nothing is rebuilt on the hot path.
237 pub(super) decay_bg_a: wgpu::BindGroup,
238 pub(super) decay_bg_b: wgpu::BindGroup,
239 pub(super) present_bg_a: wgpu::BindGroup,
240 pub(super) present_bg_b: wgpu::BindGroup,
241 /// The accumulation grid this block was built for; `render` compares the
242 /// requested grid against it and rebuilds only this block on a difference.
243 pub(super) trail_w: u32,
244 pub(super) trail_h: u32,
245}
246
247impl Resources {
248 pub(super) fn build(
249 device: &wgpu::Device,
250 surface_format: wgpu::TextureFormat,
251 trail_w: u32,
252 trail_h: u32,
253 count: u32,
254 ) -> Self {
255 let pipelines = PipelineResources::build(device, surface_format, count);
256 let grid = FieldResources::build(device, &pipelines, trail_w, trail_h);
257 Self { pipelines, grid }
258 }
259
260 /// Re-allocate the accumulation field at a new grid, reusing every pipeline,
261 /// buffer, and texture that does not depend on it. The rebuilt field is
262 /// undefined, so the caller re-flags the clear (and the seed upload, which
263 /// keeps a capture reproducible from the same starting scatter).
264 pub(super) fn rebuild_grid(&mut self, device: &wgpu::Device, trail_w: u32, trail_h: u32) {
265 self.grid = FieldResources::build(device, &self.pipelines, trail_w, trail_h);
266 }
267}
268
269/// The draw pass's instance attributes, with **explicit byte offsets into
270/// [`Particle`]**.
271///
272/// **Spelled out rather than built by `vertex_attr_array!`, and that is the
273/// whole point of this constant.** That macro lays its attributes out
274/// *consecutively* — which was correct while the struct was `pos`, `seed`,
275/// `prev` and one trailing pad, and stopped being correct the moment ADR-0087
276/// put `age` and `map` past that pad. A fourth macro entry would have fetched
277/// the padding word at offset 28 and fed the draw someone else's bytes, silently
278/// and with no compile error. `the_particle_layout_carries_three_channels`
279/// measures these offsets against the struct so the two cannot drift.
280pub(super) const PARTICLE_ATTRIBUTES: &[wgpu::VertexAttribute] = &[
281 wgpu::VertexAttribute {
282 format: wgpu::VertexFormat::Float32x3,
283 offset: 0,
284 shader_location: 0, // pos (z = 0 for 2D families)
285 },
286 wgpu::VertexAttribute {
287 format: wgpu::VertexFormat::Float32,
288 offset: 12,
289 shader_location: 1, // seed
290 },
291 wgpu::VertexAttribute {
292 format: wgpu::VertexFormat::Float32x3,
293 offset: 16,
294 shader_location: 2, // prev (ADR-0069)
295 },
296 wgpu::VertexAttribute {
297 format: wgpu::VertexFormat::Float32,
298 offset: 36,
299 shader_location: 3, // map (ADR-0087) — 36, NOT 28, which is `_pad`
300 },
301 wgpu::VertexAttribute {
302 format: wgpu::VertexFormat::Float32,
303 offset: 32,
304 shader_location: 4, // age (ADR-0087)
305 },
306 wgpu::VertexAttribute {
307 format: wgpu::VertexFormat::Float32,
308 offset: 40,
309 shader_location: 5, // root (ADR-0088) — 40, the first spare word
310 },
311];
312
313impl PipelineResources {
314 pub(super) fn build(
315 device: &wgpu::Device,
316 surface_format: wgpu::TextureFormat,
317 count: u32,
318 ) -> Self {
319 // The shared bit-mixer, concatenated in — the same WGSL the tonemap's
320 // dither compiles (Plan 0082 Phase 1), so a particle's reseed kick and a
321 // display-write LSB cannot drift apart on what the hash is.
322 let step_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
323 label: Some("attractor-step-shader"),
324 source: wgpu::ShaderSource::Wgsl(format!("{}{STEP_SHADER}", gpu::HASH_WGSL).into()),
325 });
326 let draw_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
327 label: Some("attractor-draw-shader"),
328 source: wgpu::ShaderSource::Wgsl(
329 format!("{}\n{DRAW_SHADER}", crate::render::camera::CAMERA_WGSL).into(),
330 ),
331 });
332 // ADR-0048's transform, concatenated in: the same WGSL the engine trails
333 // stage compiles, so the two accumulation sinks cannot drift apart on what
334 // `fb_rotate` means.
335 let decay_shader = gpu::fullscreen_shader(
336 device,
337 "attractor-decay-shader",
338 gpu::FULLSCREEN_VS_UV_FLIPPED,
339 &format!("{}{DECAY_SHADER}", crate::render::feedback::TRANSFORM_WGSL),
340 );
341 let present_shader = gpu::fullscreen_shader(
342 device,
343 "attractor-present-shader",
344 gpu::FULLSCREEN_VS_UV_FLIPPED,
345 PRESENT_SHADER,
346 );
347
348 // Particle storage buffer: written by the compute step (STORAGE), read by
349 // the draw pass as an instance vertex buffer (VERTEX), seeded once from
350 // the CPU (COPY_DST). One buffer, two roles — no CPU round-trip.
351 //
352 // `COPY_SRC` is there for [`read_particles`], the reseed test's readback
353 // (Plan 0057 Phase 3). Carried unconditionally rather than behind
354 // `cfg(test)` so the test exercises the buffer the app actually allocates;
355 // a usage flag costs nothing that is not used, and a test running against a
356 // differently-configured resource is a test of something else.
357 let particles = device.create_buffer(&wgpu::BufferDescriptor {
358 label: Some("attractor-particles"),
359 size: (count as usize * std::mem::size_of::<Particle>()) as u64,
360 usage: wgpu::BufferUsages::STORAGE
361 | wgpu::BufferUsages::VERTEX
362 | wgpu::BufferUsages::COPY_DST
363 | wgpu::BufferUsages::COPY_SRC,
364 mapped_at_creation: false,
365 });
366 // [`STEP_SLOTS`] slots in ONE buffer, selected per dispatch by a dynamic
367 // offset.
368 //
369 // **Not two buffers behind two bind groups**, which is what this was first
370 // written as and which does not survive the software adapter: a second bind
371 // group sharing a live pipeline's layout gets aliased on WARP, so the step
372 // dispatch read the *jitter* slot — all zeros, so `count = 0`, so every
373 // invocation returned and the cloud never moved. It rendered a plausible
374 // static box, moved the golden baseline, and dropped three presets to
375 // ~0.000 in `animation`. One layout and one bind group has no aliasing
376 // surface to get wrong.
377 let step_stride = uniform_stride(device);
378 let step_uniform = gpu::uniform_buffer(
379 device,
380 "attractor-step-uniform",
381 (step_stride * STEP_SLOTS) as usize,
382 );
383 let draw_uniform =
384 gpu::uniform_buffer(device, "attractor-draw-uniform", size_of::<DrawUniform>());
385 let decay_uniform =
386 gpu::uniform_buffer(device, "attractor-decay-uniform", size_of::<DecayUniform>());
387
388 // --- compute: read_write storage + step uniform ---
389 let compute_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
390 label: Some("attractor-compute-layout"),
391 entries: &[
392 storage_entry(0),
393 wgpu::BindGroupLayoutEntry {
394 binding: 1,
395 visibility: wgpu::ShaderStages::COMPUTE,
396 ty: wgpu::BindingType::Buffer {
397 ty: wgpu::BufferBindingType::Uniform,
398 // The sub-step slots and the jitter slot, one dispatch each.
399 has_dynamic_offset: true,
400 min_binding_size: wgpu::BufferSize::new(size_of::<StepUniform>() as u64),
401 },
402 count: None,
403 },
404 ],
405 });
406 let compute_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
407 label: Some("attractor-compute-bg"),
408 layout: &compute_layout,
409 entries: &[
410 wgpu::BindGroupEntry {
411 binding: 0,
412 resource: particles.as_entire_binding(),
413 },
414 wgpu::BindGroupEntry {
415 binding: 1,
416 // A window the size of one `StepUniform`, not the whole
417 // buffer: the dynamic offset slides it between the two slots.
418 resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
419 buffer: &step_uniform,
420 offset: 0,
421 size: wgpu::BufferSize::new(size_of::<StepUniform>() as u64),
422 }),
423 },
424 ],
425 });
426 let compute_pipeline_layout =
427 device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
428 label: Some("attractor-compute-pipeline-layout"),
429 bind_group_layouts: &[Some(&compute_layout)],
430 immediate_size: 0,
431 });
432 let compute_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
433 label: Some("attractor-compute-pipeline"),
434 layout: Some(&compute_pipeline_layout),
435 module: &step_shader,
436 entry_point: Some("main"),
437 compilation_options: Default::default(),
438 cache: None,
439 });
440
441 // Shared gradient LUTs (ADR-0021): two 256×1 textures (A/B) + a repeat
442 // sampler, bound to the draw pass and sampled per-particle in the vertex
443 // shader (so VERTEX visibility).
444 let luts = palette::LutPair::new(device, "attractor");
445
446 // --- draw: the particle buffer as an instance vertex buffer, additively
447 // into the trail field (float target so the accumulation has headroom) ---
448 let lut_vertex_texture = |binding: u32| wgpu::BindGroupLayoutEntry {
449 binding,
450 visibility: wgpu::ShaderStages::VERTEX,
451 ty: wgpu::BindingType::Texture {
452 sample_type: wgpu::TextureSampleType::Float { filterable: true },
453 view_dimension: wgpu::TextureViewDimension::D2,
454 multisampled: false,
455 },
456 count: None,
457 };
458 let draw_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
459 label: Some("attractor-draw-layout"),
460 entries: &[
461 gpu::uniform(0, wgpu::ShaderStages::VERTEX),
462 lut_vertex_texture(1),
463 lut_vertex_texture(2),
464 wgpu::BindGroupLayoutEntry {
465 binding: 3,
466 visibility: wgpu::ShaderStages::VERTEX,
467 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
468 count: None,
469 },
470 ],
471 });
472 let draw_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
473 label: Some("attractor-draw-bg"),
474 layout: &draw_layout,
475 entries: &{
476 let [lut_a, lut_b, lut_sampler] = luts.bind_entries(1, 2, 3);
477 [
478 wgpu::BindGroupEntry {
479 binding: 0,
480 resource: draw_uniform.as_entire_binding(),
481 },
482 lut_a,
483 lut_b,
484 lut_sampler,
485 ]
486 },
487 });
488 let draw_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
489 label: Some("attractor-draw-pipeline-layout"),
490 bind_group_layouts: &[Some(&draw_layout)],
491 immediate_size: 0,
492 });
493 let draw_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
494 label: Some("attractor-draw-pipeline"),
495 layout: Some(&draw_pipeline_layout),
496 vertex: wgpu::VertexState {
497 module: &draw_shader,
498 entry_point: Some("vs_main"),
499 compilation_options: Default::default(),
500 buffers: &[Some(wgpu::VertexBufferLayout {
501 array_stride: std::mem::size_of::<Particle>() as u64,
502 step_mode: wgpu::VertexStepMode::Instance,
503 attributes: PARTICLE_ATTRIBUTES,
504 })],
505 },
506 fragment: Some(wgpu::FragmentState {
507 module: &draw_shader,
508 entry_point: Some("fs_main"),
509 compilation_options: Default::default(),
510 targets: &[Some(wgpu::ColorTargetState {
511 format: PingPongField::FORMAT,
512 // Additive: overlapping points bloom brighter (the dense look).
513 blend: Some(wgpu::BlendState {
514 color: wgpu::BlendComponent {
515 src_factor: wgpu::BlendFactor::One,
516 dst_factor: wgpu::BlendFactor::One,
517 operation: wgpu::BlendOperation::Add,
518 },
519 alpha: wgpu::BlendComponent::OVER,
520 }),
521 write_mask: wgpu::ColorWrites::ALL,
522 })],
523 }),
524 primitive: wgpu::PrimitiveState::default(),
525 depth_stencil: None,
526 multisample: wgpu::MultisampleState::default(),
527 multiview_mask: None,
528 cache: None,
529 });
530
531 // --- decay + present: fullscreen samples of the accumulation field ---
532 // The layouts, the sampler and both pipelines are grid-independent; only
533 // the bind groups that name the field's views are not, and those live in
534 // `FieldResources`.
535 let field_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
536 label: Some("attractor-sampler"),
537 address_mode_u: wgpu::AddressMode::ClampToEdge,
538 address_mode_v: wgpu::AddressMode::ClampToEdge,
539 address_mode_w: wgpu::AddressMode::ClampToEdge,
540 mag_filter: wgpu::FilterMode::Linear,
541 min_filter: wgpu::FilterMode::Linear,
542 ..Default::default()
543 });
544
545 let decay_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
546 label: Some("attractor-decay-layout"),
547 entries: &[
548 gpu::texture(0, true),
549 gpu::sampler(1),
550 gpu::uniform(2, wgpu::ShaderStages::FRAGMENT),
551 ],
552 });
553 let decay_pipeline = gpu::fullscreen_pipeline(
554 device,
555 &decay_shader,
556 &[&decay_layout],
557 PingPongField::FORMAT,
558 // The decay pass overwrites the trail field with the faded previous frame.
559 wgpu::BlendState::REPLACE,
560 "attractor-decay",
561 );
562
563 // Texture, sampler, uniform, **sampler again** — the fourth entry is the
564 // same sampler a second time, and it is there to make this layout a shape
565 // nothing else in the crate has. `occlude` (ADR-0085) needed a uniform in a
566 // pass that had none; see `PRESENT_SHADER` for the measurement that says a
567 // colliding shape silently mis-renders on WARP.
568 let present_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
569 label: Some("attractor-present-layout"),
570 entries: &[
571 gpu::texture(0, true),
572 gpu::sampler(1),
573 gpu::uniform(2, wgpu::ShaderStages::FRAGMENT),
574 gpu::sampler(3),
575 ],
576 });
577 let present_pipeline = gpu::fullscreen_pipeline(
578 device,
579 &present_shader,
580 &[&present_layout],
581 surface_format,
582 // Premultiplied-alpha OVER the backdrop (ADR-0026): the accumulation is
583 // emissive, so `c` adds over the atmosphere and the present's alpha
584 // (accumulated luminance) reveals bg_* in the cloud's empty space. Over
585 // the default black backdrop this equals the prior opaque present.
586 wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
587 "attractor-present",
588 );
589
590 Self {
591 compute_pipeline,
592 draw_pipeline,
593 decay_pipeline,
594 present_pipeline,
595 particles,
596 step_uniform,
597 step_stride,
598 draw_uniform,
599 decay_uniform,
600 compute_bg,
601 draw_bg,
602 luts,
603 decay_layout,
604 present_layout,
605 field_sampler,
606 count,
607 }
608 }
609}
610
611impl FieldResources {
612 /// Allocate the accumulation field at `trail_w`x`trail_h` and bind its two
613 /// views into the four decay/present groups, reusing `pipelines`' layouts,
614 /// sampler and decay uniform. No shader, pipeline, particle or LUT resource
615 /// is created here — that is the whole point of the split.
616 pub(super) fn build(
617 device: &wgpu::Device,
618 pipelines: &PipelineResources,
619 trail_w: u32,
620 trail_h: u32,
621 ) -> Self {
622 let field = PingPongField::new(device, trail_w, trail_h);
623 let decay_bg_a = blit_bind_group(
624 device,
625 &pipelines.decay_layout,
626 "attractor-decay-bg-a",
627 field.view_a(),
628 &pipelines.field_sampler,
629 Some(&pipelines.decay_uniform),
630 false,
631 );
632 let decay_bg_b = blit_bind_group(
633 device,
634 &pipelines.decay_layout,
635 "attractor-decay-bg-b",
636 field.view_b(),
637 &pipelines.field_sampler,
638 Some(&pipelines.decay_uniform),
639 false,
640 );
641 let present_bg_a = blit_bind_group(
642 device,
643 &pipelines.present_layout,
644 "attractor-present-bg-a",
645 field.view_a(),
646 &pipelines.field_sampler,
647 Some(&pipelines.decay_uniform),
648 true,
649 );
650 let present_bg_b = blit_bind_group(
651 device,
652 &pipelines.present_layout,
653 "attractor-present-bg-b",
654 field.view_b(),
655 &pipelines.field_sampler,
656 Some(&pipelines.decay_uniform),
657 true,
658 );
659 Self {
660 field,
661 decay_bg_a,
662 decay_bg_b,
663 present_bg_a,
664 present_bg_b,
665 trail_w,
666 trail_h,
667 }
668 }
669
670 /// Clear both accumulation textures to black — run once after a (re)build so
671 /// the first decay pass reads a defined (empty) trail rather than garbage.
672 pub(super) fn clear_field(&self, encoder: &mut wgpu::CommandEncoder) {
673 for view in [self.field.view_a(), self.field.view_b()] {
674 gpu::color_pass(
675 encoder,
676 "attractor-clear-pass",
677 view,
678 wgpu::LoadOp::Clear(wgpu::Color::BLACK),
679 );
680 }
681 }
682}
683
684/// Byte stride between two dynamically-offset slots of a `StepUniform`, rounded
685/// up to the adapter's `min_uniform_buffer_offset_alignment` (256 on the default
686/// limits). Read from the device rather than hardcoded: a dynamic offset that is
687/// not a multiple of it is a validation error, and the limit is the adapter's to
688/// state.
689pub(super) fn uniform_stride(device: &wgpu::Device) -> u32 {
690 let align = device.limits().min_uniform_buffer_offset_alignment.max(1);
691 size_of::<StepUniform>().next_multiple_of(align as usize) as u32
692}
693
694pub(super) fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
695 wgpu::BindGroupLayoutEntry {
696 binding,
697 visibility: wgpu::ShaderStages::COMPUTE,
698 ty: wgpu::BindingType::Buffer {
699 ty: wgpu::BufferBindingType::Storage { read_only: false },
700 has_dynamic_offset: false,
701 min_binding_size: None,
702 },
703 count: None,
704 }
705}
706
707/// A texture(+sampler)[+uniform][+sampler] bind group for the decay/present
708/// fullscreen passes.
709///
710/// `uniform` is the decay buffer for both — the retention factor for decay, and
711/// `occlude` out of the same buffer's second component for present (ADR-0085).
712/// `repeat_sampler` binds the sampler a second time at binding 3 and is the
713/// **present** pass only: it is what makes that layout a fourth shape rather than
714/// a copy of `attractor-decay-layout`'s. See `PRESENT_SHADER`.
715pub(super) fn blit_bind_group(
716 device: &wgpu::Device,
717 layout: &wgpu::BindGroupLayout,
718 label: &str,
719 input: &wgpu::TextureView,
720 sampler: &wgpu::Sampler,
721 uniform: Option<&wgpu::Buffer>,
722 repeat_sampler: bool,
723) -> wgpu::BindGroup {
724 let mut entries = vec![
725 wgpu::BindGroupEntry {
726 binding: 0,
727 resource: wgpu::BindingResource::TextureView(input),
728 },
729 wgpu::BindGroupEntry {
730 binding: 1,
731 resource: wgpu::BindingResource::Sampler(sampler),
732 },
733 ];
734 if let Some(buf) = uniform {
735 entries.push(wgpu::BindGroupEntry {
736 binding: 2,
737 resource: buf.as_entire_binding(),
738 });
739 }
740 if repeat_sampler {
741 entries.push(wgpu::BindGroupEntry {
742 binding: 3,
743 resource: wgpu::BindingResource::Sampler(sampler),
744 });
745 }
746 device.create_bind_group(&wgpu::BindGroupDescriptor {
747 label: Some(label),
748 layout,
749 entries: &entries,
750 })
751}