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}