#[derive(Debug, Clone, PartialEq)]
pub struct GpuParticle {
pub position: [f64; 3],
pub velocity: [f64; 3],
pub lifetime: f64,
pub color: [f32; 4],
}
impl GpuParticle {
pub fn new(position: [f64; 3], velocity: [f64; 3], lifetime: f64, color: [f32; 4]) -> Self {
Self {
position,
velocity,
lifetime,
color,
}
}
pub fn is_alive(&self) -> bool {
self.lifetime > 0.0
}
}
#[derive(Debug, Clone)]
pub struct EmitterConfig {
pub origin: [f64; 3],
pub initial_speed: f64,
pub spread_radians: f64,
pub particle_lifetime: f64,
pub color: [f32; 4],
}
impl Default for EmitterConfig {
fn default() -> Self {
Self {
origin: [0.0; 3],
initial_speed: 1.0,
spread_radians: 0.3,
particle_lifetime: 2.0,
color: [1.0, 1.0, 1.0, 1.0],
}
}
}
#[derive(Debug, Clone)]
pub struct GpuParticleSystem {
pub config: EmitterConfig,
pub particles: Vec<GpuParticle>,
pub max_particles: usize,
}
impl GpuParticleSystem {
pub fn new(config: EmitterConfig, max_particles: usize) -> Self {
Self {
config,
particles: Vec::with_capacity(max_particles),
max_particles,
}
}
pub fn active_count(&self) -> usize {
self.particles.len()
}
}
pub fn gpu_emit_particles(system: &mut GpuParticleSystem, n: usize) {
let cfg = &system.config;
let slots = system.max_particles.saturating_sub(system.particles.len());
let to_spawn = n.min(slots);
for i in 0..to_spawn {
let angle = if to_spawn > 1 {
let t = i as f64 / (to_spawn - 1) as f64;
(t - 0.5) * 2.0 * cfg.spread_radians
} else {
0.0
};
let vx = angle.sin() * cfg.initial_speed;
let vz = angle.cos() * cfg.initial_speed;
let velocity = [vx, 0.0, vz];
system.particles.push(GpuParticle::new(
cfg.origin,
velocity,
cfg.particle_lifetime,
cfg.color,
));
}
}
pub fn gpu_integrate_particles(system: &mut GpuParticleSystem, dt: f64) {
for p in &mut system.particles {
p.position[0] += p.velocity[0] * dt;
p.position[1] += p.velocity[1] * dt;
p.position[2] += p.velocity[2] * dt;
p.lifetime -= dt;
}
}
pub fn gpu_kill_dead_particles(system: &mut GpuParticleSystem) {
system.particles.retain(|p| p.is_alive());
}
pub fn gpu_sort_by_depth(system: &mut GpuParticleSystem, camera_dir: [f64; 3]) {
system.particles.sort_by(|a, b| {
let da = dot3(a.position, camera_dir);
let db = dot3(b.position, camera_dir);
db.partial_cmp(&da).unwrap_or(std::cmp::Ordering::Equal)
});
}
pub fn spawn_burst(system: &mut GpuParticleSystem, n: usize) {
gpu_emit_particles(system, n);
}
#[allow(dead_code)]
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[cfg(test)]
mod tests {
use super::*;
fn default_system(max: usize) -> GpuParticleSystem {
GpuParticleSystem::new(EmitterConfig::default(), max)
}
#[test]
fn test_particle_is_alive_positive_lifetime() {
let p = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4]);
assert!(p.is_alive());
}
#[test]
fn test_particle_is_dead_zero_lifetime() {
let p = GpuParticle::new([0.0; 3], [0.0; 3], 0.0, [1.0; 4]);
assert!(!p.is_alive());
}
#[test]
fn test_particle_is_dead_negative_lifetime() {
let p = GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]);
assert!(!p.is_alive());
}
#[test]
fn test_particle_new_fields() {
let pos = [1.0, 2.0, 3.0];
let vel = [0.1, 0.2, 0.3];
let lt = 5.0;
let col = [0.5, 0.5, 0.5, 1.0];
let p = GpuParticle::new(pos, vel, lt, col);
assert_eq!(p.position, pos);
assert_eq!(p.velocity, vel);
assert!((p.lifetime - lt).abs() < 1e-12);
assert_eq!(p.color, col);
}
#[test]
fn test_emitter_default_speed_positive() {
let cfg = EmitterConfig::default();
assert!(cfg.initial_speed > 0.0);
}
#[test]
fn test_emitter_default_lifetime_positive() {
let cfg = EmitterConfig::default();
assert!(cfg.particle_lifetime > 0.0);
}
#[test]
fn test_system_starts_empty() {
let sys = default_system(100);
assert_eq!(sys.active_count(), 0);
}
#[test]
fn test_system_max_particles_stored() {
let sys = default_system(42);
assert_eq!(sys.max_particles, 42);
}
#[test]
fn test_emit_spawns_n_particles() {
let mut sys = default_system(100);
gpu_emit_particles(&mut sys, 10);
assert_eq!(sys.active_count(), 10);
}
#[test]
fn test_emit_respects_max_particles() {
let mut sys = default_system(5);
gpu_emit_particles(&mut sys, 100);
assert_eq!(sys.active_count(), 5);
}
#[test]
fn test_emit_zero_particles() {
let mut sys = default_system(100);
gpu_emit_particles(&mut sys, 0);
assert_eq!(sys.active_count(), 0);
}
#[test]
fn test_emit_single_particle_at_origin() {
let mut sys = default_system(10);
gpu_emit_particles(&mut sys, 1);
assert_eq!(sys.particles[0].position, [0.0; 3]);
}
#[test]
fn test_emit_particles_have_positive_lifetime() {
let mut sys = default_system(10);
gpu_emit_particles(&mut sys, 5);
for p in &sys.particles {
assert!(p.lifetime > 0.0);
}
}
#[test]
fn test_integrate_moves_position() {
let mut sys = default_system(10);
sys.particles
.push(GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 5.0, [1.0; 4]));
gpu_integrate_particles(&mut sys, 1.0);
assert!((sys.particles[0].position[0] - 1.0).abs() < 1e-12);
}
#[test]
fn test_integrate_decrements_lifetime() {
let mut sys = default_system(10);
sys.particles
.push(GpuParticle::new([0.0; 3], [0.0; 3], 3.0, [1.0; 4]));
gpu_integrate_particles(&mut sys, 1.0);
assert!((sys.particles[0].lifetime - 2.0).abs() < 1e-12);
}
#[test]
fn test_integrate_zero_dt_no_movement() {
let mut sys = default_system(10);
sys.particles.push(GpuParticle::new(
[1.0, 2.0, 3.0],
[5.0, 5.0, 5.0],
1.0,
[1.0; 4],
));
gpu_integrate_particles(&mut sys, 0.0);
assert_eq!(sys.particles[0].position, [1.0, 2.0, 3.0]);
}
#[test]
fn test_integrate_multiple_steps() {
let mut sys = default_system(10);
sys.particles
.push(GpuParticle::new([0.0; 3], [2.0, 0.0, 0.0], 10.0, [1.0; 4]));
gpu_integrate_particles(&mut sys, 0.5);
gpu_integrate_particles(&mut sys, 0.5);
assert!((sys.particles[0].position[0] - 2.0).abs() < 1e-10);
}
#[test]
fn test_kill_removes_dead_particles() {
let mut sys = default_system(10);
sys.particles
.push(GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]));
sys.particles
.push(GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4]));
gpu_kill_dead_particles(&mut sys);
assert_eq!(sys.active_count(), 1);
assert!(sys.particles[0].is_alive());
}
#[test]
fn test_kill_all_dead() {
let mut sys = default_system(10);
for _ in 0..5 {
sys.particles
.push(GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]));
}
gpu_kill_dead_particles(&mut sys);
assert_eq!(sys.active_count(), 0);
}
#[test]
fn test_kill_none_dead() {
let mut sys = default_system(10);
gpu_emit_particles(&mut sys, 5);
gpu_kill_dead_particles(&mut sys);
assert_eq!(sys.active_count(), 5);
}
#[test]
fn test_integrate_then_kill() {
let mut sys = default_system(10);
sys.particles
.push(GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 0.5, [1.0; 4]));
gpu_integrate_particles(&mut sys, 1.0); gpu_kill_dead_particles(&mut sys);
assert_eq!(sys.active_count(), 0);
}
#[test]
fn test_sort_by_depth_back_to_front() {
let mut sys = default_system(10);
sys.particles
.push(GpuParticle::new([0.0, 0.0, 1.0], [0.0; 3], 1.0, [1.0; 4]));
sys.particles
.push(GpuParticle::new([0.0, 0.0, 5.0], [0.0; 3], 1.0, [1.0; 4]));
let cam = [0.0, 0.0, 1.0]; gpu_sort_by_depth(&mut sys, cam);
assert!((sys.particles[0].position[2] - 5.0).abs() < 1e-12);
}
#[test]
fn test_sort_by_depth_single_particle() {
let mut sys = default_system(10);
sys.particles
.push(GpuParticle::new([1.0, 2.0, 3.0], [0.0; 3], 1.0, [1.0; 4]));
gpu_sort_by_depth(&mut sys, [0.0, 0.0, 1.0]);
assert_eq!(sys.active_count(), 1);
}
#[test]
fn test_sort_by_depth_preserves_count() {
let mut sys = default_system(20);
gpu_emit_particles(&mut sys, 10);
gpu_sort_by_depth(&mut sys, [1.0, 0.0, 0.0]);
assert_eq!(sys.active_count(), 10);
}
#[test]
fn test_spawn_burst_emits_all_at_once() {
let mut sys = default_system(50);
spawn_burst(&mut sys, 20);
assert_eq!(sys.active_count(), 20);
}
#[test]
fn test_spawn_burst_respects_max() {
let mut sys = default_system(5);
spawn_burst(&mut sys, 100);
assert_eq!(sys.active_count(), 5);
}
#[test]
fn test_full_lifecycle() {
let mut sys = default_system(100);
spawn_burst(&mut sys, 30);
assert_eq!(sys.active_count(), 30);
let dt = EmitterConfig::default().particle_lifetime + 0.1;
gpu_integrate_particles(&mut sys, dt);
gpu_kill_dead_particles(&mut sys);
assert_eq!(sys.active_count(), 0);
}
#[test]
fn test_emission_incremental() {
let mut sys = default_system(100);
gpu_emit_particles(&mut sys, 10);
gpu_emit_particles(&mut sys, 10);
assert_eq!(sys.active_count(), 20);
}
#[test]
fn test_particle_color_propagated() {
let cfg = EmitterConfig {
color: [1.0, 0.0, 0.0, 1.0],
..Default::default()
};
let mut sys = GpuParticleSystem::new(cfg, 10);
gpu_emit_particles(&mut sys, 1);
assert_eq!(sys.particles[0].color, [1.0, 0.0, 0.0, 1.0]);
}
}