Skip to main content

concinnity_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::render_types::ParticleParams;
9use alloc::vec::Vec;
10use concinnity_core::math::{cos, floor, sqrt};
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                    tracing::error!(
150                        "GraphicsSystem: ParticleEmitter {} references out-of-range texture handle {} (only {} textures)",
151                        e.asset_id,
152                        handle.index(),
153                        texture_count
154                    );
155                    continue;
156                }
157                slot
158            }
159        };
160        let direction = normalise_direction(e.direction);
161        let spread_cos = cos(e.spread_deg.clamp(0.0, 180.0).to_radians());
162        let lifetime_min = e.lifetime_min.max(MIN_LIFETIME);
163        let lifetime_max = e.lifetime_max.max(lifetime_min);
164        let speed_min = e.speed_min.max(0.0);
165        let speed_max = e.speed_max.max(speed_min);
166        out.push(ParticleEmitterRecord {
167            texture_slot: slot,
168            position: e.position,
169            direction,
170            spread_cos,
171            speed_min,
172            speed_max,
173            lifetime_min,
174            lifetime_max,
175            gravity: e.gravity,
176            spawn_rate: e.spawn_rate.max(0.0),
177            max_particles,
178            size_start: e.size_start.max(0.0),
179            size_end: e.size_end.max(0.0),
180            color_start: sanitised_color(e.color_start),
181            color_end: sanitised_color(e.color_end),
182        });
183    }
184    out
185}
186
187fn normalise_direction(d: [f32; 3]) -> [f32; 3] {
188    let len = sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
189    if !len.is_finite() || len < 1e-6 {
190        // A zero / non-finite direction falls back to world-up so the cone
191        // still has a well-defined axis. The asset-side default is `[0, 1, 0]`,
192        // so this only matters for hand-built records or pathological JSON.
193        [0.0, 1.0, 0.0]
194    } else {
195        [d[0] / len, d[1] / len, d[2] / len]
196    }
197}
198
199fn sanitised_color(c: [f32; 4]) -> [f32; 4] {
200    let mut out = c;
201    for x in out.iter_mut() {
202        if !x.is_finite() {
203            *x = 0.0;
204        }
205    }
206    out
207}
208
209/// Per-emitter spawn accumulator. The runtime keeps one of these per record
210/// and feeds the integer overflow into the compute kernel as `spawn_budget`
211/// each frame. Fractional carry-over keeps low spawn rates honest even when
212/// the frame-time is below the per-particle interval.
213#[derive(Debug, Clone, Copy, Default)]
214pub struct ParticleSpawnState {
215    /// Fractional particles owed by this emitter, carried forward across
216    /// frames. Cleared by `take_budget` after harvesting the integer part.
217    pub accumulator: f32,
218}
219
220impl ParticleSpawnState {
221    /// Add this frame's spawn allotment and pop off the integer part. Returns
222    /// `0` when the emitter is paused (`spawn_rate <= 0`).
223    pub fn take_budget(&mut self, dt: f32, spawn_rate: f32, max_particles: u32) -> u32 {
224        if spawn_rate <= 0.0 || dt <= 0.0 || !dt.is_finite() {
225            return 0;
226        }
227        self.accumulator += spawn_rate * dt;
228        // A pool of `max_particles` slots cannot absorb more than that many
229        // spawns in a single dispatch; extra budget would just churn the RNG
230        // for nothing.
231        let max_per_frame = max_particles as f32;
232        if self.accumulator > max_per_frame {
233            self.accumulator = max_per_frame;
234        }
235        let whole = floor(self.accumulator) as u32;
236        self.accumulator -= whole as f32;
237        whole
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::components::ParticleEmitter;
245
246    #[test]
247    fn invisible_emitter_is_skipped() {
248        let e = ParticleEmitter {
249            visible: false,
250            ..Default::default()
251        };
252        assert!(build_particle_records(&[&e], 0).is_empty());
253    }
254
255    #[test]
256    fn out_of_range_texture_handle_drops_emitter() {
257        let e = ParticleEmitter {
258            texture: Some(crate::ecs::TextureHandle(999)),
259            ..Default::default()
260        };
261        assert!(build_particle_records(&[&e], 0).is_empty());
262    }
263
264    #[test]
265    fn emitter_texture_handle_is_used_directly_as_the_slot() {
266        let e = ParticleEmitter {
267            texture: Some(crate::ecs::TextureHandle(2)),
268            ..Default::default()
269        };
270        let recs = build_particle_records(&[&e], 5);
271        assert_eq!(recs.len(), 1);
272        assert_eq!(recs[0].texture_slot, 2);
273    }
274
275    #[test]
276    fn emitter_without_texture_uses_fallback_slot() {
277        let e = ParticleEmitter::default();
278        let recs = build_particle_records(&[&e], 0);
279        assert_eq!(recs.len(), 1);
280        assert_eq!(recs[0].texture_slot, 0);
281    }
282
283    #[test]
284    fn build_normalises_direction() {
285        let e = ParticleEmitter {
286            direction: [0.0, 5.0, 0.0],
287            ..Default::default()
288        };
289        let recs = build_particle_records(&[&e], 0);
290        let d = recs[0].direction;
291        let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
292        assert!((len - 1.0).abs() < 1e-5);
293    }
294
295    #[test]
296    fn build_falls_back_for_zero_direction() {
297        let e = ParticleEmitter {
298            direction: [0.0, 0.0, 0.0],
299            ..Default::default()
300        };
301        let recs = build_particle_records(&[&e], 0);
302        assert_eq!(recs[0].direction, [0.0, 1.0, 0.0]);
303    }
304
305    #[test]
306    fn build_clamps_max_particles_to_engine_cap() {
307        let e = ParticleEmitter {
308            max_particles: u32::MAX,
309            ..Default::default()
310        };
311        let recs = build_particle_records(&[&e], 0);
312        assert_eq!(recs[0].max_particles, MAX_PARTICLES_PER_EMITTER);
313    }
314
315    #[test]
316    fn build_precomputes_spread_cosine() {
317        let e = ParticleEmitter {
318            spread_deg: 0.0,
319            ..Default::default()
320        };
321        let recs = build_particle_records(&[&e], 0);
322        assert!((recs[0].spread_cos - 1.0).abs() < 1e-6);
323    }
324
325    #[test]
326    fn build_lifts_lifetime_max_to_min() {
327        let e = ParticleEmitter {
328            lifetime_min: 3.0,
329            lifetime_max: 0.1,
330            ..Default::default()
331        };
332        let recs = build_particle_records(&[&e], 0);
333        assert!(recs[0].lifetime_max >= recs[0].lifetime_min);
334    }
335
336    #[test]
337    fn spawn_state_emits_integer_budget() {
338        let mut s = ParticleSpawnState::default();
339        let b = s.take_budget(0.5, 10.0, 100);
340        assert_eq!(b, 5);
341        let b = s.take_budget(0.5, 10.0, 100);
342        assert_eq!(b, 5);
343    }
344
345    #[test]
346    fn spawn_state_carries_fraction_across_frames() {
347        let mut s = ParticleSpawnState::default();
348        // 1.5 particles/frame; should alternate 1, 2, 1, 2 in the budget.
349        let dt = 1.0;
350        let rate = 1.5;
351        let cap = 100;
352        let mut total = 0;
353        for _ in 0..4 {
354            total += s.take_budget(dt, rate, cap);
355        }
356        assert_eq!(total, 6);
357    }
358
359    #[test]
360    fn spawn_state_zero_rate_returns_zero() {
361        let mut s = ParticleSpawnState::default();
362        assert_eq!(s.take_budget(1.0, 0.0, 100), 0);
363        assert_eq!(s.accumulator, 0.0);
364    }
365
366    #[test]
367    fn spawn_state_caps_at_pool_capacity() {
368        let mut s = ParticleSpawnState::default();
369        // 1000 particles/sec, but pool only holds 10 slots: the kernel can
370        // never absorb more than `max_particles` in a single dispatch.
371        let b = s.take_budget(1.0, 1000.0, 10);
372        assert!(b <= 10);
373    }
374
375    fn make_record(position: [f32; 3]) -> ParticleEmitterRecord {
376        ParticleEmitterRecord {
377            texture_slot: 0,
378            position,
379            direction: [0.0, 1.0, 0.0],
380            spread_cos: 1.0,
381            speed_min: 1.0,
382            speed_max: 2.0,
383            lifetime_min: 1.0,
384            lifetime_max: 2.0,
385            gravity: [0.0, 0.0, 0.0],
386            spawn_rate: 32.0,
387            max_particles: 64,
388            size_start: 0.1,
389            size_end: 0.1,
390            color_start: [1.0; 4],
391            color_end: [1.0; 4],
392        }
393    }
394
395    #[test]
396    fn aabb_centres_on_emission_origin() {
397        let r = make_record([3.0, 4.0, -5.0]);
398        let (mn, mx) = r.aabb();
399        let cx = 0.5 * (mn[0] + mx[0]);
400        let cy = 0.5 * (mn[1] + mx[1]);
401        let cz = 0.5 * (mn[2] + mx[2]);
402        assert!((cx - 3.0).abs() < 1e-5);
403        assert!((cy - 4.0).abs() < 1e-5);
404        assert!((cz + 5.0).abs() < 1e-5);
405    }
406
407    #[test]
408    fn aabb_radius_covers_speed_reach() {
409        // No gravity, no billboard size: radius should equal speed_max *
410        // lifetime_max = 2 * 2 = 4 (plus a small billboard term ~0.07).
411        let mut r = make_record([0.0; 3]);
412        r.size_start = 0.0;
413        r.size_end = 0.0;
414        r.gravity = [0.0; 3];
415        let (mn, mx) = r.aabb();
416        // Half-extent on each axis equals the radius.
417        let radius = 0.5 * (mx[0] - mn[0]);
418        let expected = r.speed_max * r.lifetime_max; // = 4.0
419        assert!((radius - expected).abs() < 1e-5);
420    }
421
422    #[test]
423    fn aabb_includes_gravity_drift() {
424        // Gravity 10 m/s² for 2 s integrates to 0.5 * 10 * 4 = 20 m drift,
425        // dominates over the 4 m speed reach. Total radius ≈ 24.
426        let mut r = make_record([0.0; 3]);
427        r.size_start = 0.0;
428        r.size_end = 0.0;
429        r.gravity = [0.0, -10.0, 0.0];
430        let (mn, mx) = r.aabb();
431        let radius = 0.5 * (mx[0] - mn[0]);
432        let expected = r.speed_max * r.lifetime_max + 0.5 * 10.0 * r.lifetime_max.powi(2);
433        assert!((radius - expected).abs() < 1e-4);
434    }
435
436    #[test]
437    fn aabb_includes_billboard_size() {
438        // No motion, no gravity: only the billboard half-diagonal matters.
439        // size = 1.0 → half-diag = sqrt(2)/2 ≈ 0.707.
440        let mut r = make_record([0.0; 3]);
441        r.speed_min = 0.0;
442        r.speed_max = 0.0;
443        r.gravity = [0.0; 3];
444        r.size_start = 1.0;
445        r.size_end = 1.0;
446        let (mn, mx) = r.aabb();
447        let radius = 0.5 * (mx[0] - mn[0]);
448        let expected = 0.5 * 1.0 * core::f32::consts::SQRT_2;
449        assert!((radius - expected).abs() < 1e-5);
450    }
451}