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
165fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
168 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
169}
170
171#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn default_system(max: usize) -> GpuParticleSystem {
178 GpuParticleSystem::new(EmitterConfig::default(), max)
179 }
180
181 #[test]
184 fn test_particle_is_alive_positive_lifetime() {
185 let p = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4]);
186 assert!(p.is_alive());
187 }
188
189 #[test]
190 fn test_particle_is_dead_zero_lifetime() {
191 let p = GpuParticle::new([0.0; 3], [0.0; 3], 0.0, [1.0; 4]);
192 assert!(!p.is_alive());
193 }
194
195 #[test]
196 fn test_particle_is_dead_negative_lifetime() {
197 let p = GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]);
198 assert!(!p.is_alive());
199 }
200
201 #[test]
202 fn test_particle_new_fields() {
203 let pos = [1.0, 2.0, 3.0];
204 let vel = [0.1, 0.2, 0.3];
205 let lt = 5.0;
206 let col = [0.5, 0.5, 0.5, 1.0];
207 let p = GpuParticle::new(pos, vel, lt, col);
208 assert_eq!(p.position, pos);
209 assert_eq!(p.velocity, vel);
210 assert!((p.lifetime - lt).abs() < 1e-12);
211 assert_eq!(p.color, col);
212 }
213
214 #[test]
217 fn test_emitter_default_speed_positive() {
218 let cfg = EmitterConfig::default();
219 assert!(cfg.initial_speed > 0.0);
220 }
221
222 #[test]
223 fn test_emitter_default_lifetime_positive() {
224 let cfg = EmitterConfig::default();
225 assert!(cfg.particle_lifetime > 0.0);
226 }
227
228 #[test]
231 fn test_system_starts_empty() {
232 let sys = default_system(100);
233 assert_eq!(sys.active_count(), 0);
234 }
235
236 #[test]
237 fn test_system_max_particles_stored() {
238 let sys = default_system(42);
239 assert_eq!(sys.max_particles, 42);
240 }
241
242 #[test]
245 fn test_emit_spawns_n_particles() {
246 let mut sys = default_system(100);
247 gpu_emit_particles(&mut sys, 10);
248 assert_eq!(sys.active_count(), 10);
249 }
250
251 #[test]
252 fn test_emit_respects_max_particles() {
253 let mut sys = default_system(5);
254 gpu_emit_particles(&mut sys, 100);
255 assert_eq!(sys.active_count(), 5);
256 }
257
258 #[test]
259 fn test_emit_zero_particles() {
260 let mut sys = default_system(100);
261 gpu_emit_particles(&mut sys, 0);
262 assert_eq!(sys.active_count(), 0);
263 }
264
265 #[test]
266 fn test_emit_single_particle_at_origin() {
267 let mut sys = default_system(10);
268 gpu_emit_particles(&mut sys, 1);
269 assert_eq!(sys.particles[0].position, [0.0; 3]);
270 }
271
272 #[test]
273 fn test_emit_particles_have_positive_lifetime() {
274 let mut sys = default_system(10);
275 gpu_emit_particles(&mut sys, 5);
276 for p in &sys.particles {
277 assert!(p.lifetime > 0.0);
278 }
279 }
280
281 #[test]
284 fn test_integrate_moves_position() {
285 let mut sys = default_system(10);
286 sys.particles
287 .push(GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 5.0, [1.0; 4]));
288 gpu_integrate_particles(&mut sys, 1.0);
289 assert!((sys.particles[0].position[0] - 1.0).abs() < 1e-12);
290 }
291
292 #[test]
293 fn test_integrate_decrements_lifetime() {
294 let mut sys = default_system(10);
295 sys.particles
296 .push(GpuParticle::new([0.0; 3], [0.0; 3], 3.0, [1.0; 4]));
297 gpu_integrate_particles(&mut sys, 1.0);
298 assert!((sys.particles[0].lifetime - 2.0).abs() < 1e-12);
299 }
300
301 #[test]
302 fn test_integrate_zero_dt_no_movement() {
303 let mut sys = default_system(10);
304 sys.particles.push(GpuParticle::new(
305 [1.0, 2.0, 3.0],
306 [5.0, 5.0, 5.0],
307 1.0,
308 [1.0; 4],
309 ));
310 gpu_integrate_particles(&mut sys, 0.0);
311 assert_eq!(sys.particles[0].position, [1.0, 2.0, 3.0]);
312 }
313
314 #[test]
315 fn test_integrate_multiple_steps() {
316 let mut sys = default_system(10);
317 sys.particles
318 .push(GpuParticle::new([0.0; 3], [2.0, 0.0, 0.0], 10.0, [1.0; 4]));
319 gpu_integrate_particles(&mut sys, 0.5);
320 gpu_integrate_particles(&mut sys, 0.5);
321 assert!((sys.particles[0].position[0] - 2.0).abs() < 1e-10);
322 }
323
324 #[test]
327 fn test_kill_removes_dead_particles() {
328 let mut sys = default_system(10);
329 sys.particles
330 .push(GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]));
331 sys.particles
332 .push(GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4]));
333 gpu_kill_dead_particles(&mut sys);
334 assert_eq!(sys.active_count(), 1);
335 assert!(sys.particles[0].is_alive());
336 }
337
338 #[test]
339 fn test_kill_all_dead() {
340 let mut sys = default_system(10);
341 for _ in 0..5 {
342 sys.particles
343 .push(GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]));
344 }
345 gpu_kill_dead_particles(&mut sys);
346 assert_eq!(sys.active_count(), 0);
347 }
348
349 #[test]
350 fn test_kill_none_dead() {
351 let mut sys = default_system(10);
352 gpu_emit_particles(&mut sys, 5);
353 gpu_kill_dead_particles(&mut sys);
354 assert_eq!(sys.active_count(), 5);
355 }
356
357 #[test]
358 fn test_integrate_then_kill() {
359 let mut sys = default_system(10);
360 sys.particles
361 .push(GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 0.5, [1.0; 4]));
362 gpu_integrate_particles(&mut sys, 1.0); gpu_kill_dead_particles(&mut sys);
364 assert_eq!(sys.active_count(), 0);
365 }
366
367 #[test]
370 fn test_sort_by_depth_back_to_front() {
371 let mut sys = default_system(10);
372 sys.particles
374 .push(GpuParticle::new([0.0, 0.0, 1.0], [0.0; 3], 1.0, [1.0; 4]));
375 sys.particles
376 .push(GpuParticle::new([0.0, 0.0, 5.0], [0.0; 3], 1.0, [1.0; 4]));
377 let cam = [0.0, 0.0, 1.0]; gpu_sort_by_depth(&mut sys, cam);
379 assert!((sys.particles[0].position[2] - 5.0).abs() < 1e-12);
381 }
382
383 #[test]
384 fn test_sort_by_depth_single_particle() {
385 let mut sys = default_system(10);
386 sys.particles
387 .push(GpuParticle::new([1.0, 2.0, 3.0], [0.0; 3], 1.0, [1.0; 4]));
388 gpu_sort_by_depth(&mut sys, [0.0, 0.0, 1.0]);
389 assert_eq!(sys.active_count(), 1);
390 }
391
392 #[test]
393 fn test_sort_by_depth_preserves_count() {
394 let mut sys = default_system(20);
395 gpu_emit_particles(&mut sys, 10);
396 gpu_sort_by_depth(&mut sys, [1.0, 0.0, 0.0]);
397 assert_eq!(sys.active_count(), 10);
398 }
399
400 #[test]
403 fn test_spawn_burst_emits_all_at_once() {
404 let mut sys = default_system(50);
405 spawn_burst(&mut sys, 20);
406 assert_eq!(sys.active_count(), 20);
407 }
408
409 #[test]
410 fn test_spawn_burst_respects_max() {
411 let mut sys = default_system(5);
412 spawn_burst(&mut sys, 100);
413 assert_eq!(sys.active_count(), 5);
414 }
415
416 #[test]
419 fn test_full_lifecycle() {
420 let mut sys = default_system(100);
421 spawn_burst(&mut sys, 30);
422 assert_eq!(sys.active_count(), 30);
423 let dt = EmitterConfig::default().particle_lifetime + 0.1;
425 gpu_integrate_particles(&mut sys, dt);
426 gpu_kill_dead_particles(&mut sys);
427 assert_eq!(sys.active_count(), 0);
428 }
429
430 #[test]
431 fn test_emission_incremental() {
432 let mut sys = default_system(100);
433 gpu_emit_particles(&mut sys, 10);
434 gpu_emit_particles(&mut sys, 10);
435 assert_eq!(sys.active_count(), 20);
436 }
437
438 #[test]
439 fn test_particle_color_propagated() {
440 let cfg = EmitterConfig {
441 color: [1.0, 0.0, 0.0, 1.0],
442 ..Default::default()
443 };
444 let mut sys = GpuParticleSystem::new(cfg, 10);
445 gpu_emit_particles(&mut sys, 1);
446 assert_eq!(sys.particles[0].color, [1.0, 0.0, 0.0, 1.0]);
447 }
448}