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