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}