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