Skip to main content

viewport_lib/resources/gpu/
gpu_particles.rs

1//! GPU particle systems.
2//!
3//! A particle system owns a persistent GPU buffer holding `capacity` particles.
4//! Each particle stores its world-space position, velocity, lifetime remaining,
5//! starting lifetime, colour, and size.
6//!
7//! The host calls [`DeviceResources::create_gpu_particle_system`] once at
8//! startup to allocate the buffer, then submits a
9//! [`GpuParticleSystemItem`](crate::renderer::GpuParticleSystemItem) per frame.
10//! The renderer dispatches an emit compute pass (recycling dead particles back
11//! into live ones based on `EmitterConfig`), then a sim compute pass
12//! (integrating `ForceField`s and decrementing lifetime), then draws the live
13//! particles via the route picked in [`GpuParticleSystemConfig::render`].
14//!
15//! Dead particles are not compacted. The emit shader scans for slots with
16//! `lifetime <= 0` and reuses them; the draw shader emits a degenerate clip
17//! position for dead slots so they cost nothing in the rasteriser. Compaction
18//! would require a prefix sum each frame and is not worth the cost at the
19//! particle counts the API targets (1k - 200k).
20
21use bytemuck::{Pod, Zeroable};
22use wgpu::util::DeviceExt;
23
24use crate::renderer::{ParticleMeshAlign, SpriteBlend, SpriteLitParams, SpriteSizeMode};
25
26/// GPU particle-system compute/draw pipelines, their layouts, and the live
27/// systems. All pipelines are lazily built; `systems` holds the persistent
28/// per-system GPU state, indexed by `GpuParticleSystemId`.
29#[derive(Default)]
30pub(crate) struct ParticleResources {
31    /// Live particle systems. Slots can be reused after `drop_gpu_particle_system`.
32    pub(crate) systems: Vec<Option<ParticleSystem>>,
33    /// Layout for the emit + sim compute pipelines (group 1).
34    pub(crate) sim_bgl: Option<wgpu::BindGroupLayout>,
35    /// Layout for emit/sim params (group 0).
36    pub(crate) params_bgl: Option<wgpu::BindGroupLayout>,
37    /// Layout for the particle-sprite draw pipeline (group 1).
38    pub(crate) draw_bgl: Option<wgpu::BindGroupLayout>,
39    /// Compute pipeline that pops free-list slots and writes new particles.
40    pub(crate) emit_pipeline: Option<wgpu::ComputePipeline>,
41    /// Compute pipeline that integrates forces and decrements lifetime.
42    pub(crate) sim_pipeline: Option<wgpu::ComputePipeline>,
43    /// Draw pipeline variants for the particle-sprite shader, keyed by blend.
44    pub(crate) sprite_pipeline_alpha: Option<crate::resources::DualPipeline>,
45    pub(crate) sprite_pipeline_additive: Option<crate::resources::DualPipeline>,
46    pub(crate) sprite_pipeline_premultiplied: Option<crate::resources::DualPipeline>,
47    /// Lit variants of the GPU particle sprite pipelines.
48    pub(crate) sprite_lit_pipeline_alpha: Option<crate::resources::DualPipeline>,
49    pub(crate) sprite_lit_pipeline_additive: Option<crate::resources::DualPipeline>,
50    pub(crate) sprite_lit_pipeline_premultiplied: Option<crate::resources::DualPipeline>,
51    /// Group 2 BGL for the lit particle path: optional normal map + sampler.
52    pub(crate) sprite_lit_bgl: Option<wgpu::BindGroupLayout>,
53    /// Fallback bind group for the lit particle normal-map binding (group 2).
54    pub(crate) sprite_lit_fallback_bg: Option<wgpu::BindGroup>,
55    /// Layout for the mesh-route particle draw pipeline (group 1).
56    pub(crate) mesh_draw_bgl: Option<wgpu::BindGroupLayout>,
57    /// Draw pipeline variants for the particle-mesh shader, keyed by blend.
58    pub(crate) mesh_pipeline_alpha: Option<crate::resources::DualPipeline>,
59    pub(crate) mesh_pipeline_additive: Option<crate::resources::DualPipeline>,
60    pub(crate) mesh_pipeline_premultiplied: Option<crate::resources::DualPipeline>,
61}
62
63crate::resources::handle::registry_handle! {
64    /// Handle to a persistent GPU particle system.
65    ///
66    /// Returned by [`DeviceResources::create_gpu_particle_system`]. Stable
67    /// until [`DeviceResources::drop_gpu_particle_system`] is called. An
68    /// append-only registry handle.
69    pub struct GpuParticleSystemId;
70}
71
72/// Persistent configuration for a particle system. Set at creation; the render
73/// route and capacity are stable for the system's lifetime.
74#[derive(Debug, Clone)]
75#[non_exhaustive]
76pub struct GpuParticleSystemConfig {
77    /// Maximum number of simultaneously live particles. Memory cost scales
78    /// linearly with this value (currently 80 bytes per particle plus a small
79    /// fixed overhead).
80    pub capacity: u32,
81    /// How the live particles are drawn each frame.
82    pub render: ParticleRender,
83}
84
85impl Default for GpuParticleSystemConfig {
86    fn default() -> Self {
87        Self {
88            capacity: 10_000,
89            render: ParticleRender::default(),
90        }
91    }
92}
93
94/// How a particle system draws its live particles.
95#[derive(Debug, Clone)]
96#[non_exhaustive]
97pub enum ParticleRender {
98    /// Draw each particle as a camera-facing billboard sprite.
99    Sprite {
100        /// Optional texture sampled per fragment. `None` renders solid quads
101        /// tinted by the particle colour.
102        texture_id: Option<crate::resources::TextureId>,
103        /// GPU blend state.
104        blend: SpriteBlend,
105        /// Screen-space or world-space sizing for the per-particle `size`.
106        size_mode: SpriteSizeMode,
107        /// Whether the draw writes to depth.
108        depth_write: bool,
109        /// When `true`, the draw runs through the lit particle sprite pipeline
110        /// and picks up the scene lighting environment. Default `false`
111        /// preserves the emissive billboard look.
112        lit: bool,
113        /// Lighting parameters used when `lit` is `true`.
114        lit_params: SpriteLitParams,
115        /// Optional tangent-space normal map for the `NormalMap` mode.
116        normal_texture_id: Option<crate::resources::TextureId>,
117    },
118    /// Draw each particle as an instance of an uploaded mesh. The vertex
119    /// shader composes the per-instance transform from the live particle's
120    /// position, velocity, `size`, and (for `Random` align) the spawn seed.
121    /// Unlit; the particle colour multiplies an optional albedo sample.
122    Mesh {
123        /// Mesh handle returned by `DeviceResources::upload_mesh_data`.
124        mesh_id: crate::resources::mesh::mesh_store::MeshId,
125        /// Optional albedo texture handle. `None` renders flat-tinted.
126        texture_id: Option<crate::resources::TextureId>,
127        /// GPU blend state.
128        blend: SpriteBlend,
129        /// How per-particle rotation is derived.
130        align: ParticleMeshAlign,
131    },
132}
133
134impl Default for ParticleRender {
135    fn default() -> Self {
136        ParticleRender::Sprite {
137            texture_id: None,
138            blend: SpriteBlend::AlphaBlend,
139            size_mode: SpriteSizeMode::ScreenSpace,
140            depth_write: false,
141            lit: false,
142            lit_params: SpriteLitParams {
143                roughness: 0.9,
144                normal_mode: crate::renderer::SpriteNormalMode::Spherical,
145                receive_shadows: false,
146                ambient_scale: 1.0,
147            },
148            normal_texture_id: None,
149        }
150    }
151}
152
153/// One particle as it lives on the GPU. Layout matches `Particle` in
154/// `particle_emit.wgsl` and `particle_sim.wgsl`. Eighty bytes, naturally
155/// 16-byte aligned.
156#[repr(C)]
157#[derive(Copy, Clone, Pod, Zeroable)]
158pub(crate) struct GpuParticle {
159    pub position: [f32; 3],
160    pub lifetime: f32, // seconds remaining; <= 0 means dead
161    pub velocity: [f32; 3],
162    pub max_lifetime: f32, // initial lifetime, used for fade ramps
163    pub colour: [f32; 4],
164    pub size: f32,
165    /// Stable per-spawn seed used by the mesh draw route for `Random` align
166    /// rotation. Written by `particle_emit.wgsl`; left untouched by the sim.
167    pub spawn_seed: f32,
168    pub _pad: [f32; 2],
169}
170
171/// Uniform buffer matching `EmitParams` in `particle_emit.wgsl`. 96 bytes.
172#[repr(C)]
173#[derive(Copy, Clone, Pod, Zeroable)]
174pub(crate) struct EmitParamsGpu {
175    pub spawn_min: [f32; 3],
176    pub spawn_kind: u32, // 0=Point, 1=Box, 2=Sphere
177    pub spawn_max: [f32; 3],
178    pub spawn_radius: f32, // sphere radius (Sphere only)
179    pub vel_min: [f32; 3],
180    pub vel_kind: u32, // 0=Fixed, 1=UniformBox, 2=UniformCone
181    pub vel_max: [f32; 3],
182    pub cone_half_angle: f32,
183    pub vel_axis: [f32; 3],
184    pub cone_min_speed: f32,
185    pub colour: [f32; 4],
186    pub spawn_count: u32,
187    pub capacity: u32,
188    pub rng_seed: u32,
189    pub size: f32,
190    pub lifetime_min: f32,
191    pub lifetime_max: f32,
192    pub cone_max_speed: f32,
193    pub _pad: f32,
194}
195
196/// Maximum number of forces in a single sim dispatch. Forces are inlined into
197/// the uniform buffer; bump this if it becomes a real limit (so far no game
198/// effect uses more than 3-4 forces simultaneously).
199pub(crate) const MAX_FORCES: usize = 8;
200
201/// One force on the GPU. Tagged union; 32 bytes.
202#[repr(C)]
203#[derive(Copy, Clone, Pod, Zeroable)]
204pub(crate) struct GpuForce {
205    pub kind: u32, // 0=Gravity, 1=Drag, 2=PointAttractor
206    pub _pad: [u32; 3],
207    pub v0: [f32; 4], // Gravity: xyz=acceleration / Drag: x=coefficient / Attractor: xyz=position, w=strength
208    pub v1: [f32; 4], // Attractor: x=falloff
209}
210
211/// Uniform buffer matching `SimParams` in `particle_sim.wgsl`. Forces are
212/// inlined so the sim pipeline reads from a single uniform binding.
213#[repr(C)]
214#[derive(Copy, Clone, Pod, Zeroable)]
215pub(crate) struct SimParamsGpu {
216    pub dt: f32,
217    pub capacity: u32,
218    pub force_count: u32,
219    pub _pad: u32,
220    pub forces: [GpuForce; MAX_FORCES],
221}
222
223/// Per-system persistent GPU state.
224pub(crate) struct ParticleSystem {
225    pub capacity: u32,
226    pub render: ParticleRender,
227    /// `capacity` particles in `GpuParticle` layout. STORAGE + VERTEX usage so
228    /// the draw pipeline can bind it directly.
229    pub particle_buf: wgpu::Buffer,
230    /// Single atomic u32 counter rewritten by the host before each emit
231    /// dispatch and decremented by emit threads as they claim slots. Reused
232    /// across frames; nothing is preserved between dispatches.
233    pub emit_counter_buf: wgpu::Buffer,
234    /// Bind group for the sim/emit compute pipelines (group 1).
235    pub sim_bg: wgpu::BindGroup,
236    /// Bind group for the sprite draw pipeline (group 1). `None` when the
237    /// system's render route is not `Sprite`.
238    pub draw_bg: Option<wgpu::BindGroup>,
239    /// Bind group for the mesh draw pipeline (group 1). `None` when the
240    /// system's render route is not `Mesh`.
241    pub draw_bg_mesh: Option<wgpu::BindGroup>,
242    /// Group 2 bind group for the lit draw pipeline (normal map + sampler).
243    /// `None` when the system's render route is not lit.
244    pub draw_lit_normal_bg: Option<wgpu::BindGroup>,
245    /// Uniform buffers backing whichever draw bind group is populated.
246    pub _draw_uniform_buf: Option<wgpu::Buffer>,
247    /// Whether the slot is in use. The slot is reused lazily by future creates.
248    pub alive: bool,
249    /// Frame count since creation; used to seed the emit RNG so a freshly
250    /// created system gets a different sequence from one that has been
251    /// running for a while.
252    pub frame_counter: u32,
253    /// Fractional spawn accumulator. `rate * dt` rarely lands on an integer
254    /// per frame; the fractional remainder rolls over to the next frame so
255    /// the long-term average emission matches the configured rate.
256    pub spawn_accumulator: f32,
257}
258
259/// Which draw family the render loop should dispatch for this system.
260#[derive(Copy, Clone, Debug)]
261pub(crate) enum ParticleDrawRoute {
262    Sprite {
263        lit: bool,
264    },
265    Mesh {
266        mesh_id: crate::resources::mesh::mesh_store::MeshId,
267    },
268}
269
270/// Per-frame data for one particle system, populated in prepare and consumed
271/// in render.
272pub(crate) struct ParticleFrameData {
273    /// Index into `particle_systems`.
274    pub system_idx: usize,
275    /// Picked at submit time; consumed by the draw pipeline router.
276    pub blend: SpriteBlend,
277    /// Whether to skip the draw (hidden item).
278    pub hidden: bool,
279    /// Which draw family to dispatch.
280    pub route: ParticleDrawRoute,
281}
282
283impl crate::resources::DeviceResources {
284    /// Allocate a persistent GPU particle system.
285    ///
286    /// The returned [`GpuParticleSystemId`] stays valid until
287    /// [`drop_gpu_particle_system`](Self::drop_gpu_particle_system) is called
288    /// or the renderer is dropped.
289    pub fn create_gpu_particle_system(
290        &mut self,
291        device: &wgpu::Device,
292        queue: &wgpu::Queue,
293        config: &GpuParticleSystemConfig,
294    ) -> GpuParticleSystemId {
295        self.ensure_particle_pipelines(device);
296
297        let capacity = config.capacity.max(1);
298
299        // Persistent particle buffer, initialised to all-dead.
300        let particle_bytes_len = (capacity as usize) * std::mem::size_of::<GpuParticle>();
301        let zero_particles = vec![0u8; particle_bytes_len];
302        let particle_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
303            label: Some("gpu_particle_buf"),
304            contents: &zero_particles,
305            usage: wgpu::BufferUsages::STORAGE
306                | wgpu::BufferUsages::VERTEX
307                | wgpu::BufferUsages::COPY_DST,
308        });
309
310        // Atomic counter rewritten per emit dispatch.
311        let emit_counter_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
312            label: Some("gpu_particle_emit_counter"),
313            contents: bytemuck::bytes_of(&0u32),
314            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
315        });
316
317        // Draw-side state. The sprite route uses the existing sprite-style
318        // uniform; the mesh route uses a smaller uniform with the align mode
319        // and a `has_texture` flag.
320        enum DrawState {
321            Sprite {
322                texture_id: Option<crate::resources::TextureId>,
323                size_mode: SpriteSizeMode,
324                lit: bool,
325                lit_params: SpriteLitParams,
326                normal_texture_id: Option<crate::resources::TextureId>,
327            },
328            Mesh {
329                texture_id: Option<crate::resources::TextureId>,
330                align: ParticleMeshAlign,
331            },
332        }
333        let draw_state = match &config.render {
334            ParticleRender::Sprite {
335                texture_id,
336                blend: _,
337                size_mode,
338                depth_write: _,
339                lit,
340                lit_params,
341                normal_texture_id,
342            } => DrawState::Sprite {
343                texture_id: *texture_id,
344                size_mode: *size_mode,
345                lit: *lit,
346                lit_params: *lit_params,
347                normal_texture_id: *normal_texture_id,
348            },
349            ParticleRender::Mesh {
350                texture_id,
351                blend: _,
352                align,
353                mesh_id: _,
354            } => DrawState::Mesh {
355                texture_id: *texture_id,
356                align: *align,
357            },
358        };
359
360        let _ = queue; // queue currently unused; reserved for textures upload paths
361
362        let sim_bgl = self
363            .particle
364            .sim_bgl
365            .as_ref()
366            .expect("ensure_particle_pipelines failed to create sim BGL");
367        let sim_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
368            label: Some("gpu_particle_sim_bg"),
369            layout: sim_bgl,
370            entries: &[
371                wgpu::BindGroupEntry {
372                    binding: 0,
373                    resource: particle_buf.as_entire_binding(),
374                },
375                wgpu::BindGroupEntry {
376                    binding: 1,
377                    resource: emit_counter_buf.as_entire_binding(),
378                },
379            ],
380        });
381
382        // Per-route draw resources. Exactly one of `sprite_draw_bg` and
383        // `mesh_draw_bg` is populated; the other arm stays `None`.
384        let mut sprite_draw_bg: Option<wgpu::BindGroup> = None;
385        let mut mesh_draw_bg: Option<wgpu::BindGroup> = None;
386        let mut sprite_lit_normal_bg: Option<wgpu::BindGroup> = None;
387        let draw_uniform_buf: Option<wgpu::Buffer>;
388
389        match draw_state {
390            DrawState::Sprite {
391                texture_id,
392                size_mode,
393                lit,
394                lit_params,
395                normal_texture_id,
396            } => {
397                #[repr(C)]
398                #[derive(Copy, Clone, Pod, Zeroable)]
399                struct SpriteDrawUniform {
400                    model: [[f32; 4]; 4],
401                    world_space: u32,
402                    has_texture: u32,
403                    normal_mode: u32,
404                    has_normal_map: u32,
405                    ambient_scale: f32,
406                    roughness: f32,
407                    _pad0: u32,
408                    _pad1: u32,
409                }
410                let normal_mode_u32 = match lit_params.normal_mode {
411                    crate::renderer::SpriteNormalMode::Spherical => 0u32,
412                    crate::renderer::SpriteNormalMode::Flat => 1u32,
413                    crate::renderer::SpriteNormalMode::NormalMap => 2u32,
414                };
415                let uniform = SpriteDrawUniform {
416                    model: glam::Mat4::IDENTITY.to_cols_array_2d(),
417                    world_space: matches!(size_mode, SpriteSizeMode::WorldSpace) as u32,
418                    has_texture: texture_id.is_some() as u32,
419                    normal_mode: normal_mode_u32,
420                    has_normal_map: normal_texture_id.is_some() as u32,
421                    ambient_scale: lit_params.ambient_scale,
422                    roughness: lit_params.roughness,
423                    _pad0: 0,
424                    _pad1: 0,
425                };
426                let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
427                    label: Some("gpu_particle_sprite_draw_uniform"),
428                    contents: bytemuck::bytes_of(&uniform),
429                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
430                });
431                let texture_view = match texture_id {
432                    Some(id) if self.content.textures.get(id).is_some() => {
433                        &self.content.textures.get(id).unwrap().view
434                    }
435                    _ => &self.content.fallback_lut_view,
436                };
437                let draw_bgl = self
438                    .particle
439                    .draw_bgl
440                    .as_ref()
441                    .expect("ensure_particle_pipelines failed to create draw BGL");
442                sprite_draw_bg = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
443                    label: Some("gpu_particle_draw_bg"),
444                    layout: draw_bgl,
445                    entries: &[
446                        wgpu::BindGroupEntry {
447                            binding: 0,
448                            resource: uniform_buf.as_entire_binding(),
449                        },
450                        wgpu::BindGroupEntry {
451                            binding: 1,
452                            resource: wgpu::BindingResource::TextureView(texture_view),
453                        },
454                        wgpu::BindGroupEntry {
455                            binding: 2,
456                            resource: wgpu::BindingResource::Sampler(&self.material_sampler),
457                        },
458                        wgpu::BindGroupEntry {
459                            binding: 3,
460                            resource: particle_buf.as_entire_binding(),
461                        },
462                    ],
463                }));
464                if lit {
465                    let lit_bgl = self
466                        .particle
467                        .sprite_lit_bgl
468                        .as_ref()
469                        .expect("ensure_particle_pipelines failed to create lit BGL");
470                    let normal_view = match normal_texture_id {
471                        Some(id) if self.content.textures.get(id).is_some() => {
472                            &self.content.textures.get(id).unwrap().view
473                        }
474                        _ => &self.fallback_normal_map_view,
475                    };
476                    sprite_lit_normal_bg =
477                        Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
478                            label: Some("gpu_particle_lit_normal_bg"),
479                            layout: lit_bgl,
480                            entries: &[
481                                wgpu::BindGroupEntry {
482                                    binding: 0,
483                                    resource: wgpu::BindingResource::TextureView(normal_view),
484                                },
485                                wgpu::BindGroupEntry {
486                                    binding: 1,
487                                    resource: wgpu::BindingResource::Sampler(
488                                        &self.material_sampler,
489                                    ),
490                                },
491                            ],
492                        }));
493                }
494                draw_uniform_buf = Some(uniform_buf);
495            }
496            DrawState::Mesh { texture_id, align } => {
497                #[repr(C)]
498                #[derive(Copy, Clone, Pod, Zeroable)]
499                struct MeshDrawUniform {
500                    align: u32,
501                    has_texture: u32,
502                    _pad0: u32,
503                    _pad1: u32,
504                }
505                let align_u32 = match align {
506                    ParticleMeshAlign::Identity => 0u32,
507                    ParticleMeshAlign::Velocity => 1u32,
508                    ParticleMeshAlign::Random => 2u32,
509                };
510                let uniform = MeshDrawUniform {
511                    align: align_u32,
512                    has_texture: texture_id.is_some() as u32,
513                    _pad0: 0,
514                    _pad1: 0,
515                };
516                let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
517                    label: Some("gpu_particle_mesh_draw_uniform"),
518                    contents: bytemuck::bytes_of(&uniform),
519                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
520                });
521                let texture_view = match texture_id {
522                    Some(id) if self.content.textures.get(id).is_some() => {
523                        &self.content.textures.get(id).unwrap().view
524                    }
525                    _ => &self.fallback_texture.view,
526                };
527                let mesh_bgl = self
528                    .particle
529                    .mesh_draw_bgl
530                    .as_ref()
531                    .expect("ensure_particle_pipelines failed to create mesh draw BGL");
532                mesh_draw_bg = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
533                    label: Some("gpu_particle_mesh_draw_bg"),
534                    layout: mesh_bgl,
535                    entries: &[
536                        wgpu::BindGroupEntry {
537                            binding: 0,
538                            resource: uniform_buf.as_entire_binding(),
539                        },
540                        wgpu::BindGroupEntry {
541                            binding: 1,
542                            resource: wgpu::BindingResource::TextureView(texture_view),
543                        },
544                        wgpu::BindGroupEntry {
545                            binding: 2,
546                            resource: wgpu::BindingResource::Sampler(&self.material_sampler),
547                        },
548                        wgpu::BindGroupEntry {
549                            binding: 3,
550                            resource: particle_buf.as_entire_binding(),
551                        },
552                    ],
553                }));
554                draw_uniform_buf = Some(uniform_buf);
555            }
556        }
557
558        let system = ParticleSystem {
559            capacity,
560            render: config.render.clone(),
561            particle_buf,
562            emit_counter_buf,
563            sim_bg,
564            draw_bg: sprite_draw_bg,
565            draw_bg_mesh: mesh_draw_bg,
566            draw_lit_normal_bg: sprite_lit_normal_bg,
567            _draw_uniform_buf: draw_uniform_buf,
568            alive: true,
569            frame_counter: 0,
570            spawn_accumulator: 0.0,
571        };
572
573        if let Some(idx) = self
574            .particle
575            .systems
576            .iter()
577            .position(|slot: &Option<ParticleSystem>| slot.as_ref().is_none_or(|s| !s.alive))
578        {
579            self.particle.systems[idx] = Some(system);
580            GpuParticleSystemId(idx)
581        } else {
582            self.particle.systems.push(Some(system));
583            GpuParticleSystemId(self.particle.systems.len() - 1)
584        }
585    }
586
587    /// Release a particle system. The handle becomes invalid; the slot is
588    /// reused on the next `create_gpu_particle_system` call.
589    pub fn drop_gpu_particle_system(&mut self, id: GpuParticleSystemId) {
590        if let Some(Some(s)) = self.particle.systems.get_mut(id.0) {
591            s.alive = false;
592        }
593    }
594
595    #[allow(dead_code)]
596    pub(crate) fn particle_system(&self, id: GpuParticleSystemId) -> Option<&ParticleSystem> {
597        self.particle
598            .systems
599            .get(id.0)?
600            .as_ref()
601            .filter(|s| s.alive)
602    }
603
604    #[allow(dead_code)]
605    pub(crate) fn particle_system_mut(
606        &mut self,
607        id: GpuParticleSystemId,
608    ) -> Option<&mut ParticleSystem> {
609        self.particle
610            .systems
611            .get_mut(id.0)?
612            .as_mut()
613            .filter(|s| s.alive)
614    }
615
616    /// Lazily create the compute + draw pipelines used by every particle
617    /// system. No-op once the bind group layouts are present.
618    pub(crate) fn ensure_particle_pipelines(&mut self, device: &wgpu::Device) {
619        if self.particle.sim_bgl.is_some() {
620            return;
621        }
622
623        // Group 0: emit/sim params (uniform).
624        let params_bgl = crate::resources::builders::uniform_bgl(
625            device,
626            "gpu_particle_params_bgl",
627            wgpu::ShaderStages::COMPUTE,
628        );
629
630        // Group 1 (sim/emit): particle buffer + atomic emit counter.
631        let sim_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
632            label: Some("gpu_particle_sim_bgl"),
633            entries: &[
634                wgpu::BindGroupLayoutEntry {
635                    binding: 0,
636                    visibility: wgpu::ShaderStages::COMPUTE,
637                    ty: wgpu::BindingType::Buffer {
638                        ty: wgpu::BufferBindingType::Storage { read_only: false },
639                        has_dynamic_offset: false,
640                        min_binding_size: None,
641                    },
642                    count: None,
643                },
644                wgpu::BindGroupLayoutEntry {
645                    binding: 1,
646                    visibility: wgpu::ShaderStages::COMPUTE,
647                    ty: wgpu::BindingType::Buffer {
648                        ty: wgpu::BufferBindingType::Storage { read_only: false },
649                        has_dynamic_offset: false,
650                        min_binding_size: None,
651                    },
652                    count: None,
653                },
654            ],
655        });
656
657        // Group 1 (draw): sprite uniform + texture + sampler + particle buffer.
658        let draw_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
659            label: Some("gpu_particle_draw_bgl"),
660            entries: &[
661                wgpu::BindGroupLayoutEntry {
662                    binding: 0,
663                    visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
664                    ty: wgpu::BindingType::Buffer {
665                        ty: wgpu::BufferBindingType::Uniform,
666                        has_dynamic_offset: false,
667                        min_binding_size: None,
668                    },
669                    count: None,
670                },
671                wgpu::BindGroupLayoutEntry {
672                    binding: 1,
673                    visibility: wgpu::ShaderStages::FRAGMENT,
674                    ty: wgpu::BindingType::Texture {
675                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
676                        view_dimension: wgpu::TextureViewDimension::D2,
677                        multisampled: false,
678                    },
679                    count: None,
680                },
681                wgpu::BindGroupLayoutEntry {
682                    binding: 2,
683                    visibility: wgpu::ShaderStages::FRAGMENT,
684                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
685                    count: None,
686                },
687                wgpu::BindGroupLayoutEntry {
688                    binding: 3,
689                    visibility: wgpu::ShaderStages::VERTEX,
690                    ty: wgpu::BindingType::Buffer {
691                        ty: wgpu::BufferBindingType::Storage { read_only: true },
692                        has_dynamic_offset: false,
693                        min_binding_size: None,
694                    },
695                    count: None,
696                },
697            ],
698        });
699
700        // Compute pipelines.
701        let emit_shader = crate::resources::builders::wgsl_module(
702            device,
703            "particle_emit_shader",
704            crate::resources::builders::wgsl_source!("particle_emit"),
705        );
706        let sim_shader = crate::resources::builders::wgsl_module(
707            device,
708            "particle_sim_shader",
709            crate::resources::builders::wgsl_source!("particle_sim"),
710        );
711
712        let compute_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
713            label: Some("particle_compute_layout"),
714            bind_group_layouts: &[&params_bgl, &sim_bgl],
715            push_constant_ranges: &[],
716        });
717
718        let emit_pipeline = crate::resources::builders::compute_pipeline(
719            device,
720            "particle_emit_pipeline",
721            &compute_layout,
722            &emit_shader,
723            "emit_main",
724        );
725
726        let sim_pipeline = crate::resources::builders::compute_pipeline(
727            device,
728            "particle_sim_pipeline",
729            &compute_layout,
730            &sim_shader,
731            "sim_main",
732        );
733
734        // Draw pipelines: three blend variants of the same shader.
735        let sprite_shader = crate::resources::builders::wgsl_module(
736            device,
737            "particle_sprite_shader",
738            crate::resources::builders::wgsl_source!("particle_sprite"),
739        );
740
741        let draw_layout = crate::resources::builders::standard_scene_layout(
742            device,
743            "particle_draw_layout",
744            &self.camera_bind_group_layout,
745            &draw_bgl,
746        );
747
748        let sample_count = self.sample_count;
749        let ldr_format = self.target_format;
750        let alpha = wgpu::BlendState::ALPHA_BLENDING;
751        let additive = crate::resources::builders::ADDITIVE_BLEND;
752        let premul = crate::resources::builders::PREMULTIPLIED_BLEND;
753
754        // Particle sprites are billboards: `Less` depth test, no depth write, no
755        // culling. Only the blend mode varies across the three variants.
756        let make_draw = |blend: wgpu::BlendState, label: &str| {
757            crate::resources::builders::build_dual_pipeline(
758                device,
759                &crate::resources::builders::DualPipelineDesc {
760                    label,
761                    layout: &draw_layout,
762                    shader: &sprite_shader,
763                    vertex_entry: "vs_main",
764                    fragment_entry: "fs_main",
765                    vertex_buffers: &[],
766                    blend: Some(blend),
767                    topology: wgpu::PrimitiveTopology::TriangleList,
768                    cull_mode: None,
769                    depth_write: false,
770                    depth_compare: wgpu::CompareFunction::Less,
771                    sample_count,
772                    ldr_format,
773                },
774            )
775        };
776
777        self.particle.sprite_pipeline_alpha = Some(make_draw(alpha, "particle_sprite_alpha"));
778        self.particle.sprite_pipeline_additive =
779            Some(make_draw(additive, "particle_sprite_additive"));
780        self.particle.sprite_pipeline_premultiplied =
781            Some(make_draw(premul, "particle_sprite_premultiplied"));
782
783        // Lit GPU particle sprite pipelines. Same vertex inputs and draw BGL
784        // as the emissive path; group 2 adds the optional normal-map binding
785        // and the shader pulls scene lighting via the camera bind group.
786        let lit_bgl = crate::resources::builders::texture_sampler_bgl(
787            device,
788            "gpu_particle_lit_bgl",
789            wgpu::ShaderStages::FRAGMENT,
790        );
791
792        let lit_shader = crate::resources::builders::wgsl_module(
793            device,
794            "particle_sprite_lit_shader",
795            crate::resources::builders::wgsl_source!("particle_sprite_lit"),
796        );
797
798        let lit_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
799            label: Some("particle_draw_lit_layout"),
800            bind_group_layouts: &[&self.camera_bind_group_layout, &draw_bgl, &lit_bgl],
801            push_constant_ranges: &[],
802        });
803
804        let make_lit_draw = |blend: wgpu::BlendState, label: &str| {
805            crate::resources::builders::build_dual_pipeline(
806                device,
807                &crate::resources::builders::DualPipelineDesc {
808                    label,
809                    layout: &lit_layout,
810                    shader: &lit_shader,
811                    vertex_entry: "vs_main",
812                    fragment_entry: "fs_main",
813                    vertex_buffers: &[],
814                    blend: Some(blend),
815                    topology: wgpu::PrimitiveTopology::TriangleList,
816                    cull_mode: None,
817                    depth_write: false,
818                    depth_compare: wgpu::CompareFunction::Less,
819                    sample_count,
820                    ldr_format,
821                },
822            )
823        };
824
825        self.particle.sprite_lit_pipeline_alpha =
826            Some(make_lit_draw(alpha, "particle_sprite_lit_alpha"));
827        self.particle.sprite_lit_pipeline_additive =
828            Some(make_lit_draw(additive, "particle_sprite_lit_additive"));
829        self.particle.sprite_lit_pipeline_premultiplied =
830            Some(make_lit_draw(premul, "particle_sprite_lit_premultiplied"));
831
832        let lit_fallback_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
833            label: Some("gpu_particle_lit_fallback_bg"),
834            layout: &lit_bgl,
835            entries: &[
836                wgpu::BindGroupEntry {
837                    binding: 0,
838                    resource: wgpu::BindingResource::TextureView(&self.fallback_normal_map_view),
839                },
840                wgpu::BindGroupEntry {
841                    binding: 1,
842                    resource: wgpu::BindingResource::Sampler(&self.material_sampler),
843                },
844            ],
845        });
846
847        self.particle.sprite_lit_bgl = Some(lit_bgl);
848        self.particle.sprite_lit_fallback_bg = Some(lit_fallback_bg);
849
850        // Mesh-route draw pipelines. Same blend variants as the sprite route,
851        // but the vertex stage consumes the mesh's standard `Vertex` layout
852        // on slot 0 and composes the per-instance transform inline from the
853        // bound particle storage buffer.
854        let mesh_draw_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
855            label: Some("gpu_particle_mesh_draw_bgl"),
856            entries: &[
857                wgpu::BindGroupLayoutEntry {
858                    binding: 0,
859                    visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
860                    ty: wgpu::BindingType::Buffer {
861                        ty: wgpu::BufferBindingType::Uniform,
862                        has_dynamic_offset: false,
863                        min_binding_size: None,
864                    },
865                    count: None,
866                },
867                wgpu::BindGroupLayoutEntry {
868                    binding: 1,
869                    visibility: wgpu::ShaderStages::FRAGMENT,
870                    ty: wgpu::BindingType::Texture {
871                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
872                        view_dimension: wgpu::TextureViewDimension::D2,
873                        multisampled: false,
874                    },
875                    count: None,
876                },
877                wgpu::BindGroupLayoutEntry {
878                    binding: 2,
879                    visibility: wgpu::ShaderStages::FRAGMENT,
880                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
881                    count: None,
882                },
883                wgpu::BindGroupLayoutEntry {
884                    binding: 3,
885                    visibility: wgpu::ShaderStages::VERTEX,
886                    ty: wgpu::BindingType::Buffer {
887                        ty: wgpu::BufferBindingType::Storage { read_only: true },
888                        has_dynamic_offset: false,
889                        min_binding_size: None,
890                    },
891                    count: None,
892                },
893            ],
894        });
895
896        let mesh_shader = crate::resources::builders::wgsl_module(
897            device,
898            "particle_mesh_shader",
899            crate::resources::builders::wgsl_source!("particle_mesh"),
900        );
901
902        let mesh_layout = crate::resources::builders::standard_scene_layout(
903            device,
904            "particle_mesh_draw_layout",
905            &self.camera_bind_group_layout,
906            &mesh_draw_bgl,
907        );
908
909        // Particle meshes are closed solids, so back-face culled; still no depth
910        // write since particles draw transparently after the opaque pass.
911        let make_mesh_draw = |blend: wgpu::BlendState, label: &str| {
912            crate::resources::builders::build_dual_pipeline(
913                device,
914                &crate::resources::builders::DualPipelineDesc {
915                    label,
916                    layout: &mesh_layout,
917                    shader: &mesh_shader,
918                    vertex_entry: "vs_main",
919                    fragment_entry: "fs_main",
920                    vertex_buffers: &[crate::resources::types::Vertex::buffer_layout()],
921                    blend: Some(blend),
922                    topology: wgpu::PrimitiveTopology::TriangleList,
923                    cull_mode: Some(wgpu::Face::Back),
924                    depth_write: false,
925                    depth_compare: wgpu::CompareFunction::Less,
926                    sample_count,
927                    ldr_format,
928                },
929            )
930        };
931
932        self.particle.mesh_pipeline_alpha = Some(make_mesh_draw(alpha, "particle_mesh_alpha"));
933        self.particle.mesh_pipeline_additive =
934            Some(make_mesh_draw(additive, "particle_mesh_additive"));
935        self.particle.mesh_pipeline_premultiplied =
936            Some(make_mesh_draw(premul, "particle_mesh_premultiplied"));
937        self.particle.mesh_draw_bgl = Some(mesh_draw_bgl);
938
939        self.particle.params_bgl = Some(params_bgl);
940        self.particle.sim_bgl = Some(sim_bgl);
941        self.particle.draw_bgl = Some(draw_bgl);
942        self.particle.emit_pipeline = Some(emit_pipeline);
943        self.particle.sim_pipeline = Some(sim_pipeline);
944    }
945
946    /// Run emit + sim compute passes for every particle system referenced this
947    /// frame. Returns per-job draw metadata for the render phase.
948    pub(crate) fn run_particle_jobs(
949        &mut self,
950        device: &wgpu::Device,
951        queue: &wgpu::Queue,
952        items: &[crate::renderer::GpuParticleSystemItem],
953    ) -> Vec<ParticleFrameData> {
954        if items.is_empty() {
955            return Vec::new();
956        }
957        self.ensure_particle_pipelines(device);
958
959        let emit_pipeline = self
960            .particle
961            .emit_pipeline
962            .as_ref()
963            .expect("particle pipelines should exist after ensure")
964            .clone();
965        let sim_pipeline = self
966            .particle
967            .sim_pipeline
968            .as_ref()
969            .expect("particle pipelines should exist after ensure")
970            .clone();
971        let params_bgl = self
972            .particle
973            .params_bgl
974            .as_ref()
975            .expect("particle params BGL")
976            .clone();
977
978        let mut frame_data: Vec<ParticleFrameData> = Vec::with_capacity(items.len());
979        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
980            label: Some("particle_compute_encoder"),
981        });
982
983        for item in items {
984            let idx = item.system_id.0;
985            let (blend, route) = match self.particle.systems.get(idx).and_then(|s| s.as_ref()) {
986                Some(s) if s.alive => match &s.render {
987                    ParticleRender::Sprite { blend, lit, .. } => {
988                        (*blend, ParticleDrawRoute::Sprite { lit: *lit })
989                    }
990                    ParticleRender::Mesh { blend, mesh_id, .. } => {
991                        (*blend, ParticleDrawRoute::Mesh { mesh_id: *mesh_id })
992                    }
993                },
994                _ => continue,
995            };
996
997            let hidden = item.settings.hidden;
998
999            // Pull mutable state out, build dispatch state outside the borrow.
1000            let (
1001                capacity,
1002                particle_buf_binding,
1003                emit_counter_binding,
1004                sim_bg,
1005                spawn_count,
1006                frame_counter,
1007            ) = {
1008                let system = self.particle.systems[idx].as_mut().unwrap();
1009                let dt = item.time_step.max(0.0);
1010                system.spawn_accumulator += item.emitter.rate * dt;
1011                let spawn_count = system.spawn_accumulator.floor() as u32;
1012                system.spawn_accumulator -= spawn_count as f32;
1013                system.frame_counter = system.frame_counter.wrapping_add(1);
1014                let frame_counter = system.frame_counter;
1015                (
1016                    system.capacity,
1017                    system.particle_buf.as_entire_binding(),
1018                    system.emit_counter_buf.as_entire_binding(),
1019                    // Need to clone the bind group; wgpu allows this cheaply (Arc inside).
1020                    system.sim_bg.clone(),
1021                    spawn_count,
1022                    frame_counter,
1023                )
1024            };
1025            let _ = (particle_buf_binding, emit_counter_binding); // bound through sim_bg
1026
1027            let workgroups = capacity.div_ceil(64);
1028
1029            // ----- Emit -----
1030            if spawn_count > 0 {
1031                queue.write_buffer(
1032                    &self.particle.systems[idx]
1033                        .as_ref()
1034                        .unwrap()
1035                        .emit_counter_buf,
1036                    0,
1037                    bytemuck::bytes_of(&spawn_count),
1038                );
1039
1040                let emit_params =
1041                    build_emit_params(&item.emitter, capacity, spawn_count, frame_counter);
1042                let emit_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1043                    label: Some("particle_emit_params"),
1044                    contents: bytemuck::bytes_of(&emit_params),
1045                    usage: wgpu::BufferUsages::UNIFORM,
1046                });
1047                let emit_params_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1048                    label: Some("particle_emit_params_bg"),
1049                    layout: &params_bgl,
1050                    entries: &[wgpu::BindGroupEntry {
1051                        binding: 0,
1052                        resource: emit_uniform.as_entire_binding(),
1053                    }],
1054                });
1055
1056                let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1057                    label: Some("particle_emit_pass"),
1058                    timestamp_writes: None,
1059                });
1060                pass.set_pipeline(&emit_pipeline);
1061                pass.set_bind_group(0, &emit_params_bg, &[]);
1062                pass.set_bind_group(1, &sim_bg, &[]);
1063                pass.dispatch_workgroups(workgroups, 1, 1);
1064            }
1065
1066            // ----- Sim -----
1067            let sim_params = build_sim_params(item.time_step, capacity, &item.forces);
1068            let sim_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1069                label: Some("particle_sim_params"),
1070                contents: bytemuck::bytes_of(&sim_params),
1071                usage: wgpu::BufferUsages::UNIFORM,
1072            });
1073            let sim_params_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1074                label: Some("particle_sim_params_bg"),
1075                layout: &params_bgl,
1076                entries: &[wgpu::BindGroupEntry {
1077                    binding: 0,
1078                    resource: sim_uniform.as_entire_binding(),
1079                }],
1080            });
1081
1082            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1083                label: Some("particle_sim_pass"),
1084                timestamp_writes: None,
1085            });
1086            pass.set_pipeline(&sim_pipeline);
1087            pass.set_bind_group(0, &sim_params_bg, &[]);
1088            pass.set_bind_group(1, &sim_bg, &[]);
1089            pass.dispatch_workgroups(workgroups, 1, 1);
1090            drop(pass);
1091
1092            frame_data.push(ParticleFrameData {
1093                system_idx: idx,
1094                blend,
1095                hidden,
1096                route,
1097            });
1098        }
1099
1100        queue.submit(std::iter::once(encoder.finish()));
1101        frame_data
1102    }
1103}
1104
1105fn build_emit_params(
1106    e: &crate::renderer::EmitterConfig,
1107    capacity: u32,
1108    spawn_count: u32,
1109    frame_counter: u32,
1110) -> EmitParamsGpu {
1111    use crate::renderer::{SpawnShape, VelocityDist};
1112
1113    let mut out = EmitParamsGpu {
1114        spawn_min: [0.0; 3],
1115        spawn_kind: 0,
1116        spawn_max: [0.0; 3],
1117        spawn_radius: 0.0,
1118        vel_min: [0.0; 3],
1119        vel_kind: 0,
1120        vel_max: [0.0; 3],
1121        cone_half_angle: 0.0,
1122        vel_axis: [0.0; 3],
1123        cone_min_speed: 0.0,
1124        colour: e.colour,
1125        spawn_count,
1126        capacity,
1127        rng_seed: frame_counter.wrapping_mul(0x9E3779B1),
1128        size: e.size,
1129        lifetime_min: e.lifetime.0,
1130        lifetime_max: e.lifetime.1,
1131        cone_max_speed: 0.0,
1132        _pad: 0.0,
1133    };
1134
1135    match e.spawn_shape {
1136        SpawnShape::Point(p) => {
1137            out.spawn_kind = 0;
1138            out.spawn_min = p;
1139        }
1140        SpawnShape::Box { min, max } => {
1141            out.spawn_kind = 1;
1142            out.spawn_min = min;
1143            out.spawn_max = max;
1144        }
1145        SpawnShape::Sphere { center, radius } => {
1146            out.spawn_kind = 2;
1147            out.spawn_min = center;
1148            out.spawn_radius = radius;
1149        }
1150    }
1151
1152    match e.initial_velocity {
1153        VelocityDist::Fixed(v) => {
1154            out.vel_kind = 0;
1155            out.vel_min = v;
1156        }
1157        VelocityDist::UniformBox { min, max } => {
1158            out.vel_kind = 1;
1159            out.vel_min = min;
1160            out.vel_max = max;
1161        }
1162        VelocityDist::UniformCone {
1163            axis,
1164            half_angle,
1165            min_speed,
1166            max_speed,
1167        } => {
1168            out.vel_kind = 2;
1169            out.vel_axis = axis;
1170            out.cone_half_angle = half_angle;
1171            out.cone_min_speed = min_speed;
1172            out.cone_max_speed = max_speed;
1173        }
1174    }
1175
1176    out
1177}
1178
1179fn build_sim_params(
1180    dt: f32,
1181    capacity: u32,
1182    forces: &[crate::renderer::ForceField],
1183) -> SimParamsGpu {
1184    use crate::renderer::ForceField;
1185
1186    let mut gpu_forces = [GpuForce {
1187        kind: 0,
1188        _pad: [0; 3],
1189        v0: [0.0; 4],
1190        v1: [0.0; 4],
1191    }; MAX_FORCES];
1192
1193    let n = forces.len().min(MAX_FORCES);
1194    for (i, f) in forces.iter().take(n).enumerate() {
1195        match *f {
1196            ForceField::Gravity(a) => {
1197                gpu_forces[i].kind = 0;
1198                gpu_forces[i].v0 = [a[0], a[1], a[2], 0.0];
1199            }
1200            ForceField::Drag(k) => {
1201                gpu_forces[i].kind = 1;
1202                gpu_forces[i].v0 = [k, 0.0, 0.0, 0.0];
1203            }
1204            ForceField::PointAttractor {
1205                position,
1206                strength,
1207                falloff,
1208            } => {
1209                gpu_forces[i].kind = 2;
1210                gpu_forces[i].v0 = [position[0], position[1], position[2], strength];
1211                gpu_forces[i].v1 = [falloff, 0.0, 0.0, 0.0];
1212            }
1213        }
1214    }
1215
1216    SimParamsGpu {
1217        dt,
1218        capacity,
1219        force_count: n as u32,
1220        _pad: 0,
1221        forces: gpu_forces,
1222    }
1223}