Skip to main content

concinnity_core/components/
particle_emitter.rs

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