Skip to main content

proof_engine/particle/
gpu_particles.rs

1//! GPU particle system — compute shader driven, double-buffered, indirect draw.
2//!
3//! Moves particle simulation entirely to the GPU for massive particle counts
4//! (50,000–131,072) at 60fps with zero CPU readback.
5//!
6//! Architecture:
7//! ```text
8//! Frame N:
9//!   1. Dispatch compute_update: SSBO_A → SSBO_B (simulate)
10//!   2. Dispatch compute_emit: append new particles to SSBO_B
11//!   3. Dispatch compute_compact: count alive particles → indirect draw buffer
12//!   4. Draw: glDrawArraysIndirect reading from SSBO_B (render)
13//!   5. Swap: A ↔ B
14//! ```
15//!
16//! The CPU never reads particle data. Force fields, engine types, and corruption
17//! are passed as uniforms.
18
19use glam::{Vec3, Vec4};
20use std::sync::atomic::{AtomicU32, Ordering};
21
22// ── GPU Particle struct (mirrors compute shader layout) ─────────────────────
23
24/// Per-particle data stored in GPU SSBO.  Must be 64 bytes, aligned for std430.
25///
26/// Layout matches `particle_update.comp` exactly.
27#[repr(C, align(16))]
28#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
29pub struct GpuParticle {
30    /// World-space position.
31    pub position: [f32; 3],
32    /// Padding for vec3 alignment.
33    pub _pad0: f32,
34    /// World-space velocity.
35    pub velocity: [f32; 3],
36    /// Padding for vec3 alignment.
37    pub _pad1: f32,
38    /// RGBA color (alpha fades over lifetime).
39    pub color: [f32; 4],
40    /// Remaining life in seconds (0 = dead).
41    pub life: f32,
42    /// Total lifespan in seconds (for computing age fraction).
43    pub max_life: f32,
44    /// Visual size multiplier.
45    pub size: f32,
46    /// Which mathematical engine drives this particle's behavior.
47    /// 0=Linear, 1=Lorenz, 2=Mandelbrot, 3=Julia, 4=Rossler,
48    /// 5=Aizawa, 6=Thomas, 7=Halvorsen, 8=Chen, 9=Dadras
49    pub engine_type: u32,
50    /// Per-particle random seed for variation.
51    pub seed: f32,
52    /// Particle flags (bitfield): 1=affected_by_fields, 2=has_trail, 4=collides
53    pub flags: u32,
54    /// Reserved for future use.
55    pub _reserved: [f32; 2],
56}
57
58// Verify size at compile time.
59const _: () = assert!(std::mem::size_of::<GpuParticle>() == 80);
60
61impl GpuParticle {
62    pub fn dead() -> Self {
63        Self {
64            position: [0.0; 3],
65            _pad0: 0.0,
66            velocity: [0.0; 3],
67            _pad1: 0.0,
68            color: [0.0; 4],
69            life: 0.0,
70            max_life: 0.0,
71            size: 0.0,
72            engine_type: 0,
73            seed: 0.0,
74            flags: 0,
75            _reserved: [0.0; 2],
76        }
77    }
78}
79
80// ── Force field description (uploaded as uniform array) ─────────────────────
81
82/// A force field descriptor passed to the compute shader as a uniform.
83///
84/// Up to 16 force fields can be active simultaneously.
85#[repr(C)]
86#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
87pub struct GpuForceField {
88    /// World-space center of the field.
89    pub position: [f32; 3],
90    /// Field strength (positive = attract, negative = repel).
91    pub strength: f32,
92    /// Field type: 0=gravity, 1=vortex, 2=repulsion, 3=directional, 4=noise, 5=drag
93    pub field_type: u32,
94    /// Effective radius (beyond this, force is zero).
95    pub radius: f32,
96    /// Falloff exponent (1=linear, 2=inverse square, etc.).
97    pub falloff: f32,
98    /// Padding.
99    pub _pad: f32,
100}
101
102/// Maximum number of simultaneous force fields in the compute shader.
103pub const MAX_GPU_FORCE_FIELDS: usize = 16;
104
105// ── Emitter configuration ───────────────────────────────────────────────────
106
107/// Configuration for emitting new particles on the GPU.
108#[derive(Clone, Debug)]
109pub struct GpuEmitterConfig {
110    /// Number of particles to emit this frame.
111    pub emit_count: u32,
112    /// Emission center in world space.
113    pub origin: Vec3,
114    /// Emission radius (particles spawn in a sphere around origin).
115    pub radius: f32,
116    /// Speed range for initial velocity.
117    pub speed_min: f32,
118    pub speed_max: f32,
119    /// Lifetime range.
120    pub life_min: f32,
121    pub life_max: f32,
122    /// Engine type for new particles.
123    pub engine_type: u32,
124    /// Size range.
125    pub size_min: f32,
126    pub size_max: f32,
127    /// Base color for new particles.
128    pub color: Vec4,
129    /// Particle flags.
130    pub flags: u32,
131}
132
133impl Default for GpuEmitterConfig {
134    fn default() -> Self {
135        Self {
136            emit_count: 0,
137            origin: Vec3::ZERO,
138            radius: 1.0,
139            speed_min: 0.5,
140            speed_max: 2.0,
141            life_min: 2.0,
142            life_max: 5.0,
143            engine_type: 0,
144            size_min: 0.5,
145            size_max: 1.5,
146            color: Vec4::ONE,
147            flags: 1, // affected_by_fields
148        }
149    }
150}
151
152// ── Engine distribution ─────────────────────────────────────────────────────
153
154/// Distribution of particles across mathematical engine types for the chaos field.
155#[derive(Clone, Debug)]
156pub struct EngineDistribution {
157    /// Particles per engine type (10 engines, index = engine_type).
158    pub counts: [u32; 10],
159    /// Total particle count.
160    pub total: u32,
161}
162
163impl EngineDistribution {
164    /// Even distribution across all 10 engine types.
165    pub fn even(total: u32) -> Self {
166        let per = total / 10;
167        let remainder = total % 10;
168        let mut counts = [per; 10];
169        for i in 0..remainder as usize {
170            counts[i] += 1;
171        }
172        Self { counts, total }
173    }
174
175    /// Custom distribution with weights (normalized to total).
176    pub fn weighted(total: u32, weights: &[f32; 10]) -> Self {
177        let sum: f32 = weights.iter().sum();
178        let mut counts = [0u32; 10];
179        let mut assigned = 0u32;
180        for i in 0..10 {
181            counts[i] = ((weights[i] / sum) * total as f32).round() as u32;
182            assigned += counts[i];
183        }
184        // Assign remainder to first engine.
185        if assigned < total {
186            counts[0] += total - assigned;
187        }
188        Self { counts, total }
189    }
190
191    /// Heavy on Lorenz and Rossler (good for chaos field).
192    pub fn chaos_field(total: u32) -> Self {
193        Self::weighted(total, &[
194            0.05, // Linear
195            0.20, // Lorenz
196            0.10, // Mandelbrot
197            0.10, // Julia
198            0.15, // Rossler
199            0.10, // Aizawa
200            0.08, // Thomas
201            0.08, // Halvorsen
202            0.07, // Chen
203            0.07, // Dadras
204        ])
205    }
206}
207
208// ── GPU Particle System ─────────────────────────────────────────────────────
209
210/// The main GPU particle system.
211///
212/// Manages double-buffered SSBOs, compute shader dispatches, and indirect
213/// rendering.  All particle simulation happens on the GPU — the CPU only
214/// uploads uniform parameters (force fields, corruption, time).
215pub struct GpuParticleSystem {
216    /// Maximum number of particles the system supports.
217    pub max_particles: u32,
218    /// Currently alive particle count (approximate — GPU-authoritative).
219    pub alive_count_approx: u32,
220    /// Which buffer is the current read source (A=0, B=1).
221    pub current_read: u32,
222    /// Corruption parameter (0.0 = normal, affects engine behaviors).
223    pub corruption: f32,
224    /// Active force fields.
225    pub force_fields: Vec<GpuForceField>,
226    /// Pending emitter configs for this frame.
227    pub pending_emits: Vec<GpuEmitterConfig>,
228    /// Whether the system has been initialized with initial particles.
229    pub initialized: bool,
230    /// Per-engine particle counts for initial seeding.
231    pub distribution: EngineDistribution,
232    /// Depth layer assignments: how many layers and their Z offsets.
233    pub depth_layers: Vec<f32>,
234    /// Global damping factor.
235    pub damping: f32,
236    /// Gravity vector.
237    pub gravity: Vec3,
238    /// Wind vector.
239    pub wind: Vec3,
240    /// Turbulence strength.
241    pub turbulence: f32,
242}
243
244impl GpuParticleSystem {
245    /// Create a new GPU particle system.
246    pub fn new(max_particles: u32) -> Self {
247        Self {
248            max_particles,
249            alive_count_approx: 0,
250            current_read: 0,
251            corruption: 0.0,
252            force_fields: Vec::with_capacity(MAX_GPU_FORCE_FIELDS),
253            pending_emits: Vec::new(),
254            initialized: false,
255            distribution: EngineDistribution::chaos_field(max_particles),
256            depth_layers: vec![-5.0, 0.0, 5.0],
257            damping: 0.99,
258            gravity: Vec3::ZERO,
259            wind: Vec3::ZERO,
260            turbulence: 0.0,
261        }
262    }
263
264    /// Create a chaos field system with default 50,000 particles across 3 depth layers.
265    pub fn chaos_field() -> Self {
266        let mut sys = Self::new(50_000);
267        sys.depth_layers = vec![-8.0, 0.0, 8.0];
268        sys.damping = 0.995;
269        sys.turbulence = 0.3;
270        sys
271    }
272
273    /// Create a large chaos field with 131,072 particles.
274    pub fn chaos_field_large() -> Self {
275        let mut sys = Self::new(131_072);
276        sys.depth_layers = vec![-12.0, -4.0, 4.0, 12.0];
277        sys.damping = 0.997;
278        sys.turbulence = 0.2;
279        sys
280    }
281
282    /// Generate initial particle data for CPU upload.
283    ///
284    /// This creates a buffer of `max_particles` particles distributed according
285    /// to `self.distribution`, positioned randomly within a bounding volume.
286    pub fn generate_initial_particles(&self, bounds: Vec3) -> Vec<GpuParticle> {
287        let mut particles = Vec::with_capacity(self.max_particles as usize);
288        let mut rng_state = 12345u32;
289
290        // Simple LCG for reproducible randomness.
291        let mut rng = || -> f32 {
292            rng_state = rng_state.wrapping_mul(1664525).wrapping_add(1013904223);
293            (rng_state as f32) / (u32::MAX as f32)
294        };
295
296        let num_layers = self.depth_layers.len().max(1);
297
298        for engine_type in 0..10u32 {
299            let count = self.distribution.counts[engine_type as usize];
300            for i in 0..count {
301                let layer_idx = (i as usize) % num_layers;
302                let z = if layer_idx < self.depth_layers.len() {
303                    self.depth_layers[layer_idx]
304                } else {
305                    0.0
306                };
307
308                let x = (rng() - 0.5) * bounds.x * 2.0;
309                let y = (rng() - 0.5) * bounds.y * 2.0;
310                let z = z + (rng() - 0.5) * 2.0;
311
312                let vx = (rng() - 0.5) * 0.5;
313                let vy = (rng() - 0.5) * 0.5;
314                let vz = (rng() - 0.5) * 0.1;
315
316                let life = 5.0 + rng() * 10.0;
317                let size = 0.3 + rng() * 0.7;
318
319                // Color based on engine type.
320                let color = engine_color(engine_type, rng());
321
322                particles.push(GpuParticle {
323                    position: [x, y, z],
324                    _pad0: 0.0,
325                    velocity: [vx, vy, vz],
326                    _pad1: 0.0,
327                    color: color.to_array(),
328                    life,
329                    max_life: life,
330                    size,
331                    engine_type,
332                    seed: rng(),
333                    flags: 1, // affected_by_fields
334                    _reserved: [0.0; 2],
335                });
336            }
337        }
338
339        // Fill remaining slots with dead particles.
340        while particles.len() < self.max_particles as usize {
341            particles.push(GpuParticle::dead());
342        }
343
344        particles
345    }
346
347    /// Add a force field for this frame.
348    pub fn add_force_field(&mut self, field: GpuForceField) {
349        if self.force_fields.len() < MAX_GPU_FORCE_FIELDS {
350            self.force_fields.push(field);
351        }
352    }
353
354    /// Add a temporary impact force field (e.g., from a combat hit).
355    pub fn add_impact_field(&mut self, position: Vec3, strength: f32, radius: f32) {
356        self.add_force_field(GpuForceField {
357            position: position.to_array(),
358            strength,
359            field_type: 0, // gravity
360            radius,
361            falloff: 2.0, // inverse square
362            _pad: 0.0,
363        });
364    }
365
366    /// Add a vortex force field.
367    pub fn add_vortex_field(&mut self, position: Vec3, strength: f32, radius: f32) {
368        self.add_force_field(GpuForceField {
369            position: position.to_array(),
370            strength,
371            field_type: 1, // vortex
372            radius,
373            falloff: 1.0,
374            _pad: 0.0,
375        });
376    }
377
378    /// Add a repulsion field (explosion shockwave).
379    pub fn add_repulsion_field(&mut self, position: Vec3, strength: f32, radius: f32) {
380        self.add_force_field(GpuForceField {
381            position: position.to_array(),
382            strength: -strength.abs(),
383            field_type: 2, // repulsion
384            radius,
385            falloff: 2.0,
386            _pad: 0.0,
387        });
388    }
389
390    /// Queue particles for emission this frame.
391    pub fn emit(&mut self, config: GpuEmitterConfig) {
392        self.pending_emits.push(config);
393    }
394
395    /// Queue a burst of particles with a specific engine type.
396    pub fn emit_burst(&mut self, origin: Vec3, count: u32, engine_type: u32, color: Vec4) {
397        self.emit(GpuEmitterConfig {
398            emit_count: count,
399            origin,
400            engine_type,
401            color,
402            ..GpuEmitterConfig::default()
403        });
404    }
405
406    /// Clear all force fields. Call at the start of each frame.
407    pub fn clear_frame_state(&mut self) {
408        self.force_fields.clear();
409        self.pending_emits.clear();
410    }
411
412    /// Swap read/write buffers after compute dispatch.
413    pub fn swap_buffers(&mut self) {
414        self.current_read = 1 - self.current_read;
415    }
416
417    /// Get the compute dispatch parameters for the update pass.
418    pub fn update_dispatch_params(&self) -> GpuParticleDispatchParams {
419        GpuParticleDispatchParams {
420            particle_count: self.max_particles,
421            workgroup_size: 256,
422            corruption: self.corruption,
423            damping: self.damping,
424            gravity: self.gravity.to_array(),
425            wind: self.wind.to_array(),
426            turbulence: self.turbulence,
427            force_field_count: self.force_fields.len() as u32,
428        }
429    }
430
431    /// Get the indirect draw parameters.
432    pub fn indirect_draw_params(&self) -> GpuIndirectDrawParams {
433        GpuIndirectDrawParams {
434            vertex_count: 6, // quad (2 triangles)
435            instance_count: self.alive_count_approx,
436            first_vertex: 0,
437            first_instance: 0,
438        }
439    }
440}
441
442impl Default for GpuParticleSystem {
443    fn default() -> Self {
444        Self::new(50_000)
445    }
446}
447
448// ── Dispatch parameters ─────────────────────────────────────────────────────
449
450/// Parameters passed to the compute shader update dispatch.
451#[derive(Clone, Debug)]
452pub struct GpuParticleDispatchParams {
453    pub particle_count: u32,
454    pub workgroup_size: u32,
455    pub corruption: f32,
456    pub damping: f32,
457    pub gravity: [f32; 3],
458    pub wind: [f32; 3],
459    pub turbulence: f32,
460    pub force_field_count: u32,
461}
462
463impl GpuParticleDispatchParams {
464    /// Number of workgroups needed.
465    pub fn num_workgroups(&self) -> u32 {
466        (self.particle_count + self.workgroup_size - 1) / self.workgroup_size
467    }
468}
469
470/// Indirect draw arguments (matches GL_DRAW_INDIRECT_BUFFER layout).
471#[repr(C)]
472#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
473pub struct GpuIndirectDrawParams {
474    pub vertex_count: u32,
475    pub instance_count: u32,
476    pub first_vertex: u32,
477    pub first_instance: u32,
478}
479
480// ── Temporal force fields ───────────────────────────────────────────────────
481
482/// A force field with a limited lifetime that fades out.
483#[derive(Clone, Debug)]
484pub struct TemporalForceField {
485    pub field: GpuForceField,
486    pub life: f32,
487    pub max_life: f32,
488    pub fade_start: f32, // fraction of life at which to start fading (0.5 = fade last half)
489}
490
491impl TemporalForceField {
492    pub fn new(field: GpuForceField, duration: f32) -> Self {
493        Self {
494            field,
495            life: duration,
496            max_life: duration,
497            fade_start: 0.3,
498        }
499    }
500
501    /// Tick and return true if still alive.
502    pub fn tick(&mut self, dt: f32) -> bool {
503        self.life -= dt;
504        if self.life <= 0.0 {
505            return false;
506        }
507        // Fade strength.
508        let life_frac = self.life / self.max_life;
509        if life_frac < self.fade_start {
510            let fade = life_frac / self.fade_start;
511            self.field.strength *= fade;
512        }
513        true
514    }
515
516    pub fn is_alive(&self) -> bool {
517        self.life > 0.0
518    }
519}
520
521/// Manager for temporal force fields.
522pub struct TemporalFieldManager {
523    fields: Vec<TemporalForceField>,
524}
525
526impl TemporalFieldManager {
527    pub fn new() -> Self {
528        Self { fields: Vec::with_capacity(32) }
529    }
530
531    /// Add a temporal force field.
532    pub fn add(&mut self, field: TemporalForceField) {
533        self.fields.push(field);
534    }
535
536    /// Add an impact field that lasts `duration` seconds.
537    pub fn add_impact(&mut self, position: Vec3, strength: f32, radius: f32, duration: f32) {
538        self.add(TemporalForceField::new(
539            GpuForceField {
540                position: position.to_array(),
541                strength,
542                field_type: 0,
543                radius,
544                falloff: 2.0,
545                _pad: 0.0,
546            },
547            duration,
548        ));
549    }
550
551    /// Add a vortex that lasts `duration` seconds.
552    pub fn add_vortex(&mut self, position: Vec3, strength: f32, radius: f32, duration: f32) {
553        self.add(TemporalForceField::new(
554            GpuForceField {
555                position: position.to_array(),
556                strength,
557                field_type: 1,
558                radius,
559                falloff: 1.0,
560                _pad: 0.0,
561            },
562            duration,
563        ));
564    }
565
566    /// Add a repulsion shockwave.
567    pub fn add_shockwave(&mut self, position: Vec3, strength: f32, radius: f32, duration: f32) {
568        self.add(TemporalForceField::new(
569            GpuForceField {
570                position: position.to_array(),
571                strength: -strength.abs(),
572                field_type: 2,
573                radius,
574                falloff: 2.0,
575                _pad: 0.0,
576            },
577            duration,
578        ));
579    }
580
581    /// Tick all fields, remove dead ones, and collect survivors into a GpuParticleSystem.
582    pub fn tick_and_apply(&mut self, dt: f32, gpu_sys: &mut GpuParticleSystem) {
583        self.fields.retain_mut(|f| {
584            if f.tick(dt) {
585                gpu_sys.add_force_field(f.field);
586                true
587            } else {
588                false
589            }
590        });
591    }
592
593    /// Number of active fields.
594    pub fn count(&self) -> usize {
595        self.fields.len()
596    }
597
598    /// Clear all fields.
599    pub fn clear(&mut self) {
600        self.fields.clear();
601    }
602}
603
604impl Default for TemporalFieldManager {
605    fn default() -> Self {
606        Self::new()
607    }
608}
609
610// ── Engine color palette ────────────────────────────────────────────────────
611
612/// Get a characteristic color for a given engine type.
613fn engine_color(engine_type: u32, variation: f32) -> Vec4 {
614    let v = variation * 0.2; // ±10% variation
615    match engine_type {
616        0 => Vec4::new(0.5 + v, 0.5 + v, 0.5 + v, 0.8), // Linear: gray
617        1 => Vec4::new(0.2 + v, 0.4, 1.0, 0.9),           // Lorenz: blue
618        2 => Vec4::new(0.8, 0.2 + v, 0.8, 0.85),          // Mandelbrot: magenta
619        3 => Vec4::new(0.1, 0.8 + v, 0.8, 0.85),          // Julia: cyan
620        4 => Vec4::new(1.0, 0.4 + v, 0.1, 0.9),           // Rossler: orange
621        5 => Vec4::new(0.3, 0.9 + v, 0.3, 0.85),          // Aizawa: green
622        6 => Vec4::new(0.9, 0.9 + v, 0.2, 0.85),          // Thomas: yellow
623        7 => Vec4::new(1.0, 0.2, 0.3 + v, 0.9),           // Halvorsen: red
624        8 => Vec4::new(0.6, 0.3 + v, 1.0, 0.85),          // Chen: purple
625        9 => Vec4::new(0.9, 0.7 + v, 0.5, 0.85),          // Dadras: tan
626        _ => Vec4::new(1.0, 1.0, 1.0, 0.8),
627    }
628}
629
630// ── Chaos Field Presets ─────────────────────────────────────────────────────
631
632/// Pre-configured chaos field setups for different game contexts.
633pub struct ChaosFieldPresets;
634
635impl ChaosFieldPresets {
636    /// Default exploration chaos field: calm, slow-moving.
637    pub fn exploration() -> GpuParticleSystem {
638        let mut sys = GpuParticleSystem::new(30_000);
639        sys.damping = 0.998;
640        sys.turbulence = 0.1;
641        sys.corruption = 0.0;
642        sys
643    }
644
645    /// Combat chaos field: more intense, reactive to hits.
646    pub fn combat() -> GpuParticleSystem {
647        let mut sys = GpuParticleSystem::new(50_000);
648        sys.damping = 0.995;
649        sys.turbulence = 0.3;
650        sys.corruption = 0.1;
651        sys
652    }
653
654    /// Boss fight: maximum intensity.
655    pub fn boss_fight() -> GpuParticleSystem {
656        let mut sys = GpuParticleSystem::new(80_000);
657        sys.damping = 0.99;
658        sys.turbulence = 0.5;
659        sys.corruption = 0.3;
660        sys
661    }
662
663    /// Corruption zone: heavy distortion.
664    pub fn corruption_zone(corruption_level: f32) -> GpuParticleSystem {
665        let mut sys = GpuParticleSystem::new(65_000);
666        sys.damping = 0.985;
667        sys.turbulence = 0.7;
668        sys.corruption = corruption_level.clamp(0.0, 1.0);
669        sys
670    }
671
672    /// Menu background: gentle, decorative.
673    pub fn menu_background() -> GpuParticleSystem {
674        let mut sys = GpuParticleSystem::new(15_000);
675        sys.damping = 0.999;
676        sys.turbulence = 0.05;
677        sys.corruption = 0.0;
678        sys.depth_layers = vec![-3.0, 0.0, 3.0];
679        sys
680    }
681}
682
683// ── Tests ───────────────────────────────────────────────────────────────────
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[test]
690    fn gpu_particle_size() {
691        assert_eq!(std::mem::size_of::<GpuParticle>(), 80);
692    }
693
694    #[test]
695    fn indirect_draw_params_size() {
696        assert_eq!(std::mem::size_of::<GpuIndirectDrawParams>(), 16);
697    }
698
699    #[test]
700    fn even_distribution() {
701        let dist = EngineDistribution::even(1000);
702        assert_eq!(dist.counts.iter().sum::<u32>(), 1000);
703    }
704
705    #[test]
706    fn chaos_field_distribution() {
707        let dist = EngineDistribution::chaos_field(50_000);
708        assert_eq!(dist.total, 50_000);
709        // Lorenz should have more than linear.
710        assert!(dist.counts[1] > dist.counts[0]);
711    }
712
713    #[test]
714    fn generate_initial_fills_to_max() {
715        let sys = GpuParticleSystem::new(100);
716        let particles = sys.generate_initial_particles(Vec3::new(10.0, 10.0, 5.0));
717        assert_eq!(particles.len(), 100);
718    }
719
720    #[test]
721    fn temporal_field_fades() {
722        let mut field = TemporalForceField::new(
723            GpuForceField {
724                position: [0.0; 3],
725                strength: 10.0,
726                field_type: 0,
727                radius: 5.0,
728                falloff: 2.0,
729                _pad: 0.0,
730            },
731            1.0,
732        );
733        assert!(field.tick(0.5));
734        assert!(field.tick(0.4));
735        assert!(!field.tick(0.2)); // dead
736    }
737
738    #[test]
739    fn temporal_manager_removes_dead() {
740        let mut mgr = TemporalFieldManager::new();
741        let mut sys = GpuParticleSystem::new(100);
742        mgr.add_impact(Vec3::ZERO, 10.0, 5.0, 0.1);
743        mgr.add_impact(Vec3::ONE, 5.0, 3.0, 1.0);
744        mgr.tick_and_apply(0.2, &mut sys);
745        assert_eq!(mgr.count(), 1); // first one died
746        assert_eq!(sys.force_fields.len(), 1); // only survivor applied
747    }
748
749    #[test]
750    fn swap_buffers() {
751        let mut sys = GpuParticleSystem::new(100);
752        assert_eq!(sys.current_read, 0);
753        sys.swap_buffers();
754        assert_eq!(sys.current_read, 1);
755        sys.swap_buffers();
756        assert_eq!(sys.current_read, 0);
757    }
758
759    #[test]
760    fn dispatch_params_workgroups() {
761        let sys = GpuParticleSystem::new(1000);
762        let params = sys.update_dispatch_params();
763        assert_eq!(params.num_workgroups(), 4); // ceil(1000/256)
764    }
765
766    #[test]
767    fn force_field_limit() {
768        let mut sys = GpuParticleSystem::new(100);
769        for _ in 0..20 {
770            sys.add_impact_field(Vec3::ZERO, 1.0, 1.0);
771        }
772        assert_eq!(sys.force_fields.len(), MAX_GPU_FORCE_FIELDS);
773    }
774}