Skip to main content

concinnity_core/render/
particles.rs

1//! Backend-agnostic resolution of `ParticleEmitter` components into the
2//! `ParticleEmitterRecord`s the backends consume. Each record carries the
3//! clamped emitter tunables, the resolved texture pool slot, and the per-frame
4//! uniform builder the GPU compute + render passes share. Pure CPU; the
5//! per-emitter GPU buffers themselves are allocated by the backend at init.
6
7use crate::components::ParticleEmitter;
8use crate::gfx::render_types::ParticleParams;
9use crate::math::{cos, floor, sqrt};
10use alloc::vec::Vec;
11
12/// Upper bound on the per-emitter pool the backend will allocate. Each slot
13/// is 32 bytes on the GPU (matching `Particle` in `shaders/particle_types.slang`),
14/// so 65 536 slots = 2 MiB per emitter, already well past the visual point
15/// of diminishing returns for a billboard pool.
16pub const MAX_PARTICLES_PER_EMITTER: u32 = 65_536;
17
18// Hard floor on lifetime so the per-particle `age / lifetime` ratio never
19// divides by zero in the render kernel.
20const MIN_LIFETIME: f32 = 0.001;
21
22/// Resolved per-emitter state threaded into the backend at init. The backend
23/// allocates one GPU particle pool of `max_particles` slots per record and
24/// drives its compute + render passes from these fields each frame.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct ParticleEmitterRecord {
27    /// Index of the albedo texture in the renderer's bindless / per-frame
28    /// texture pool. `0` means "no texture authored": the renderer's white
29    /// fallback at slot 0 is sampled and the colour gradient still shows.
30    pub texture_slot: usize,
31    /// World-space spawn origin.
32    pub position: [f32; 3],
33    /// Mean emission direction, unit-length. The compute kernel samples a
34    /// new particle's initial velocity from the cone of half-angle
35    /// `spread_cos` around this vector.
36    pub direction: [f32; 3],
37    /// Cosine of the cone half-angle. `1.0` = straight jet, `-1.0` = full
38    /// sphere. Pre-computed so the kernel does not call `cos()` per spawn.
39    pub spread_cos: f32,
40    /// Inclusive lower bound on the initial particle speed (m/s).
41    pub speed_min: f32,
42    /// Inclusive upper bound on the initial particle speed (m/s).
43    pub speed_max: f32,
44    /// Inclusive lower bound on the particle lifetime (seconds).
45    pub lifetime_min: f32,
46    /// Inclusive upper bound on the particle lifetime (seconds).
47    pub lifetime_max: f32,
48    /// Constant acceleration applied each frame, in m/s².
49    pub gravity: [f32; 3],
50    /// Particles spawned per second.
51    pub spawn_rate: f32,
52    /// Pool size in slots. Live + dead particles share this fixed pool.
53    pub max_particles: u32,
54    /// World-space billboard side length at `age = 0` (m).
55    pub size_start: f32,
56    /// World-space billboard side length at `age = lifetime` (m).
57    pub size_end: f32,
58    /// Linear-space RGBA at `age = 0`.
59    pub color_start: [f32; 4],
60    /// Linear-space RGBA at `age = lifetime`.
61    pub color_end: [f32; 4],
62}
63
64impl ParticleEmitterRecord {
65    /// Conservative world-space AABB enclosing every particle this emitter
66    /// could spawn over its full lifetime. Used by the per-frame frustum-cull
67    /// skip so an off-screen emitter pays no _render_ cost; the compute
68    /// kernel still ticks so the pool keeps evolving while the camera looks
69    /// away.
70    ///
71    /// The bound is a sphere centred on the emission point and is intentionally
72    /// loose: it ignores the cone-spread restriction (`spread_cos`) so the
73    /// same AABB also covers full-sphere emitters, and it sums the worst-case
74    /// ballistic terms: `speed_max * lifetime_max` (straight-line reach),
75    /// `0.5 * |gravity| * lifetime_max²` (gravity drift), and a
76    /// `max(size_start, size_end) * sqrt(2) / 2` half-diagonal for the
77    /// camera-facing billboard quad. A tighter cone-aware bound is a future
78    /// refinement; this version is correct (never false-cull) and cheap.
79    pub fn aabb(&self) -> ([f32; 3], [f32; 3]) {
80        let speed_reach = self.speed_max * self.lifetime_max;
81        let gx = self.gravity[0];
82        let gy = self.gravity[1];
83        let gz = self.gravity[2];
84        let g_mag = sqrt(gx * gx + gy * gy + gz * gz);
85        let g_drift = 0.5 * g_mag * self.lifetime_max * self.lifetime_max;
86        let max_size = self.size_start.max(self.size_end);
87        // The billboard quad is a square of side `size`, viewed any way; the
88        // bounding sphere of a unit square has radius sqrt(2)/2.
89        let billboard_radius = 0.5 * max_size * core::f32::consts::SQRT_2;
90        let r = speed_reach + g_drift + billboard_radius;
91        let c = self.position;
92        (
93            [c[0] - r, c[1] - r, c[2] - r],
94            [c[0] + r, c[1] + r, c[2] + r],
95        )
96    }
97
98    /// Build the per-frame compute + render uniform from this record's static
99    /// fields and the dynamic spawn / time state the runtime carries.
100    ///
101    /// `dt` is the elapsed seconds since the previous compute dispatch (the
102    /// integration step); `spawn_budget` is `floor(spawn_accumulator)`, the
103    /// integer count of fresh particles the kernel may emit this frame; and
104    /// `random_seed` is the per-frame seed the kernel mixes with the thread
105    /// id to drive its cheap on-GPU RNG.
106    pub fn params(&self, dt: f32, spawn_budget: u32, random_seed: u32) -> ParticleParams {
107        ParticleParams {
108            position: self.position,
109            spread_cos: self.spread_cos,
110            direction: self.direction,
111            speed_min: self.speed_min,
112            gravity: self.gravity,
113            speed_max: self.speed_max,
114            color_start: self.color_start,
115            color_end: self.color_end,
116            lifetime_min: self.lifetime_min,
117            lifetime_max: self.lifetime_max,
118            size_start: self.size_start,
119            size_end: self.size_end,
120            dt: dt.max(0.0),
121            spawn_budget,
122            random_seed,
123            max_particles: self.max_particles,
124        }
125    }
126}
127
128/// Resolve a list of `ParticleEmitter` components into `ParticleEmitterRecord`s
129/// the backend can consume. Skips invisible emitters and emitters whose pool
130/// would be empty. An emitter's `texture` carries its cook-assigned
131/// `TextureHandle`, whose value is the texture's albedo pool slot; `texture_count`
132/// is the pool size and bounds the handle. An out-of-range handle is logged and
133/// dropped; an emitter with no `texture` falls back to slot 0 (white).
134pub fn build_particle_records(
135    emitters: &[&ParticleEmitter],
136    texture_count: usize,
137) -> Vec<ParticleEmitterRecord> {
138    let mut out = Vec::new();
139    for e in emitters {
140        if !e.visible {
141            continue;
142        }
143        let max_particles = e.max_particles.clamp(1, MAX_PARTICLES_PER_EMITTER);
144        let slot = match e.texture {
145            None => 0,
146            Some(handle) => {
147                let slot = handle.index();
148                if slot >= texture_count {
149                    continue;
150                }
151                slot
152            }
153        };
154        let direction = normalise_direction(e.direction);
155        let spread_cos = cos(e.spread_deg.clamp(0.0, 180.0).to_radians());
156        let lifetime_min = e.lifetime_min.max(MIN_LIFETIME);
157        let lifetime_max = e.lifetime_max.max(lifetime_min);
158        let speed_min = e.speed_min.max(0.0);
159        let speed_max = e.speed_max.max(speed_min);
160        out.push(ParticleEmitterRecord {
161            texture_slot: slot,
162            position: e.position,
163            direction,
164            spread_cos,
165            speed_min,
166            speed_max,
167            lifetime_min,
168            lifetime_max,
169            gravity: e.gravity,
170            spawn_rate: e.spawn_rate.max(0.0),
171            max_particles,
172            size_start: e.size_start.max(0.0),
173            size_end: e.size_end.max(0.0),
174            color_start: sanitised_color(e.color_start),
175            color_end: sanitised_color(e.color_end),
176        });
177    }
178    out
179}
180
181fn normalise_direction(d: [f32; 3]) -> [f32; 3] {
182    let len = sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
183    if !len.is_finite() || len < 1e-6 {
184        // A zero / non-finite direction falls back to world-up so the cone
185        // still has a well-defined axis. The asset-side default is `[0, 1, 0]`,
186        // so this only matters for hand-built records or pathological JSON.
187        [0.0, 1.0, 0.0]
188    } else {
189        [d[0] / len, d[1] / len, d[2] / len]
190    }
191}
192
193fn sanitised_color(c: [f32; 4]) -> [f32; 4] {
194    let mut out = c;
195    for x in out.iter_mut() {
196        if !x.is_finite() {
197            *x = 0.0;
198        }
199    }
200    out
201}
202
203/// Per-emitter spawn accumulator. The runtime keeps one of these per record
204/// and feeds the integer overflow into the compute kernel as `spawn_budget`
205/// each frame. Fractional carry-over keeps low spawn rates honest even when
206/// the frame-time is below the per-particle interval.
207#[derive(Debug, Clone, Copy, Default)]
208pub struct ParticleSpawnState {
209    /// Fractional particles owed by this emitter, carried forward across
210    /// frames. Cleared by `take_budget` after harvesting the integer part.
211    pub accumulator: f32,
212}
213
214impl ParticleSpawnState {
215    /// Add this frame's spawn allotment and pop off the integer part. Returns
216    /// `0` when the emitter is paused (`spawn_rate <= 0`).
217    pub fn take_budget(&mut self, dt: f32, spawn_rate: f32, max_particles: u32) -> u32 {
218        if spawn_rate <= 0.0 || dt <= 0.0 || !dt.is_finite() {
219            return 0;
220        }
221        self.accumulator += spawn_rate * dt;
222        // A pool of `max_particles` slots cannot absorb more than that many
223        // spawns in a single dispatch; extra budget would just churn the RNG
224        // for nothing.
225        let max_per_frame = max_particles as f32;
226        if self.accumulator > max_per_frame {
227            self.accumulator = max_per_frame;
228        }
229        let whole = floor(self.accumulator) as u32;
230        self.accumulator -= whole as f32;
231        whole
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::components::ParticleEmitter;
239
240    #[test]
241    fn invisible_emitter_is_skipped() {
242        let e = ParticleEmitter {
243            visible: false,
244            ..Default::default()
245        };
246        assert!(build_particle_records(&[&e], 0).is_empty());
247    }
248
249    #[test]
250    fn out_of_range_texture_handle_drops_emitter() {
251        let e = ParticleEmitter {
252            texture: Some(crate::ecs::TextureHandle(999)),
253            ..Default::default()
254        };
255        assert!(build_particle_records(&[&e], 0).is_empty());
256    }
257
258    #[test]
259    fn emitter_texture_handle_is_used_directly_as_the_slot() {
260        let e = ParticleEmitter {
261            texture: Some(crate::ecs::TextureHandle(2)),
262            ..Default::default()
263        };
264        let recs = build_particle_records(&[&e], 5);
265        assert_eq!(recs.len(), 1);
266        assert_eq!(recs[0].texture_slot, 2);
267    }
268
269    #[test]
270    fn emitter_without_texture_uses_fallback_slot() {
271        let e = ParticleEmitter::default();
272        let recs = build_particle_records(&[&e], 0);
273        assert_eq!(recs.len(), 1);
274        assert_eq!(recs[0].texture_slot, 0);
275    }
276
277    #[test]
278    fn build_normalises_direction() {
279        let e = ParticleEmitter {
280            direction: [0.0, 5.0, 0.0],
281            ..Default::default()
282        };
283        let recs = build_particle_records(&[&e], 0);
284        let d = recs[0].direction;
285        let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
286        assert!((len - 1.0).abs() < 1e-5);
287    }
288
289    #[test]
290    fn build_falls_back_for_zero_direction() {
291        let e = ParticleEmitter {
292            direction: [0.0, 0.0, 0.0],
293            ..Default::default()
294        };
295        let recs = build_particle_records(&[&e], 0);
296        assert_eq!(recs[0].direction, [0.0, 1.0, 0.0]);
297    }
298
299    #[test]
300    fn build_clamps_max_particles_to_engine_cap() {
301        let e = ParticleEmitter {
302            max_particles: u32::MAX,
303            ..Default::default()
304        };
305        let recs = build_particle_records(&[&e], 0);
306        assert_eq!(recs[0].max_particles, MAX_PARTICLES_PER_EMITTER);
307    }
308
309    #[test]
310    fn build_precomputes_spread_cosine() {
311        let e = ParticleEmitter {
312            spread_deg: 0.0,
313            ..Default::default()
314        };
315        let recs = build_particle_records(&[&e], 0);
316        assert!((recs[0].spread_cos - 1.0).abs() < 1e-6);
317    }
318
319    #[test]
320    fn build_lifts_lifetime_max_to_min() {
321        let e = ParticleEmitter {
322            lifetime_min: 3.0,
323            lifetime_max: 0.1,
324            ..Default::default()
325        };
326        let recs = build_particle_records(&[&e], 0);
327        assert!(recs[0].lifetime_max >= recs[0].lifetime_min);
328    }
329
330    #[test]
331    fn spawn_state_emits_integer_budget() {
332        let mut s = ParticleSpawnState::default();
333        let b = s.take_budget(0.5, 10.0, 100);
334        assert_eq!(b, 5);
335        let b = s.take_budget(0.5, 10.0, 100);
336        assert_eq!(b, 5);
337    }
338
339    #[test]
340    fn spawn_state_carries_fraction_across_frames() {
341        let mut s = ParticleSpawnState::default();
342        // 1.5 particles/frame; should alternate 1, 2, 1, 2 in the budget.
343        let dt = 1.0;
344        let rate = 1.5;
345        let cap = 100;
346        let mut total = 0;
347        for _ in 0..4 {
348            total += s.take_budget(dt, rate, cap);
349        }
350        assert_eq!(total, 6);
351    }
352
353    #[test]
354    fn spawn_state_zero_rate_returns_zero() {
355        let mut s = ParticleSpawnState::default();
356        assert_eq!(s.take_budget(1.0, 0.0, 100), 0);
357        assert_eq!(s.accumulator, 0.0);
358    }
359
360    #[test]
361    fn spawn_state_caps_at_pool_capacity() {
362        let mut s = ParticleSpawnState::default();
363        // 1000 particles/sec, but pool only holds 10 slots: the kernel can
364        // never absorb more than `max_particles` in a single dispatch.
365        let b = s.take_budget(1.0, 1000.0, 10);
366        assert!(b <= 10);
367    }
368
369    fn make_record(position: [f32; 3]) -> ParticleEmitterRecord {
370        ParticleEmitterRecord {
371            texture_slot: 0,
372            position,
373            direction: [0.0, 1.0, 0.0],
374            spread_cos: 1.0,
375            speed_min: 1.0,
376            speed_max: 2.0,
377            lifetime_min: 1.0,
378            lifetime_max: 2.0,
379            gravity: [0.0, 0.0, 0.0],
380            spawn_rate: 32.0,
381            max_particles: 64,
382            size_start: 0.1,
383            size_end: 0.1,
384            color_start: [1.0; 4],
385            color_end: [1.0; 4],
386        }
387    }
388
389    #[test]
390    fn aabb_centres_on_emission_origin() {
391        let r = make_record([3.0, 4.0, -5.0]);
392        let (mn, mx) = r.aabb();
393        let cx = 0.5 * (mn[0] + mx[0]);
394        let cy = 0.5 * (mn[1] + mx[1]);
395        let cz = 0.5 * (mn[2] + mx[2]);
396        assert!((cx - 3.0).abs() < 1e-5);
397        assert!((cy - 4.0).abs() < 1e-5);
398        assert!((cz + 5.0).abs() < 1e-5);
399    }
400
401    #[test]
402    fn aabb_radius_covers_speed_reach() {
403        // No gravity, no billboard size: radius should equal speed_max *
404        // lifetime_max = 2 * 2 = 4 (plus a small billboard term ~0.07).
405        let mut r = make_record([0.0; 3]);
406        r.size_start = 0.0;
407        r.size_end = 0.0;
408        r.gravity = [0.0; 3];
409        let (mn, mx) = r.aabb();
410        // Half-extent on each axis equals the radius.
411        let radius = 0.5 * (mx[0] - mn[0]);
412        let expected = r.speed_max * r.lifetime_max; // = 4.0
413        assert!((radius - expected).abs() < 1e-5);
414    }
415
416    #[test]
417    fn aabb_includes_gravity_drift() {
418        // Gravity 10 m/s² for 2 s integrates to 0.5 * 10 * 4 = 20 m drift,
419        // dominates over the 4 m speed reach. Total radius ≈ 24.
420        let mut r = make_record([0.0; 3]);
421        r.size_start = 0.0;
422        r.size_end = 0.0;
423        r.gravity = [0.0, -10.0, 0.0];
424        let (mn, mx) = r.aabb();
425        let radius = 0.5 * (mx[0] - mn[0]);
426        let expected = r.speed_max * r.lifetime_max + 0.5 * 10.0 * r.lifetime_max.powi(2);
427        assert!((radius - expected).abs() < 1e-4);
428    }
429
430    #[test]
431    fn aabb_includes_billboard_size() {
432        // No motion, no gravity: only the billboard half-diagonal matters.
433        // size = 1.0 → half-diag = sqrt(2)/2 ≈ 0.707.
434        let mut r = make_record([0.0; 3]);
435        r.speed_min = 0.0;
436        r.speed_max = 0.0;
437        r.gravity = [0.0; 3];
438        r.size_start = 1.0;
439        r.size_end = 1.0;
440        let (mn, mx) = r.aabb();
441        let radius = 0.5 * (mx[0] - mn[0]);
442        let expected = 0.5 * 1.0 * core::f32::consts::SQRT_2;
443        assert!((radius - expected).abs() < 1e-5);
444    }
445
446    fn one_record(emitter: ParticleEmitter) -> ParticleEmitterRecord {
447        *build_particle_records(&[&emitter], 4)
448            .first()
449            .expect("a visible emitter with a pool makes a record")
450    }
451
452    // The uniform is the record's static fields plus the three the runtime
453    // carries per dispatch, so a field that stopped being forwarded shows up
454    // as a kernel reading a stale or zeroed value.
455    #[test]
456    fn the_per_frame_uniform_carries_the_records_fields() {
457        let record = one_record(ParticleEmitter::default());
458        let params = record.params(0.016, 5, 42);
459
460        assert_eq!(params.position, record.position);
461        assert_eq!(params.direction, record.direction);
462        assert_eq!(params.spread_cos, record.spread_cos);
463        assert_eq!(params.speed_min, record.speed_min);
464        assert_eq!(params.speed_max, record.speed_max);
465        assert_eq!(params.gravity, record.gravity);
466        assert_eq!(params.color_start, record.color_start);
467        assert_eq!(params.color_end, record.color_end);
468        assert_eq!(params.lifetime_min, record.lifetime_min);
469        assert_eq!(params.lifetime_max, record.lifetime_max);
470        assert_eq!(params.size_start, record.size_start);
471        assert_eq!(params.size_end, record.size_end);
472        assert_eq!(params.max_particles, record.max_particles);
473
474        assert_eq!(params.dt, 0.016);
475        assert_eq!(params.spawn_budget, 5);
476        assert_eq!(params.random_seed, 42);
477    }
478
479    // A clock that went backwards between dispatches integrates as a stopped
480    // one, rather than pulling every live particle back along its velocity.
481    #[test]
482    fn a_backwards_step_integrates_as_a_stopped_one() {
483        let record = one_record(ParticleEmitter::default());
484        assert_eq!(record.params(-1.0, 0, 0).dt, 0.0);
485    }
486
487    // A non-finite authored colour would propagate NaN through the gradient
488    // the kernel lerps, so it reads as zero instead.
489    #[test]
490    fn a_non_finite_colour_channel_reads_as_zero() {
491        assert_eq!(
492            sanitised_color([f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 1.0]),
493            [0.0, 0.0, 0.0, 1.0]
494        );
495        assert_eq!(
496            sanitised_color([0.25, 0.5, 0.75, 1.0]),
497            [0.25, 0.5, 0.75, 1.0]
498        );
499    }
500}