Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_fx/
particles.rs

1//! Fixed-size particle pool. Covers the "explosion burst" case
2//! 99% of arcade games want:
3//!
4//! - Spawn N coloured particles at a point, each with a random
5//!   velocity spread.
6//! - Each frame: integrate position + velocity, apply gravity,
7//!   decrement TTL.
8//! - Render as `RectFlat`s into an ordering table, with colour
9//!   and size tapering as TTL approaches zero.
10//!
11//! Const-generic pool size -- each consumer picks what fits its
12//! scene budget:
13//!
14//! ```ignore
15//! static mut PARTICLES: ParticlePool<48> = ParticlePool::new();
16//! ```
17//!
18//! Values beyond what you pass in on spawn are 0-initialised;
19//! `ttl == 0` marks a slot as empty. No allocation, ever.
20
21use psx_gpu::frame::{OtFrame, PrimitiveArena};
22use psx_gpu::prim::RectFlat;
23
24use crate::rng::LcgRng;
25
26/// One live particle.
27///
28/// Positions are in screen-space pixels (i16). Velocities are
29/// Q4.4 sub-pixel -- `vx = 32` means 2 px / frame. That keeps
30/// enough resolution for slow drift + fast bursts out of the
31/// same i16 channel.
32#[derive(Copy, Clone, Debug)]
33pub struct Particle {
34    /// Top-left x position in pixels.
35    pub x: i16,
36    /// Top-left y position in pixels.
37    pub y: i16,
38    /// Fraction of a pixel `x` has advanced, in sixteenths (`0..=15`). The
39    /// pool carries the part of `vx` that does not fill a whole pixel here,
40    /// so slow particles drift instead of freezing.
41    pub x_frac: u8,
42    /// Fraction of a pixel `y` has advanced, in sixteenths (`0..=15`).
43    pub y_frac: u8,
44    /// Velocity along x, Q4.4 pixels/frame.
45    pub vx: i16,
46    /// Velocity along y, Q4.4 pixels/frame. Positive = downward.
47    pub vy: i16,
48    /// Colour at spawn -- the renderer scales this by
49    /// `ttl / spawn_ttl` for a fade-out.
50    pub r: u8,
51    /// Green at spawn.
52    pub g: u8,
53    /// Blue at spawn.
54    pub b: u8,
55    /// Frames remaining. `0` = slot empty.
56    pub ttl: u8,
57    /// TTL originally passed at spawn. Kept so the fade ratio is
58    /// correct when different bursts have different lifespans.
59    pub spawn_ttl: u8,
60}
61
62impl Particle {
63    /// A dead / empty slot. Useful for static-array init.
64    pub const fn empty() -> Self {
65        Self {
66            x: 0,
67            y: 0,
68            x_frac: 0,
69            y_frac: 0,
70            vx: 0,
71            vy: 0,
72            r: 0,
73            g: 0,
74            b: 0,
75            ttl: 0,
76            spawn_ttl: 0,
77        }
78    }
79
80    /// Is this particle currently alive and worth rendering?
81    pub const fn is_alive(&self) -> bool {
82        self.ttl != 0
83    }
84
85    /// The rect this particle draws as: colour faded by `ttl / spawn_ttl`,
86    /// 3 px for the first half of its life and 2 px after.
87    #[inline]
88    fn rect(&self, shake: (i16, i16)) -> RectFlat {
89        let denom = self.spawn_ttl.max(1) as u16;
90        let scale = self.ttl as u16;
91        let r = ((self.r as u16 * scale) / denom) as u8;
92        let g = ((self.g as u16 * scale) / denom) as u8;
93        let b = ((self.b as u16 * scale) / denom) as u8;
94        let size = if (self.ttl as u16) * 2 > denom { 3 } else { 2 };
95        RectFlat::new(self.x + shake.0, self.y + shake.1, size, size, r, g, b)
96    }
97}
98
99/// Fixed-capacity pool of `N` particles.
100///
101/// Uses a linear search for free slots on spawn. `N` up to ~128
102/// is comfortably fast on a 33 MHz MIPS -- if you're routinely
103/// saturating a 256+ pool you want a proper object pool with a
104/// free-list, which lives at the engine layer.
105pub struct ParticlePool<const N: usize> {
106    particles: [Particle; N],
107}
108
109impl<const N: usize> ParticlePool<N> {
110    /// A brand-new empty pool. Const-safe for static-array init.
111    pub const fn new() -> Self {
112        Self {
113            particles: [Particle::empty(); N],
114        }
115    }
116
117    /// Spawn `count` particles at `centre` with velocities
118    /// scattered within `[-velocity_range, +velocity_range]` on
119    /// each axis (Q4.4). Colour and TTL are uniform across the
120    /// burst.
121    ///
122    /// Finds free slots via linear scan; if the pool is full the
123    /// burst is truncated silently. Callers that care about
124    /// guaranteed-spawns should size the pool to
125    /// `max_concurrent_bursts * count_per_burst`.
126    ///
127    /// `rng` is stepped for each spawned particle, seeding two
128    /// signed values per axis.
129    pub fn spawn_burst(
130        &mut self,
131        rng: &mut LcgRng,
132        centre: (i16, i16),
133        color: (u8, u8, u8),
134        count: usize,
135        velocity_range: i16,
136        ttl: u8,
137    ) {
138        let mut spawned = 0;
139        for slot in self.particles.iter_mut() {
140            if slot.ttl != 0 {
141                continue;
142            }
143            let vx = rng.signed(velocity_range);
144            let vy = rng.signed(velocity_range);
145            *slot = Particle {
146                x: centre.0,
147                y: centre.1,
148                x_frac: 0,
149                y_frac: 0,
150                vx,
151                vy,
152                r: color.0,
153                g: color.1,
154                b: color.2,
155                ttl,
156                spawn_ttl: ttl,
157            };
158            spawned += 1;
159            if spawned >= count {
160                return;
161            }
162        }
163    }
164
165    /// One simulation step for every live particle:
166    /// - Position += velocity (Q4.4: whole pixels move `x`, the remainder
167    ///   carries in `x_frac` to the next frame).
168    /// - `vy += gravity` (Q4.4, so 1 = 0.0625 px/frame² falling).
169    /// - `ttl -= 1` -- particle expires when it hits zero.
170    pub fn update(&mut self, gravity: i16) {
171        for p in self.particles.iter_mut() {
172            if p.ttl == 0 {
173                continue;
174            }
175            (p.x, p.x_frac) = advance(p.x, p.x_frac, p.vx);
176            (p.y, p.y_frac) = advance(p.y, p.y_frac, p.vy);
177            p.vy = p.vy.saturating_add(gravity);
178            p.ttl -= 1;
179        }
180    }
181
182    /// Clear every slot. Use when resetting between levels so
183    /// leftover particles don't float in from the previous scene.
184    pub fn clear(&mut self) {
185        for p in self.particles.iter_mut() {
186            *p = Particle::empty();
187        }
188    }
189
190    /// Render every live particle as a `RectFlat` from `rects` into
191    /// `frame` at slot `z`, stopping when the arena runs out. Returns the
192    /// number of rects written.
193    ///
194    /// Colour fades with `ttl / spawn_ttl` and size tapers from 3 px to
195    /// 2 px.
196    pub fn render_into_frame<'f, const OT_N: usize>(
197        &self,
198        frame: &mut OtFrame<'f, OT_N>,
199        rects: &mut PrimitiveArena<'f, RectFlat>,
200        z: u8,
201        shake: (i16, i16),
202    ) -> usize {
203        let mut written = 0;
204        for p in self.particles.iter() {
205            if p.ttl == 0 {
206                continue;
207            }
208            let Some(rect) = rects.push(p.rect(shake)) else {
209                break;
210            };
211            frame.add(z as usize, rect);
212            written += 1;
213        }
214        written
215    }
216
217    /// How many slots currently hold live particles. O(N) scan --
218    /// useful for debugging budgets + tests, not hot-path code.
219    pub fn live_count(&self) -> usize {
220        self.particles.iter().filter(|p| p.ttl != 0).count()
221    }
222
223    /// Immutable view of the raw particle array. Debugging
224    /// helper; most consumers don't need this.
225    pub fn particles(&self) -> &[Particle; N] {
226        &self.particles
227    }
228}
229
230impl<const N: usize> Default for ParticlePool<N> {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236/// Move a pixel position by a Q4.4 velocity, carrying the fraction.
237///
238/// The position is `pos + frac / 16`; adding `velocity / 16` floors toward
239/// negative infinity, so a particle at rest-plus-drift moves the same
240/// distance left as right. The whole-pixel part wraps like the old
241/// `wrapping_add`.
242#[inline]
243const fn advance(pos: i16, frac: u8, velocity: i16) -> (i16, u8) {
244    let total = (pos as i32) * 16 + frac as i32 + velocity as i32;
245    ((total >> 4) as i16, (total & 15) as u8)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use psx_gpu::ot::OrderingTable;
252
253    #[test]
254    fn render_into_frame_stops_when_the_arena_is_full() {
255        let mut pool: ParticlePool<8> = ParticlePool::new();
256        let mut rng = LcgRng::new(7);
257        pool.spawn_burst(&mut rng, (50, 50), (200, 100, 50), 5, 16, 20);
258        let mut storage: [RectFlat; 3] =
259            core::array::from_fn(|_| RectFlat::new(0, 0, 0, 0, 0, 0, 0));
260        let mut ot: OrderingTable<4> = OrderingTable::new();
261        let mut frame = ot.frame();
262        let mut arena = PrimitiveArena::new(&mut storage);
263        assert_eq!(pool.render_into_frame(&mut frame, &mut arena, 2, (0, 0)), 3);
264        assert_eq!(arena.remaining(), 0);
265        drop(frame);
266        // SAFETY: the table only links `storage`, still alive here.
267        assert_eq!(unsafe { ot.packets() }.count(), 3);
268    }
269
270    #[test]
271    fn empty_pool_is_empty() {
272        let pool: ParticlePool<16> = ParticlePool::new();
273        assert_eq!(pool.live_count(), 0);
274    }
275
276    #[test]
277    fn spawn_burst_fills_slots() {
278        let mut pool: ParticlePool<16> = ParticlePool::new();
279        let mut rng = LcgRng::new(42);
280        pool.spawn_burst(&mut rng, (100, 100), (255, 0, 0), 6, 32, 30);
281        assert_eq!(pool.live_count(), 6);
282    }
283
284    #[test]
285    fn spawn_burst_truncates_at_capacity() {
286        let mut pool: ParticlePool<4> = ParticlePool::new();
287        let mut rng = LcgRng::new(1);
288        pool.spawn_burst(&mut rng, (0, 0), (255, 255, 255), 16, 32, 30);
289        assert_eq!(pool.live_count(), 4);
290    }
291
292    #[test]
293    fn update_decrements_ttl() {
294        let mut pool: ParticlePool<4> = ParticlePool::new();
295        let mut rng = LcgRng::new(1);
296        pool.spawn_burst(&mut rng, (0, 0), (255, 255, 255), 2, 0, 3);
297        assert_eq!(pool.live_count(), 2);
298        pool.update(0);
299        pool.update(0);
300        pool.update(0);
301        assert_eq!(pool.live_count(), 0, "all slots should expire after TTL");
302    }
303
304    #[test]
305    fn clear_empties_pool() {
306        let mut pool: ParticlePool<8> = ParticlePool::new();
307        let mut rng = LcgRng::new(1);
308        pool.spawn_burst(&mut rng, (0, 0), (255, 255, 255), 4, 0, 30);
309        pool.clear();
310        assert_eq!(pool.live_count(), 0);
311    }
312
313    #[test]
314    fn update_integrates_velocity() {
315        let mut pool: ParticlePool<2> = ParticlePool::new();
316        // Manually place a particle with known vx so we know the
317        // expected motion.
318        pool.particles[0] = Particle {
319            x: 10,
320            y: 20,
321            x_frac: 0,
322            y_frac: 0,
323            vx: 32, // Q4.4 = 2 px/frame
324            vy: 16, // Q4.4 = 1 px/frame
325            r: 0,
326            g: 0,
327            b: 0,
328            ttl: 5,
329            spawn_ttl: 5,
330        };
331        pool.update(0);
332        assert_eq!(pool.particles[0].x, 12);
333        assert_eq!(pool.particles[0].y, 21);
334        assert_eq!(pool.particles[0].ttl, 4);
335    }
336    /// One particle at (0, 0) with the given velocity, advanced `frames` times.
337    fn drift(vx: i16, vy: i16, frames: u32) -> (i16, i16) {
338        let mut pool: ParticlePool<1> = ParticlePool::new();
339        pool.particles[0] = Particle {
340            ttl: 200,
341            spawn_ttl: 200,
342            vx,
343            vy,
344            ..Particle::empty()
345        };
346        for _ in 0..frames {
347            pool.update(0);
348        }
349        (pool.particles[0].x, pool.particles[0].y)
350    }
351
352    #[test]
353    fn a_slow_particle_drifts_instead_of_freezing() {
354        // Q4.4: vx = 8 is half a pixel a frame. It used to truncate to 0 forever.
355        assert_eq!(drift(8, 0, 2), (1, 0));
356        assert_eq!(drift(8, 0, 32), (16, 0));
357        // 1 sixteenth of a pixel a frame: one pixel after 16 frames.
358        assert_eq!(drift(1, 1, 15), (0, 0));
359        assert_eq!(drift(1, 1, 16), (1, 1));
360    }
361
362    #[test]
363    fn total_distance_is_velocity_times_frames_in_sixteenths() {
364        // Oracle: floor(frames * v / 16) for any velocity, either sign.
365        for v in [-31i16, -17, -16, -9, -1, 0, 1, 7, 16, 17, 24, 31, 32] {
366            for frames in [1u32, 2, 3, 10, 33] {
367                let expected = (frames as i32 * v as i32).div_euclid(16);
368                assert_eq!(
369                    drift(v, 0, frames).0 as i32,
370                    expected,
371                    "v={v} frames={frames}"
372                );
373            }
374        }
375    }
376
377    #[test]
378    fn velocities_between_16_and_31_no_longer_all_move_one_pixel() {
379        // 16..=31 used to be exactly 1 px/frame; 24 is 1.5.
380        assert_eq!(drift(24, 0, 2), (3, 0));
381        assert_eq!(drift(16, 0, 2), (2, 0));
382    }
383}