1use crate::components::ParticleEmitter;
8use crate::render_types::ParticleParams;
9use alloc::vec::Vec;
10use concinnity_core::math::{cos, floor, sqrt};
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 tracing::error!(
150 "GraphicsSystem: ParticleEmitter {} references out-of-range texture handle {} (only {} textures)",
151 e.asset_id,
152 handle.index(),
153 texture_count
154 );
155 continue;
156 }
157 slot
158 }
159 };
160 let direction = normalise_direction(e.direction);
161 let spread_cos = cos(e.spread_deg.clamp(0.0, 180.0).to_radians());
162 let lifetime_min = e.lifetime_min.max(MIN_LIFETIME);
163 let lifetime_max = e.lifetime_max.max(lifetime_min);
164 let speed_min = e.speed_min.max(0.0);
165 let speed_max = e.speed_max.max(speed_min);
166 out.push(ParticleEmitterRecord {
167 texture_slot: slot,
168 position: e.position,
169 direction,
170 spread_cos,
171 speed_min,
172 speed_max,
173 lifetime_min,
174 lifetime_max,
175 gravity: e.gravity,
176 spawn_rate: e.spawn_rate.max(0.0),
177 max_particles,
178 size_start: e.size_start.max(0.0),
179 size_end: e.size_end.max(0.0),
180 color_start: sanitised_color(e.color_start),
181 color_end: sanitised_color(e.color_end),
182 });
183 }
184 out
185}
186
187fn normalise_direction(d: [f32; 3]) -> [f32; 3] {
188 let len = sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
189 if !len.is_finite() || len < 1e-6 {
190 [0.0, 1.0, 0.0]
194 } else {
195 [d[0] / len, d[1] / len, d[2] / len]
196 }
197}
198
199fn sanitised_color(c: [f32; 4]) -> [f32; 4] {
200 let mut out = c;
201 for x in out.iter_mut() {
202 if !x.is_finite() {
203 *x = 0.0;
204 }
205 }
206 out
207}
208
209#[derive(Debug, Clone, Copy, Default)]
214pub struct ParticleSpawnState {
215 pub accumulator: f32,
218}
219
220impl ParticleSpawnState {
221 pub fn take_budget(&mut self, dt: f32, spawn_rate: f32, max_particles: u32) -> u32 {
224 if spawn_rate <= 0.0 || dt <= 0.0 || !dt.is_finite() {
225 return 0;
226 }
227 self.accumulator += spawn_rate * dt;
228 let max_per_frame = max_particles as f32;
232 if self.accumulator > max_per_frame {
233 self.accumulator = max_per_frame;
234 }
235 let whole = floor(self.accumulator) as u32;
236 self.accumulator -= whole as f32;
237 whole
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use crate::components::ParticleEmitter;
245
246 #[test]
247 fn invisible_emitter_is_skipped() {
248 let e = ParticleEmitter {
249 visible: false,
250 ..Default::default()
251 };
252 assert!(build_particle_records(&[&e], 0).is_empty());
253 }
254
255 #[test]
256 fn out_of_range_texture_handle_drops_emitter() {
257 let e = ParticleEmitter {
258 texture: Some(crate::ecs::TextureHandle(999)),
259 ..Default::default()
260 };
261 assert!(build_particle_records(&[&e], 0).is_empty());
262 }
263
264 #[test]
265 fn emitter_texture_handle_is_used_directly_as_the_slot() {
266 let e = ParticleEmitter {
267 texture: Some(crate::ecs::TextureHandle(2)),
268 ..Default::default()
269 };
270 let recs = build_particle_records(&[&e], 5);
271 assert_eq!(recs.len(), 1);
272 assert_eq!(recs[0].texture_slot, 2);
273 }
274
275 #[test]
276 fn emitter_without_texture_uses_fallback_slot() {
277 let e = ParticleEmitter::default();
278 let recs = build_particle_records(&[&e], 0);
279 assert_eq!(recs.len(), 1);
280 assert_eq!(recs[0].texture_slot, 0);
281 }
282
283 #[test]
284 fn build_normalises_direction() {
285 let e = ParticleEmitter {
286 direction: [0.0, 5.0, 0.0],
287 ..Default::default()
288 };
289 let recs = build_particle_records(&[&e], 0);
290 let d = recs[0].direction;
291 let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
292 assert!((len - 1.0).abs() < 1e-5);
293 }
294
295 #[test]
296 fn build_falls_back_for_zero_direction() {
297 let e = ParticleEmitter {
298 direction: [0.0, 0.0, 0.0],
299 ..Default::default()
300 };
301 let recs = build_particle_records(&[&e], 0);
302 assert_eq!(recs[0].direction, [0.0, 1.0, 0.0]);
303 }
304
305 #[test]
306 fn build_clamps_max_particles_to_engine_cap() {
307 let e = ParticleEmitter {
308 max_particles: u32::MAX,
309 ..Default::default()
310 };
311 let recs = build_particle_records(&[&e], 0);
312 assert_eq!(recs[0].max_particles, MAX_PARTICLES_PER_EMITTER);
313 }
314
315 #[test]
316 fn build_precomputes_spread_cosine() {
317 let e = ParticleEmitter {
318 spread_deg: 0.0,
319 ..Default::default()
320 };
321 let recs = build_particle_records(&[&e], 0);
322 assert!((recs[0].spread_cos - 1.0).abs() < 1e-6);
323 }
324
325 #[test]
326 fn build_lifts_lifetime_max_to_min() {
327 let e = ParticleEmitter {
328 lifetime_min: 3.0,
329 lifetime_max: 0.1,
330 ..Default::default()
331 };
332 let recs = build_particle_records(&[&e], 0);
333 assert!(recs[0].lifetime_max >= recs[0].lifetime_min);
334 }
335
336 #[test]
337 fn spawn_state_emits_integer_budget() {
338 let mut s = ParticleSpawnState::default();
339 let b = s.take_budget(0.5, 10.0, 100);
340 assert_eq!(b, 5);
341 let b = s.take_budget(0.5, 10.0, 100);
342 assert_eq!(b, 5);
343 }
344
345 #[test]
346 fn spawn_state_carries_fraction_across_frames() {
347 let mut s = ParticleSpawnState::default();
348 let dt = 1.0;
350 let rate = 1.5;
351 let cap = 100;
352 let mut total = 0;
353 for _ in 0..4 {
354 total += s.take_budget(dt, rate, cap);
355 }
356 assert_eq!(total, 6);
357 }
358
359 #[test]
360 fn spawn_state_zero_rate_returns_zero() {
361 let mut s = ParticleSpawnState::default();
362 assert_eq!(s.take_budget(1.0, 0.0, 100), 0);
363 assert_eq!(s.accumulator, 0.0);
364 }
365
366 #[test]
367 fn spawn_state_caps_at_pool_capacity() {
368 let mut s = ParticleSpawnState::default();
369 let b = s.take_budget(1.0, 1000.0, 10);
372 assert!(b <= 10);
373 }
374
375 fn make_record(position: [f32; 3]) -> ParticleEmitterRecord {
376 ParticleEmitterRecord {
377 texture_slot: 0,
378 position,
379 direction: [0.0, 1.0, 0.0],
380 spread_cos: 1.0,
381 speed_min: 1.0,
382 speed_max: 2.0,
383 lifetime_min: 1.0,
384 lifetime_max: 2.0,
385 gravity: [0.0, 0.0, 0.0],
386 spawn_rate: 32.0,
387 max_particles: 64,
388 size_start: 0.1,
389 size_end: 0.1,
390 color_start: [1.0; 4],
391 color_end: [1.0; 4],
392 }
393 }
394
395 #[test]
396 fn aabb_centres_on_emission_origin() {
397 let r = make_record([3.0, 4.0, -5.0]);
398 let (mn, mx) = r.aabb();
399 let cx = 0.5 * (mn[0] + mx[0]);
400 let cy = 0.5 * (mn[1] + mx[1]);
401 let cz = 0.5 * (mn[2] + mx[2]);
402 assert!((cx - 3.0).abs() < 1e-5);
403 assert!((cy - 4.0).abs() < 1e-5);
404 assert!((cz + 5.0).abs() < 1e-5);
405 }
406
407 #[test]
408 fn aabb_radius_covers_speed_reach() {
409 let mut r = make_record([0.0; 3]);
412 r.size_start = 0.0;
413 r.size_end = 0.0;
414 r.gravity = [0.0; 3];
415 let (mn, mx) = r.aabb();
416 let radius = 0.5 * (mx[0] - mn[0]);
418 let expected = r.speed_max * r.lifetime_max; assert!((radius - expected).abs() < 1e-5);
420 }
421
422 #[test]
423 fn aabb_includes_gravity_drift() {
424 let mut r = make_record([0.0; 3]);
427 r.size_start = 0.0;
428 r.size_end = 0.0;
429 r.gravity = [0.0, -10.0, 0.0];
430 let (mn, mx) = r.aabb();
431 let radius = 0.5 * (mx[0] - mn[0]);
432 let expected = r.speed_max * r.lifetime_max + 0.5 * 10.0 * r.lifetime_max.powi(2);
433 assert!((radius - expected).abs() < 1e-4);
434 }
435
436 #[test]
437 fn aabb_includes_billboard_size() {
438 let mut r = make_record([0.0; 3]);
441 r.speed_min = 0.0;
442 r.speed_max = 0.0;
443 r.gravity = [0.0; 3];
444 r.size_start = 1.0;
445 r.size_end = 1.0;
446 let (mn, mx) = r.aabb();
447 let radius = 0.5 * (mx[0] - mn[0]);
448 let expected = 0.5 * 1.0 * core::f32::consts::SQRT_2;
449 assert!((radius - expected).abs() < 1e-5);
450 }
451}