ferravex_play/
particles.rs

1//! Bounded CPU particle simulation with no allocations after pool construction.
2#[derive(Clone, Copy, Debug, PartialEq)]
3pub struct ParticleDesc {
4    pub position: [f32; 3],
5    pub velocity: [f32; 3],
6    pub acceleration: [f32; 3],
7    pub lifetime: f32,
8    pub start_color: [f32; 4],
9    pub end_color: [f32; 4],
10    pub start_size: f32,
11    pub end_size: f32,
12}
13impl Default for ParticleDesc {
14    fn default() -> Self {
15        Self {
16            position: [0.0; 3],
17            velocity: [0.0; 3],
18            acceleration: [0.0; 3],
19            lifetime: 1.0,
20            start_color: [1.0; 4],
21            end_color: [1.0, 1.0, 1.0, 0.0],
22            start_size: 1.0,
23            end_size: 0.0,
24        }
25    }
26}
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum ParticleError {
29    Full,
30    InvalidParticle,
31    InvalidDelta,
32}
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct ParticleView {
35    pub position: [f32; 3],
36    pub color: [f32; 4],
37    pub size: f32,
38    pub normalized_age: f32,
39}
40struct Particle {
41    desc: ParticleDesc,
42    age: f32,
43}
44pub struct ParticlePool {
45    particles: Vec<Particle>,
46    limit: usize,
47    dropped: u64,
48}
49impl ParticlePool {
50    pub fn new(capacity: usize) -> Self {
51        Self {
52            particles: Vec::with_capacity(capacity),
53            limit: capacity,
54            dropped: 0,
55        }
56    }
57    pub fn len(&self) -> usize {
58        self.particles.len()
59    }
60    pub fn is_empty(&self) -> bool {
61        self.particles.is_empty()
62    }
63    pub fn capacity(&self) -> usize {
64        self.limit
65    }
66    pub fn dropped(&self) -> u64 {
67        self.dropped
68    }
69    pub fn clear(&mut self) {
70        self.particles.clear();
71    }
72    pub fn spawn(&mut self, desc: ParticleDesc) -> Result<(), ParticleError> {
73        if !desc
74            .position
75            .into_iter()
76            .chain(desc.velocity)
77            .chain(desc.acceleration)
78            .chain(desc.start_color)
79            .chain(desc.end_color)
80            .chain([desc.lifetime, desc.start_size, desc.end_size])
81            .all(f32::is_finite)
82            || desc.lifetime <= 0.0
83            || desc.start_size < 0.0
84            || desc.end_size < 0.0
85        {
86            return Err(ParticleError::InvalidParticle);
87        }
88        if self.particles.len() == self.limit {
89            self.dropped = self.dropped.saturating_add(1);
90            return Err(ParticleError::Full);
91        }
92        self.particles.push(Particle { desc, age: 0.0 });
93        Ok(())
94    }
95    pub fn update(&mut self, dt: f32) -> Result<(), ParticleError> {
96        if !dt.is_finite() || dt < 0.0 {
97            return Err(ParticleError::InvalidDelta);
98        }
99        let mut i = 0;
100        while i < self.particles.len() {
101            let particle = &mut self.particles[i];
102            particle.age += dt;
103            if particle.age >= particle.desc.lifetime {
104                self.particles.swap_remove(i);
105                continue;
106            }
107            for axis in 0..3 {
108                // Analytic constant-acceleration integration makes partitioned dt agree.
109                particle.desc.position[axis] += particle.desc.velocity[axis] * dt
110                    + particle.desc.acceleration[axis] * (0.5 * dt * dt);
111                particle.desc.velocity[axis] += particle.desc.acceleration[axis] * dt;
112            }
113            if !particle
114                .desc
115                .position
116                .into_iter()
117                .chain(particle.desc.velocity)
118                .all(f32::is_finite)
119            {
120                self.particles.swap_remove(i);
121                continue;
122            }
123            i += 1;
124        }
125        Ok(())
126    }
127    pub fn iter(&self) -> impl Iterator<Item = ParticleView> + '_ {
128        self.particles.iter().map(|p| {
129            let age = (p.age / p.desc.lifetime).clamp(0.0, 1.0);
130            ParticleView {
131                position: p.desc.position,
132                color: std::array::from_fn(|i| {
133                    p.desc.start_color[i] * (1.0 - age) + p.desc.end_color[i] * age
134                }),
135                size: p.desc.start_size * (1.0 - age) + p.desc.end_size * age,
136                normalized_age: age,
137            }
138        })
139    }
140}
141#[cfg(test)]
142mod tests {
143    use super::*;
144    #[test]
145    fn capacity_is_bounded_and_expired_slots_are_reusable() {
146        let mut pool = ParticlePool::new(2);
147        pool.spawn(ParticleDesc::default()).unwrap();
148        pool.spawn(ParticleDesc::default()).unwrap();
149        assert_eq!(
150            pool.spawn(ParticleDesc::default()),
151            Err(ParticleError::Full)
152        );
153        assert_eq!(pool.dropped(), 1);
154        pool.update(1.0).unwrap();
155        assert!(pool.is_empty());
156        pool.spawn(ParticleDesc::default()).unwrap();
157        assert_eq!(pool.len(), 1);
158        assert_eq!(pool.capacity(), 2);
159    }
160    #[test]
161    fn constant_acceleration_matches_frame_partition_and_color_fades() {
162        let desc = ParticleDesc {
163            velocity: [2.0, 0.0, 0.0],
164            acceleration: [0.0, -4.0, 0.0],
165            lifetime: 2.0,
166            ..ParticleDesc::default()
167        };
168        let mut a = ParticlePool::new(1);
169        let mut b = ParticlePool::new(1);
170        a.spawn(desc).unwrap();
171        b.spawn(desc).unwrap();
172        a.update(1.0).unwrap();
173        for _ in 0..10 {
174            b.update(0.1).unwrap();
175        }
176        let av = a.iter().next().unwrap();
177        let bv = b.iter().next().unwrap();
178        assert_eq!(av.position, [2.0, -2.0, 0.0]);
179        for i in 0..3 {
180            assert!((av.position[i] - bv.position[i]).abs() < 1e-5);
181        }
182        assert_eq!(av.color[3], 0.5);
183    }
184    #[test]
185    fn invalid_values_do_not_poison_pool() {
186        let mut pool = ParticlePool::new(1);
187        assert_eq!(
188            pool.spawn(ParticleDesc {
189                lifetime: f32::NAN,
190                ..ParticleDesc::default()
191            }),
192            Err(ParticleError::InvalidParticle)
193        );
194        pool.spawn(ParticleDesc::default()).unwrap();
195        assert_eq!(pool.update(-1.0), Err(ParticleError::InvalidDelta));
196        assert_eq!(pool.iter().next().unwrap().normalized_age, 0.0);
197    }
198}