Skip to main content

embedded_3dgfx/
particles.rs

1//! Fixed-capacity, no-alloc particle system for embedded environments.
2//!
3//! Each particle is rendered as a camera-facing billboard quad whose colour
4//! and size interpolate linearly between their birth and death values.
5//!
6//! # Example
7//! ```
8//! use embedded_3dgfx::particles::{ParticleSpawn, ParticleSystem};
9//! use nalgebra::{Point3, Vector3};
10//! use embedded_graphics_core::pixelcolor::Rgb565;
11//!
12//! let mut sys: ParticleSystem<64> = ParticleSystem::new();
13//! sys.spawn(ParticleSpawn {
14//!     position:     Point3::new(0.0, 0.0, 0.0),
15//!     velocity:     Vector3::new(0.0, 2.0, 0.0),
16//!     acceleration: Vector3::zeros(),
17//!     color_start:  Rgb565::new(31, 40, 0),
18//!     color_end:    Rgb565::new(10, 0, 0),
19//!     size_start:   0.2,
20//!     size_end:     0.0,
21//!     lifetime:     1.5,
22//! });
23//! sys.update(0.016, Vector3::new(0.0, -9.81, 0.0));
24//! ```
25
26use embedded_graphics_core::pixelcolor::{Rgb565, RgbColor};
27use nalgebra::{Point3, Vector3};
28
29use crate::billboard::Billboard;
30
31/// State for a single live particle.
32#[derive(Clone, Copy)]
33pub struct Particle {
34    /// Current world-space position.
35    pub position: Point3<f32>,
36    /// Current velocity (world units/second).
37    pub velocity: Vector3<f32>,
38    /// Constant per-particle acceleration (applied in addition to gravity).
39    pub acceleration: Vector3<f32>,
40    /// Colour at birth (t = 0).
41    pub color_start: Rgb565,
42    /// Colour at death (t = 1).
43    pub color_end: Rgb565,
44    /// Billboard half-size at birth.
45    pub size_start: f32,
46    /// Billboard half-size at death.  Use `0.0` to shrink out.
47    pub size_end: f32,
48    /// Elapsed lifetime in seconds.
49    pub age: f32,
50    /// Total lifetime in seconds.
51    pub lifetime: f32,
52    pub(crate) active: bool,
53}
54
55impl Particle {
56    /// Normalised age: `0.0` at birth → `1.0` at death.
57    #[inline]
58    pub fn t(&self) -> f32 {
59        if self.lifetime > 0.0 {
60            (self.age / self.lifetime).clamp(0.0, 1.0)
61        } else {
62            1.0
63        }
64    }
65
66    /// Current interpolated colour.
67    #[inline]
68    pub fn color(&self) -> Rgb565 {
69        let t = self.t();
70        let r = lerp_ch(self.color_start.r(), self.color_end.r(), t, 31);
71        let g = lerp_ch(self.color_start.g(), self.color_end.g(), t, 63);
72        let b = lerp_ch(self.color_start.b(), self.color_end.b(), t, 31);
73        Rgb565::new(r, g, b)
74    }
75
76    /// Current interpolated size.
77    #[inline]
78    pub fn size(&self) -> f32 {
79        self.size_start + self.t() * (self.size_end - self.size_start)
80    }
81}
82
83#[inline]
84fn lerp_ch(start: u8, end: u8, t: f32, max: u8) -> u8 {
85    (start as f32 + t * (end as f32 - start as f32)).clamp(0.0, max as f32) as u8
86}
87
88/// Parameters for spawning a single particle.
89#[derive(Clone, Copy)]
90pub struct ParticleSpawn {
91    /// Initial world-space position.
92    pub position: Point3<f32>,
93    /// Initial velocity (world units/second).
94    pub velocity: Vector3<f32>,
95    /// Per-particle constant acceleration (added to gravity in `update`).
96    pub acceleration: Vector3<f32>,
97    /// Colour at birth.
98    pub color_start: Rgb565,
99    /// Colour at death.
100    pub color_end: Rgb565,
101    /// Billboard size at birth.
102    pub size_start: f32,
103    /// Billboard size at death.
104    pub size_end: f32,
105    /// Lifetime in seconds.
106    pub lifetime: f32,
107}
108
109/// Fixed-capacity particle system.
110///
111/// `N` is the maximum number of simultaneously live particles.  Slots are
112/// reused in-place so spawning never allocates.
113pub struct ParticleSystem<const N: usize> {
114    slots: [Particle; N],
115    active: usize,
116}
117
118impl<const N: usize> ParticleSystem<N> {
119    /// Create a new, empty system.
120    pub fn new() -> Self {
121        Self {
122            slots: core::array::from_fn(|_| Particle {
123                position: Point3::new(0.0, 0.0, 0.0),
124                velocity: Vector3::new(0.0, 0.0, 0.0),
125                acceleration: Vector3::new(0.0, 0.0, 0.0),
126                color_start: Rgb565::new(0, 0, 0),
127                color_end: Rgb565::new(0, 0, 0),
128                size_start: 0.0,
129                size_end: 0.0,
130                age: 0.0,
131                lifetime: 0.0,
132                active: false,
133            }),
134            active: 0,
135        }
136    }
137
138    /// Spawn a particle.  Returns `true` on success, `false` if the pool is
139    /// at capacity (`N` particles already live).
140    pub fn spawn(&mut self, s: ParticleSpawn) -> bool {
141        for slot in &mut self.slots {
142            if !slot.active {
143                *slot = Particle {
144                    position: s.position,
145                    velocity: s.velocity,
146                    acceleration: s.acceleration,
147                    color_start: s.color_start,
148                    color_end: s.color_end,
149                    size_start: s.size_start,
150                    size_end: s.size_end,
151                    age: 0.0,
152                    lifetime: s.lifetime,
153                    active: true,
154                };
155                self.active += 1;
156                return true;
157            }
158        }
159        false
160    }
161
162    /// Advance all particles by `dt` seconds.
163    ///
164    /// `gravity` is applied uniformly to every particle's velocity.  Pass
165    /// `Vector3::zeros()` to disable gravity for this system.
166    pub fn update(&mut self, dt: f32, gravity: Vector3<f32>) {
167        let mut live = 0usize;
168        for slot in &mut self.slots {
169            if !slot.active {
170                continue;
171            }
172            slot.velocity += (slot.acceleration + gravity) * dt;
173            slot.position += slot.velocity * dt;
174            slot.age += dt;
175            if slot.age >= slot.lifetime {
176                slot.active = false;
177            } else {
178                live += 1;
179            }
180        }
181        self.active = live;
182    }
183
184    /// Number of currently live particles.
185    #[inline]
186    pub fn active_count(&self) -> usize {
187        self.active
188    }
189
190    /// `true` if no particles are live.
191    #[inline]
192    pub fn is_empty(&self) -> bool {
193        self.active == 0
194    }
195
196    /// Iterate over live particles (read-only).
197    pub fn iter_active(&self) -> impl Iterator<Item = &Particle> {
198        self.slots.iter().filter(|p| p.active)
199    }
200
201    /// Record all live particles as camera-facing billboard quads into
202    /// `commands`.
203    ///
204    /// Each particle emits two
205    /// [`DrawPrimitive::ColoredTriangleWithDepth`][crate::DrawPrimitive::ColoredTriangleWithDepth]
206    /// commands.  Billboard orientation uses world-up `(0, 1, 0)`, which is
207    /// correct for all but extreme pitch angles.
208    ///
209    /// Returns the number of particles successfully projected and emitted.
210    /// Particles that project behind the camera or outside the frustum are
211    /// silently skipped.
212    pub fn record<const MAX: usize>(
213        &self,
214        engine: &crate::K3dengine,
215        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
216    ) -> usize {
217        use crate::command_buffer::RenderCommand;
218
219        if self.active == 0 {
220            return 0;
221        }
222
223        let camera_up = Vector3::new(0.0, 1.0, 0.0);
224        let vp = engine.camera.vp_matrix;
225        let mut emitted = 0usize;
226
227        for particle in self.iter_active() {
228            let size = particle.size();
229            if size <= 0.0 {
230                continue;
231            }
232            let color = particle.color();
233
234            let billboard = Billboard::new(particle.position, size, color);
235            let quad = billboard.generate_quad(engine.camera.position, camera_up);
236
237            // Triangle 1: bottom-left (0), bottom-right (1), top-right (2)
238            let Some((pts1, _)) = engine.transform_points_with_w(&[0usize, 1, 2], &quad, vp) else {
239                continue;
240            };
241
242            // Triangle 2: bottom-left (0), top-right (2), top-left (3)
243            let Some((pts2, _)) = engine.transform_points_with_w(&[0usize, 2, 3], &quad, vp) else {
244                continue;
245            };
246
247            let _ = commands.push(RenderCommand::Draw(
248                crate::DrawPrimitive::ColoredTriangleWithDepth {
249                    points: [pts1[0].xy(), pts1[1].xy(), pts1[2].xy()],
250                    depths: [pts1[0].z as f32, pts1[1].z as f32, pts1[2].z as f32],
251                    color,
252                },
253            ));
254            let _ = commands.push(RenderCommand::Draw(
255                crate::DrawPrimitive::ColoredTriangleWithDepth {
256                    points: [pts2[0].xy(), pts2[1].xy(), pts2[2].xy()],
257                    depths: [pts2[0].z as f32, pts2[1].z as f32, pts2[2].z as f32],
258                    color,
259                },
260            ));
261            emitted += 1;
262        }
263
264        emitted
265    }
266}
267
268impl<const N: usize> Default for ParticleSystem<N> {
269    fn default() -> Self {
270        Self::new()
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    extern crate std;
277    use super::*;
278    use embedded_graphics_core::pixelcolor::WebColors;
279
280    fn default_spawn(lifetime: f32) -> ParticleSpawn {
281        ParticleSpawn {
282            position: Point3::new(0.0, 0.0, 0.0),
283            velocity: Vector3::new(0.0, 1.0, 0.0),
284            acceleration: Vector3::zeros(),
285            color_start: Rgb565::CSS_RED,
286            color_end: Rgb565::CSS_BLUE,
287            size_start: 1.0,
288            size_end: 0.0,
289            lifetime,
290        }
291    }
292
293    #[test]
294    fn test_spawn_increments_count() {
295        let mut sys: ParticleSystem<4> = ParticleSystem::new();
296        assert_eq!(sys.active_count(), 0);
297        assert!(sys.spawn(default_spawn(2.0)));
298        assert_eq!(sys.active_count(), 1);
299    }
300
301    #[test]
302    fn test_pool_full_returns_false() {
303        let mut sys: ParticleSystem<2> = ParticleSystem::new();
304        assert!(sys.spawn(default_spawn(2.0)));
305        assert!(sys.spawn(default_spawn(2.0)));
306        assert!(!sys.spawn(default_spawn(2.0)));
307    }
308
309    #[test]
310    fn test_update_kills_expired_particles() {
311        let mut sys: ParticleSystem<4> = ParticleSystem::new();
312        sys.spawn(default_spawn(0.1));
313        sys.update(0.2, Vector3::zeros());
314        assert_eq!(sys.active_count(), 0);
315    }
316
317    #[test]
318    fn test_update_moves_particle_with_gravity() {
319        let mut sys: ParticleSystem<4> = ParticleSystem::new();
320        sys.spawn(default_spawn(5.0));
321        let y0 = sys.iter_active().next().unwrap().position.y;
322        sys.update(0.1, Vector3::new(0.0, -9.81, 0.0));
323        let y1 = sys.iter_active().next().unwrap().position.y;
324        // Gravity slows the upward velocity but initial velocity dominates briefly
325        assert!(y1 != y0);
326    }
327
328    #[test]
329    fn test_slot_reused_after_expiry() {
330        let mut sys: ParticleSystem<1> = ParticleSystem::new();
331        assert!(sys.spawn(default_spawn(0.05)));
332        sys.update(0.1, Vector3::zeros());
333        assert_eq!(sys.active_count(), 0);
334        assert!(sys.spawn(default_spawn(2.0)));
335        assert_eq!(sys.active_count(), 1);
336    }
337
338    #[test]
339    fn test_color_at_birth_equals_start() {
340        let mut sys: ParticleSystem<4> = ParticleSystem::new();
341        sys.spawn(default_spawn(2.0));
342        let p = sys.iter_active().next().unwrap();
343        assert_eq!(p.t(), 0.0);
344        assert_eq!(p.color(), Rgb565::CSS_RED);
345    }
346
347    #[test]
348    fn test_size_shrinks_over_time() {
349        let mut sys: ParticleSystem<4> = ParticleSystem::new();
350        sys.spawn(default_spawn(2.0));
351        let s0 = sys.iter_active().next().unwrap().size();
352        sys.update(1.0, Vector3::zeros());
353        let s1 = sys.iter_active().next().unwrap().size();
354        assert!(s1 < s0);
355    }
356}