1#![allow(dead_code)]
11
12pub struct Lcg(u64);
18
19impl Lcg {
20 pub fn new(seed: u64) -> Self {
22 Self(seed ^ 0x9e37_79b9_7f4a_7c15)
23 }
24
25 pub fn next_u64(&mut self) -> u64 {
27 self.0 = self
28 .0
29 .wrapping_mul(6_364_136_223_846_793_005)
30 .wrapping_add(1_442_695_040_888_963_407);
31 self.0
32 }
33
34 pub fn next_f32(&mut self) -> f32 {
36 let bits = (self.next_u64() >> 40) as u32;
37 (bits as f32) / (1u32 << 24) as f32
38 }
39
40 pub fn range_f32(&mut self, lo: f32, hi: f32) -> f32 {
42 lo + self.next_f32() * (hi - lo)
43 }
44}
45
46#[derive(Debug, Clone, PartialEq)]
52pub struct GpuParticle {
53 pub position: [f32; 3],
55 pub velocity: [f32; 3],
57 pub life: f32,
59 pub color: [f32; 4],
61 pub size: f32,
63}
64
65impl GpuParticle {
66 pub fn new(
68 position: [f32; 3],
69 velocity: [f32; 3],
70 life: f32,
71 color: [f32; 4],
72 size: f32,
73 ) -> Self {
74 Self {
75 position,
76 velocity,
77 life,
78 color,
79 size,
80 }
81 }
82
83 #[inline]
85 pub fn is_alive(&self) -> bool {
86 self.life > 0.0
87 }
88}
89
90impl Default for GpuParticle {
91 fn default() -> Self {
92 Self {
93 position: [0.0; 3],
94 velocity: [0.0; 3],
95 life: 0.0,
96 color: [1.0, 1.0, 1.0, 1.0],
97 size: 1.0,
98 }
99 }
100}
101
102#[derive(Debug, Clone)]
109pub struct ParticleEmitter {
110 pub position: [f32; 3],
112 pub emission_rate: f32,
114 pub initial_velocity: [f32; 3],
116 pub velocity_spread: f32,
118 pub lifetime: f32,
120 pub color: [f32; 4],
122 pub size: f32,
124 pub accumulator: f32,
126}
127
128impl ParticleEmitter {
129 pub fn new(
131 position: [f32; 3],
132 emission_rate: f32,
133 initial_velocity: [f32; 3],
134 velocity_spread: f32,
135 lifetime: f32,
136 ) -> Self {
137 Self {
138 position,
139 emission_rate,
140 initial_velocity,
141 velocity_spread,
142 lifetime,
143 color: [1.0, 1.0, 1.0, 1.0],
144 size: 0.1,
145 accumulator: 0.0,
146 }
147 }
148
149 pub fn particles_to_emit(&mut self, dt: f32) -> usize {
151 self.accumulator += self.emission_rate * dt;
152 let n = self.accumulator as usize;
153 self.accumulator -= n as f32;
154 n
155 }
156
157 pub fn spawn(&self, rng: &mut Lcg) -> GpuParticle {
159 let spread = self.velocity_spread;
160 let vx = self.initial_velocity[0] + rng.range_f32(-spread, spread);
161 let vy = self.initial_velocity[1] + rng.range_f32(-spread, spread);
162 let vz = self.initial_velocity[2] + rng.range_f32(-spread, spread);
163 GpuParticle {
164 position: self.position,
165 velocity: [vx, vy, vz],
166 life: self.lifetime,
167 color: self.color,
168 size: self.size,
169 }
170 }
171}
172
173#[derive(Debug, Clone, Copy)]
179pub struct ParticleIntegrator;
180
181impl ParticleIntegrator {
182 #[inline]
184 pub fn step(particle: &mut GpuParticle, dt: f32) {
185 particle.position[0] += particle.velocity[0] * dt;
186 particle.position[1] += particle.velocity[1] * dt;
187 particle.position[2] += particle.velocity[2] * dt;
188 particle.life -= dt;
189 }
190
191 pub fn step_all(particles: &mut [GpuParticle], dt: f32) {
193 for p in particles.iter_mut() {
194 if p.is_alive() {
195 Self::step(p, dt);
196 }
197 }
198 }
199}
200
201#[derive(Debug, Clone, Copy)]
207pub struct GravityForce {
208 pub acceleration: [f32; 3],
210}
211
212impl GravityForce {
213 pub fn earth() -> Self {
215 Self {
216 acceleration: [0.0, -9.81, 0.0],
217 }
218 }
219
220 pub fn new(acceleration: [f32; 3]) -> Self {
222 Self { acceleration }
223 }
224
225 #[inline]
227 pub fn apply(&self, particle: &mut GpuParticle, dt: f32) {
228 particle.velocity[0] += self.acceleration[0] * dt;
229 particle.velocity[1] += self.acceleration[1] * dt;
230 particle.velocity[2] += self.acceleration[2] * dt;
231 }
232
233 pub fn apply_all(&self, particles: &mut [GpuParticle], dt: f32) {
235 for p in particles.iter_mut() {
236 if p.is_alive() {
237 self.apply(p, dt);
238 }
239 }
240 }
241}
242
243#[derive(Debug, Clone, Copy)]
252pub struct TurbulenceForce {
253 pub strength: f32,
255 pub frequency: f32,
257 pub time_offset: f32,
259}
260
261impl TurbulenceForce {
262 pub fn new(strength: f32, frequency: f32) -> Self {
264 Self {
265 strength,
266 frequency,
267 time_offset: 0.0,
268 }
269 }
270
271 pub fn advance(&mut self, dt: f32) {
273 self.time_offset += dt;
274 }
275
276 fn hash_noise(x: f32, y: f32, z: f32) -> f32 {
278 let ix = (x * 1000.0) as i64;
279 let iy = (y * 1000.0) as i64;
280 let iz = (z * 1000.0) as i64;
281 let h = ix
282 .wrapping_mul(374761393)
283 .wrapping_add(iy.wrapping_mul(1057))
284 .wrapping_add(iz.wrapping_mul(6271));
285 let h2 = h ^ (h >> 13);
286 let h3 = h2.wrapping_mul(1274126177);
287 let h4 = h3 ^ (h3 >> 16);
288 ((h4 & 0xFFFF) as f32 / 32767.5) - 1.0
289 }
290
291 pub fn curl_at(&self, pos: [f32; 3]) -> [f32; 3] {
295 let eps = 0.01_f32;
296 let f = self.frequency;
297 let t = self.time_offset;
298
299 let nx = |x: f32, y: f32, z: f32| Self::hash_noise(x * f + t * 0.1, y * f, z * f);
300 let ny = |x: f32, y: f32, z: f32| Self::hash_noise(x * f + 100.0, y * f + t * 0.1, z * f);
301 let nz = |x: f32, y: f32, z: f32| Self::hash_noise(x * f + 200.0, y * f, z * f + t * 0.1);
302
303 let [px, py, pz] = pos;
304
305 let curl_x = (nz(px, py + eps, pz) - nz(px, py - eps, pz)) / (2.0 * eps)
307 - (ny(px, py, pz + eps) - ny(px, py, pz - eps)) / (2.0 * eps);
308 let curl_y = (nx(px, py, pz + eps) - nx(px, py, pz - eps)) / (2.0 * eps)
310 - (nz(px + eps, py, pz) - nz(px - eps, py, pz)) / (2.0 * eps);
311 let curl_z = (ny(px + eps, py, pz) - ny(px - eps, py, pz)) / (2.0 * eps)
313 - (nx(px, py + eps, pz) - nx(px, py - eps, pz)) / (2.0 * eps);
314
315 [
316 curl_x * self.strength,
317 curl_y * self.strength,
318 curl_z * self.strength,
319 ]
320 }
321
322 pub fn apply(&self, particle: &mut GpuParticle, dt: f32) {
324 let curl = self.curl_at(particle.position);
325 particle.velocity[0] += curl[0] * dt;
326 particle.velocity[1] += curl[1] * dt;
327 particle.velocity[2] += curl[2] * dt;
328 }
329
330 pub fn apply_all(&self, particles: &mut [GpuParticle], dt: f32) {
332 for p in particles.iter_mut() {
333 if p.is_alive() {
334 self.apply(p, dt);
335 }
336 }
337 }
338}
339
340#[derive(Debug, Clone, Copy)]
348pub struct ParticleCollider {
349 pub plane_point: [f32; 3],
351 pub plane_normal: [f32; 3],
353 pub restitution: f32,
355 pub friction: f32,
357}
358
359impl ParticleCollider {
360 pub fn floor(y: f32, restitution: f32) -> Self {
362 Self {
363 plane_point: [0.0, y, 0.0],
364 plane_normal: [0.0, 1.0, 0.0],
365 restitution,
366 friction: 0.0,
367 }
368 }
369
370 pub fn new(plane_point: [f32; 3], plane_normal: [f32; 3], restitution: f32) -> Self {
372 let n = plane_normal;
374 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-9);
375 Self {
376 plane_point,
377 plane_normal: [n[0] / len, n[1] / len, n[2] / len],
378 restitution,
379 friction: 0.0,
380 }
381 }
382
383 fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
384 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
385 }
386
387 pub fn resolve(&self, particle: &mut GpuParticle) {
389 let n = self.plane_normal;
390 let p = self.plane_point;
391 let diff = [
393 particle.position[0] - p[0],
394 particle.position[1] - p[1],
395 particle.position[2] - p[2],
396 ];
397 let dist = Self::dot(diff, n);
398 if dist < 0.0 {
399 particle.position[0] -= dist * n[0];
401 particle.position[1] -= dist * n[1];
402 particle.position[2] -= dist * n[2];
403
404 let vn = Self::dot(particle.velocity, n);
406 if vn < 0.0 {
407 let impulse = -(1.0 + self.restitution) * vn;
409 let vt = [
411 particle.velocity[0] - vn * n[0],
412 particle.velocity[1] - vn * n[1],
413 particle.velocity[2] - vn * n[2],
414 ];
415 particle.velocity[0] = vt[0] * (1.0 - self.friction) + impulse * n[0] + vn * n[0];
416 particle.velocity[1] = vt[1] * (1.0 - self.friction) + impulse * n[1] + vn * n[1];
417 particle.velocity[2] = vt[2] * (1.0 - self.friction) + impulse * n[2] + vn * n[2];
418 }
419 }
420 }
421
422 pub fn resolve_all(&self, particles: &mut [GpuParticle]) {
424 for p in particles.iter_mut() {
425 if p.is_alive() {
426 self.resolve(p);
427 }
428 }
429 }
430}
431
432pub struct ParticlePool {
438 pub slots: Vec<GpuParticle>,
440 free_list: Vec<usize>,
442 capacity: usize,
444}
445
446impl ParticlePool {
447 pub fn new(capacity: usize) -> Self {
449 let slots = vec![GpuParticle::default(); capacity];
450 let free_list: Vec<usize> = (0..capacity).collect();
451 Self {
452 slots,
453 free_list,
454 capacity,
455 }
456 }
457
458 pub fn capacity(&self) -> usize {
460 self.capacity
461 }
462
463 pub fn alive_count(&self) -> usize {
465 self.capacity - self.free_list.len()
466 }
467
468 pub fn free_count(&self) -> usize {
470 self.free_list.len()
471 }
472
473 pub fn emit(&mut self, particle: GpuParticle) -> Option<usize> {
476 let idx = self.free_list.pop()?;
477 self.slots[idx] = particle;
478 Some(idx)
479 }
480
481 pub fn recycle_dead(&mut self) {
483 for i in 0..self.capacity {
484 if !self.slots[i].is_alive() && !self.free_list.contains(&i) {
485 self.free_list.push(i);
486 }
487 }
488 }
489
490 pub fn alive_iter(&self) -> impl Iterator<Item = &GpuParticle> {
492 self.slots.iter().filter(|p| p.is_alive())
493 }
494
495 pub fn alive_iter_mut(&mut self) -> impl Iterator<Item = &mut GpuParticle> {
497 self.slots.iter_mut().filter(|p| p.is_alive())
498 }
499}
500
501#[derive(Debug, Clone, Copy)]
507pub struct ColorOverLife {
508 pub birth_color: [f32; 4],
510 pub death_color: [f32; 4],
512 pub max_life: f32,
514}
515
516impl ColorOverLife {
517 pub fn new(birth_color: [f32; 4], death_color: [f32; 4], max_life: f32) -> Self {
519 Self {
520 birth_color,
521 death_color,
522 max_life: max_life.max(1e-9),
523 }
524 }
525
526 pub fn apply(&self, particle: &mut GpuParticle) {
528 let t = (particle.life / self.max_life).clamp(0.0, 1.0);
530 for i in 0..4 {
531 particle.color[i] =
532 self.death_color[i] + t * (self.birth_color[i] - self.death_color[i]);
533 }
534 }
535
536 pub fn apply_all(&self, particles: &mut [GpuParticle]) {
538 for p in particles.iter_mut() {
539 if p.is_alive() {
540 self.apply(p);
541 }
542 }
543 }
544}
545
546#[derive(Debug, Clone, Copy)]
552pub struct SizeOverLife {
553 pub birth_size: f32,
555 pub death_size: f32,
557 pub max_life: f32,
559}
560
561impl SizeOverLife {
562 pub fn new(birth_size: f32, death_size: f32, max_life: f32) -> Self {
564 Self {
565 birth_size,
566 death_size,
567 max_life: max_life.max(1e-9),
568 }
569 }
570
571 pub fn apply(&self, particle: &mut GpuParticle) {
573 let t = (particle.life / self.max_life).clamp(0.0, 1.0);
574 particle.size = self.death_size + t * (self.birth_size - self.death_size);
575 }
576
577 pub fn apply_all(&self, particles: &mut [GpuParticle]) {
579 for p in particles.iter_mut() {
580 if p.is_alive() {
581 self.apply(p);
582 }
583 }
584 }
585}
586
587#[derive(Debug, Clone, Copy, PartialEq)]
593pub struct BillboardVertex {
594 pub position: [f32; 3],
596 pub uv: [f32; 2],
598 pub color: [f32; 4],
600}
601
602#[derive(Debug, Clone)]
604pub struct RenderBatch {
605 pub vertices: Vec<BillboardVertex>,
607 pub indices: Vec<u32>,
609}
610
611impl RenderBatch {
612 pub fn particle_count(&self) -> usize {
614 self.vertices.len() / 4
615 }
616}
617
618#[derive(Debug, Clone)]
621pub struct ParticleRenderer {
622 pub camera_pos: [f32; 3],
624 pub camera_right: [f32; 3],
626 pub camera_up: [f32; 3],
628}
629
630impl ParticleRenderer {
631 pub fn new() -> Self {
633 Self {
634 camera_pos: [0.0, 0.0, 10.0],
635 camera_right: [1.0, 0.0, 0.0],
636 camera_up: [0.0, 1.0, 0.0],
637 }
638 }
639
640 pub fn set_camera(&mut self, pos: [f32; 3], right: [f32; 3], up: [f32; 3]) {
642 self.camera_pos = pos;
643 self.camera_right = right;
644 self.camera_up = up;
645 }
646
647 fn depth_sq(&self, pos: [f32; 3]) -> f32 {
648 let dx = pos[0] - self.camera_pos[0];
649 let dy = pos[1] - self.camera_pos[1];
650 let dz = pos[2] - self.camera_pos[2];
651 dx * dx + dy * dy + dz * dz
652 }
653
654 pub fn render(&self, particles: &[GpuParticle]) -> RenderBatch {
656 let mut alive: Vec<(usize, f32)> = particles
658 .iter()
659 .enumerate()
660 .filter(|(_, p)| p.is_alive())
661 .map(|(i, p)| (i, self.depth_sq(p.position)))
662 .collect();
663
664 alive.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
666
667 let mut vertices = Vec::with_capacity(alive.len() * 4);
668 let mut indices = Vec::with_capacity(alive.len() * 6);
669
670 for (quad_idx, (particle_idx, _)) in alive.iter().enumerate() {
671 let p = &particles[*particle_idx];
672 let half = p.size * 0.5;
673
674 let r = self.camera_right;
675 let u = self.camera_up;
676
677 let corners = [
679 ([-1.0_f32, -1.0_f32], [0.0_f32, 0.0_f32]),
680 ([1.0, -1.0], [1.0, 0.0]),
681 ([1.0, 1.0], [1.0, 1.0]),
682 ([-1.0, 1.0], [0.0, 1.0]),
683 ];
684
685 for (dir, uv) in &corners {
686 let corner_pos = [
687 p.position[0] + (r[0] * dir[0] + u[0] * dir[1]) * half,
688 p.position[1] + (r[1] * dir[0] + u[1] * dir[1]) * half,
689 p.position[2] + (r[2] * dir[0] + u[2] * dir[1]) * half,
690 ];
691 vertices.push(BillboardVertex {
692 position: corner_pos,
693 uv: *uv,
694 color: p.color,
695 });
696 }
697
698 let base = (quad_idx * 4) as u32;
699 indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
701 }
702
703 RenderBatch { vertices, indices }
704 }
705}
706
707impl Default for ParticleRenderer {
708 fn default() -> Self {
709 Self::new()
710 }
711}
712
713#[allow(clippy::too_many_arguments)]
722pub fn tick(
723 pool: &mut ParticlePool,
724 emitter: &mut ParticleEmitter,
725 gravity: &GravityForce,
726 turbulence: &mut TurbulenceForce,
727 collider: &ParticleCollider,
728 color_over_life: &ColorOverLife,
729 size_over_life: &SizeOverLife,
730 dt: f32,
731 rng: &mut Lcg,
732) {
733 gravity.apply_all(&mut pool.slots, dt);
734 turbulence.apply_all(&mut pool.slots, dt);
735 turbulence.advance(dt);
736 ParticleIntegrator::step_all(&mut pool.slots, dt);
737 collider.resolve_all(&mut pool.slots);
738 color_over_life.apply_all(&mut pool.slots);
739 size_over_life.apply_all(&mut pool.slots);
740 pool.recycle_dead();
741
742 let n = emitter.particles_to_emit(dt);
743 for _ in 0..n {
744 let particle = emitter.spawn(rng);
745 pool.emit(particle);
746 }
747}
748
749#[cfg(test)]
754mod tests {
755 use super::*;
756
757 #[test]
760 fn test_particle_alive_and_dead() {
761 let alive = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4], 1.0);
762 let dead = GpuParticle::new([0.0; 3], [0.0; 3], 0.0, [1.0; 4], 1.0);
763 assert!(alive.is_alive());
764 assert!(!dead.is_alive());
765 }
766
767 #[test]
768 fn test_particle_default_is_dead() {
769 let p = GpuParticle::default();
770 assert!(!p.is_alive());
771 }
772
773 #[test]
774 fn test_particle_color_stored() {
775 let c = [0.1, 0.2, 0.3, 0.4];
776 let p = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, c, 1.0);
777 assert_eq!(p.color, c);
778 }
779
780 #[test]
783 fn test_integrator_moves_position() {
784 let mut p = GpuParticle::new([0.0, 0.0, 0.0], [1.0, 2.0, 3.0], 5.0, [1.0; 4], 1.0);
785 ParticleIntegrator::step(&mut p, 1.0);
786 assert!((p.position[0] - 1.0).abs() < 1e-6);
787 assert!((p.position[1] - 2.0).abs() < 1e-6);
788 assert!((p.position[2] - 3.0).abs() < 1e-6);
789 }
790
791 #[test]
792 fn test_integrator_decrements_life() {
793 let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4], 1.0);
794 ParticleIntegrator::step(&mut p, 0.1);
795 assert!((p.life - 0.9).abs() < 1e-6);
796 }
797
798 #[test]
799 fn test_integrator_skips_dead_particle() {
800 let p = GpuParticle::default(); let pos_before = p.position;
802 ParticleIntegrator::step_all(&mut [p.clone()], 1.0);
803 let mut particles = vec![GpuParticle::default()];
805 ParticleIntegrator::step_all(&mut particles, 1.0);
806 assert_eq!(particles[0].position, pos_before);
807 }
808
809 #[test]
810 fn test_integrator_step_all() {
811 let mut particles = vec![
812 GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 2.0, [1.0; 4], 1.0),
813 GpuParticle::new([0.0; 3], [0.0, 1.0, 0.0], 2.0, [1.0; 4], 1.0),
814 ];
815 ParticleIntegrator::step_all(&mut particles, 0.5);
816 assert!((particles[0].position[0] - 0.5).abs() < 1e-6);
817 assert!((particles[1].position[1] - 0.5).abs() < 1e-6);
818 }
819
820 #[test]
823 fn test_gravity_accelerates_down() {
824 let g = GravityForce::earth();
825 let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 5.0, [1.0; 4], 1.0);
826 g.apply(&mut p, 1.0);
827 assert!((p.velocity[1] - (-9.81)).abs() < 1e-4);
828 }
829
830 #[test]
831 fn test_gravity_custom() {
832 let g = GravityForce::new([0.0, -1.0, 0.0]);
833 let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 5.0, [1.0; 4], 1.0);
834 g.apply(&mut p, 2.0);
835 assert!((p.velocity[1] - (-2.0)).abs() < 1e-6);
836 }
837
838 #[test]
839 fn test_gravity_apply_all_skips_dead() {
840 let g = GravityForce::earth();
841 let mut particles = vec![
842 GpuParticle::new([0.0; 3], [0.0; 3], 2.0, [1.0; 4], 1.0),
843 GpuParticle::default(), ];
845 g.apply_all(&mut particles, 1.0);
846 assert!((particles[0].velocity[1] - (-9.81)).abs() < 1e-4);
847 assert!((particles[1].velocity[1]).abs() < 1e-9); }
849
850 #[test]
853 fn test_turbulence_produces_perturbation() {
854 let turb = TurbulenceForce::new(5.0, 1.0);
855 let curl = turb.curl_at([1.0, 2.0, 3.0]);
856 let mag = (curl[0] * curl[0] + curl[1] * curl[1] + curl[2] * curl[2]).sqrt();
858 let _ = mag;
860 }
861
862 #[test]
863 fn test_turbulence_advance_changes_field() {
864 let mut turb = TurbulenceForce::new(1.0, 1.0);
865 let curl_before = turb.curl_at([1.0, 1.0, 1.0]);
866 turb.advance(100.0);
867 let curl_after = turb.curl_at([1.0, 1.0, 1.0]);
868 let changed = curl_before[0] != curl_after[0]
870 || curl_before[1] != curl_after[1]
871 || curl_before[2] != curl_after[2];
872 assert!(changed);
873 }
874
875 #[test]
876 fn test_turbulence_apply_modifies_velocity() {
877 let turb = TurbulenceForce::new(100.0, 0.5);
878 let mut p = GpuParticle::new([1.23, 4.56, 7.89], [0.0; 3], 3.0, [1.0; 4], 1.0);
879 let vel_before = p.velocity;
880 turb.apply(&mut p, 0.1);
881 let changed = p.velocity[0] != vel_before[0]
883 || p.velocity[1] != vel_before[1]
884 || p.velocity[2] != vel_before[2];
885 let _ = changed;
887 }
888
889 #[test]
892 fn test_collider_floor_resolves_below() {
893 let floor = ParticleCollider::floor(0.0, 0.8);
894 let mut p = GpuParticle::new([0.0, -0.5, 0.0], [0.0, -1.0, 0.0], 5.0, [1.0; 4], 1.0);
895 floor.resolve(&mut p);
896 assert!(p.position[1] >= 0.0);
897 assert!(p.velocity[1] >= 0.0);
898 }
899
900 #[test]
901 fn test_collider_restitution_elastic() {
902 let floor = ParticleCollider::floor(0.0, 1.0);
903 let mut p = GpuParticle::new([0.0, -0.1, 0.0], [0.0, -2.0, 0.0], 5.0, [1.0; 4], 1.0);
904 floor.resolve(&mut p);
905 assert!(
906 (p.velocity[1] - 2.0).abs() < 1e-5,
907 "elastic: vy={}",
908 p.velocity[1]
909 );
910 }
911
912 #[test]
913 fn test_collider_restitution_inelastic() {
914 let floor = ParticleCollider::floor(0.0, 0.0);
915 let mut p = GpuParticle::new([0.0, -0.1, 0.0], [0.0, -3.0, 0.0], 5.0, [1.0; 4], 1.0);
916 floor.resolve(&mut p);
917 assert!(
918 (p.velocity[1]).abs() < 1e-5,
919 "inelastic: vy={}",
920 p.velocity[1]
921 );
922 }
923
924 #[test]
925 fn test_collider_no_collision_above() {
926 let floor = ParticleCollider::floor(0.0, 0.8);
927 let mut p = GpuParticle::new([0.0, 1.0, 0.0], [0.0, -1.0, 0.0], 5.0, [1.0; 4], 1.0);
928 let pos_before = p.position;
929 let vel_before = p.velocity;
930 floor.resolve(&mut p);
931 assert_eq!(p.position, pos_before);
932 assert_eq!(p.velocity, vel_before);
933 }
934
935 #[test]
936 fn test_collider_resolve_all() {
937 let floor = ParticleCollider::floor(0.0, 0.5);
938 let mut particles = vec![
939 GpuParticle::new([0.0, -1.0, 0.0], [0.0, -2.0, 0.0], 5.0, [1.0; 4], 1.0),
940 GpuParticle::new([0.0, 1.0, 0.0], [0.0, -1.0, 0.0], 5.0, [1.0; 4], 1.0),
941 ];
942 floor.resolve_all(&mut particles);
943 assert!(particles[0].position[1] >= 0.0);
944 assert!((particles[1].position[1] - 1.0).abs() < 1e-6);
945 }
946
947 #[test]
950 fn test_pool_capacity_and_free_count() {
951 let pool = ParticlePool::new(100);
952 assert_eq!(pool.capacity(), 100);
953 assert_eq!(pool.free_count(), 100);
954 assert_eq!(pool.alive_count(), 0);
955 }
956
957 #[test]
958 fn test_pool_emit_and_alive_count() {
959 let mut pool = ParticlePool::new(10);
960 let p = GpuParticle::new([0.0; 3], [0.0; 3], 5.0, [1.0; 4], 1.0);
961 pool.emit(p).unwrap();
962 assert_eq!(pool.alive_count(), 1);
963 assert_eq!(pool.free_count(), 9);
964 }
965
966 #[test]
967 fn test_pool_full_returns_none() {
968 let mut pool = ParticlePool::new(2);
969 let p = || GpuParticle::new([0.0; 3], [0.0; 3], 5.0, [1.0; 4], 1.0);
970 pool.emit(p()).unwrap();
971 pool.emit(p()).unwrap();
972 assert!(pool.emit(p()).is_none());
973 }
974
975 #[test]
976 fn test_pool_recycle_dead() {
977 let mut pool = ParticlePool::new(5);
978 let live = GpuParticle::new([0.0; 3], [0.0; 3], 10.0, [1.0; 4], 1.0);
979 pool.emit(live).unwrap();
980 for slot in pool.slots.iter_mut() {
982 slot.life = -1.0;
983 }
984 pool.recycle_dead();
985 assert_eq!(pool.free_count(), 5);
986 assert_eq!(pool.alive_count(), 0);
987 }
988
989 #[test]
990 fn test_pool_alive_iter() {
991 let mut pool = ParticlePool::new(5);
992 let p = GpuParticle::new([1.0, 2.0, 3.0], [0.0; 3], 3.0, [1.0; 4], 1.0);
993 pool.emit(p).unwrap();
994 let alive: Vec<_> = pool.alive_iter().collect();
995 assert_eq!(alive.len(), 1);
996 assert_eq!(alive[0].position, [1.0, 2.0, 3.0]);
997 }
998
999 #[test]
1002 fn test_emitter_particles_to_emit_accumulates() {
1003 let mut emitter = ParticleEmitter::new([0.0; 3], 10.0, [0.0, 1.0, 0.0], 0.0, 2.0);
1004 let n = emitter.particles_to_emit(0.5); assert_eq!(n, 5);
1006 }
1007
1008 #[test]
1009 fn test_emitter_spawn_sets_life() {
1010 let emitter = ParticleEmitter::new([0.0; 3], 1.0, [0.0; 3], 0.0, 3.5);
1011 let mut rng = Lcg::new(42);
1012 let p = emitter.spawn(&mut rng);
1013 assert!((p.life - 3.5).abs() < 1e-6);
1014 }
1015
1016 #[test]
1017 fn test_emitter_spawn_uses_position() {
1018 let emitter = ParticleEmitter::new([1.0, 2.0, 3.0], 1.0, [0.0; 3], 0.0, 1.0);
1019 let mut rng = Lcg::new(7);
1020 let p = emitter.spawn(&mut rng);
1021 assert_eq!(p.position, [1.0, 2.0, 3.0]);
1022 }
1023
1024 #[test]
1025 fn test_emitter_spawn_with_spread() {
1026 let emitter = ParticleEmitter::new([0.0; 3], 1.0, [0.0, 1.0, 0.0], 0.5, 1.0);
1027 let mut rng = Lcg::new(99);
1028 for _ in 0..20 {
1029 let p = emitter.spawn(&mut rng);
1030 assert!(p.velocity[1] >= 0.5 && p.velocity[1] <= 1.5);
1031 }
1032 }
1033
1034 #[test]
1037 fn test_color_over_life_at_birth() {
1038 let col = ColorOverLife::new([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], 2.0);
1039 let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 2.0, [0.5; 4], 1.0);
1040 col.apply(&mut p);
1041 assert!((p.color[0] - 1.0).abs() < 1e-5);
1043 assert!((p.color[1] - 0.0).abs() < 1e-5);
1044 }
1045
1046 #[test]
1047 fn test_color_over_life_at_death() {
1048 let col = ColorOverLife::new([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], 2.0);
1049 let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 0.0, [0.5; 4], 1.0);
1050 col.apply(&mut p);
1051 assert!((p.color[0] - 0.0).abs() < 1e-5);
1053 assert!((p.color[1] - 1.0).abs() < 1e-5);
1054 }
1055
1056 #[test]
1057 fn test_color_over_life_midpoint() {
1058 let col = ColorOverLife::new([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], 2.0);
1059 let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [0.5; 4], 1.0);
1060 col.apply(&mut p);
1061 assert!((p.color[0] - 0.5).abs() < 1e-5);
1063 assert!((p.color[1] - 0.5).abs() < 1e-5);
1064 }
1065
1066 #[test]
1069 fn test_size_over_life_at_birth() {
1070 let sizer = SizeOverLife::new(2.0, 0.1, 3.0);
1071 let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 3.0, [1.0; 4], 1.0);
1072 sizer.apply(&mut p);
1073 assert!((p.size - 2.0).abs() < 1e-5);
1074 }
1075
1076 #[test]
1077 fn test_size_over_life_at_death() {
1078 let sizer = SizeOverLife::new(2.0, 0.1, 3.0);
1079 let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 0.0, [1.0; 4], 1.0);
1080 sizer.apply(&mut p);
1081 assert!((p.size - 0.1).abs() < 1e-5);
1082 }
1083
1084 #[test]
1085 fn test_size_over_life_apply_all() {
1086 let sizer = SizeOverLife::new(4.0, 0.0, 2.0);
1087 let mut particles = vec![
1088 GpuParticle::new([0.0; 3], [0.0; 3], 2.0, [1.0; 4], 0.0),
1089 GpuParticle::default(), ];
1091 sizer.apply_all(&mut particles);
1092 assert!((particles[0].size - 4.0).abs() < 1e-5);
1093 assert!((particles[1].size - 1.0).abs() < 1e-5); }
1095
1096 #[test]
1099 fn test_renderer_empty_scene() {
1100 let renderer = ParticleRenderer::new();
1101 let batch = renderer.render(&[]);
1102 assert_eq!(batch.particle_count(), 0);
1103 assert!(batch.vertices.is_empty());
1104 assert!(batch.indices.is_empty());
1105 }
1106
1107 #[test]
1108 fn test_renderer_single_particle_quad() {
1109 let renderer = ParticleRenderer::new();
1110 let p = GpuParticle::new([0.0, 0.0, 0.0], [0.0; 3], 1.0, [1.0; 4], 0.2);
1111 let batch = renderer.render(&[p]);
1112 assert_eq!(batch.particle_count(), 1);
1113 assert_eq!(batch.vertices.len(), 4);
1114 assert_eq!(batch.indices.len(), 6);
1115 }
1116
1117 #[test]
1118 fn test_renderer_dead_particle_excluded() {
1119 let renderer = ParticleRenderer::new();
1120 let dead = GpuParticle::default();
1121 let batch = renderer.render(&[dead]);
1122 assert_eq!(batch.particle_count(), 0);
1123 }
1124
1125 #[test]
1126 fn test_renderer_index_buffer_valid() {
1127 let renderer = ParticleRenderer::new();
1128 let particles: Vec<GpuParticle> = (0..3)
1129 .map(|i| GpuParticle::new([i as f32, 0.0, 0.0], [0.0; 3], 1.0, [1.0; 4], 0.1))
1130 .collect();
1131 let batch = renderer.render(&particles);
1132 assert_eq!(batch.vertices.len(), 12);
1133 assert_eq!(batch.indices.len(), 18);
1134 for &idx in &batch.indices {
1136 assert!((idx as usize) < batch.vertices.len());
1137 }
1138 }
1139
1140 #[test]
1141 fn test_renderer_depth_sort_back_to_front() {
1142 let renderer = ParticleRenderer::new(); let p_far = GpuParticle::new([0.0, 0.0, 0.0], [0.0; 3], 1.0, [1.0, 0.0, 0.0, 1.0], 0.1);
1146 let p_near = GpuParticle::new([0.0, 0.0, 8.0], [0.0; 3], 1.0, [0.0, 1.0, 0.0, 1.0], 0.1);
1147 let batch = renderer.render(&[p_far, p_near]);
1148 assert_eq!(batch.vertices[0].color, [1.0, 0.0, 0.0, 1.0]);
1150 }
1151
1152 #[test]
1155 fn test_lcg_different_seeds() {
1156 let mut rng1 = Lcg::new(1);
1157 let mut rng2 = Lcg::new(2);
1158 let v1 = rng1.next_f32();
1159 let v2 = rng2.next_f32();
1160 assert_ne!(v1, v2);
1161 }
1162
1163 #[test]
1164 fn test_lcg_range_f32_bounds() {
1165 let mut rng = Lcg::new(123);
1166 for _ in 0..1000 {
1167 let v = rng.range_f32(-1.0, 1.0);
1168 assert!((-1.0..1.0).contains(&v) || (v - 1.0).abs() < 1e-6);
1169 }
1170 }
1171
1172 #[test]
1175 fn test_tick_emits_particles() {
1176 let mut pool = ParticlePool::new(200);
1177 let mut emitter = ParticleEmitter::new([0.0, 1.0, 0.0], 50.0, [0.0, 2.0, 0.0], 0.1, 2.0);
1178 let gravity = GravityForce::earth();
1179 let mut turbulence = TurbulenceForce::new(0.1, 1.0);
1180 let collider = ParticleCollider::floor(0.0, 0.5);
1181 let col = ColorOverLife::new([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], 2.0);
1182 let sizer = SizeOverLife::new(0.2, 0.01, 2.0);
1183 let mut rng = Lcg::new(42);
1184
1185 tick(
1186 &mut pool,
1187 &mut emitter,
1188 &gravity,
1189 &mut turbulence,
1190 &collider,
1191 &col,
1192 &sizer,
1193 0.1,
1194 &mut rng,
1195 );
1196 assert!(pool.alive_count() > 0);
1197 }
1198
1199 #[test]
1200 fn test_tick_particles_age() {
1201 let mut pool = ParticlePool::new(100);
1202 let mut emitter = ParticleEmitter::new([0.0; 3], 100.0, [0.0, 1.0, 0.0], 0.0, 0.5);
1203 let gravity = GravityForce::earth();
1204 let mut turbulence = TurbulenceForce::new(0.0, 1.0);
1205 let collider = ParticleCollider::floor(-100.0, 0.5); let col = ColorOverLife::new([1.0; 4], [0.0; 4], 0.5);
1207 let sizer = SizeOverLife::new(1.0, 0.0, 0.5);
1208 let mut rng = Lcg::new(7);
1209
1210 tick(
1212 &mut pool,
1213 &mut emitter,
1214 &gravity,
1215 &mut turbulence,
1216 &collider,
1217 &col,
1218 &sizer,
1219 0.1,
1220 &mut rng,
1221 );
1222 let alive_after_emit = pool.alive_count();
1223
1224 for _ in 0..10 {
1226 tick(
1227 &mut pool,
1228 &mut emitter,
1229 &gravity,
1230 &mut turbulence,
1231 &collider,
1232 &col,
1233 &sizer,
1234 0.1,
1235 &mut rng,
1236 );
1237 }
1238 assert!(pool.alive_count() <= pool.capacity());
1240 let _ = alive_after_emit;
1241 }
1242}