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