Skip to main content

concinnity_asset/
particle_emitter.rs

1// Billboard particle-emitter schema.
2
3use crate::{AssetId, TextureHandle, de_opt_texture_handle};
4
5/// A billboard particle emitter.
6///
7/// Particles spawn from `position` in a cone centred on `direction` (half-angle
8/// `spread_deg`), with a speed drawn from `[speed_min, speed_max]` and a
9/// lifetime from `[lifetime_min, lifetime_max]`. Over each particle's life its
10/// size interpolates from `size_start` to `size_end` and its colour from
11/// `color_start` to `color_end`. Each particle is drawn as a camera-facing quad
12/// textured by `texture`.
13///
14/// The pool holds `max_particles` particles; new ones spawn at `spawn_rate` per
15/// second, reusing slots as old particles die.
16///
17/// ```rust
18/// # use concinnity_asset::ParticleEmitter;
19/// ParticleEmitter {
20///     position: [0.0, 1.0, 0.0],
21///     direction: [0.0, 1.0, 0.0],
22///     spread_deg: 25.0,
23///     speed_min: 2.0,
24///     speed_max: 5.0,
25///     lifetime_min: 0.5,
26///     ..Default::default()
27/// };
28/// ```
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30#[serde(default)]
31pub struct ParticleEmitter {
32    /// Asset identity; injected via `inject_name`. Not part of `args`.
33    #[serde(skip)]
34    pub asset_id: AssetId,
35    /// [Texture](#texture) sampled per particle. `None` uses a white fallback so
36    /// the colour gradient still shows.
37    #[serde(deserialize_with = "de_opt_texture_handle")]
38    pub texture: Option<TextureHandle>,
39    /// World-space spawn origin.
40    pub position: [f32; 3],
41    /// Mean emission direction. The cone of width `spread_deg` is centred on
42    /// this vector. Normalised on load; a zero vector falls back to `[0, 1, 0]`.
43    pub direction: [f32; 3],
44    /// Cone half-angle in degrees around `direction`. `0` emits a straight
45    /// jet; `180` emits in all directions.
46    pub spread_deg: f32,
47    /// Lower bound on initial speed (m/s). Floored at 0.
48    pub speed_min: f32,
49    /// Upper bound on initial speed (m/s). Lifted to at least `speed_min`.
50    pub speed_max: f32,
51    /// Lower bound on particle lifetime (seconds). Must be > 0.
52    pub lifetime_min: f32,
53    /// Upper bound on particle lifetime (seconds). Lifted to at least
54    /// `lifetime_min`.
55    pub lifetime_max: f32,
56    /// Constant acceleration applied to each particle, in world units per second
57    /// squared.
58    pub gravity: [f32; 3],
59    /// Particles spawned per second. `0` produces a one-shot burst that then
60    /// empties as particles age out.
61    pub spawn_rate: f32,
62    /// Maximum number of particles alive at once. Clamped to `[1, 65536]`.
63    pub max_particles: u32,
64    /// Billboard side length at spawn, in world units.
65    pub size_start: f32,
66    /// Billboard side length at death, in world units.
67    pub size_end: f32,
68    /// Linear-space RGBA multiplier applied to the texture at spawn.
69    pub color_start: [f32; 4],
70    /// Linear-space RGBA multiplier applied to the texture at death.
71    pub color_end: [f32; 4],
72    /// When false the emitter is skipped each frame.
73    pub visible: bool,
74}
75
76impl Default for ParticleEmitter {
77    fn default() -> Self {
78        Self {
79            asset_id: AssetId::default(),
80            texture: None,
81            position: [0.0, 0.0, 0.0],
82            direction: [0.0, 1.0, 0.0],
83            spread_deg: 15.0,
84            speed_min: 1.0,
85            speed_max: 2.0,
86            lifetime_min: 1.0,
87            lifetime_max: 2.0,
88            gravity: [0.0, -9.8, 0.0],
89            spawn_rate: 32.0,
90            max_particles: 256,
91            size_start: 0.2,
92            size_end: 0.05,
93            color_start: [1.0, 1.0, 1.0, 1.0],
94            color_end: [1.0, 1.0, 1.0, 0.0],
95            visible: true,
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn a_blank_emitter_sprays_upward_and_fades_out() {
106        let e = ParticleEmitter::default();
107        assert_eq!(e.direction, [0.0, 1.0, 0.0]);
108        assert_eq!(e.gravity, [0.0, -9.8, 0.0]);
109        assert_eq!(e.spread_deg, 15.0);
110        assert!(e.speed_min <= e.speed_max);
111        assert!(e.lifetime_min <= e.lifetime_max);
112        // Particles shrink and fade over their life rather than popping out.
113        assert!(e.size_end < e.size_start);
114        assert_eq!(e.color_start[3], 1.0);
115        assert_eq!(e.color_end[3], 0.0);
116        assert_eq!(e.spawn_rate, 32.0);
117        assert_eq!(e.max_particles, 256);
118        assert!(e.visible);
119        assert!(e.texture.is_none());
120    }
121
122    #[test]
123    fn an_authored_emitter_parses_and_round_trips_through_postcard() {
124        crate::test_support::install_resolvers();
125        let e: ParticleEmitter = serde_json::from_str(
126            r#"{"texture":"tex_spark","position":[0,1,0],"direction":[0,0,1],"spread_deg":45,
127                "speed_min":2,"speed_max":6,"lifetime_min":0.5,"lifetime_max":1.5,
128                "gravity":[0,0,0],"spawn_rate":120,"max_particles":2048,
129                "size_start":0.05,"size_end":0.2,"color_start":[1,0.6,0.2,1],
130                "color_end":[1,0,0,0],"visible":false}"#,
131        )
132        .unwrap();
133        assert_eq!(e.texture, Some(TextureHandle(9)));
134        assert!(!e.visible);
135        // A spark grows as it cools, so size_end above size_start is allowed.
136        assert!(e.size_end > e.size_start);
137
138        let bytes = postcard::to_allocvec(&e).unwrap();
139        let back: ParticleEmitter = postcard::from_bytes(&bytes).unwrap();
140        assert_eq!(back.texture, Some(TextureHandle(9)));
141        assert_eq!(back.direction, [0.0, 0.0, 1.0]);
142        assert_eq!(back.gravity, [0.0, 0.0, 0.0]);
143        assert_eq!(back.max_particles, 2048);
144        assert_eq!(back.color_start, [1.0, 0.6, 0.2, 1.0]);
145        assert_eq!(back.asset_id, AssetId::default());
146    }
147}