1#[derive(Debug, Clone, PartialEq)]
12pub struct GpuParticle {
13 pub position: [f64; 3],
15 pub velocity: [f64; 3],
17 pub lifetime: f64,
19 pub color: [f32; 4],
21}
22
23impl GpuParticle {
24 pub fn new(position: [f64; 3], velocity: [f64; 3], lifetime: f64, color: [f32; 4]) -> Self {
26 Self {
27 position,
28 velocity,
29 lifetime,
30 color,
31 }
32 }
33
34 pub fn is_alive(&self) -> bool {
36 self.lifetime > 0.0
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct EmitterConfig {
43 pub origin: [f64; 3],
45 pub initial_speed: f64,
47 pub spread_radians: f64,
49 pub particle_lifetime: f64,
51 pub color: [f32; 4],
53}
54
55impl Default for EmitterConfig {
56 fn default() -> Self {
57 Self {
58 origin: [0.0; 3],
59 initial_speed: 1.0,
60 spread_radians: 0.3,
61 particle_lifetime: 2.0,
62 color: [1.0, 1.0, 1.0, 1.0],
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct GpuParticleSystem {
70 pub config: EmitterConfig,
72 pub particles: Vec<GpuParticle>,
74 pub max_particles: usize,
76}
77
78impl GpuParticleSystem {
79 pub fn new(config: EmitterConfig, max_particles: usize) -> Self {
81 Self {
82 config,
83 particles: Vec::with_capacity(max_particles),
84 max_particles,
85 }
86 }
87
88 pub fn active_count(&self) -> usize {
90 self.particles.len()
91 }
92}
93
94pub fn gpu_emit_particles(system: &mut GpuParticleSystem, n: usize) {
101 let cfg = &system.config;
102 let slots = system.max_particles.saturating_sub(system.particles.len());
103 let to_spawn = n.min(slots);
104
105 for i in 0..to_spawn {
106 let angle = if to_spawn > 1 {
108 let t = i as f64 / (to_spawn - 1) as f64;
109 (t - 0.5) * 2.0 * cfg.spread_radians
110 } else {
111 0.0
112 };
113 let vx = angle.sin() * cfg.initial_speed;
114 let vz = angle.cos() * cfg.initial_speed;
115 let velocity = [vx, 0.0, vz];
116 system.particles.push(GpuParticle::new(
117 cfg.origin,
118 velocity,
119 cfg.particle_lifetime,
120 cfg.color,
121 ));
122 }
123}
124
125pub fn gpu_integrate_particles(system: &mut GpuParticleSystem, dt: f64) {
129 for p in &mut system.particles {
130 p.position[0] += p.velocity[0] * dt;
131 p.position[1] += p.velocity[1] * dt;
132 p.position[2] += p.velocity[2] * dt;
133 p.lifetime -= dt;
134 }
135}
136
137pub fn gpu_kill_dead_particles(system: &mut GpuParticleSystem) {
141 system.particles.retain(|p| p.is_alive());
142}
143
144pub fn gpu_sort_by_depth(system: &mut GpuParticleSystem, camera_dir: [f64; 3]) {
150 system.particles.sort_by(|a, b| {
151 let da = dot3(a.position, camera_dir);
152 let db = dot3(b.position, camera_dir);
153 db.partial_cmp(&da).unwrap_or(std::cmp::Ordering::Equal)
155 });
156}
157
158pub fn spawn_burst(system: &mut GpuParticleSystem, n: usize) {
162 gpu_emit_particles(system, n);
163}
164
165#[allow(dead_code)]
168fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
169 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
170}
171
172#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn default_system(max: usize) -> GpuParticleSystem {
179 GpuParticleSystem::new(EmitterConfig::default(), max)
180 }
181
182 #[test]
185 fn test_particle_is_alive_positive_lifetime() {
186 let p = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4]);
187 assert!(p.is_alive());
188 }
189
190 #[test]
191 fn test_particle_is_dead_zero_lifetime() {
192 let p = GpuParticle::new([0.0; 3], [0.0; 3], 0.0, [1.0; 4]);
193 assert!(!p.is_alive());
194 }
195
196 #[test]
197 fn test_particle_is_dead_negative_lifetime() {
198 let p = GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]);
199 assert!(!p.is_alive());
200 }
201
202 #[test]
203 fn test_particle_new_fields() {
204 let pos = [1.0, 2.0, 3.0];
205 let vel = [0.1, 0.2, 0.3];
206 let lt = 5.0;
207 let col = [0.5, 0.5, 0.5, 1.0];
208 let p = GpuParticle::new(pos, vel, lt, col);
209 assert_eq!(p.position, pos);
210 assert_eq!(p.velocity, vel);
211 assert!((p.lifetime - lt).abs() < 1e-12);
212 assert_eq!(p.color, col);
213 }
214
215 #[test]
218 fn test_emitter_default_speed_positive() {
219 let cfg = EmitterConfig::default();
220 assert!(cfg.initial_speed > 0.0);
221 }
222
223 #[test]
224 fn test_emitter_default_lifetime_positive() {
225 let cfg = EmitterConfig::default();
226 assert!(cfg.particle_lifetime > 0.0);
227 }
228
229 #[test]
232 fn test_system_starts_empty() {
233 let sys = default_system(100);
234 assert_eq!(sys.active_count(), 0);
235 }
236
237 #[test]
238 fn test_system_max_particles_stored() {
239 let sys = default_system(42);
240 assert_eq!(sys.max_particles, 42);
241 }
242
243 #[test]
246 fn test_emit_spawns_n_particles() {
247 let mut sys = default_system(100);
248 gpu_emit_particles(&mut sys, 10);
249 assert_eq!(sys.active_count(), 10);
250 }
251
252 #[test]
253 fn test_emit_respects_max_particles() {
254 let mut sys = default_system(5);
255 gpu_emit_particles(&mut sys, 100);
256 assert_eq!(sys.active_count(), 5);
257 }
258
259 #[test]
260 fn test_emit_zero_particles() {
261 let mut sys = default_system(100);
262 gpu_emit_particles(&mut sys, 0);
263 assert_eq!(sys.active_count(), 0);
264 }
265
266 #[test]
267 fn test_emit_single_particle_at_origin() {
268 let mut sys = default_system(10);
269 gpu_emit_particles(&mut sys, 1);
270 assert_eq!(sys.particles[0].position, [0.0; 3]);
271 }
272
273 #[test]
274 fn test_emit_particles_have_positive_lifetime() {
275 let mut sys = default_system(10);
276 gpu_emit_particles(&mut sys, 5);
277 for p in &sys.particles {
278 assert!(p.lifetime > 0.0);
279 }
280 }
281
282 #[test]
285 fn test_integrate_moves_position() {
286 let mut sys = default_system(10);
287 sys.particles
288 .push(GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 5.0, [1.0; 4]));
289 gpu_integrate_particles(&mut sys, 1.0);
290 assert!((sys.particles[0].position[0] - 1.0).abs() < 1e-12);
291 }
292
293 #[test]
294 fn test_integrate_decrements_lifetime() {
295 let mut sys = default_system(10);
296 sys.particles
297 .push(GpuParticle::new([0.0; 3], [0.0; 3], 3.0, [1.0; 4]));
298 gpu_integrate_particles(&mut sys, 1.0);
299 assert!((sys.particles[0].lifetime - 2.0).abs() < 1e-12);
300 }
301
302 #[test]
303 fn test_integrate_zero_dt_no_movement() {
304 let mut sys = default_system(10);
305 sys.particles.push(GpuParticle::new(
306 [1.0, 2.0, 3.0],
307 [5.0, 5.0, 5.0],
308 1.0,
309 [1.0; 4],
310 ));
311 gpu_integrate_particles(&mut sys, 0.0);
312 assert_eq!(sys.particles[0].position, [1.0, 2.0, 3.0]);
313 }
314
315 #[test]
316 fn test_integrate_multiple_steps() {
317 let mut sys = default_system(10);
318 sys.particles
319 .push(GpuParticle::new([0.0; 3], [2.0, 0.0, 0.0], 10.0, [1.0; 4]));
320 gpu_integrate_particles(&mut sys, 0.5);
321 gpu_integrate_particles(&mut sys, 0.5);
322 assert!((sys.particles[0].position[0] - 2.0).abs() < 1e-10);
323 }
324
325 #[test]
328 fn test_kill_removes_dead_particles() {
329 let mut sys = default_system(10);
330 sys.particles
331 .push(GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]));
332 sys.particles
333 .push(GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4]));
334 gpu_kill_dead_particles(&mut sys);
335 assert_eq!(sys.active_count(), 1);
336 assert!(sys.particles[0].is_alive());
337 }
338
339 #[test]
340 fn test_kill_all_dead() {
341 let mut sys = default_system(10);
342 for _ in 0..5 {
343 sys.particles
344 .push(GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]));
345 }
346 gpu_kill_dead_particles(&mut sys);
347 assert_eq!(sys.active_count(), 0);
348 }
349
350 #[test]
351 fn test_kill_none_dead() {
352 let mut sys = default_system(10);
353 gpu_emit_particles(&mut sys, 5);
354 gpu_kill_dead_particles(&mut sys);
355 assert_eq!(sys.active_count(), 5);
356 }
357
358 #[test]
359 fn test_integrate_then_kill() {
360 let mut sys = default_system(10);
361 sys.particles
362 .push(GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 0.5, [1.0; 4]));
363 gpu_integrate_particles(&mut sys, 1.0); gpu_kill_dead_particles(&mut sys);
365 assert_eq!(sys.active_count(), 0);
366 }
367
368 #[test]
371 fn test_sort_by_depth_back_to_front() {
372 let mut sys = default_system(10);
373 sys.particles
375 .push(GpuParticle::new([0.0, 0.0, 1.0], [0.0; 3], 1.0, [1.0; 4]));
376 sys.particles
377 .push(GpuParticle::new([0.0, 0.0, 5.0], [0.0; 3], 1.0, [1.0; 4]));
378 let cam = [0.0, 0.0, 1.0]; gpu_sort_by_depth(&mut sys, cam);
380 assert!((sys.particles[0].position[2] - 5.0).abs() < 1e-12);
382 }
383
384 #[test]
385 fn test_sort_by_depth_single_particle() {
386 let mut sys = default_system(10);
387 sys.particles
388 .push(GpuParticle::new([1.0, 2.0, 3.0], [0.0; 3], 1.0, [1.0; 4]));
389 gpu_sort_by_depth(&mut sys, [0.0, 0.0, 1.0]);
390 assert_eq!(sys.active_count(), 1);
391 }
392
393 #[test]
394 fn test_sort_by_depth_preserves_count() {
395 let mut sys = default_system(20);
396 gpu_emit_particles(&mut sys, 10);
397 gpu_sort_by_depth(&mut sys, [1.0, 0.0, 0.0]);
398 assert_eq!(sys.active_count(), 10);
399 }
400
401 #[test]
404 fn test_spawn_burst_emits_all_at_once() {
405 let mut sys = default_system(50);
406 spawn_burst(&mut sys, 20);
407 assert_eq!(sys.active_count(), 20);
408 }
409
410 #[test]
411 fn test_spawn_burst_respects_max() {
412 let mut sys = default_system(5);
413 spawn_burst(&mut sys, 100);
414 assert_eq!(sys.active_count(), 5);
415 }
416
417 #[test]
420 fn test_full_lifecycle() {
421 let mut sys = default_system(100);
422 spawn_burst(&mut sys, 30);
423 assert_eq!(sys.active_count(), 30);
424 let dt = EmitterConfig::default().particle_lifetime + 0.1;
426 gpu_integrate_particles(&mut sys, dt);
427 gpu_kill_dead_particles(&mut sys);
428 assert_eq!(sys.active_count(), 0);
429 }
430
431 #[test]
432 fn test_emission_incremental() {
433 let mut sys = default_system(100);
434 gpu_emit_particles(&mut sys, 10);
435 gpu_emit_particles(&mut sys, 10);
436 assert_eq!(sys.active_count(), 20);
437 }
438
439 #[test]
440 fn test_particle_color_propagated() {
441 let cfg = EmitterConfig {
442 color: [1.0, 0.0, 0.0, 1.0],
443 ..Default::default()
444 };
445 let mut sys = GpuParticleSystem::new(cfg, 10);
446 gpu_emit_particles(&mut sys, 1);
447 assert_eq!(sys.particles[0].color, [1.0, 0.0, 0.0, 1.0]);
448 }
449}