use std::collections::HashMap;
use galeon_engine_macros::Component;
use crate::commands::Commands;
use crate::entity::Entity;
use crate::game_loop::FixedTimestep;
use crate::system_param::{QueryMut, Res};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FloatDist {
Constant(f32),
Uniform { min: f32, max: f32 },
}
impl FloatDist {
pub fn sample(&self, rng: &mut ParticleRng) -> f32 {
match *self {
Self::Constant(v) => v,
Self::Uniform { min, max } => lerp_uniform(min, max, rng.next_f32()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Vec3Dist {
Constant([f32; 3]),
UniformBox { min: [f32; 3], max: [f32; 3] },
}
impl Vec3Dist {
pub fn sample(&self, rng: &mut ParticleRng) -> [f32; 3] {
match *self {
Self::Constant(v) => v,
Self::UniformBox { min, max } => [
lerp_uniform(min[0], max[0], rng.next_f32()),
lerp_uniform(min[1], max[1], rng.next_f32()),
lerp_uniform(min[2], max[2], rng.next_f32()),
],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ColorDist {
Constant([f32; 3]),
UniformBox { min: [f32; 3], max: [f32; 3] },
}
impl ColorDist {
pub fn sample(&self, rng: &mut ParticleRng) -> [f32; 3] {
match *self {
Self::Constant(v) => v,
Self::UniformBox { min, max } => [
lerp_uniform(min[0], max[0], rng.next_f32()),
lerp_uniform(min[1], max[1], rng.next_f32()),
lerp_uniform(min[2], max[2], rng.next_f32()),
],
}
}
}
fn lerp_uniform(a: f32, b: f32, t: f32) -> f32 {
let lo = a.min(b);
let hi = a.max(b);
lo + t * (hi - lo)
}
#[derive(Debug, Clone)]
pub struct ParticleRng {
state: u64,
}
impl ParticleRng {
pub fn from_seed(seed: u64) -> Self {
let mut state = seed
.wrapping_add(0x9E37_79B9_7F4A_7C15)
.wrapping_mul(0xBF58_476D_1CE4_E5B9);
state ^= state >> 30;
state = state.wrapping_mul(0x94D0_49BB_1331_11EB);
state ^= state >> 27;
if state == 0 {
state = 0xDEAD_BEEF_DEAD_BEEF;
}
Self { state }
}
pub fn next_u64(&mut self) -> u64 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.state = x;
x
}
pub fn next_f32(&mut self) -> f32 {
let bits = (self.next_u64() >> 40) as u32; bits as f32 / (1u32 << 24) as f32
}
}
#[derive(Component, Debug, Clone)]
pub struct Emitter {
pub rate: f32,
pub lifetime: FloatDist,
pub velocity: Vec3Dist,
pub size: FloatDist,
pub color: ColorDist,
pub max: u32,
pub spawn_accumulator: f32,
pub rng: ParticleRng,
}
impl Emitter {
pub fn new(rate: f32, lifetime: FloatDist, max: u32) -> Self {
Self {
rate,
lifetime,
velocity: Vec3Dist::Constant([0.0; 3]),
size: FloatDist::Constant(1.0),
color: ColorDist::Constant([1.0, 1.0, 1.0]),
max,
spawn_accumulator: 0.0,
rng: ParticleRng::from_seed(0),
}
}
pub fn with_velocity(mut self, velocity: Vec3Dist) -> Self {
self.velocity = velocity;
self
}
pub fn with_size(mut self, size: FloatDist) -> Self {
self.size = size;
self
}
pub fn with_color(mut self, color: ColorDist) -> Self {
self.color = color;
self
}
pub fn with_seed(mut self, seed: u64) -> Self {
self.rng = ParticleRng::from_seed(seed);
self
}
}
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub struct Particle {
pub source: Entity,
pub age: f32,
pub lifetime: f32,
pub velocity: [f32; 3],
pub size: f32,
pub color: [f32; 3],
}
#[derive(Component, Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Billboard;
pub fn emitter_spawn_expire_system(
ts: Res<'_, FixedTimestep>,
mut emitters: QueryMut<'_, Emitter>,
mut particles: QueryMut<'_, Particle>,
mut cmds: Commands<'_>,
) {
let step = ts.step as f32;
let mut alive_per_emitter: HashMap<Entity, u32> = HashMap::new();
for (entity, particle) in particles.iter_mut() {
let p: &mut Particle = particle;
p.age += step;
if p.age >= p.lifetime {
cmds.despawn(entity);
} else {
*alive_per_emitter.entry(p.source).or_insert(0) += 1;
}
}
for (emitter_entity, emitter) in emitters.iter_mut() {
let e: &mut Emitter = emitter;
let alive = alive_per_emitter.get(&emitter_entity).copied().unwrap_or(0);
let headroom = e.max.saturating_sub(alive);
if headroom == 0 {
e.spawn_accumulator = 0.0;
continue;
}
e.spawn_accumulator += e.rate * step;
if e.spawn_accumulator < 0.0 {
e.spawn_accumulator = 0.0;
continue;
}
let want = e.spawn_accumulator.floor() as u32;
let n = want.min(headroom);
e.spawn_accumulator -= n as f32;
for _ in 0..n {
let particle = Particle {
source: emitter_entity,
age: 0.0,
lifetime: e.lifetime.sample(&mut e.rng),
velocity: e.velocity.sample(&mut e.rng),
size: e.size.sample(&mut e.rng),
color: e.color.sample(&mut e.rng),
};
cmds.spawn((particle, Billboard));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::Engine;
use crate::game_loop::FixedTimestep;
use crate::system_param::QueryMut;
#[test]
fn rng_zero_seed_does_not_stick() {
let mut rng = ParticleRng::from_seed(0);
for _ in 0..16 {
assert_ne!(rng.next_u64(), 0);
}
}
#[test]
fn rng_next_f32_in_unit_interval() {
let mut rng = ParticleRng::from_seed(42);
for _ in 0..1024 {
let v = rng.next_f32();
assert!((0.0..1.0).contains(&v), "value {v} outside [0,1)");
}
}
#[test]
fn rng_seed_determinism() {
let mut a = ParticleRng::from_seed(0xC0FFEE);
let mut b = ParticleRng::from_seed(0xC0FFEE);
for _ in 0..32 {
assert_eq!(a.next_u64(), b.next_u64());
}
}
#[test]
fn rng_distinct_seeds_diverge() {
let mut a = ParticleRng::from_seed(1);
let mut b = ParticleRng::from_seed(2);
let mut diverged = false;
for _ in 0..16 {
if a.next_u64() != b.next_u64() {
diverged = true;
break;
}
}
assert!(diverged, "seeds 1 and 2 produced identical streams");
}
#[test]
fn float_constant_returns_value() {
let mut rng = ParticleRng::from_seed(0);
let d = FloatDist::Constant(2.5);
for _ in 0..8 {
assert_eq!(d.sample(&mut rng), 2.5);
}
}
#[test]
fn float_uniform_within_bounds() {
let mut rng = ParticleRng::from_seed(7);
let d = FloatDist::Uniform { min: 1.0, max: 3.0 };
for _ in 0..256 {
let v = d.sample(&mut rng);
assert!((1.0..3.0).contains(&v), "value {v} outside [1,3)");
}
}
#[test]
fn float_uniform_swapped_bounds_tolerated() {
let mut rng = ParticleRng::from_seed(7);
let d = FloatDist::Uniform { min: 3.0, max: 1.0 };
for _ in 0..64 {
let v = d.sample(&mut rng);
assert!((1.0..3.0).contains(&v), "value {v} outside [1,3)");
}
}
#[test]
fn vec3_uniform_box_per_axis_bounds() {
let mut rng = ParticleRng::from_seed(7);
let d = Vec3Dist::UniformBox {
min: [-1.0, 0.0, 5.0],
max: [1.0, 2.0, 7.0],
};
for _ in 0..64 {
let v = d.sample(&mut rng);
assert!((-1.0..1.0).contains(&v[0]));
assert!((0.0..2.0).contains(&v[1]));
assert!((5.0..7.0).contains(&v[2]));
}
}
#[test]
fn color_constant_returns_value() {
let mut rng = ParticleRng::from_seed(0);
let d = ColorDist::Constant([0.25, 0.5, 0.75]);
assert_eq!(d.sample(&mut rng), [0.25, 0.5, 0.75]);
}
#[test]
fn emitter_new_defaults() {
let e = Emitter::new(10.0, FloatDist::Constant(1.0), 100);
assert_eq!(e.rate, 10.0);
assert_eq!(e.max, 100);
assert_eq!(e.spawn_accumulator, 0.0);
assert_eq!(e.velocity, Vec3Dist::Constant([0.0; 3]));
assert_eq!(e.size, FloatDist::Constant(1.0));
assert_eq!(e.color, ColorDist::Constant([1.0; 3]));
}
#[test]
fn emitter_builders_are_chainable() {
let e = Emitter::new(10.0, FloatDist::Constant(1.0), 50)
.with_velocity(Vec3Dist::Constant([0.0, 1.0, 0.0]))
.with_size(FloatDist::Constant(0.5))
.with_color(ColorDist::Constant([1.0, 0.0, 0.0]))
.with_seed(0xFEED);
assert_eq!(e.velocity, Vec3Dist::Constant([0.0, 1.0, 0.0]));
assert_eq!(e.size, FloatDist::Constant(0.5));
assert_eq!(e.color, ColorDist::Constant([1.0, 0.0, 0.0]));
}
fn engine_with_spawn_system() -> Engine {
let mut engine = Engine::new();
engine.set_tick_rate(10.0);
engine.add_system::<(
Res<'_, FixedTimestep>,
QueryMut<'_, Emitter>,
QueryMut<'_, Particle>,
Commands<'_>,
)>(
"simulate",
"emitter_spawn_expire",
emitter_spawn_expire_system,
);
engine
}
fn particle_count(engine: &Engine) -> usize {
engine.world().query::<&Particle>().count()
}
#[test]
fn no_emitters_no_particles() {
let mut engine = engine_with_spawn_system();
engine.tick(1.0);
assert_eq!(particle_count(&engine), 0);
}
#[test]
fn emitter_at_30hz_spawns_30_per_second() {
let mut engine = engine_with_spawn_system();
engine
.world_mut()
.spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 1000),));
for _ in 0..10 {
engine.tick(0.1);
}
assert_eq!(particle_count(&engine), 30);
}
#[test]
fn emitter_respects_max_cap() {
let mut engine = engine_with_spawn_system();
engine
.world_mut()
.spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 20),));
for _ in 0..10 {
engine.tick(0.1);
}
assert_eq!(particle_count(&engine), 20);
}
#[test]
fn fractional_rate_accumulates_across_ticks() {
let mut engine = engine_with_spawn_system();
engine
.world_mut()
.spawn((Emitter::new(15.0, FloatDist::Constant(100.0), 1000),));
for _ in 0..10 {
engine.tick(0.1);
}
assert_eq!(particle_count(&engine), 15);
}
#[test]
fn rate_below_one_per_tick_eventually_spawns() {
let mut engine = engine_with_spawn_system();
engine
.world_mut()
.spawn((Emitter::new(5.0, FloatDist::Constant(100.0), 1000),));
for _ in 0..10 {
engine.tick(0.1);
}
assert_eq!(particle_count(&engine), 5);
}
#[test]
fn particles_expire_after_lifetime() {
let mut engine = engine_with_spawn_system();
engine
.world_mut()
.spawn((Emitter::new(10.0, FloatDist::Constant(0.05), 1000),));
engine.tick(0.1);
assert_eq!(particle_count(&engine), 1);
engine.tick(0.1);
assert_eq!(particle_count(&engine), 1);
}
#[test]
fn newly_spawned_particles_visible_after_apply_commands() {
let mut engine = engine_with_spawn_system();
engine
.world_mut()
.spawn((Emitter::new(10.0, FloatDist::Constant(100.0), 1000),));
engine.tick(0.1);
assert_eq!(particle_count(&engine), 1);
}
#[test]
fn spawned_particles_carry_billboard_tag() {
let mut engine = engine_with_spawn_system();
engine
.world_mut()
.spawn((Emitter::new(10.0, FloatDist::Constant(100.0), 1000),));
engine.tick(0.1);
let particles = engine.world().query::<&Particle>().count();
let billboards = engine.world().query::<&Billboard>().count();
assert_eq!(particles, 1);
assert_eq!(billboards, 1);
}
#[test]
fn paused_engine_does_not_spawn() {
let mut engine = engine_with_spawn_system();
engine
.world_mut()
.spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 1000),));
engine.pause();
for _ in 0..10 {
engine.tick(0.1);
}
assert_eq!(particle_count(&engine), 0);
}
#[test]
fn multiple_emitters_independent_caps() {
let mut engine = engine_with_spawn_system();
engine
.world_mut()
.spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 5),));
engine
.world_mut()
.spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 7),));
for _ in 0..10 {
engine.tick(0.1);
}
assert_eq!(particle_count(&engine), 12);
}
#[test]
fn emitter_despawn_strands_particles_until_expire() {
let mut engine = engine_with_spawn_system();
let emitter =
engine
.world_mut()
.spawn((Emitter::new(10.0, FloatDist::Constant(0.3), 1000),));
for _ in 0..3 {
engine.tick(0.1);
}
let alive_before = particle_count(&engine);
assert!(alive_before > 0);
engine.world_mut().despawn(emitter);
for _ in 0..4 {
engine.tick(0.1);
}
assert_eq!(particle_count(&engine), 0);
}
#[test]
fn deterministic_with_same_seed() {
fn run() -> Vec<(f32, [f32; 3], f32, [f32; 3])> {
let mut engine = engine_with_spawn_system();
engine.world_mut().spawn((Emitter::new(
30.0,
FloatDist::Uniform { min: 0.5, max: 1.5 },
1000,
)
.with_velocity(Vec3Dist::UniformBox {
min: [-1.0, 0.0, -1.0],
max: [1.0, 1.0, 1.0],
})
.with_size(FloatDist::Uniform { min: 0.1, max: 0.3 })
.with_color(ColorDist::UniformBox {
min: [0.0; 3],
max: [1.0; 3],
})
.with_seed(0xCAFE_BABE),));
engine.tick(0.5);
let mut samples: Vec<_> = engine
.world()
.query::<&Particle>()
.map(|(_, p)| (p.lifetime, p.velocity, p.size, p.color))
.collect();
samples.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
samples
}
assert_eq!(run(), run());
}
#[test]
fn rate_zero_emitter_never_spawns() {
let mut engine = engine_with_spawn_system();
engine
.world_mut()
.spawn((Emitter::new(0.0, FloatDist::Constant(100.0), 1000),));
for _ in 0..100 {
engine.tick(0.1);
}
assert_eq!(particle_count(&engine), 0);
}
#[test]
fn at_cap_drops_accumulator_does_not_burst() {
let mut engine = engine_with_spawn_system();
let emitter = engine
.world_mut()
.spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 5),));
for _ in 0..20 {
engine.tick(0.1);
}
let acc = engine
.world()
.get::<Emitter>(emitter)
.map(|e| e.spawn_accumulator)
.expect("emitter alive");
assert_eq!(acc, 0.0);
}
}