1use super::types::{ParticleBuffer, ParticleRenderData, SimpleRng, SortedParticleRenderData};
6
7pub fn extract_render_data(
11 buffer: &ParticleBuffer,
12 color_young: [f32; 4],
13 color_old: [f32; 4],
14 base_size: f32,
15) -> Vec<ParticleRenderData> {
16 let mut data = Vec::new();
17 for i in 0..buffer.count {
18 if !buffer.is_alive(i) {
19 continue;
20 }
21 let age = buffer.ages[i];
22 let initial_lifetime = age + buffer.lifetimes[i];
23 let age_norm = if initial_lifetime > 0.0 {
24 (age / initial_lifetime).clamp(0.0, 1.0)
25 } else {
26 1.0
27 };
28 let color = [
29 color_young[0] + (color_old[0] - color_young[0]) * age_norm,
30 color_young[1] + (color_old[1] - color_young[1]) * age_norm,
31 color_young[2] + (color_old[2] - color_young[2]) * age_norm,
32 color_young[3] + (color_old[3] - color_young[3]) * age_norm,
33 ];
34 let size = base_size * (1.0 - 0.5 * age_norm);
35 data.push(ParticleRenderData {
36 position: buffer.get_position(i),
37 color,
38 size,
39 age_normalized: age_norm,
40 });
41 }
42 data
43}
44pub fn compute_total_momentum(buffer: &ParticleBuffer) -> [f32; 3] {
46 let mut px = 0.0f32;
47 let mut py = 0.0f32;
48 let mut pz = 0.0f32;
49 for i in 0..buffer.count {
50 if !buffer.is_alive(i) {
51 continue;
52 }
53 let m = buffer.masses[i];
54 px += m * buffer.velocities_x[i];
55 py += m * buffer.velocities_y[i];
56 pz += m * buffer.velocities_z[i];
57 }
58 [px, py, pz]
59}
60pub fn morton_encode(x: u32, y: u32, z: u32) -> u32 {
64 let xm = morton_part1by2(x);
65 let ym = morton_part1by2(y);
66 let zm = morton_part1by2(z);
67 xm | (ym << 1) | (zm << 2)
68}
69pub(super) fn morton_part1by2(mut x: u32) -> u32 {
70 x &= 0x000003ff;
71 x = (x ^ (x << 16)) & 0xff0000ff;
72 x = (x ^ (x << 8)) & 0x0300f00f;
73 x = (x ^ (x << 4)) & 0x030c30c3;
74 x = (x ^ (x << 2)) & 0x09249249;
75 x
76}
77#[allow(clippy::too_many_arguments)]
81pub fn compute_morton_codes(
82 buffer: &ParticleBuffer,
83 origin: [f32; 3],
84 extent: [f32; 3],
85 grid_cells: u32,
86) -> Vec<(u32, usize)> {
87 let mut codes: Vec<(u32, usize)> = Vec::new();
88 let gc = grid_cells as f32;
89 for i in 0..buffer.count {
90 if !buffer.is_alive(i) {
91 continue;
92 }
93 let px = ((buffer.positions_x[i] - origin[0]) / extent[0] * gc).clamp(0.0, gc - 1.0) as u32;
94 let py = ((buffer.positions_y[i] - origin[1]) / extent[1] * gc).clamp(0.0, gc - 1.0) as u32;
95 let pz = ((buffer.positions_z[i] - origin[2]) / extent[2] * gc).clamp(0.0, gc - 1.0) as u32;
96 codes.push((morton_encode(px, py, pz), i));
97 }
98 codes.sort_unstable_by_key(|&(code, _)| code);
99 codes
100}
101pub fn sort_particles_morton(
106 buffer: &ParticleBuffer,
107 origin: [f32; 3],
108 extent: [f32; 3],
109 grid_cells: u32,
110) -> ParticleBuffer {
111 let sorted = compute_morton_codes(buffer, origin, extent, grid_cells);
112 let mut new_buf = ParticleBuffer::new(buffer.count);
113 for (slot, &(_, old_idx)) in sorted.iter().enumerate() {
114 new_buf.positions_x[slot] = buffer.positions_x[old_idx];
115 new_buf.positions_y[slot] = buffer.positions_y[old_idx];
116 new_buf.positions_z[slot] = buffer.positions_z[old_idx];
117 new_buf.velocities_x[slot] = buffer.velocities_x[old_idx];
118 new_buf.velocities_y[slot] = buffer.velocities_y[old_idx];
119 new_buf.velocities_z[slot] = buffer.velocities_z[old_idx];
120 new_buf.masses[slot] = buffer.masses[old_idx];
121 new_buf.lifetimes[slot] = buffer.lifetimes[old_idx];
122 new_buf.ages[slot] = buffer.ages[old_idx];
123 }
124 new_buf
125}
126pub fn prepare_sorted_render_data(
130 buffer: &ParticleBuffer,
131 color_young: [f32; 4],
132 color_old: [f32; 4],
133 base_size: f32,
134 camera_pos: [f32; 3],
135 camera_forward: [f32; 3],
136) -> Vec<SortedParticleRenderData> {
137 let mut result = Vec::new();
138 for i in 0..buffer.count {
139 if !buffer.is_alive(i) {
140 continue;
141 }
142 let age = buffer.ages[i];
143 let initial_lifetime = age + buffer.lifetimes[i];
144 let age_norm = if initial_lifetime > 0.0 {
145 (age / initial_lifetime).clamp(0.0, 1.0)
146 } else {
147 1.0
148 };
149 let color = [
150 color_young[0] + (color_old[0] - color_young[0]) * age_norm,
151 color_young[1] + (color_old[1] - color_young[1]) * age_norm,
152 color_young[2] + (color_old[2] - color_young[2]) * age_norm,
153 color_young[3] + (color_old[3] - color_young[3]) * age_norm,
154 ];
155 let size = base_size * (1.0 - 0.5 * age_norm);
156 let dx = buffer.positions_x[i] - camera_pos[0];
157 let dy = buffer.positions_y[i] - camera_pos[1];
158 let dz = buffer.positions_z[i] - camera_pos[2];
159 let depth = dx * camera_forward[0] + dy * camera_forward[1] + dz * camera_forward[2];
160 result.push(SortedParticleRenderData {
161 render_data: ParticleRenderData {
162 position: [
163 buffer.positions_x[i],
164 buffer.positions_y[i],
165 buffer.positions_z[i],
166 ],
167 color,
168 size,
169 age_normalized: age_norm,
170 },
171 sort_key: depth,
172 buffer_index: i,
173 });
174 }
175 result.sort_unstable_by(|a, b| {
176 b.sort_key
177 .partial_cmp(&a.sort_key)
178 .unwrap_or(std::cmp::Ordering::Equal)
179 });
180 result
181}
182pub fn compute_center_of_mass(buffer: &ParticleBuffer) -> [f32; 3] {
184 let mut total_mass = 0.0f32;
185 let mut cx = 0.0f32;
186 let mut cy = 0.0f32;
187 let mut cz = 0.0f32;
188 for i in 0..buffer.count {
189 if !buffer.is_alive(i) {
190 continue;
191 }
192 let m = buffer.masses[i];
193 cx += m * buffer.positions_x[i];
194 cy += m * buffer.positions_y[i];
195 cz += m * buffer.positions_z[i];
196 total_mass += m;
197 }
198 if total_mass > 0.0 {
199 [cx / total_mass, cy / total_mass, cz / total_mass]
200 } else {
201 [0.0, 0.0, 0.0]
202 }
203}
204pub fn compute_velocity_histogram(buffer: &ParticleBuffer, max_speed: f32, dv: f32) -> Vec<usize> {
209 let dv = dv.max(1e-12);
210 let n_bins = (max_speed / dv).ceil() as usize;
211 let mut hist = vec![0usize; n_bins.max(1)];
212 for i in 0..buffer.count {
213 if !buffer.is_alive(i) {
214 continue;
215 }
216 let vx = buffer.velocities_x[i];
217 let vy = buffer.velocities_y[i];
218 let vz = buffer.velocities_z[i];
219 let speed = (vx * vx + vy * vy + vz * vz).sqrt();
220 if speed < max_speed {
221 let bin = (speed / dv) as usize;
222 if bin < n_bins {
223 hist[bin] += 1;
224 }
225 }
226 }
227 hist
228}
229pub fn compute_angular_momentum(buffer: &ParticleBuffer) -> [f32; 3] {
235 let mut lx = 0.0f32;
236 let mut ly = 0.0f32;
237 let mut lz = 0.0f32;
238 for i in 0..buffer.count {
239 if !buffer.is_alive(i) {
240 continue;
241 }
242 let m = buffer.masses[i];
243 let rx = buffer.positions_x[i];
244 let ry = buffer.positions_y[i];
245 let rz = buffer.positions_z[i];
246 let vx = buffer.velocities_x[i];
247 let vy = buffer.velocities_y[i];
248 let vz = buffer.velocities_z[i];
249 lx += m * (ry * vz - rz * vy);
250 ly += m * (rz * vx - rx * vz);
251 lz += m * (rx * vy - ry * vx);
252 }
253 [lx, ly, lz]
254}
255#[allow(clippy::too_many_arguments)]
262pub fn emit_burst(
263 buffer: &mut ParticleBuffer,
264 origin: [f32; 3],
265 velocity: [f32; 3],
266 spread: f32,
267 lifetime: f32,
268 mass: f32,
269 count: usize,
270 seed: u64,
271) -> usize {
272 let mut rng = SimpleRng::new(seed);
273 let mut spawned = 0usize;
274 for _ in 0..count {
275 let dir = rng.next_unit_sphere();
276 let vel = [
277 velocity[0] + dir[0] * spread,
278 velocity[1] + dir[1] * spread,
279 velocity[2] + dir[2] * spread,
280 ];
281 if buffer.add_particle(origin, vel, mass, lifetime).is_some() {
282 spawned += 1;
283 }
284 }
285 spawned
286}
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use crate::BoundingBoxKill;
291 use crate::DragForce;
292 use crate::EmitterShape;
293 use crate::FloorCollision;
294 use crate::GpuParticleEmitter;
295 use crate::GpuParticleLayout;
296 use crate::GravityForce;
297 use crate::GridParticleCollision;
298 use crate::ParticleEmitter;
299 use crate::ParticleIntegrator;
300 use crate::ParticleLifetimeManager;
301 use crate::ParticleRepulsion;
302 use crate::ParticleStats;
303 use crate::ParticleSystem;
304 use crate::ParticleSystemStats;
305 use crate::RadialForceField;
306 use crate::VortexForceField;
307 #[test]
308 fn test_particle_buffer_add_and_get_position() {
309 let mut buf = ParticleBuffer::new(4);
310 let idx = buf.add_particle([1.0, 2.0, 3.0], [0.0, 0.0, 0.0], 1.0, 5.0);
311 assert!(idx.is_some());
312 let i = idx.unwrap();
313 let pos = buf.get_position(i);
314 assert!((pos[0] - 1.0).abs() < 1e-6);
315 assert!((pos[1] - 2.0).abs() < 1e-6);
316 assert!((pos[2] - 3.0).abs() < 1e-6);
317 }
318 #[test]
319 fn test_particle_buffer_is_alive_after_kill() {
320 let mut buf = ParticleBuffer::new(4);
321 let i = buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
322 assert!(buf.is_alive(i));
323 buf.kill(i);
324 assert!(!buf.is_alive(i));
325 }
326 #[test]
327 fn test_gravity_force_increases_downward_velocity() {
328 let mut buf = ParticleBuffer::new(1);
329 buf.add_particle([0.0; 3], [0.0, 0.0, 0.0], 1.0, 10.0)
330 .unwrap();
331 let grav = GravityForce {
332 g: [0.0, -9.81, 0.0],
333 };
334 grav.apply(&mut buf, 1.0);
335 let vel = buf.get_velocity(0);
336 assert!(vel[1] < 0.0, "vy should be negative after gravity");
337 assert!((vel[1] + 9.81).abs() < 1e-4);
338 }
339 #[test]
340 fn test_integrator_moves_particles() {
341 let mut buf = ParticleBuffer::new(1);
342 buf.add_particle([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0, 10.0)
343 .unwrap();
344 ParticleIntegrator::integrate(&mut buf, 1.0);
345 let pos = buf.get_position(0);
346 assert!(
347 (pos[0] - 1.0).abs() < 1e-6,
348 "particle should move 1 unit in x"
349 );
350 }
351 #[test]
352 fn test_floor_collision_reflects_particle() {
353 let mut buf = ParticleBuffer::new(1);
354 buf.add_particle([0.0, -0.5, 0.0], [0.0, -3.0, 0.0], 1.0, 10.0)
355 .unwrap();
356 let floor = FloorCollision {
357 y: 0.0,
358 restitution: 0.8,
359 };
360 floor.apply(&mut buf);
361 let pos = buf.get_position(0);
362 let vel = buf.get_velocity(0);
363 assert!(pos[1] >= 0.0, "particle should be at or above floor");
364 assert!(
365 vel[1] > 0.0,
366 "velocity y should be positive after reflection"
367 );
368 assert!(
369 (vel[1] - 2.4).abs() < 1e-5,
370 "reflected vy = 3.0 * 0.8 = 2.4"
371 );
372 }
373 #[test]
374 fn test_bounding_box_kill_removes_out_of_bounds() {
375 let mut buf = ParticleBuffer::new(3);
376 buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 10.0)
377 .unwrap();
378 buf.add_particle([100.0, 0.0, 0.0], [0.0; 3], 1.0, 10.0)
379 .unwrap();
380 buf.add_particle([0.0, -50.0, 0.0], [0.0; 3], 1.0, 10.0)
381 .unwrap();
382 let kill = BoundingBoxKill {
383 min: [-10.0; 3],
384 max: [10.0; 3],
385 };
386 kill.apply(&mut buf);
387 assert!(buf.is_alive(0), "particle inside box should survive");
388 assert!(!buf.is_alive(1), "particle outside x should be killed");
389 assert!(!buf.is_alive(2), "particle outside y should be killed");
390 }
391 #[test]
392 fn test_particle_system_step_no_panic() {
393 let mut sys = ParticleSystem::new(64);
394 let emitter = ParticleEmitter::new([0.0; 3], 10.0, [0.0, 1.0, 0.0], 3.0);
395 sys.add_emitter(emitter);
396 sys.floor = Some(FloorCollision {
397 y: -5.0,
398 restitution: 0.5,
399 });
400 for _ in 0..30 {
401 sys.step(1.0 / 60.0);
402 }
403 }
404 #[test]
405 fn test_gpu_particle_layout_round_trip() {
406 let mut buf = ParticleBuffer::new(4);
407 buf.add_particle([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], 0.5, 7.0)
408 .unwrap();
409 buf.add_particle([-1.0, 0.5, 2.0], [0.1, 0.2, 0.3], 2.0, 3.5)
410 .unwrap();
411 let flat = GpuParticleLayout::to_f32_buffer(&buf);
412 assert_eq!(flat.len(), 4 * GpuParticleLayout::stride());
413 let restored = GpuParticleLayout::from_f32_buffer(&flat, 4);
414 let p0 = restored.get_position(0);
415 assert!((p0[0] - 1.0).abs() < 1e-6);
416 assert!((p0[1] - 2.0).abs() < 1e-6);
417 assert!((p0[2] - 3.0).abs() < 1e-6);
418 let v1 = restored.get_velocity(1);
419 assert!((v1[0] - 0.1).abs() < 1e-6);
420 assert!((v1[1] - 0.2).abs() < 1e-6);
421 assert!((v1[2] - 0.3).abs() < 1e-6);
422 assert!(!restored.is_alive(2));
423 assert!(!restored.is_alive(3));
424 }
425 #[test]
426 fn test_radial_force_attraction() {
427 let mut buf = ParticleBuffer::new(1);
428 buf.add_particle([5.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
429 .unwrap();
430 let field = RadialForceField {
431 center: [0.0, 0.0, 0.0],
432 strength: 10.0,
433 falloff: 1.0,
434 min_distance: 0.01,
435 };
436 field.apply(&mut buf, 1.0);
437 let vel = buf.get_velocity(0);
438 assert!(
439 vel[0] < 0.0,
440 "should attract toward center, got vx={}",
441 vel[0]
442 );
443 }
444 #[test]
445 fn test_radial_force_repulsion() {
446 let mut buf = ParticleBuffer::new(1);
447 buf.add_particle([5.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
448 .unwrap();
449 let field = RadialForceField {
450 center: [0.0, 0.0, 0.0],
451 strength: -10.0,
452 falloff: 1.0,
453 min_distance: 0.01,
454 };
455 field.apply(&mut buf, 1.0);
456 let vel = buf.get_velocity(0);
457 assert!(vel[0] > 0.0, "should repel from center, got vx={}", vel[0]);
458 }
459 #[test]
460 fn test_radial_force_dead_particle_ignored() {
461 let mut buf = ParticleBuffer::new(2);
462 buf.add_particle([5.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
463 .unwrap();
464 buf.add_particle([3.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
465 .unwrap();
466 buf.kill(1);
467 let field = RadialForceField {
468 center: [0.0, 0.0, 0.0],
469 strength: 10.0,
470 falloff: 1.0,
471 min_distance: 0.01,
472 };
473 field.apply(&mut buf, 1.0);
474 let vel1 = buf.get_velocity(1);
475 assert!((vel1[0]).abs() < 1e-6);
476 }
477 #[test]
478 fn test_vortex_force_field() {
479 let mut buf = ParticleBuffer::new(1);
480 buf.add_particle([1.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
481 .unwrap();
482 let vortex = VortexForceField {
483 center: [0.0, 0.0],
484 angular_velocity: 10.0,
485 radius: 5.0,
486 };
487 vortex.apply(&mut buf, 1.0);
488 let vel = buf.get_velocity(0);
489 assert!(
490 vel[2].abs() > 0.0 || vel[0].abs() > 0.0,
491 "vortex should add tangential velocity"
492 );
493 }
494 #[test]
495 fn test_vortex_outside_radius() {
496 let mut buf = ParticleBuffer::new(1);
497 buf.add_particle([100.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
498 .unwrap();
499 let vortex = VortexForceField {
500 center: [0.0, 0.0],
501 angular_velocity: 10.0,
502 radius: 5.0,
503 };
504 vortex.apply(&mut buf, 1.0);
505 let vel = buf.get_velocity(0);
506 assert!((vel[0]).abs() < 1e-6);
507 assert!((vel[2]).abs() < 1e-6);
508 }
509 #[test]
510 fn test_particle_repulsion_two_particles() {
511 let mut buf = ParticleBuffer::new(2);
512 buf.add_particle([0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
513 .unwrap();
514 buf.add_particle([0.5, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
515 .unwrap();
516 let repulsion = ParticleRepulsion {
517 strength: 10.0,
518 radius: 1.0,
519 };
520 repulsion.apply(&mut buf, 1.0);
521 let v0 = buf.get_velocity(0);
522 let v1 = buf.get_velocity(1);
523 assert!(v0[0] < 0.0, "particle 0 should move left");
524 assert!(v1[0] > 0.0, "particle 1 should move right");
525 let total = v0[0] + v1[0];
526 assert!(total.abs() < 1e-5, "momentum not conserved: {total}");
527 }
528 #[test]
529 fn test_particle_repulsion_outside_radius() {
530 let mut buf = ParticleBuffer::new(2);
531 buf.add_particle([0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
532 .unwrap();
533 buf.add_particle([10.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
534 .unwrap();
535 let repulsion = ParticleRepulsion {
536 strength: 10.0,
537 radius: 1.0,
538 };
539 repulsion.apply(&mut buf, 1.0);
540 let v0 = buf.get_velocity(0);
541 let v1 = buf.get_velocity(1);
542 assert!((v0[0]).abs() < 1e-6);
543 assert!((v1[0]).abs() < 1e-6);
544 }
545 #[test]
546 fn test_extract_render_data_empty_buffer() {
547 let buf = ParticleBuffer::new(4);
548 let data = extract_render_data(&buf, [1.0; 4], [0.0; 4], 1.0);
549 assert!(data.is_empty());
550 }
551 #[test]
552 fn test_extract_render_data_alive_only() {
553 let mut buf = ParticleBuffer::new(4);
554 buf.add_particle([1.0, 2.0, 3.0], [0.0; 3], 1.0, 5.0)
555 .unwrap();
556 buf.add_particle([4.0, 5.0, 6.0], [0.0; 3], 1.0, 5.0)
557 .unwrap();
558 let data = extract_render_data(&buf, [1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], 2.0);
559 assert_eq!(data.len(), 2);
560 assert!((data[0].position[0] - 1.0).abs() < 1e-6);
561 assert!(data[0].size > 0.0);
562 assert!(data[0].age_normalized >= 0.0 && data[0].age_normalized <= 1.0);
563 }
564 #[test]
565 fn test_extract_render_data_color_interpolation() {
566 let mut buf = ParticleBuffer::new(1);
567 buf.add_particle([0.0; 3], [0.0; 3], 1.0, 2.0).unwrap();
568 buf.ages[0] = 1.0;
569 buf.lifetimes[0] = 1.0;
570 let data = extract_render_data(&buf, [1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], 1.0);
571 assert_eq!(data.len(), 1);
572 assert!((data[0].color[0] - 0.5).abs() < 1e-4);
573 assert!((data[0].color[2] - 0.5).abs() < 1e-4);
574 }
575 #[test]
576 fn test_compute_total_momentum() {
577 let mut buf = ParticleBuffer::new(2);
578 buf.add_particle([0.0; 3], [1.0, 0.0, 0.0], 2.0, 10.0)
579 .unwrap();
580 buf.add_particle([0.0; 3], [-1.0, 0.0, 0.0], 3.0, 10.0)
581 .unwrap();
582 let p = compute_total_momentum(&buf);
583 assert!((p[0] - (-1.0)).abs() < 1e-5);
584 }
585 #[test]
586 fn test_compute_total_momentum_dead_ignored() {
587 let mut buf = ParticleBuffer::new(2);
588 buf.add_particle([0.0; 3], [1.0, 0.0, 0.0], 2.0, 10.0)
589 .unwrap();
590 buf.add_particle([0.0; 3], [10.0, 0.0, 0.0], 3.0, 10.0)
591 .unwrap();
592 buf.kill(1);
593 let p = compute_total_momentum(&buf);
594 assert!((p[0] - 2.0).abs() < 1e-5);
595 }
596 #[test]
597 fn test_compute_center_of_mass() {
598 let mut buf = ParticleBuffer::new(2);
599 buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 10.0)
600 .unwrap();
601 buf.add_particle([10.0, 0.0, 0.0], [0.0; 3], 1.0, 10.0)
602 .unwrap();
603 let com = compute_center_of_mass(&buf);
604 assert!((com[0] - 5.0).abs() < 1e-5);
605 }
606 #[test]
607 fn test_compute_center_of_mass_weighted() {
608 let mut buf = ParticleBuffer::new(2);
609 buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 10.0)
610 .unwrap();
611 buf.add_particle([10.0, 0.0, 0.0], [0.0; 3], 3.0, 10.0)
612 .unwrap();
613 let com = compute_center_of_mass(&buf);
614 assert!((com[0] - 7.5).abs() < 1e-5);
615 }
616 #[test]
617 fn test_compute_center_of_mass_empty() {
618 let buf = ParticleBuffer::new(4);
619 let com = compute_center_of_mass(&buf);
620 assert!((com[0]).abs() < 1e-6);
621 }
622 #[test]
623 fn test_particle_stats_basic() {
624 let mut buf = ParticleBuffer::new(3);
625 buf.add_particle([1.0, 2.0, 3.0], [1.0, 0.0, 0.0], 1.0, 5.0)
626 .unwrap();
627 buf.add_particle([4.0, 5.0, 6.0], [0.0, 2.0, 0.0], 2.0, 5.0)
628 .unwrap();
629 let stats = ParticleStats::compute(&buf);
630 assert_eq!(stats.active, 2);
631 assert!(stats.avg_speed > 0.0);
632 assert!(stats.total_kinetic_energy > 0.0);
633 }
634 #[test]
635 fn test_particle_stats_no_alive() {
636 let buf = ParticleBuffer::new(4);
637 let stats = ParticleStats::compute(&buf);
638 assert_eq!(stats.active, 0);
639 assert!((stats.avg_speed).abs() < 1e-6);
640 }
641 #[test]
642 fn test_emitter_inactive_no_emission() {
643 let mut emitter = ParticleEmitter::new([0.0; 3], 1000.0, [0.0; 3], 1.0);
644 emitter.active = false;
645 let mut buf = ParticleBuffer::new(100);
646 let spawned = emitter.emit(&mut buf, 1.0, 42);
647 assert_eq!(spawned, 0);
648 }
649 #[test]
650 fn test_emitter_emits_particles() {
651 let mut emitter = ParticleEmitter::new([0.0; 3], 100.0, [0.0, 1.0, 0.0], 5.0);
652 let mut buf = ParticleBuffer::new(200);
653 let spawned = emitter.emit(&mut buf, 1.0, 42);
654 assert!(spawned > 0, "should emit particles");
655 }
656 #[test]
657 fn test_emitter_with_spread() {
658 let mut emitter = ParticleEmitter::new([0.0; 3], 10.0, [0.0, 1.0, 0.0], 5.0);
659 emitter.velocity_spread = 0.5;
660 let mut buf = ParticleBuffer::new(20);
661 emitter.emit(&mut buf, 1.0, 42);
662 assert!(buf.active_count() > 0);
663 }
664 #[test]
665 fn test_drag_reduces_speed() {
666 let mut buf = ParticleBuffer::new(1);
667 buf.add_particle([0.0; 3], [10.0, 0.0, 0.0], 1.0, 10.0)
668 .unwrap();
669 let drag = DragForce { coefficient: 0.5 };
670 drag.apply(&mut buf, 1.0);
671 let vel = buf.get_velocity(0);
672 assert!(vel[0] < 10.0, "drag should reduce speed");
673 assert!(vel[0] > 0.0, "speed should stay positive");
674 }
675 #[test]
676 fn test_active_count() {
677 let mut buf = ParticleBuffer::new(5);
678 buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
679 buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
680 buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
681 assert_eq!(buf.active_count(), 3);
682 buf.kill(1);
683 assert_eq!(buf.active_count(), 2);
684 }
685 #[test]
686 fn test_gpu_emitter_continuous_burst_count_zero() {
687 let emitter = GpuParticleEmitter::new_continuous([0.0; 3], 50.0, 2.0);
688 assert_eq!(emitter.burst_count(), 0);
689 }
690 #[test]
691 fn test_gpu_emitter_burst_count() {
692 let emitter = GpuParticleEmitter::new_burst([0.0; 3], 100, 2.0);
693 assert_eq!(emitter.burst_count(), 100);
694 }
695 #[test]
696 fn test_gpu_emitter_burst_fires_once() {
697 let mut emitter = GpuParticleEmitter::new_burst([0.0; 3], 5, 1.5);
698 let mut buf = ParticleBuffer::new(32);
699 let first = emitter.emit(&mut buf, 0.016);
700 assert_eq!(first, 5);
701 let second = emitter.emit(&mut buf, 0.016);
702 assert_eq!(second, 0);
703 }
704 #[test]
705 fn test_gpu_emitter_continuous_accumulates() {
706 let mut emitter = GpuParticleEmitter::new_continuous([0.0; 3], 100.0, 2.0);
707 let mut buf = ParticleBuffer::new(64);
708 let spawned = emitter.emit(&mut buf, 0.1);
709 assert_eq!(spawned, 10);
710 }
711 #[test]
712 fn test_gpu_emitter_sphere_shape_positions_in_radius() {
713 let mut emitter = GpuParticleEmitter::new_continuous([0.0; 3], 100.0, 2.0);
714 emitter.shape = EmitterShape::Sphere { radius: 3.0 };
715 let mut buf = ParticleBuffer::new(64);
716 emitter.emit(&mut buf, 1.0);
717 let count = buf.active_count();
718 assert!(count > 0);
719 for i in 0..count {
720 let px = buf.positions_x[i];
721 let py = buf.positions_y[i];
722 let pz = buf.positions_z[i];
723 let r = (px * px + py * py + pz * pz).sqrt();
724 assert!(r <= 3.0 + 1e-3, "sphere sample outside radius: r={r}");
725 }
726 }
727 #[test]
728 fn test_gpu_emitter_box_shape_positions_in_bounds() {
729 let mut emitter = GpuParticleEmitter::new_continuous([0.0; 3], 50.0, 2.0);
730 emitter.shape = EmitterShape::Box {
731 half_extents: [1.0, 2.0, 0.5],
732 };
733 let mut buf = ParticleBuffer::new(64);
734 emitter.emit(&mut buf, 1.0);
735 let count = buf.active_count();
736 assert!(count > 0);
737 for i in 0..count {
738 assert!(buf.positions_x[i].abs() <= 1.0 + 1e-3);
739 assert!(buf.positions_y[i].abs() <= 2.0 + 1e-3);
740 assert!(buf.positions_z[i].abs() <= 0.5 + 1e-3);
741 }
742 }
743 #[test]
744 fn test_lifetime_manager_no_spawn_fraction() {
745 let mgr = ParticleLifetimeManager::new();
746 let buf = ParticleBuffer::new(4);
747 let frac = mgr.alive_fraction(&buf);
748 assert!((frac).abs() < 1e-5, "no spawns → fraction=0");
749 }
750 #[test]
751 fn test_lifetime_manager_spawn_records_count() {
752 let mut mgr = ParticleLifetimeManager::new();
753 mgr.record_spawn(1.0);
754 mgr.record_spawn(2.0);
755 mgr.record_spawn(3.0);
756 assert_eq!(mgr.total_spawned, 3);
757 assert!((mgr.min_observed_lifetime - 1.0).abs() < 1e-5);
758 assert!((mgr.max_observed_lifetime - 3.0).abs() < 1e-5);
759 }
760 #[test]
761 fn test_lifetime_manager_expiration_count() {
762 let mut mgr = ParticleLifetimeManager::new();
763 mgr.record_spawn(5.0);
764 mgr.record_spawn(5.0);
765 mgr.record_expiration();
766 assert_eq!(mgr.total_expired, 1);
767 }
768 #[test]
769 fn test_lifetime_manager_alive_fraction_with_active() {
770 let mut mgr = ParticleLifetimeManager::new();
771 for _ in 0..4 {
772 mgr.record_spawn(5.0);
773 }
774 let mut buf = ParticleBuffer::new(4);
775 buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
776 buf.add_particle([1.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
777 let frac = mgr.alive_fraction(&buf);
778 assert!((frac - 0.5).abs() < 1e-5, "expected 0.5 got {frac}");
779 }
780 #[test]
781 fn test_morton_encode_zeros() {
782 assert_eq!(morton_encode(0, 0, 0), 0);
783 }
784 #[test]
785 fn test_morton_encode_axes_distinct() {
786 let cx = morton_encode(1, 0, 0);
787 let cy = morton_encode(0, 1, 0);
788 let cz = morton_encode(0, 0, 1);
789 assert_ne!(cx, 0);
790 assert_ne!(cy, 0);
791 assert_ne!(cz, 0);
792 assert_ne!(cx, cy);
793 assert_ne!(cy, cz);
794 assert_ne!(cx, cz);
795 }
796 #[test]
797 fn test_compute_morton_codes_length() {
798 let mut buf = ParticleBuffer::new(4);
799 buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
800 .unwrap();
801 buf.add_particle([1.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
802 .unwrap();
803 buf.add_particle([0.0, 1.0, 0.0], [0.0; 3], 1.0, 5.0)
804 .unwrap();
805 buf.add_particle([0.0, 0.0, 1.0], [0.0; 3], 1.0, 5.0)
806 .unwrap();
807 let codes = compute_morton_codes(&buf, [0.0; 3], [2.0; 3], 16);
808 assert_eq!(codes.len(), 4);
809 }
810 #[test]
811 fn test_compute_morton_codes_sorted_ascending() {
812 let mut buf = ParticleBuffer::new(3);
813 buf.add_particle([1.0, 1.0, 1.0], [0.0; 3], 1.0, 5.0)
814 .unwrap();
815 buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
816 .unwrap();
817 buf.add_particle([0.5, 0.5, 0.5], [0.0; 3], 1.0, 5.0)
818 .unwrap();
819 let codes = compute_morton_codes(&buf, [0.0; 3], [2.0; 3], 16);
820 for w in codes.windows(2) {
821 assert!(
822 w[0].0 <= w[1].0,
823 "codes not sorted: {} > {}",
824 w[0].0,
825 w[1].0
826 );
827 }
828 }
829 #[test]
830 fn test_sort_particles_morton_origin_first() {
831 let mut buf = ParticleBuffer::new(3);
832 buf.add_particle([1.0, 1.0, 1.0], [0.0; 3], 1.0, 5.0)
833 .unwrap();
834 buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
835 .unwrap();
836 buf.add_particle([0.5, 0.5, 0.5], [0.0; 3], 1.0, 5.0)
837 .unwrap();
838 let sorted = sort_particles_morton(&buf, [0.0; 3], [2.0; 3], 16);
839 assert!(
840 (sorted.positions_x[0]).abs() < 1e-5,
841 "origin should be first"
842 );
843 }
844 #[test]
845 fn test_grid_collision_no_overlap() {
846 let mut buf = ParticleBuffer::new(3);
847 buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
848 .unwrap();
849 buf.add_particle([10.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
850 .unwrap();
851 buf.add_particle([0.0, 10.0, 0.0], [0.0; 3], 1.0, 5.0)
852 .unwrap();
853 let col = GridParticleCollision::new(2.0, 0.3, 0.8);
854 col.resolve(&mut buf);
855 for i in 0..3 {
856 let v = buf.get_velocity(i);
857 assert!((v[0]).abs() < 1e-5, "particle {i} should have zero vx");
858 }
859 }
860 #[test]
861 fn test_grid_collision_overlapping_pair_momentum_conserved() {
862 let mut buf = ParticleBuffer::new(2);
863 buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
864 .unwrap();
865 buf.add_particle([0.5, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
866 .unwrap();
867 let col = GridParticleCollision::new(1.0, 0.4, 0.5);
868 col.resolve(&mut buf);
869 let v0 = buf.get_velocity(0);
870 let v1 = buf.get_velocity(1);
871 let total_px = v0[0] * buf.masses[0] + v1[0] * buf.masses[1];
872 assert!(total_px.abs() < 1e-4, "momentum not conserved: {total_px}");
873 }
874 #[test]
875 fn test_prepare_sorted_render_data_empty() {
876 let buf = ParticleBuffer::new(4);
877 let data =
878 prepare_sorted_render_data(&buf, [1.0; 4], [0.5; 4], 1.0, [0.0; 3], [0.0, 0.0, 1.0]);
879 assert!(data.is_empty());
880 }
881 #[test]
882 fn test_prepare_sorted_render_data_back_to_front() {
883 let mut buf = ParticleBuffer::new(3);
884 buf.add_particle([0.0, 0.0, 1.0], [0.0; 3], 1.0, 5.0)
885 .unwrap();
886 buf.add_particle([0.0, 0.0, 5.0], [0.0; 3], 1.0, 5.0)
887 .unwrap();
888 buf.add_particle([0.0, 0.0, 3.0], [0.0; 3], 1.0, 5.0)
889 .unwrap();
890 let camera_fwd = [0.0_f32, 0.0, 1.0];
891 let data = prepare_sorted_render_data(&buf, [1.0; 4], [0.0; 4], 1.0, [0.0; 3], camera_fwd);
892 assert_eq!(data.len(), 3);
893 assert!(
894 data[0].sort_key >= data[1].sort_key,
895 "first entry should be farthest: {} >= {}",
896 data[0].sort_key,
897 data[1].sort_key
898 );
899 assert!(
900 data[1].sort_key >= data[2].sort_key,
901 "second entry should be middle: {} >= {}",
902 data[1].sort_key,
903 data[2].sort_key
904 );
905 }
906 #[test]
907 fn test_prepare_sorted_render_data_position_preserved() {
908 let mut buf = ParticleBuffer::new(1);
909 buf.add_particle([1.0, 2.0, 3.0], [0.0; 3], 1.0, 5.0)
910 .unwrap();
911 let data = prepare_sorted_render_data(
912 &buf,
913 [1.0_f32, 0.0, 0.0, 1.0],
914 [0.0_f32, 1.0, 0.0, 0.5],
915 2.0,
916 [0.0; 3],
917 [0.0, 0.0, 1.0],
918 );
919 assert_eq!(data.len(), 1);
920 assert!((data[0].render_data.position[0] - 1.0).abs() < 1e-5);
921 assert!((data[0].render_data.position[1] - 2.0).abs() < 1e-5);
922 assert!((data[0].render_data.position[2] - 3.0).abs() < 1e-5);
923 assert!(data[0].render_data.size > 0.0);
924 assert!(data[0].sort_key.abs() > 0.0);
925 }
926 #[test]
927 fn test_particle_system_stats_extended_empty() {
928 let buf = ParticleBuffer::new(8);
929 let stats = ParticleSystemStats::compute_extended(&buf);
930 assert_eq!(stats.basic.active, 0);
931 assert_eq!(stats.capacity, 8);
932 assert!(!stats.is_near_capacity(0.9));
933 }
934 #[test]
935 fn test_particle_system_stats_near_capacity() {
936 let mut buf = ParticleBuffer::new(4);
937 buf.add_particle([0.0; 3], [1.0, 0.0, 0.0], 1.0, 5.0)
938 .unwrap();
939 buf.add_particle([1.0; 3], [0.0, 1.0, 0.0], 1.0, 5.0)
940 .unwrap();
941 buf.add_particle([2.0; 3], [0.0, 0.0, 1.0], 1.0, 5.0)
942 .unwrap();
943 buf.add_particle([3.0; 3], [1.0, 1.0, 0.0], 1.0, 5.0)
944 .unwrap();
945 let stats = ParticleSystemStats::compute_extended(&buf);
946 assert!(
947 stats.is_near_capacity(0.9),
948 "4/4 active should be near capacity"
949 );
950 }
951 #[test]
952 fn test_particle_system_stats_fill_ratio() {
953 let mut buf = ParticleBuffer::new(4);
954 buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
955 buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
956 let stats = ParticleSystemStats::compute_extended(&buf);
957 assert!(
958 (stats.fill_ratio - 0.5).abs() < 1e-5,
959 "expected 0.5 got {}",
960 stats.fill_ratio
961 );
962 }
963 #[test]
964 fn test_particle_system_stats_kinetic_energy() {
965 let mut buf = ParticleBuffer::new(2);
966 buf.add_particle([0.0; 3], [2.0, 0.0, 0.0], 1.0, 5.0)
967 .unwrap();
968 buf.add_particle([0.0; 3], [0.0, 4.0, 0.0], 2.0, 5.0)
969 .unwrap();
970 let stats = ParticleSystemStats::compute_extended(&buf);
971 assert!(
972 (stats.total_kinetic_energy - 18.0).abs() < 1e-3,
973 "expected KE=18 got {}",
974 stats.total_kinetic_energy
975 );
976 }
977 #[test]
978 fn test_particle_system_stats_mean_age_zero_at_spawn() {
979 let mut buf = ParticleBuffer::new(2);
980 buf.add_particle([0.0; 3], [1.0, 0.0, 0.0], 1.0, 5.0)
981 .unwrap();
982 buf.add_particle([0.0; 3], [0.0, 1.0, 0.0], 1.0, 5.0)
983 .unwrap();
984 let stats = ParticleSystemStats::compute_extended(&buf);
985 assert!(
986 (stats.mean_age).abs() < 1e-5,
987 "fresh particles should have mean_age=0"
988 );
989 }
990 #[test]
991 fn test_velocity_histogram_basic_bin() {
992 let mut buf = ParticleBuffer::new(2);
993 buf.add_particle([0.0; 3], [1.0, 0.0, 0.0], 1.0, 5.0)
994 .unwrap();
995 buf.add_particle([0.0; 3], [3.0, 0.0, 0.0], 1.0, 5.0)
996 .unwrap();
997 let hist = compute_velocity_histogram(&buf, 5.0, 1.0);
998 assert!(hist[1] >= 1, "bin 1 should contain the speed=1.0 particle");
999 assert!(hist[3] >= 1, "bin 3 should contain the speed=3.0 particle");
1000 }
1001 #[test]
1002 fn test_velocity_histogram_empty_buffer() {
1003 let buf = ParticleBuffer::new(4);
1004 let hist = compute_velocity_histogram(&buf, 5.0, 1.0);
1005 let total: usize = hist.iter().sum();
1006 assert_eq!(total, 0, "empty buffer yields zero histogram");
1007 }
1008 #[test]
1009 fn test_velocity_histogram_length() {
1010 let buf = ParticleBuffer::new(1);
1011 let hist = compute_velocity_histogram(&buf, 10.0, 2.0);
1012 assert_eq!(hist.len(), 5, "ceil(10/2)=5 bins");
1013 }
1014 #[test]
1015 fn test_angular_momentum_z_axis() {
1016 let mut buf = ParticleBuffer::new(1);
1017 buf.add_particle([1.0, 0.0, 0.0], [0.0, 2.0, 0.0], 1.0, 5.0)
1018 .unwrap();
1019 let l = compute_angular_momentum(&buf);
1020 assert!((l[2] - 2.0).abs() < 1e-5, "Lz should be 2, got {}", l[2]);
1021 assert!(l[0].abs() < 1e-5);
1022 assert!(l[1].abs() < 1e-5);
1023 }
1024 #[test]
1025 fn test_angular_momentum_zero_for_radial_motion() {
1026 let mut buf = ParticleBuffer::new(1);
1027 buf.add_particle([1.0, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0, 5.0)
1028 .unwrap();
1029 let l = compute_angular_momentum(&buf);
1030 assert!(l[0].abs() < 1e-6);
1031 assert!(l[1].abs() < 1e-6);
1032 assert!(l[2].abs() < 1e-6);
1033 }
1034 #[test]
1035 fn test_angular_momentum_empty_buffer() {
1036 let buf = ParticleBuffer::new(4);
1037 let l = compute_angular_momentum(&buf);
1038 assert!(l[0].abs() < 1e-6 && l[1].abs() < 1e-6 && l[2].abs() < 1e-6);
1039 }
1040 #[test]
1041 fn test_emit_burst_count() {
1042 let mut buf = ParticleBuffer::new(10);
1043 let spawned = emit_burst(&mut buf, [0.0; 3], [0.0, 1.0, 0.0], 0.1, 5.0, 1.0, 5, 42);
1044 assert_eq!(spawned, 5, "should emit exactly 5 particles");
1045 }
1046 #[test]
1047 fn test_emit_burst_respects_buffer_capacity() {
1048 let mut buf = ParticleBuffer::new(3);
1049 let spawned = emit_burst(&mut buf, [0.0; 3], [0.0, 1.0, 0.0], 0.0, 5.0, 1.0, 100, 99);
1050 assert_eq!(spawned, 3, "cannot exceed buffer capacity");
1051 }
1052 #[test]
1053 fn test_emit_burst_particles_are_alive() {
1054 let mut buf = ParticleBuffer::new(5);
1055 emit_burst(&mut buf, [1.0, 2.0, 3.0], [0.0; 3], 0.0, 5.0, 1.0, 3, 7);
1056 let alive: usize = (0..buf.count).filter(|&i| buf.is_alive(i)).count();
1057 assert_eq!(alive, 3);
1058 }
1059}