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(
449                                        &self.material_sampler,
450                                    ),
451                                },
452                            ],
453                        }));
454                }
455                draw_uniform_buf = Some(uniform_buf);
456            }
457            DrawState::Mesh { texture_id, align } => {
458                #[repr(C)]
459                #[derive(Copy, Clone, Pod, Zeroable)]
460                struct MeshDrawUniform {
461                    align: u32,
462                    has_texture: u32,
463                    _pad0: u32,
464                    _pad1: u32,
465                }
466                let align_u32 = match align {
467                    ParticleMeshAlign::Identity => 0u32,
468                    ParticleMeshAlign::Velocity => 1u32,
469                    ParticleMeshAlign::Random => 2u32,
470                };
471                let uniform = MeshDrawUniform {
472                    align: align_u32,
473                    has_texture: texture_id.is_some() as u32,
474                    _pad0: 0,
475                    _pad1: 0,
476                };
477                let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
478                    label: Some("gpu_particle_mesh_draw_uniform"),
479                    contents: bytemuck::bytes_of(&uniform),
480                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
481                });
482                let texture_view = match texture_id {
483                    Some(id) if (id as usize) < self.textures.len() => {
484                        &self.textures[id as usize].view
485                    }
486                    _ => &self.fallback_texture.view,
487                };
488                let mesh_bgl = self
489                    .particle_mesh_draw_bgl
490                    .as_ref()
491                    .expect("ensure_particle_pipelines failed to create mesh draw BGL");
492                mesh_draw_bg = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
493                    label: Some("gpu_particle_mesh_draw_bg"),
494                    layout: mesh_bgl,
495                    entries: &[
496                        wgpu::BindGroupEntry {
497                            binding: 0,
498                            resource: uniform_buf.as_entire_binding(),
499                        },
500                        wgpu::BindGroupEntry {
501                            binding: 1,
502                            resource: wgpu::BindingResource::TextureView(texture_view),
503                        },
504                        wgpu::BindGroupEntry {
505                            binding: 2,
506                            resource: wgpu::BindingResource::Sampler(&self.material_sampler),
507                        },
508                        wgpu::BindGroupEntry {
509                            binding: 3,
510                            resource: particle_buf.as_entire_binding(),
511                        },
512                    ],
513                }));
514                draw_uniform_buf = Some(uniform_buf);
515            }
516        }
517
518        let system = ParticleSystem {
519            capacity,
520            render: config.render.clone(),
521            particle_buf,
522            emit_counter_buf,
523            sim_bg,
524            draw_bg: sprite_draw_bg,
525            draw_bg_mesh: mesh_draw_bg,
526            draw_lit_normal_bg: sprite_lit_normal_bg,
527            _draw_uniform_buf: draw_uniform_buf,
528            alive: true,
529            frame_counter: 0,
530            spawn_accumulator: 0.0,
531        };
532
533        if let Some(idx) = self
534            .particle_systems
535            .iter()
536            .position(|slot: &Option<ParticleSystem>| slot.as_ref().is_none_or(|s| !s.alive))
537        {
538            self.particle_systems[idx] = Some(system);
539            GpuParticleSystemId(idx)
540        } else {
541            self.particle_systems.push(Some(system));
542            GpuParticleSystemId(self.particle_systems.len() - 1)
543        }
544    }
545
546    /// Release a particle system. The handle becomes invalid; the slot is
547    /// reused on the next `create_gpu_particle_system` call.
548    pub fn drop_gpu_particle_system(&mut self, id: GpuParticleSystemId) {
549        if let Some(Some(s)) = self.particle_systems.get_mut(id.0) {
550            s.alive = false;
551        }
552    }
553
554    #[allow(dead_code)]
555    pub(crate) fn particle_system(&self, id: GpuParticleSystemId) -> Option<&ParticleSystem> {
556        self.particle_systems
557            .get(id.0)?
558            .as_ref()
559            .filter(|s| s.alive)
560    }
561
562    #[allow(dead_code)]
563    pub(crate) fn particle_system_mut(
564        &mut self,
565        id: GpuParticleSystemId,
566    ) -> Option<&mut ParticleSystem> {
567        self.particle_systems
568            .get_mut(id.0)?
569            .as_mut()
570            .filter(|s| s.alive)
571    }
572
573    /// Lazily create the compute + draw pipelines used by every particle
574    /// system. No-op once the bind group layouts are present.
575    pub(crate) fn ensure_particle_pipelines(&mut self, device: &wgpu::Device) {
576        if self.particle_sim_bgl.is_some() {
577            return;
578        }
579
580        // Group 0: emit/sim params (uniform).
581        let params_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
582            label: Some("gpu_particle_params_bgl"),
583            entries: &[wgpu::BindGroupLayoutEntry {
584                binding: 0,
585                visibility: wgpu::ShaderStages::COMPUTE,
586                ty: wgpu::BindingType::Buffer {
587                    ty: wgpu::BufferBindingType::Uniform,
588                    has_dynamic_offset: false,
589                    min_binding_size: None,
590                },
591                count: None,
592            }],
593        });
594
595        // Group 1 (sim/emit): particle buffer + atomic emit counter.
596        let sim_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
597            label: Some("gpu_particle_sim_bgl"),
598            entries: &[
599                wgpu::BindGroupLayoutEntry {
600                    binding: 0,
601                    visibility: wgpu::ShaderStages::COMPUTE,
602                    ty: wgpu::BindingType::Buffer {
603                        ty: wgpu::BufferBindingType::Storage { read_only: false },
604                        has_dynamic_offset: false,
605                        min_binding_size: None,
606                    },
607                    count: None,
608                },
609                wgpu::BindGroupLayoutEntry {
610                    binding: 1,
611                    visibility: wgpu::ShaderStages::COMPUTE,
612                    ty: wgpu::BindingType::Buffer {
613                        ty: wgpu::BufferBindingType::Storage { read_only: false },
614                        has_dynamic_offset: false,
615                        min_binding_size: None,
616                    },
617                    count: None,
618                },
619            ],
620        });
621
622        // Group 1 (draw): sprite uniform + texture + sampler + particle buffer.
623        let draw_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
624            label: Some("gpu_particle_draw_bgl"),
625            entries: &[
626                wgpu::BindGroupLayoutEntry {
627                    binding: 0,
628                    visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
629                    ty: wgpu::BindingType::Buffer {
630                        ty: wgpu::BufferBindingType::Uniform,
631                        has_dynamic_offset: false,
632                        min_binding_size: None,
633                    },
634                    count: None,
635                },
636                wgpu::BindGroupLayoutEntry {
637                    binding: 1,
638                    visibility: wgpu::ShaderStages::FRAGMENT,
639                    ty: wgpu::BindingType::Texture {
640                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
641                        view_dimension: wgpu::TextureViewDimension::D2,
642                        multisampled: false,
643                    },
644                    count: None,
645                },
646                wgpu::BindGroupLayoutEntry {
647                    binding: 2,
648                    visibility: wgpu::ShaderStages::FRAGMENT,
649                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
650                    count: None,
651                },
652                wgpu::BindGroupLayoutEntry {
653                    binding: 3,
654                    visibility: wgpu::ShaderStages::VERTEX,
655                    ty: wgpu::BindingType::Buffer {
656                        ty: wgpu::BufferBindingType::Storage { read_only: true },
657                        has_dynamic_offset: false,
658                        min_binding_size: None,
659                    },
660                    count: None,
661                },
662            ],
663        });
664
665        // Compute pipelines.
666        let emit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
667            label: Some("particle_emit_shader"),
668            source: wgpu::ShaderSource::Wgsl(
669                include_str!(concat!(env!("OUT_DIR"), "/particle_emit.wgsl")).into(),
670            ),
671        });
672        let sim_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
673            label: Some("particle_sim_shader"),
674            source: wgpu::ShaderSource::Wgsl(
675                include_str!(concat!(env!("OUT_DIR"), "/particle_sim.wgsl")).into(),
676            ),
677        });
678
679        let compute_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
680            label: Some("particle_compute_layout"),
681            bind_group_layouts: &[&params_bgl, &sim_bgl],
682            push_constant_ranges: &[],
683        });
684
685        let emit_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
686            label: Some("particle_emit_pipeline"),
687            layout: Some(&compute_layout),
688            module: &emit_shader,
689            entry_point: Some("emit_main"),
690            compilation_options: wgpu::PipelineCompilationOptions::default(),
691            cache: None,
692        });
693
694        let sim_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
695            label: Some("particle_sim_pipeline"),
696            layout: Some(&compute_layout),
697            module: &sim_shader,
698            entry_point: Some("sim_main"),
699            compilation_options: wgpu::PipelineCompilationOptions::default(),
700            cache: None,
701        });
702
703        // Draw pipelines: three blend variants of the same shader.
704        let sprite_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
705            label: Some("particle_sprite_shader"),
706            source: wgpu::ShaderSource::Wgsl(
707                include_str!(concat!(env!("OUT_DIR"), "/particle_sprite.wgsl")).into(),
708            ),
709        });
710
711        let draw_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
712            label: Some("particle_draw_layout"),
713            bind_group_layouts: &[&self.camera_bind_group_layout, &draw_bgl],
714            push_constant_ranges: &[],
715        });
716
717        let sample_count = self.sample_count;
718        let additive = wgpu::BlendState {
719            color: wgpu::BlendComponent {
720                src_factor: wgpu::BlendFactor::One,
721                dst_factor: wgpu::BlendFactor::One,
722                operation: wgpu::BlendOperation::Add,
723            },
724            alpha: wgpu::BlendComponent {
725                src_factor: wgpu::BlendFactor::One,
726                dst_factor: wgpu::BlendFactor::One,
727                operation: wgpu::BlendOperation::Add,
728            },
729        };
730        let premul = wgpu::BlendState {
731            color: wgpu::BlendComponent {
732                src_factor: wgpu::BlendFactor::One,
733                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
734                operation: wgpu::BlendOperation::Add,
735            },
736            alpha: wgpu::BlendComponent {
737                src_factor: wgpu::BlendFactor::One,
738                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
739                operation: wgpu::BlendOperation::Add,
740            },
741        };
742
743        let make_draw = |fmt: wgpu::TextureFormat, blend: wgpu::BlendState, label: &str| {
744            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
745                label: Some(label),
746                layout: Some(&draw_layout),
747                vertex: wgpu::VertexState {
748                    module: &sprite_shader,
749                    entry_point: Some("vs_main"),
750                    buffers: &[],
751                    compilation_options: wgpu::PipelineCompilationOptions::default(),
752                },
753                fragment: Some(wgpu::FragmentState {
754                    module: &sprite_shader,
755                    entry_point: Some("fs_main"),
756                    targets: &[Some(wgpu::ColorTargetState {
757                        format: fmt,
758                        blend: Some(blend),
759                        write_mask: wgpu::ColorWrites::ALL,
760                    })],
761                    compilation_options: wgpu::PipelineCompilationOptions::default(),
762                }),
763                primitive: wgpu::PrimitiveState {
764                    topology: wgpu::PrimitiveTopology::TriangleList,
765                    cull_mode: None,
766                    ..Default::default()
767                },
768                depth_stencil: Some(wgpu::DepthStencilState {
769                    format: wgpu::TextureFormat::Depth24PlusStencil8,
770                    depth_write_enabled: false,
771                    depth_compare: wgpu::CompareFunction::Less,
772                    stencil: wgpu::StencilState::default(),
773                    bias: wgpu::DepthBiasState::default(),
774                }),
775                multisample: wgpu::MultisampleState {
776                    count: sample_count,
777                    ..Default::default()
778                },
779                multiview: None,
780                cache: None,
781            })
782        };
783
784        let ldr = self.target_format;
785        let hdr = wgpu::TextureFormat::Rgba16Float;
786        let alpha = wgpu::BlendState::ALPHA_BLENDING;
787        self.particle_sprite_pipeline_alpha = Some(crate::resources::DualPipeline {
788            ldr: make_draw(ldr, alpha, "particle_sprite_alpha"),
789            hdr: make_draw(hdr, alpha, "particle_sprite_alpha"),
790        });
791        self.particle_sprite_pipeline_additive = Some(crate::resources::DualPipeline {
792            ldr: make_draw(ldr, additive, "particle_sprite_additive"),
793            hdr: make_draw(hdr, additive, "particle_sprite_additive"),
794        });
795        self.particle_sprite_pipeline_premultiplied = Some(crate::resources::DualPipeline {
796            ldr: make_draw(ldr, premul, "particle_sprite_premultiplied"),
797            hdr: make_draw(hdr, premul, "particle_sprite_premultiplied"),
798        });
799
800        // Lit GPU particle sprite pipelines. Same vertex inputs and draw BGL
801        // as the emissive path; group 2 adds the optional normal-map binding
802        // and the shader pulls scene lighting via the camera bind group.
803        let lit_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
804            label: Some("gpu_particle_lit_bgl"),
805            entries: &[
806                wgpu::BindGroupLayoutEntry {
807                    binding: 0,
808                    visibility: wgpu::ShaderStages::FRAGMENT,
809                    ty: wgpu::BindingType::Texture {
810                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
811                        view_dimension: wgpu::TextureViewDimension::D2,
812                        multisampled: false,
813                    },
814                    count: None,
815                },
816                wgpu::BindGroupLayoutEntry {
817                    binding: 1,
818                    visibility: wgpu::ShaderStages::FRAGMENT,
819                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
820                    count: None,
821                },
822            ],
823        });
824
825        let lit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
826            label: Some("particle_sprite_lit_shader"),
827            source: wgpu::ShaderSource::Wgsl(
828                include_str!(concat!(env!("OUT_DIR"), "/particle_sprite_lit.wgsl")).into(),
829            ),
830        });
831
832        let lit_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
833            label: Some("particle_draw_lit_layout"),
834            bind_group_layouts: &[&self.camera_bind_group_layout, &draw_bgl, &lit_bgl],
835            push_constant_ranges: &[],
836        });
837
838        let make_lit_draw = |fmt: wgpu::TextureFormat, blend: wgpu::BlendState, label: &str| {
839            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
840                label: Some(label),
841                layout: Some(&lit_layout),
842                vertex: wgpu::VertexState {
843                    module: &lit_shader,
844                    entry_point: Some("vs_main"),
845                    buffers: &[],
846                    compilation_options: wgpu::PipelineCompilationOptions::default(),
847                },
848                fragment: Some(wgpu::FragmentState {
849                    module: &lit_shader,
850                    entry_point: Some("fs_main"),
851                    targets: &[Some(wgpu::ColorTargetState {
852                        format: fmt,
853                        blend: Some(blend),
854                        write_mask: wgpu::ColorWrites::ALL,
855                    })],
856                    compilation_options: wgpu::PipelineCompilationOptions::default(),
857                }),
858                primitive: wgpu::PrimitiveState {
859                    topology: wgpu::PrimitiveTopology::TriangleList,
860                    cull_mode: None,
861                    ..Default::default()
862                },
863                depth_stencil: Some(wgpu::DepthStencilState {
864                    format: wgpu::TextureFormat::Depth24PlusStencil8,
865                    depth_write_enabled: false,
866                    depth_compare: wgpu::CompareFunction::Less,
867                    stencil: wgpu::StencilState::default(),
868                    bias: wgpu::DepthBiasState::default(),
869                }),
870                multisample: wgpu::MultisampleState {
871                    count: sample_count,
872                    ..Default::default()
873                },
874                multiview: None,
875                cache: None,
876            })
877        };
878
879        self.particle_sprite_lit_pipeline_alpha = Some(crate::resources::DualPipeline {
880            ldr: make_lit_draw(ldr, alpha, "particle_sprite_lit_alpha"),
881            hdr: make_lit_draw(hdr, alpha, "particle_sprite_lit_alpha"),
882        });
883        self.particle_sprite_lit_pipeline_additive = Some(crate::resources::DualPipeline {
884            ldr: make_lit_draw(ldr, additive, "particle_sprite_lit_additive"),
885            hdr: make_lit_draw(hdr, additive, "particle_sprite_lit_additive"),
886        });
887        self.particle_sprite_lit_pipeline_premultiplied = Some(crate::resources::DualPipeline {
888            ldr: make_lit_draw(ldr, premul, "particle_sprite_lit_premultiplied"),
889            hdr: make_lit_draw(hdr, premul, "particle_sprite_lit_premultiplied"),
890        });
891
892        let lit_fallback_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
893            label: Some("gpu_particle_lit_fallback_bg"),
894            layout: &lit_bgl,
895            entries: &[
896                wgpu::BindGroupEntry {
897                    binding: 0,
898                    resource: wgpu::BindingResource::TextureView(&self.fallback_normal_map_view),
899                },
900                wgpu::BindGroupEntry {
901                    binding: 1,
902                    resource: wgpu::BindingResource::Sampler(&self.material_sampler),
903                },
904            ],
905        });
906
907        self.particle_sprite_lit_bgl = Some(lit_bgl);
908        self.particle_sprite_lit_fallback_bg = Some(lit_fallback_bg);
909
910        // Mesh-route draw pipelines. Same blend variants as the sprite route,
911        // but the vertex stage consumes the mesh's standard `Vertex` layout
912        // on slot 0 and composes the per-instance transform inline from the
913        // bound particle storage buffer.
914        let mesh_draw_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
915            label: Some("gpu_particle_mesh_draw_bgl"),
916            entries: &[
917                wgpu::BindGroupLayoutEntry {
918                    binding: 0,
919                    visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
920                    ty: wgpu::BindingType::Buffer {
921                        ty: wgpu::BufferBindingType::Uniform,
922                        has_dynamic_offset: false,
923                        min_binding_size: None,
924                    },
925                    count: None,
926                },
927                wgpu::BindGroupLayoutEntry {
928                    binding: 1,
929                    visibility: wgpu::ShaderStages::FRAGMENT,
930                    ty: wgpu::BindingType::Texture {
931                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
932                        view_dimension: wgpu::TextureViewDimension::D2,
933                        multisampled: false,
934                    },
935                    count: None,
936                },
937                wgpu::BindGroupLayoutEntry {
938                    binding: 2,
939                    visibility: wgpu::ShaderStages::FRAGMENT,
940                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
941                    count: None,
942                },
943                wgpu::BindGroupLayoutEntry {
944                    binding: 3,
945                    visibility: wgpu::ShaderStages::VERTEX,
946                    ty: wgpu::BindingType::Buffer {
947                        ty: wgpu::BufferBindingType::Storage { read_only: true },
948                        has_dynamic_offset: false,
949                        min_binding_size: None,
950                    },
951                    count: None,
952                },
953            ],
954        });
955
956        let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
957            label: Some("particle_mesh_shader"),
958            source: wgpu::ShaderSource::Wgsl(
959                include_str!(concat!(env!("OUT_DIR"), "/particle_mesh.wgsl")).into(),
960            ),
961        });
962
963        let mesh_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
964            label: Some("particle_mesh_draw_layout"),
965            bind_group_layouts: &[&self.camera_bind_group_layout, &mesh_draw_bgl],
966            push_constant_ranges: &[],
967        });
968
969        let make_mesh_draw = |fmt: wgpu::TextureFormat, blend: wgpu::BlendState, label: &str| {
970            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
971                label: Some(label),
972                layout: Some(&mesh_layout),
973                vertex: wgpu::VertexState {
974                    module: &mesh_shader,
975                    entry_point: Some("vs_main"),
976                    buffers: &[crate::resources::types::Vertex::buffer_layout()],
977                    compilation_options: wgpu::PipelineCompilationOptions::default(),
978                },
979                fragment: Some(wgpu::FragmentState {
980                    module: &mesh_shader,
981                    entry_point: Some("fs_main"),
982                    targets: &[Some(wgpu::ColorTargetState {
983                        format: fmt,
984                        blend: Some(blend),
985                        write_mask: wgpu::ColorWrites::ALL,
986                    })],
987                    compilation_options: wgpu::PipelineCompilationOptions::default(),
988                }),
989                primitive: wgpu::PrimitiveState {
990                    topology: wgpu::PrimitiveTopology::TriangleList,
991                    cull_mode: Some(wgpu::Face::Back),
992                    ..Default::default()
993                },
994                depth_stencil: Some(wgpu::DepthStencilState {
995                    format: wgpu::TextureFormat::Depth24PlusStencil8,
996                    depth_write_enabled: false,
997                    depth_compare: wgpu::CompareFunction::Less,
998                    stencil: wgpu::StencilState::default(),
999                    bias: wgpu::DepthBiasState::default(),
1000                }),
1001                multisample: wgpu::MultisampleState {
1002                    count: sample_count,
1003                    ..Default::default()
1004                },
1005                multiview: None,
1006                cache: None,
1007            })
1008        };
1009
1010        self.particle_mesh_pipeline_alpha = Some(crate::resources::DualPipeline {
1011            ldr: make_mesh_draw(ldr, alpha, "particle_mesh_alpha"),
1012            hdr: make_mesh_draw(hdr, alpha, "particle_mesh_alpha"),
1013        });
1014        self.particle_mesh_pipeline_additive = Some(crate::resources::DualPipeline {
1015            ldr: make_mesh_draw(ldr, additive, "particle_mesh_additive"),
1016            hdr: make_mesh_draw(hdr, additive, "particle_mesh_additive"),
1017        });
1018        self.particle_mesh_pipeline_premultiplied = Some(crate::resources::DualPipeline {
1019            ldr: make_mesh_draw(ldr, premul, "particle_mesh_premultiplied"),
1020            hdr: make_mesh_draw(hdr, premul, "particle_mesh_premultiplied"),
1021        });
1022        self.particle_mesh_draw_bgl = Some(mesh_draw_bgl);
1023
1024        self.particle_params_bgl = Some(params_bgl);
1025        self.particle_sim_bgl = Some(sim_bgl);
1026        self.particle_draw_bgl = Some(draw_bgl);
1027        self.particle_emit_pipeline = Some(emit_pipeline);
1028        self.particle_sim_pipeline = Some(sim_pipeline);
1029    }
1030
1031    /// Run emit + sim compute passes for every particle system referenced this
1032    /// frame. Returns per-job draw metadata for the render phase.
1033    pub(crate) fn run_particle_jobs(
1034        &mut self,
1035        device: &wgpu::Device,
1036        queue: &wgpu::Queue,
1037        items: &[crate::renderer::GpuParticleSystemItem],
1038    ) -> Vec<ParticleFrameData> {
1039        if items.is_empty() {
1040            return Vec::new();
1041        }
1042        self.ensure_particle_pipelines(device);
1043
1044        let emit_pipeline = self
1045            .particle_emit_pipeline
1046            .as_ref()
1047            .expect("particle pipelines should exist after ensure")
1048            .clone();
1049        let sim_pipeline = self
1050            .particle_sim_pipeline
1051            .as_ref()
1052            .expect("particle pipelines should exist after ensure")
1053            .clone();
1054        let params_bgl = self
1055            .particle_params_bgl
1056            .as_ref()
1057            .expect("particle params BGL")
1058            .clone();
1059
1060        let mut frame_data: Vec<ParticleFrameData> = Vec::with_capacity(items.len());
1061        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1062            label: Some("particle_compute_encoder"),
1063        });
1064
1065        for item in items {
1066            let idx = item.system_id.0;
1067            let (blend, route) = match self.particle_systems.get(idx).and_then(|s| s.as_ref()) {
1068                Some(s) if s.alive => match &s.render {
1069                    ParticleRender::Sprite { blend, lit, .. } => {
1070                        (*blend, ParticleDrawRoute::Sprite { lit: *lit })
1071                    }
1072                    ParticleRender::Mesh { blend, mesh_id, .. } => {
1073                        (*blend, ParticleDrawRoute::Mesh { mesh_id: *mesh_id })
1074                    }
1075                },
1076                _ => continue,
1077            };
1078
1079            let hidden = item.settings.hidden;
1080
1081            // Pull mutable state out, build dispatch state outside the borrow.
1082            let (
1083                capacity,
1084                particle_buf_binding,
1085                emit_counter_binding,
1086                sim_bg,
1087                spawn_count,
1088                frame_counter,
1089            ) = {
1090                let system = self.particle_systems[idx].as_mut().unwrap();
1091                let dt = item.time_step.max(0.0);
1092                system.spawn_accumulator += item.emitter.rate * dt;
1093                let spawn_count = system.spawn_accumulator.floor() as u32;
1094                system.spawn_accumulator -= spawn_count as f32;
1095                system.frame_counter = system.frame_counter.wrapping_add(1);
1096                let frame_counter = system.frame_counter;
1097                (
1098                    system.capacity,
1099                    system.particle_buf.as_entire_binding(),
1100                    system.emit_counter_buf.as_entire_binding(),
1101                    // Need to clone the bind group; wgpu allows this cheaply (Arc inside).
1102                    system.sim_bg.clone(),
1103                    spawn_count,
1104                    frame_counter,
1105                )
1106            };
1107            let _ = (particle_buf_binding, emit_counter_binding); // bound through sim_bg
1108
1109            let workgroups = capacity.div_ceil(64);
1110
1111            // ----- Emit -----
1112            if spawn_count > 0 {
1113                queue.write_buffer(
1114                    &self.particle_systems[idx]
1115                        .as_ref()
1116                        .unwrap()
1117                        .emit_counter_buf,
1118                    0,
1119                    bytemuck::bytes_of(&spawn_count),
1120                );
1121
1122                let emit_params =
1123                    build_emit_params(&item.emitter, capacity, spawn_count, frame_counter);
1124                let emit_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1125                    label: Some("particle_emit_params"),
1126                    contents: bytemuck::bytes_of(&emit_params),
1127                    usage: wgpu::BufferUsages::UNIFORM,
1128                });
1129                let emit_params_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1130                    label: Some("particle_emit_params_bg"),
1131                    layout: &params_bgl,
1132                    entries: &[wgpu::BindGroupEntry {
1133                        binding: 0,
1134                        resource: emit_uniform.as_entire_binding(),
1135                    }],
1136                });
1137
1138                let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1139                    label: Some("particle_emit_pass"),
1140                    timestamp_writes: None,
1141                });
1142                pass.set_pipeline(&emit_pipeline);
1143                pass.set_bind_group(0, &emit_params_bg, &[]);
1144                pass.set_bind_group(1, &sim_bg, &[]);
1145                pass.dispatch_workgroups(workgroups, 1, 1);
1146            }
1147
1148            // ----- Sim -----
1149            let sim_params = build_sim_params(item.time_step, capacity, &item.forces);
1150            let sim_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1151                label: Some("particle_sim_params"),
1152                contents: bytemuck::bytes_of(&sim_params),
1153                usage: wgpu::BufferUsages::UNIFORM,
1154            });
1155            let sim_params_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1156                label: Some("particle_sim_params_bg"),
1157                layout: &params_bgl,
1158                entries: &[wgpu::BindGroupEntry {
1159                    binding: 0,
1160                    resource: sim_uniform.as_entire_binding(),
1161                }],
1162            });
1163
1164            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1165                label: Some("particle_sim_pass"),
1166                timestamp_writes: None,
1167            });
1168            pass.set_pipeline(&sim_pipeline);
1169            pass.set_bind_group(0, &sim_params_bg, &[]);
1170            pass.set_bind_group(1, &sim_bg, &[]);
1171            pass.dispatch_workgroups(workgroups, 1, 1);
1172            drop(pass);
1173
1174            frame_data.push(ParticleFrameData {
1175                system_idx: idx,
1176                blend,
1177                hidden,
1178                route,
1179            });
1180        }
1181
1182        queue.submit(std::iter::once(encoder.finish()));
1183        frame_data
1184    }
1185}
1186
1187fn build_emit_params(
1188    e: &crate::renderer::EmitterConfig,
1189    capacity: u32,
1190    spawn_count: u32,
1191    frame_counter: u32,
1192) -> EmitParamsGpu {
1193    use crate::renderer::{SpawnShape, VelocityDist};
1194
1195    let mut out = EmitParamsGpu {
1196        spawn_min: [0.0; 3],
1197        spawn_kind: 0,
1198        spawn_max: [0.0; 3],
1199        spawn_radius: 0.0,
1200        vel_min: [0.0; 3],
1201        vel_kind: 0,
1202        vel_max: [0.0; 3],
1203        cone_half_angle: 0.0,
1204        vel_axis: [0.0; 3],
1205        cone_min_speed: 0.0,
1206        colour: e.colour,
1207        spawn_count,
1208        capacity,
1209        rng_seed: frame_counter.wrapping_mul(0x9E3779B1),
1210        size: e.size,
1211        lifetime_min: e.lifetime.0,
1212        lifetime_max: e.lifetime.1,
1213        cone_max_speed: 0.0,
1214        _pad: 0.0,
1215    };
1216
1217    match e.spawn_shape {
1218        SpawnShape::Point(p) => {
1219            out.spawn_kind = 0;
1220            out.spawn_min = p;
1221        }
1222        SpawnShape::Box { min, max } => {
1223            out.spawn_kind = 1;
1224            out.spawn_min = min;
1225            out.spawn_max = max;
1226        }
1227        SpawnShape::Sphere { center, radius } => {
1228            out.spawn_kind = 2;
1229            out.spawn_min = center;
1230            out.spawn_radius = radius;
1231        }
1232    }
1233
1234    match e.initial_velocity {
1235        VelocityDist::Fixed(v) => {
1236            out.vel_kind = 0;
1237            out.vel_min = v;
1238        }
1239        VelocityDist::UniformBox { min, max } => {
1240            out.vel_kind = 1;
1241            out.vel_min = min;
1242            out.vel_max = max;
1243        }
1244        VelocityDist::UniformCone {
1245            axis,
1246            half_angle,
1247            min_speed,
1248            max_speed,
1249        } => {
1250            out.vel_kind = 2;
1251            out.vel_axis = axis;
1252            out.cone_half_angle = half_angle;
1253            out.cone_min_speed = min_speed;
1254            out.cone_max_speed = max_speed;
1255        }
1256    }
1257
1258    out
1259}
1260
1261fn build_sim_params(
1262    dt: f32,
1263    capacity: u32,
1264    forces: &[crate::renderer::ForceField],
1265) -> SimParamsGpu {
1266    use crate::renderer::ForceField;
1267
1268    let mut gpu_forces = [GpuForce {
1269        kind: 0,
1270        _pad: [0; 3],
1271        v0: [0.0; 4],
1272        v1: [0.0; 4],
1273    }; MAX_FORCES];
1274
1275    let n = forces.len().min(MAX_FORCES);
1276    for (i, f) in forces.iter().take(n).enumerate() {
1277        match *f {
1278            ForceField::Gravity(a) => {
1279                gpu_forces[i].kind = 0;
1280                gpu_forces[i].v0 = [a[0], a[1], a[2], 0.0];
1281            }
1282            ForceField::Drag(k) => {
1283                gpu_forces[i].kind = 1;
1284                gpu_forces[i].v0 = [k, 0.0, 0.0, 0.0];
1285            }
1286            ForceField::PointAttractor {
1287                position,
1288                strength,
1289                falloff,
1290            } => {
1291                gpu_forces[i].kind = 2;
1292                gpu_forces[i].v0 = [position[0], position[1], position[2], strength];
1293                gpu_forces[i].v1 = [falloff, 0.0, 0.0, 0.0];
1294            }
1295        }
1296    }
1297
1298    SimParamsGpu {
1299        dt,
1300        capacity,
1301        force_count: n as u32,
1302        _pad: 0,
1303        forces: gpu_forces,
1304    }
1305}