Skip to main content

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