1use crate::components::ParticleEmitter;
8use crate::gfx::render_types::ParticleParams;
9use crate::math::{cos, floor, sqrt};
10use alloc::vec::Vec;
11
12pub const MAX_PARTICLES_PER_EMITTER: u32 = 65_536;
17
18const MIN_LIFETIME: f32 = 0.001;
21
22#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct ParticleEmitterRecord {
27 pub texture_slot: usize,
31 pub position: [f32; 3],
33 pub direction: [f32; 3],
37 pub spread_cos: f32,
40 pub speed_min: f32,
42 pub speed_max: f32,
44 pub lifetime_min: f32,
46 pub lifetime_max: f32,
48 pub gravity: [f32; 3],
50 pub spawn_rate: f32,
52 pub max_particles: u32,
54 pub size_start: f32,
56 pub size_end: f32,
58 pub color_start: [f32; 4],
60 pub color_end: [f32; 4],
62}
63
64impl ParticleEmitterRecord {
65 pub fn aabb(&self) -> ([f32; 3], [f32; 3]) {
80 let speed_reach = self.speed_max * self.lifetime_max;
81 let gx = self.gravity[0];
82 let gy = self.gravity[1];
83 let gz = self.gravity[2];
84 let g_mag = sqrt(gx * gx + gy * gy + gz * gz);
85 let g_drift = 0.5 * g_mag * self.lifetime_max * self.lifetime_max;
86 let max_size = self.size_start.max(self.size_end);
87 let billboard_radius = 0.5 * max_size * core::f32::consts::SQRT_2;
90 let r = speed_reach + g_drift + billboard_radius;
91 let c = self.position;
92 (
93 [c[0] - r, c[1] - r, c[2] - r],
94 [c[0] + r, c[1] + r, c[2] + r],
95 )
96 }
97
98 pub fn params(&self, dt: f32, spawn_budget: u32, random_seed: u32) -> ParticleParams {
107 ParticleParams {
108 position: self.position,
109 spread_cos: self.spread_cos,
110 direction: self.direction,
111 speed_min: self.speed_min,
112 gravity: self.gravity,
113 speed_max: self.speed_max,
114 color_start: self.color_start,
115 color_end: self.color_end,
116 lifetime_min: self.lifetime_min,
117 lifetime_max: self.lifetime_max,
118 size_start: self.size_start,
119 size_end: self.size_end,
120 dt: dt.max(0.0),
121 spawn_budget,
122 random_seed,
123 max_particles: self.max_particles,
124 }
125 }
126}
127
128pub fn build_particle_records(
135 emitters: &[&ParticleEmitter],
136 texture_count: usize,
137) -> Vec<ParticleEmitterRecord> {
138 let mut out = Vec::new();
139 for e in emitters {
140 if !e.visible {
141 continue;
142 }
143 let max_particles = e.max_particles.clamp(1, MAX_PARTICLES_PER_EMITTER);
144 let slot = match e.texture {
145 None => 0,
146 Some(handle) => {
147 let slot = handle.index();
148 if slot >= texture_count {
149 continue;
150 }
151 slot
152 }
153 };
154 let direction = normalise_direction(e.direction);
155 let spread_cos = cos(e.spread_deg.clamp(0.0, 180.0).to_radians());
156 let lifetime_min = e.lifetime_min.max(MIN_LIFETIME);
157 let lifetime_max = e.lifetime_max.max(lifetime_min);
158 let speed_min = e.speed_min.max(0.0);
159 let speed_max = e.speed_max.max(speed_min);
160 out.push(ParticleEmitterRecord {
161 texture_slot: slot,
162 position: e.position,
163 direction,
164 spread_cos,
165 speed_min,
166 speed_max,
167 lifetime_min,
168 lifetime_max,
169 gravity: e.gravity,
170 spawn_rate: e.spawn_rate.max(0.0),
171 max_particles,
172 size_start: e.size_start.max(0.0),
173 size_end: e.size_end.max(0.0),
174 color_start: sanitised_color(e.color_start),
175 color_end: sanitised_color(e.color_end),
176 });
177 }
178 out
179}
180
181fn normalise_direction(d: [f32; 3]) -> [f32; 3] {
182 let len = sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
183 if !len.is_finite() || len < 1e-6 {
184 [0.0, 1.0, 0.0]
188 } else {
189 [d[0] / len, d[1] / len, d[2] / len]
190 }
191}
192
193fn sanitised_color(c: [f32; 4]) -> [f32; 4] {
194 let mut out = c;
195 for x in out.iter_mut() {
196 if !x.is_finite() {
197 *x = 0.0;
198 }
199 }
200 out
201}
202
203#[derive(Debug, Clone, Copy, Default)]
208pub struct ParticleSpawnState {
209 pub accumulator: f32,
212}
213
214impl ParticleSpawnState {
215 pub fn take_budget(&mut self, dt: f32, spawn_rate: f32, max_particles: u32) -> u32 {
218 if spawn_rate <= 0.0 || dt <= 0.0 || !dt.is_finite() {
219 return 0;
220 }
221 self.accumulator += spawn_rate * dt;
222 let max_per_frame = max_particles as f32;
226 if self.accumulator > max_per_frame {
227 self.accumulator = max_per_frame;
228 }
229 let whole = floor(self.accumulator) as u32;
230 self.accumulator -= whole as f32;
231 whole
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::components::ParticleEmitter;
239
240 #[test]
241 fn invisible_emitter_is_skipped() {
242 let e = ParticleEmitter {
243 visible: false,
244 ..Default::default()
245 };
246 assert!(build_particle_records(&[&e], 0).is_empty());
247 }
248
249 #[test]
250 fn out_of_range_texture_handle_drops_emitter() {
251 let e = ParticleEmitter {
252 texture: Some(crate::ecs::TextureHandle(999)),
253 ..Default::default()
254 };
255 assert!(build_particle_records(&[&e], 0).is_empty());
256 }
257
258 #[test]
259 fn emitter_texture_handle_is_used_directly_as_the_slot() {
260 let e = ParticleEmitter {
261 texture: Some(crate::ecs::TextureHandle(2)),
262 ..Default::default()
263 };
264 let recs = build_particle_records(&[&e], 5);
265 assert_eq!(recs.len(), 1);
266 assert_eq!(recs[0].texture_slot, 2);
267 }
268
269 #[test]
270 fn emitter_without_texture_uses_fallback_slot() {
271 let e = ParticleEmitter::default();
272 let recs = build_particle_records(&[&e], 0);
273 assert_eq!(recs.len(), 1);
274 assert_eq!(recs[0].texture_slot, 0);
275 }
276
277 #[test]
278 fn build_normalises_direction() {
279 let e = ParticleEmitter {
280 direction: [0.0, 5.0, 0.0],
281 ..Default::default()
282 };
283 let recs = build_particle_records(&[&e], 0);
284 let d = recs[0].direction;
285 let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
286 assert!((len - 1.0).abs() < 1e-5);
287 }
288
289 #[test]
290 fn build_falls_back_for_zero_direction() {
291 let e = ParticleEmitter {
292 direction: [0.0, 0.0, 0.0],
293 ..Default::default()
294 };
295 let recs = build_particle_records(&[&e], 0);
296 assert_eq!(recs[0].direction, [0.0, 1.0, 0.0]);
297 }
298
299 #[test]
300 fn build_clamps_max_particles_to_engine_cap() {
301 let e = ParticleEmitter {
302 max_particles: u32::MAX,
303 ..Default::default()
304 };
305 let recs = build_particle_records(&[&e], 0);
306 assert_eq!(recs[0].max_particles, MAX_PARTICLES_PER_EMITTER);
307 }
308
309 #[test]
310 fn build_precomputes_spread_cosine() {
311 let e = ParticleEmitter {
312 spread_deg: 0.0,
313 ..Default::default()
314 };
315 let recs = build_particle_records(&[&e], 0);
316 assert!((recs[0].spread_cos - 1.0).abs() < 1e-6);
317 }
318
319 #[test]
320 fn build_lifts_lifetime_max_to_min() {
321 let e = ParticleEmitter {
322 lifetime_min: 3.0,
323 lifetime_max: 0.1,
324 ..Default::default()
325 };
326 let recs = build_particle_records(&[&e], 0);
327 assert!(recs[0].lifetime_max >= recs[0].lifetime_min);
328 }
329
330 #[test]
331 fn spawn_state_emits_integer_budget() {
332 let mut s = ParticleSpawnState::default();
333 let b = s.take_budget(0.5, 10.0, 100);
334 assert_eq!(b, 5);
335 let b = s.take_budget(0.5, 10.0, 100);
336 assert_eq!(b, 5);
337 }
338
339 #[test]
340 fn spawn_state_carries_fraction_across_frames() {
341 let mut s = ParticleSpawnState::default();
342 let dt = 1.0;
344 let rate = 1.5;
345 let cap = 100;
346 let mut total = 0;
347 for _ in 0..4 {
348 total += s.take_budget(dt, rate, cap);
349 }
350 assert_eq!(total, 6);
351 }
352
353 #[test]
354 fn spawn_state_zero_rate_returns_zero() {
355 let mut s = ParticleSpawnState::default();
356 assert_eq!(s.take_budget(1.0, 0.0, 100), 0);
357 assert_eq!(s.accumulator, 0.0);
358 }
359
360 #[test]
361 fn spawn_state_caps_at_pool_capacity() {
362 let mut s = ParticleSpawnState::default();
363 let b = s.take_budget(1.0, 1000.0, 10);
366 assert!(b <= 10);
367 }
368
369 fn make_record(position: [f32; 3]) -> ParticleEmitterRecord {
370 ParticleEmitterRecord {
371 texture_slot: 0,
372 position,
373 direction: [0.0, 1.0, 0.0],
374 spread_cos: 1.0,
375 speed_min: 1.0,
376 speed_max: 2.0,
377 lifetime_min: 1.0,
378 lifetime_max: 2.0,
379 gravity: [0.0, 0.0, 0.0],
380 spawn_rate: 32.0,
381 max_particles: 64,
382 size_start: 0.1,
383 size_end: 0.1,
384 color_start: [1.0; 4],
385 color_end: [1.0; 4],
386 }
387 }
388
389 #[test]
390 fn aabb_centres_on_emission_origin() {
391 let r = make_record([3.0, 4.0, -5.0]);
392 let (mn, mx) = r.aabb();
393 let cx = 0.5 * (mn[0] + mx[0]);
394 let cy = 0.5 * (mn[1] + mx[1]);
395 let cz = 0.5 * (mn[2] + mx[2]);
396 assert!((cx - 3.0).abs() < 1e-5);
397 assert!((cy - 4.0).abs() < 1e-5);
398 assert!((cz + 5.0).abs() < 1e-5);
399 }
400
401 #[test]
402 fn aabb_radius_covers_speed_reach() {
403 let mut r = make_record([0.0; 3]);
406 r.size_start = 0.0;
407 r.size_end = 0.0;
408 r.gravity = [0.0; 3];
409 let (mn, mx) = r.aabb();
410 let radius = 0.5 * (mx[0] - mn[0]);
412 let expected = r.speed_max * r.lifetime_max; assert!((radius - expected).abs() < 1e-5);
414 }
415
416 #[test]
417 fn aabb_includes_gravity_drift() {
418 let mut r = make_record([0.0; 3]);
421 r.size_start = 0.0;
422 r.size_end = 0.0;
423 r.gravity = [0.0, -10.0, 0.0];
424 let (mn, mx) = r.aabb();
425 let radius = 0.5 * (mx[0] - mn[0]);
426 let expected = r.speed_max * r.lifetime_max + 0.5 * 10.0 * r.lifetime_max.powi(2);
427 assert!((radius - expected).abs() < 1e-4);
428 }
429
430 #[test]
431 fn aabb_includes_billboard_size() {
432 let mut r = make_record([0.0; 3]);
435 r.speed_min = 0.0;
436 r.speed_max = 0.0;
437 r.gravity = [0.0; 3];
438 r.size_start = 1.0;
439 r.size_end = 1.0;
440 let (mn, mx) = r.aabb();
441 let radius = 0.5 * (mx[0] - mn[0]);
442 let expected = 0.5 * 1.0 * core::f32::consts::SQRT_2;
443 assert!((radius - expected).abs() < 1e-5);
444 }
445}