use std::ops::Range;
use crate::Rgb;
use crate::{
BillboardLighting, DynSpriteTransform, EpochSlotMap, SceneRenderer, ShadowFlags, SlotHandle,
SpriteInstanceId, SpriteModelId,
};
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct EmitterId {
slot: u32,
gen: u32,
}
impl SlotHandle for EmitterId {
fn mint(slot: u32, gen: u32) -> Self {
Self { slot, gen }
}
fn parts(self) -> (u32, u32) {
(self.slot, self.gen)
}
}
#[derive(Clone, Copy, Debug)]
pub enum SpawnMode {
Rate(f32),
Burst(u32),
Manual,
}
#[derive(Clone, Copy, Debug, Default)]
pub enum EmitterShape {
#[default]
Point,
Sphere {
radius: f32,
},
Box {
half: [f32; 3],
},
}
#[derive(Clone, Debug)]
pub struct ConeDef {
pub axis: [f32; 3],
pub half_angle_deg: f32,
pub speed: Range<f32>,
}
#[derive(Clone, Debug, Default)]
pub struct VelocityDef {
pub base: [f32; 3],
pub spread: f32,
pub cone: Option<ConeDef>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum CollisionMode {
#[default]
None,
Kill,
Bounce {
restitution: f32,
},
}
#[derive(Clone, Debug)]
pub struct ParticleEmitterDef {
pub model: SpriteModelId,
pub pos: [f32; 3],
pub shape: EmitterShape,
pub spawn: SpawnMode,
pub lifetime: Range<f32>,
pub velocity: VelocityDef,
pub gravity: [f32; 3],
pub drag: f32,
pub collision: CollisionMode,
pub scale: f32,
pub scale_end: Option<f32>,
pub spin: Range<f32>,
pub fade_in_frac: f32,
pub fade_out_frac: f32,
pub tint: Rgb,
pub tint_end: Option<Rgb>,
pub material: u8,
pub lighting: BillboardLighting,
pub shadows: ShadowFlags,
}
impl ParticleEmitterDef {
#[must_use]
pub fn new(model: SpriteModelId) -> Self {
Self {
model,
pos: [0.0, 0.0, 0.0],
shape: EmitterShape::Point,
spawn: SpawnMode::Manual,
lifetime: 1.0..2.0,
velocity: VelocityDef::default(),
gravity: [0.0, 0.0, 22.0],
drag: 0.0,
collision: CollisionMode::None,
scale: 1.0,
scale_end: None,
spin: 0.0..0.0,
fade_in_frac: 0.0,
fade_out_frac: 0.25,
tint: Rgb::WHITE,
tint_end: None,
material: 0,
lighting: BillboardLighting::FaceNormal,
shadows: ShadowFlags {
casts: false,
receives: false,
},
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Particle {
pub pos: [f32; 3],
pub vel: [f32; 3],
pub age: f32,
pub lifetime: f32,
pub scale: f32,
pub yaw: f32,
pub spin_rate: f32,
pub alpha: u8,
pub tint: Rgb,
pub(crate) emitter_slot: u32,
pub(crate) instance: Option<SpriteInstanceId>,
pub(crate) last_alpha: u8,
pub(crate) last_tint: Rgb,
pub(crate) tint_start: Rgb,
}
struct EmitterState {
def: ParticleEmitterDef,
spawn_acc: f64,
live: u32,
retired: bool,
}
struct Pcg32 {
state: u64,
}
impl Pcg32 {
const MULT: u64 = 6_364_136_223_846_793_005;
const INC: u64 = 1_442_695_040_888_963_407;
fn new(seed: u64) -> Self {
let mut rng = Self {
state: seed.wrapping_add(Self::INC),
};
rng.next_u32();
rng
}
fn next_u32(&mut self) -> u32 {
let old = self.state;
self.state = old.wrapping_mul(Self::MULT).wrapping_add(Self::INC);
let xorshifted = (((old >> 18) ^ old) >> 27) as u32;
xorshifted.rotate_right((old >> 59) as u32)
}
fn next_f32(&mut self) -> f32 {
(self.next_u32() >> 8) as f32 * (1.0 / (1u32 << 24) as f32)
}
fn range_f32(&mut self, r: &Range<f32>) -> f32 {
if r.end <= r.start {
return r.start;
}
r.start + (r.end - r.start) * self.next_f32()
}
fn unit_vec(&mut self) -> [f32; 3] {
loop {
let v = [
self.next_f32() * 2.0 - 1.0,
self.next_f32() * 2.0 - 1.0,
self.next_f32() * 2.0 - 1.0,
];
let len2 = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
if len2 > 1e-4 && len2 <= 1.0 {
let inv = 1.0 / len2.sqrt();
return [v[0] * inv, v[1] * inv, v[2] * inv];
}
}
}
}
pub const DEFAULT_MAX_PARTICLES: usize = 4096;
pub const CARVE_DEBRIS_CAP: usize = 96;
pub struct ParticleSystem {
rng: Pcg32,
map: EpochSlotMap<EmitterId>,
emitters: Vec<Option<EmitterState>>,
particles: Vec<Particle>,
dead_instances: Vec<SpriteInstanceId>,
max_particles: usize,
carve_debris_cap: usize,
dropped_spawns: u64,
stale_model_kills: u64,
xf_scratch: Vec<(SpriteInstanceId, DynSpriteTransform)>,
}
impl ParticleSystem {
#[must_use]
pub fn new(seed: u64) -> Self {
Self {
rng: Pcg32::new(seed),
map: EpochSlotMap::default(),
emitters: Vec::new(),
particles: Vec::new(),
dead_instances: Vec::new(),
max_particles: DEFAULT_MAX_PARTICLES,
carve_debris_cap: CARVE_DEBRIS_CAP,
dropped_spawns: 0,
stale_model_kills: 0,
xf_scratch: Vec::new(),
}
}
pub fn set_max_particles(&mut self, max: usize) {
self.max_particles = max;
}
pub fn set_carve_debris_cap(&mut self, cap: usize) {
self.carve_debris_cap = cap.max(1);
}
pub fn add_emitter(&mut self, def: ParticleEmitterDef) -> EmitterId {
let slot = self.emitters.len() as u32;
let id = self.map.alloc(slot);
let burst = match def.spawn {
SpawnMode::Burst(n) => n,
_ => 0,
};
self.emitters.push(Some(EmitterState {
def,
spawn_acc: 0.0,
live: 0,
retired: false,
}));
if burst > 0 {
self.spawn_from(slot as usize, burst);
}
id
}
pub fn remove_emitter(&mut self, id: EmitterId) -> bool {
let Some(slot) = self.map.index(id) else {
return false;
};
if !self.map.remove(id) {
return false;
}
let state = self.emitters[slot].as_mut().expect("live map ⇒ state");
if state.live == 0 {
self.emitters[slot] = None;
} else {
state.retired = true;
}
true
}
pub fn set_emitter_pos(&mut self, id: EmitterId, pos: [f32; 3]) -> bool {
let Some(slot) = self.map.index(id) else {
return false;
};
let state = self.emitters[slot].as_mut().expect("live map ⇒ state");
state.def.pos = pos;
true
}
pub fn burst(&mut self, id: EmitterId, n: u32) -> u32 {
let Some(slot) = self.map.index(id) else {
return 0;
};
self.spawn_from(slot, n)
}
pub fn update(&mut self, dt: f64) {
self.step(dt, None);
}
pub fn update_with_scene(&mut self, dt: f64, scene: &roxlap_scene::Scene) {
self.step(dt, Some(scene));
}
fn step(&mut self, dt: f64, scene: Option<&roxlap_scene::Scene>) {
let dtf = dt.max(0.0) as f32;
let emitters = &self.emitters;
for p in &mut self.particles {
p.age += dtf;
if p.age >= p.lifetime {
continue; }
let def = &emitters[p.emitter_slot as usize]
.as_ref()
.expect("live particle ⇒ emitter state retained")
.def;
let prev = p.pos;
for a in 0..3 {
p.vel[a] += (def.gravity[a] - def.drag * p.vel[a]) * dtf;
p.pos[a] += p.vel[a] * dtf;
}
if let Some(scene) = scene {
if !matches!(def.collision, CollisionMode::None)
&& scene_solid_ahead(scene, p.pos, p.vel)
{
match def.collision {
CollisionMode::Kill => {
p.age = p.lifetime; continue;
}
CollisionMode::Bounce { restitution } => {
let mut crossed = false;
for ((prev_a, pos_a), vel_a) in prev.iter().zip(&p.pos).zip(&mut p.vel)
{
if prev_a.floor() != pos_a.floor() {
*vel_a = -*vel_a;
crossed = true;
}
}
for vel_a in &mut p.vel {
if !crossed {
*vel_a = -*vel_a;
}
*vel_a *= restitution;
}
p.pos = prev;
}
CollisionMode::None => unreachable!("guarded above"),
}
}
}
p.yaw += p.spin_rate * dtf;
let frac = p.age / p.lifetime;
if let Some(end) = def.scale_end {
p.scale = def.scale + (end - def.scale) * frac;
}
if let Some(end) = def.tint_end {
p.tint = lerp_tint(p.tint_start, end, frac);
}
p.alpha = fade_alpha(p.age, p.lifetime, def.fade_in_frac, def.fade_out_frac);
}
let mut i = 0;
while i < self.particles.len() {
if self.particles[i].age >= self.particles[i].lifetime {
let p = self.particles.swap_remove(i);
if let Some(inst) = p.instance {
self.dead_instances.push(inst);
}
self.on_particle_died(p.emitter_slot as usize);
} else {
i += 1;
}
}
for slot in 0..self.emitters.len() {
let n = {
let Some(state) = self.emitters[slot].as_mut() else {
continue;
};
if state.retired {
continue;
}
let SpawnMode::Rate(rate) = state.def.spawn else {
continue;
};
state.spawn_acc += f64::from(rate) * dt.max(0.0);
let n = state.spawn_acc.floor();
state.spawn_acc -= n;
n as u32
};
if n > 0 {
self.spawn_from(slot, n);
}
}
}
#[must_use]
pub fn particles(&self) -> &[Particle] {
&self.particles
}
#[must_use]
pub fn particle_count(&self) -> usize {
self.particles.len()
}
#[must_use]
pub fn emitter_count(&self) -> usize {
self.emitters
.iter()
.filter(|e| e.as_ref().is_some_and(|s| !s.retired))
.count()
}
#[must_use]
pub fn dropped_spawns(&self) -> u64 {
self.dropped_spawns
}
pub fn drain_dead_instances(&mut self) -> impl Iterator<Item = SpriteInstanceId> + '_ {
self.dead_instances.drain(..)
}
#[must_use]
pub fn stale_model_kills(&self) -> u64 {
self.stale_model_kills
}
pub fn sync(&mut self, renderer: &mut SceneRenderer) {
self.sync_with(renderer);
}
pub fn tick(&mut self, renderer: &mut SceneRenderer, dt: f64) {
self.step(dt, None);
self.sync_with(renderer);
}
pub fn tick_with_scene(
&mut self,
renderer: &mut SceneRenderer,
dt: f64,
scene: &roxlap_scene::Scene,
) {
self.step(dt, Some(scene));
self.sync_with(renderer);
}
fn sync_with<F: ParticleFacade>(&mut self, facade: &mut F) {
for id in self.dead_instances.drain(..) {
facade.despawn(id);
}
let mut batch = std::mem::take(&mut self.xf_scratch);
batch.clear();
let mut i = 0;
while i < self.particles.len() {
if self.particles[i].instance.is_none() {
let (slot, xf) = {
let p = &self.particles[i];
(p.emitter_slot as usize, particle_xf(p))
};
let (model, material, lighting, shadows) = {
let st = self.emitters[slot]
.as_ref()
.expect("live particle ⇒ emitter state retained");
(
st.def.model,
st.def.material,
st.def.lighting,
st.def.shadows,
)
};
let Some(id) = facade.spawn(model, xf) else {
self.stale_model_kills += 1;
self.particles.swap_remove(i);
self.on_particle_died(slot);
continue;
};
if material != 0 {
facade.set_material(id, material);
}
if lighting != BillboardLighting::default() {
facade.set_lighting(id, lighting);
}
if shadows != ShadowFlags::default() {
facade.set_shadows(id, shadows);
}
let p = &mut self.particles[i];
p.instance = Some(id);
p.last_alpha = 255;
p.last_tint = Rgb::WHITE;
} else {
let p = &self.particles[i];
batch.push((p.instance.expect("checked above"), particle_xf(p)));
}
let p = &mut self.particles[i];
if p.alpha != p.last_alpha {
facade.set_alpha(p.instance.expect("set above"), p.alpha);
p.last_alpha = p.alpha;
}
if p.tint != p.last_tint {
facade.set_tint(p.instance.expect("set above"), p.tint);
p.last_tint = p.tint;
}
i += 1;
}
if !batch.is_empty() {
facade.set_transforms(&batch);
}
self.xf_scratch = batch;
}
pub fn carve_debris(
&mut self,
scene: &mut roxlap_scene::Scene,
grid: roxlap_scene::GridId,
centre: glam::IVec3,
radius: u32,
outward: Range<f32>,
def: &ParticleEmitterDef,
) -> u32 {
let Some(g) = scene.grid_mut(grid) else {
return 0;
};
#[allow(clippy::cast_possible_wrap)]
let r = radius as i32;
let mut samples: Vec<(glam::IVec3, roxlap_formats::VoxColor)> = Vec::new();
for z in -r..=r {
for y in -r..=r {
for x in -r..=r {
if x * x + y * y + z * z > r * r {
continue;
}
let v = centre + glam::IVec3::new(x, y, z);
if let Some(col) = g.voxel_color(v) {
samples.push((v, col));
}
}
}
}
let transform = g.transform;
g.set_sphere(centre, radius, None);
if samples.is_empty() {
return 0;
}
let mut def = def.clone();
def.spawn = SpawnMode::Manual;
let id = self.add_emitter(def.clone());
let slot = self.map.index(id).expect("just allocated");
let to_world = |v: glam::IVec3| -> [f32; 3] {
let w =
transform.origin + transform.rotation * (v.as_dvec3() + glam::DVec3::splat(0.5));
#[allow(clippy::cast_possible_truncation)]
[w.x as f32, w.y as f32, w.z as f32]
};
let cw = to_world(centre);
let stride = samples.len().div_ceil(self.carve_debris_cap).max(1);
let mut spawned: u32 = 0;
for (v, col) in samples.iter().step_by(stride) {
if self.particles.len() >= self.max_particles {
self.dropped_spawns += 1;
continue;
}
let pos = to_world(*v);
let d = [pos[0] - cw[0], pos[1] - cw[1], pos[2] - cw[2]];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let dir = if len > 1e-4 {
[d[0] / len, d[1] / len, d[2] / len]
} else {
self.rng.unit_vec()
};
let mut vel = sample_velocity(&mut self.rng, &def.velocity);
let speed = self.rng.range_f32(&outward);
for a in 0..3 {
vel[a] += dir[a] * speed;
}
let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
self.particles.push(Particle {
pos,
vel,
age: 0.0,
lifetime,
scale: def.scale,
yaw: 0.0,
spin_rate: self.rng.range_f32(&def.spin),
alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
tint: col.rgb_part(),
emitter_slot: slot as u32,
instance: None,
last_alpha: 255,
last_tint: Rgb::WHITE,
tint_start: col.rgb_part(),
});
spawned += 1;
}
self.emitters[slot]
.as_mut()
.expect("slot allocated above")
.live += spawned;
self.remove_emitter(id);
spawned
}
fn spawn_from(&mut self, slot: usize, n: u32) -> u32 {
let state = self.emitters[slot]
.as_mut()
.expect("spawn_from callers hold a live slot");
let def = state.def.clone(); let mut spawned = 0;
for _ in 0..n {
if self.particles.len() >= self.max_particles {
self.dropped_spawns += u64::from(n - spawned);
break;
}
let mut pos = def.pos;
match def.shape {
EmitterShape::Point => {}
EmitterShape::Sphere { radius } => {
let dir = self.rng.unit_vec();
let r = radius * self.rng.next_f32().cbrt();
for a in 0..3 {
pos[a] += dir[a] * r;
}
}
EmitterShape::Box { half } => {
for a in 0..3 {
pos[a] += (self.rng.next_f32() * 2.0 - 1.0) * half[a];
}
}
}
let vel = sample_velocity(&mut self.rng, &def.velocity);
let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
self.particles.push(Particle {
pos,
vel,
age: 0.0,
lifetime,
scale: def.scale,
yaw: 0.0,
spin_rate: self.rng.range_f32(&def.spin),
alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
tint: def.tint,
emitter_slot: slot as u32,
instance: None,
last_alpha: 255,
last_tint: Rgb::WHITE,
tint_start: def.tint,
});
spawned += 1;
}
self.emitters[slot]
.as_mut()
.expect("slot unchanged during spawn")
.live += spawned;
spawned
}
fn on_particle_died(&mut self, slot: usize) {
let state = self.emitters[slot]
.as_mut()
.expect("live particle ⇒ emitter state retained");
state.live -= 1;
if state.retired && state.live == 0 {
self.emitters[slot] = None;
}
}
}
fn particle_xf(p: &Particle) -> DynSpriteTransform {
let k = p.scale.max(0.05);
let (c, s) = (p.yaw.cos() * k, p.yaw.sin() * k);
DynSpriteTransform {
pos: p.pos,
right: [c, s, 0.0],
up: [-s, c, 0.0],
forward: [0.0, 0.0, k],
}
}
fn sample_velocity(rng: &mut Pcg32, v: &VelocityDef) -> [f32; 3] {
let mut vel = v.base;
if v.spread > 0.0 {
let dir = rng.unit_vec();
let speed = rng.next_f32() * v.spread;
for a in 0..3 {
vel[a] += dir[a] * speed;
}
}
if let Some(cone) = &v.cone {
let dir = cone_dir(rng, cone.axis, cone.half_angle_deg);
let speed = rng.range_f32(&cone.speed);
for a in 0..3 {
vel[a] += dir[a] * speed;
}
}
vel
}
fn cone_dir(rng: &mut Pcg32, axis: [f32; 3], half_angle_deg: f32) -> [f32; 3] {
let len2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
let w = if len2 > 1e-12 {
let inv = 1.0 / len2.sqrt();
[axis[0] * inv, axis[1] * inv, axis[2] * inv]
} else {
[0.0, 0.0, -1.0]
};
let cos_half = half_angle_deg.clamp(0.0, 180.0).to_radians().cos();
let cz = 1.0 - rng.next_f32() * (1.0 - cos_half);
let sz = (1.0 - cz * cz).max(0.0).sqrt();
let phi = rng.next_f32() * std::f32::consts::TAU;
let t = if w[0].abs() < 0.5 {
[1.0, 0.0, 0.0]
} else {
[0.0, 1.0, 0.0]
};
let u = {
let c = [
w[1] * t[2] - w[2] * t[1],
w[2] * t[0] - w[0] * t[2],
w[0] * t[1] - w[1] * t[0],
];
let inv = 1.0 / (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt();
[c[0] * inv, c[1] * inv, c[2] * inv]
};
let v = [
w[1] * u[2] - w[2] * u[1],
w[2] * u[0] - w[0] * u[2],
w[0] * u[1] - w[1] * u[0],
];
let (cp, sp) = (phi.cos() * sz, phi.sin() * sz);
[
w[0] * cz + u[0] * cp + v[0] * sp,
w[1] * cz + u[1] * cp + v[1] * sp,
w[2] * cz + u[2] * cp + v[2] * sp,
]
}
fn scene_solid_ahead(scene: &roxlap_scene::Scene, pos: [f32; 3], vel: [f32; 3]) -> bool {
let world = glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
let dir = glam::DVec3::new(f64::from(vel[0]), f64::from(vel[1]), f64::from(vel[2]));
scene.resolve_voxel(world, dir).is_some()
}
fn lerp_tint(a: Rgb, b: Rgb, t: f32) -> Rgb {
let t = t.clamp(0.0, 1.0);
let ch = |sh: u32| {
let (ca, cb) = ((a.0 >> sh) & 0xff, (b.0 >> sh) & 0xff);
let m = ca as f32 + (cb as f32 - ca as f32) * t;
((m as u32) & 0xff) << sh
};
Rgb(ch(16) | ch(8) | ch(0))
}
pub(crate) trait ParticleFacade {
fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId>;
fn despawn(&mut self, id: SpriteInstanceId);
fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]);
fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8);
fn set_tint(&mut self, id: SpriteInstanceId, tint: Rgb);
fn set_material(&mut self, id: SpriteInstanceId, material: u8);
fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting);
fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags);
}
impl ParticleFacade for SceneRenderer {
fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId> {
self.add_sprite_instance_posed(model, xf)
}
fn despawn(&mut self, id: SpriteInstanceId) {
self.remove_sprite_instance(id);
}
fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
self.set_sprite_instance_transforms(batch);
}
fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
self.set_sprite_instance_alpha(id, alpha);
}
fn set_tint(&mut self, id: SpriteInstanceId, tint: Rgb) {
self.set_sprite_instance_tint(id, tint);
}
fn set_material(&mut self, id: SpriteInstanceId, material: u8) {
self.set_sprite_instance_material(id, material);
}
fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting) {
self.set_sprite_instance_lighting(id, mode);
}
fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags) {
self.set_sprite_instance_shadow_flags(id, flags);
}
}
fn fade_alpha(age: f32, lifetime: f32, in_frac: f32, out_frac: f32) -> u8 {
let frac = age / lifetime;
let mut a = 1.0_f32;
if in_frac > 0.0 {
a = a.min((frac / in_frac.min(1.0)).clamp(0.0, 1.0));
}
if out_frac > 0.0 {
a = a.min(((1.0 - frac) / out_frac.min(1.0)).clamp(0.0, 1.0));
}
(a * 255.0) as u8
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_model() -> SpriteModelId {
SpriteModelId::mint(0, 0)
}
fn base_def() -> ParticleEmitterDef {
ParticleEmitterDef {
spawn: SpawnMode::Manual,
lifetime: 1.0..1.0,
gravity: [0.0, 0.0, 0.0],
fade_out_frac: 0.0,
..ParticleEmitterDef::new(dummy_model())
}
}
#[test]
fn same_seed_is_bit_identical() {
let run = || {
let mut sys = ParticleSystem::new(0x00C0_FFEE);
let em = sys.add_emitter(ParticleEmitterDef {
spawn: SpawnMode::Rate(120.0),
lifetime: 0.3..0.9,
velocity: VelocityDef {
base: [0.0, 0.0, -10.0],
spread: 4.0,
..VelocityDef::default()
},
gravity: [0.0, 0.0, 22.0],
..ParticleEmitterDef::new(dummy_model())
});
sys.burst(em, 7);
for _ in 0..60 {
sys.update(1.0 / 60.0);
}
sys.particles()
.iter()
.map(|p| (p.pos, p.vel, p.age, p.lifetime))
.collect::<Vec<_>>()
};
let (a, b) = (run(), run());
assert_eq!(a.len(), b.len());
for (pa, pb) in a.iter().zip(&b) {
assert_eq!(pa, pb, "same seed must be bit-identical");
}
assert!(!a.is_empty());
}
#[test]
fn rate_accumulates_across_frames() {
let mut sys = ParticleSystem::new(1);
sys.add_emitter(ParticleEmitterDef {
spawn: SpawnMode::Rate(10.0),
lifetime: 100.0..100.0,
..base_def()
});
for _ in 0..10 {
sys.update(0.1);
}
assert_eq!(sys.particle_count(), 10);
let mut slow = ParticleSystem::new(2);
slow.add_emitter(ParticleEmitterDef {
spawn: SpawnMode::Rate(0.5),
lifetime: 100.0..100.0,
..base_def()
});
for _ in 0..20 {
slow.update(0.1);
}
assert_eq!(slow.particle_count(), 1);
}
#[test]
fn burst_mode_fires_on_add() {
let mut sys = ParticleSystem::new(3);
sys.add_emitter(ParticleEmitterDef {
spawn: SpawnMode::Burst(5),
..base_def()
});
assert_eq!(sys.particle_count(), 5);
}
#[test]
fn budget_drops_spawns_and_counts_them() {
let mut sys = ParticleSystem::new(4);
sys.set_max_particles(5);
let em = sys.add_emitter(base_def());
assert_eq!(sys.burst(em, 10), 5);
assert_eq!(sys.particle_count(), 5);
assert_eq!(sys.dropped_spawns(), 5);
}
#[test]
fn particles_die_at_lifetime() {
let mut sys = ParticleSystem::new(5);
let em = sys.add_emitter(ParticleEmitterDef {
lifetime: 0.5..0.5,
..base_def()
});
sys.burst(em, 3);
sys.update(0.3);
assert_eq!(sys.particle_count(), 3);
sys.update(0.3); assert_eq!(sys.particle_count(), 0);
assert_eq!(sys.drain_dead_instances().count(), 0);
}
#[test]
fn semi_implicit_euler_gravity() {
let mut sys = ParticleSystem::new(6);
let em = sys.add_emitter(ParticleEmitterDef {
gravity: [0.0, 0.0, 10.0],
lifetime: 100.0..100.0,
..base_def()
});
sys.burst(em, 1);
sys.update(0.1);
let p = sys.particles()[0];
assert!((p.vel[2] - 1.0).abs() < 1e-6);
assert!((p.pos[2] - 0.1).abs() < 1e-6);
}
#[test]
fn fade_curve_hits_endpoints() {
assert_eq!(fade_alpha(0.0, 1.0, 0.0, 0.5), 255);
assert_eq!(fade_alpha(0.5, 1.0, 0.0, 0.5), 255); assert_eq!(fade_alpha(0.75, 1.0, 0.0, 0.5), 127); assert_eq!(fade_alpha(1.0, 1.0, 0.0, 0.5), 0);
assert_eq!(fade_alpha(0.99, 1.0, 0.0, 0.0), 255); assert_eq!(fade_alpha(0.0, 1.0, 0.25, 0.0), 0);
assert_eq!(fade_alpha(0.125, 1.0, 0.25, 0.0), 127);
assert_eq!(fade_alpha(0.25, 1.0, 0.25, 0.0), 255);
assert_eq!(fade_alpha(0.5, 1.0, 1.0, 1.0), 127); }
#[test]
fn shapes_sample_within_bounds() {
let mut sys = ParticleSystem::new(20);
let sphere = sys.add_emitter(ParticleEmitterDef {
pos: [10.0, 0.0, 0.0],
shape: EmitterShape::Sphere { radius: 3.0 },
..base_def()
});
let boxy = sys.add_emitter(ParticleEmitterDef {
pos: [-10.0, 0.0, 0.0],
shape: EmitterShape::Box {
half: [1.0, 2.0, 0.5],
},
..base_def()
});
sys.burst(sphere, 64);
sys.burst(boxy, 64);
let mut spread = false;
for p in sys.particles() {
if p.pos[0] > 0.0 {
let d = [p.pos[0] - 10.0, p.pos[1], p.pos[2]];
let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
assert!(r <= 3.0 + 1e-4, "sphere sample outside radius: {r}");
spread |= r > 0.1;
} else {
let d = [p.pos[0] + 10.0, p.pos[1], p.pos[2]];
assert!(
d[0].abs() <= 1.0 + 1e-4
&& d[1].abs() <= 2.0 + 1e-4
&& d[2].abs() <= 0.5 + 1e-4,
"box sample outside half-extents: {d:?}"
);
spread |= d[1].abs() > 1.0; }
}
assert!(spread, "samples must actually spread out");
}
#[test]
fn cone_stays_within_half_angle() {
let mut sys = ParticleSystem::new(21);
let axis = [0.0, 1.0, -1.0]; let em = sys.add_emitter(ParticleEmitterDef {
velocity: VelocityDef {
cone: Some(ConeDef {
axis,
half_angle_deg: 30.0,
speed: 5.0..10.0,
}),
..VelocityDef::default()
},
..base_def()
});
sys.burst(em, 64);
let inv = 1.0 / (2.0_f32).sqrt();
let w = [0.0, inv, -inv];
let cos_half = 30.0_f32.to_radians().cos();
for p in sys.particles() {
let sp = (p.vel[0] * p.vel[0] + p.vel[1] * p.vel[1] + p.vel[2] * p.vel[2]).sqrt();
assert!(
(5.0 - 1e-3..10.0 + 1e-3).contains(&sp),
"speed in range: {sp}"
);
let cosang = (p.vel[0] * w[0] + p.vel[1] * w[1] + p.vel[2] * w[2]) / sp;
assert!(
cosang >= cos_half - 1e-4,
"direction within the cone: cos {cosang} < {cos_half}"
);
}
assert_eq!(
cone_dir(&mut Pcg32::new(1), [0.0; 3], 0.0),
[0.0, 0.0, -1.0]
);
}
#[test]
fn spin_rotates_pose_and_keeps_scale() {
let mut sys = ParticleSystem::new(22);
let em = sys.add_emitter(ParticleEmitterDef {
spin: 2.0..2.0,
scale: 3.0,
lifetime: 100.0..100.0,
..base_def()
});
sys.burst(em, 1);
sys.update(0.25); let p = sys.particles()[0];
assert!((p.yaw - 0.5).abs() < 1e-6);
let xf = particle_xf(&p);
let len = |v: [f32; 3]| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
assert!((len(xf.right) - 3.0).abs() < 1e-5);
assert!((len(xf.up) - 3.0).abs() < 1e-5);
assert!(xf.right[1].abs() > 0.1, "yaw actually rotates the basis");
let dot = xf.right[0] * xf.up[0] + xf.right[1] * xf.up[1];
assert!(dot.abs() < 1e-5);
}
#[test]
fn scale_and_tint_lerp_over_life() {
let mut sys = ParticleSystem::new(23);
let em = sys.add_emitter(ParticleEmitterDef {
lifetime: 1.0..1.0,
scale: 1.0,
scale_end: Some(3.0),
tint: Rgb(0x00FF_0000),
tint_end: Some(Rgb(0x0000_00FF)),
..base_def()
});
sys.burst(em, 1);
sys.update(0.5);
let p = sys.particles()[0];
assert!((p.scale - 2.0).abs() < 1e-5, "mid-life scale: {}", p.scale);
let (r, b) = ((p.tint.0 >> 16) & 0xff, p.tint.0 & 0xff);
assert!(
(126..=128).contains(&r) && (126..=128).contains(&b),
"mid tint: {:#08x}",
p.tint.0
);
let mut f = Mock::default();
sys.sync_with(&mut f);
assert_eq!(f.tints.len(), 1);
sys.update(0.1);
sys.sync_with(&mut f);
assert_eq!(f.tints.len(), 2, "changed tint re-syncs");
}
#[test]
fn retired_emitter_drains_then_frees() {
let mut sys = ParticleSystem::new(7);
let em = sys.add_emitter(ParticleEmitterDef {
spawn: SpawnMode::Rate(1000.0),
lifetime: 0.2..0.2,
..base_def()
});
sys.update(0.01);
assert!(sys.particle_count() > 0);
assert!(sys.remove_emitter(em));
assert_eq!(sys.emitter_count(), 0);
assert!(!sys.remove_emitter(em));
assert!(!sys.set_emitter_pos(em, [1.0, 2.0, 3.0]));
assert_eq!(sys.burst(em, 10), 0);
let live = sys.particle_count();
sys.update(0.3);
assert_eq!(sys.particle_count(), 0);
assert!(live > 0);
assert!(sys.emitters.iter().all(Option::is_none));
}
#[derive(Default)]
struct Mock {
next_slot: u32,
fail_spawn: bool,
spawns: Vec<(SpriteModelId, DynSpriteTransform)>,
despawns: Vec<SpriteInstanceId>,
batch_sizes: Vec<usize>,
alphas: Vec<(SpriteInstanceId, u8)>,
tints: Vec<Rgb>,
materials: Vec<u8>,
lightings: Vec<BillboardLighting>,
shadows: Vec<ShadowFlags>,
}
impl ParticleFacade for Mock {
fn spawn(
&mut self,
model: SpriteModelId,
xf: DynSpriteTransform,
) -> Option<SpriteInstanceId> {
if self.fail_spawn {
return None;
}
self.spawns.push((model, xf));
let id = SpriteInstanceId {
slot: self.next_slot,
gen: 0,
};
self.next_slot += 1;
Some(id)
}
fn despawn(&mut self, id: SpriteInstanceId) {
self.despawns.push(id);
}
fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
self.batch_sizes.push(batch.len());
}
fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
self.alphas.push((id, alpha));
}
fn set_tint(&mut self, _id: SpriteInstanceId, tint: Rgb) {
self.tints.push(tint);
}
fn set_material(&mut self, _id: SpriteInstanceId, material: u8) {
self.materials.push(material);
}
fn set_lighting(&mut self, _id: SpriteInstanceId, mode: BillboardLighting) {
self.lightings.push(mode);
}
fn set_shadows(&mut self, _id: SpriteInstanceId, flags: ShadowFlags) {
self.shadows.push(flags);
}
}
#[test]
fn sync_spawns_once_then_batch_moves() {
let mut sys = ParticleSystem::new(10);
let em = sys.add_emitter(ParticleEmitterDef {
lifetime: 100.0..100.0,
..base_def()
});
sys.burst(em, 3);
let mut f = Mock::default();
sys.sync_with(&mut f);
assert_eq!(f.spawns.len(), 3);
assert!(f.batch_sizes.is_empty());
assert_eq!(f.shadows.len(), 3);
assert!(f.materials.is_empty() && f.tints.is_empty() && f.lightings.is_empty());
assert!(f.alphas.is_empty(), "alpha 255 == facade default");
sys.update(0.01);
sys.sync_with(&mut f);
assert_eq!(f.spawns.len(), 3);
assert_eq!(f.batch_sizes, vec![3]);
}
#[test]
fn sync_one_time_setup_honours_def() {
let mut sys = ParticleSystem::new(11);
let em = sys.add_emitter(ParticleEmitterDef {
lifetime: 100.0..100.0,
tint: Rgb(0x00FF_0000),
material: 5,
lighting: BillboardLighting::FullBright,
shadows: ShadowFlags::default(), ..base_def()
});
sys.burst(em, 1);
let mut f = Mock::default();
sys.sync_with(&mut f);
assert_eq!(f.materials, vec![5]);
assert_eq!(f.tints, vec![Rgb(0x00FF_0000)]);
assert_eq!(f.lightings, vec![BillboardLighting::FullBright]);
assert!(f.shadows.is_empty(), "facade-default shadows skip the call");
sys.update(0.01);
sys.sync_with(&mut f);
assert_eq!(f.materials.len() + f.tints.len() + f.lightings.len(), 3);
}
#[test]
fn sync_despawns_dead_and_writes_alpha_on_change_only() {
let mut sys = ParticleSystem::new(12);
let em = sys.add_emitter(ParticleEmitterDef {
lifetime: 1.0..1.0,
fade_out_frac: 0.5,
..base_def()
});
sys.burst(em, 2);
let mut f = Mock::default();
sys.sync_with(&mut f);
sys.update(0.25);
sys.sync_with(&mut f);
assert!(f.alphas.is_empty());
sys.update(0.5); sys.sync_with(&mut f);
assert_eq!(f.alphas.len(), 2);
assert!(f.alphas.iter().all(|&(_, a)| a == 127));
sys.update(0.5);
assert_eq!(sys.particle_count(), 0);
sys.sync_with(&mut f);
assert_eq!(f.despawns.len(), 2);
}
#[test]
fn stale_model_spawn_kills_particle() {
let mut sys = ParticleSystem::new(13);
let em = sys.add_emitter(base_def());
sys.burst(em, 2);
let mut f = Mock {
fail_spawn: true,
..Mock::default()
};
sys.sync_with(&mut f);
assert_eq!(sys.particle_count(), 0);
assert_eq!(sys.stale_model_kills(), 2);
assert!(sys.remove_emitter(em));
assert!(sys.emitters.iter().all(Option::is_none));
}
#[test]
fn particle_xf_scales_and_clamps() {
let p = Particle {
pos: [1.0, 2.0, 3.0],
vel: [0.0; 3],
age: 0.0,
lifetime: 1.0,
scale: 2.0,
yaw: 0.0,
spin_rate: 0.0,
alpha: 255,
tint: Rgb(0),
emitter_slot: 0,
instance: None,
last_alpha: 255,
last_tint: Rgb::WHITE,
tint_start: Rgb(0),
};
let xf = particle_xf(&p);
assert_eq!(xf.pos, [1.0, 2.0, 3.0]);
assert_eq!((xf.right[0], xf.up[1], xf.forward[2]), (2.0, 2.0, 2.0));
let tiny = particle_xf(&Particle { scale: 0.0, ..p });
assert_eq!(tiny.right[0], 0.05);
}
fn scene_with_floor() -> roxlap_scene::Scene {
use glam::{DVec3, IVec3};
let mut scene = roxlap_scene::Scene::new();
let id = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
let g = scene.grid_mut(id).expect("fresh grid");
g.set_rect(
IVec3::new(-16, -16, 10),
IVec3::new(16, 16, 12),
Some(roxlap_formats::VoxColor(0x80FF_FFFF)),
);
scene
}
fn falling(collision: CollisionMode) -> ParticleEmitterDef {
ParticleEmitterDef {
pos: [0.0, 0.0, 5.0],
velocity: VelocityDef {
base: [0.0, 0.0, 20.0],
..VelocityDef::default()
},
lifetime: 100.0..100.0,
collision,
..base_def() }
}
#[test]
fn collision_kill_dies_on_contact() {
let scene = scene_with_floor();
let mut sys = ParticleSystem::new(30);
let em = sys.add_emitter(falling(CollisionMode::Kill));
sys.burst(em, 1);
for _ in 0..20 {
sys.update_with_scene(0.05, &scene);
}
assert_eq!(sys.particle_count(), 0, "dies at the slab");
let mut free = ParticleSystem::new(30);
let em = free.add_emitter(falling(CollisionMode::Kill));
free.burst(em, 1);
for _ in 0..20 {
free.update(0.05);
}
assert_eq!(free.particle_count(), 1);
assert!(free.particles()[0].pos[2] > 12.0, "fell straight through");
}
#[test]
fn collision_none_passes_through() {
let scene = scene_with_floor();
let mut sys = ParticleSystem::new(31);
let em = sys.add_emitter(falling(CollisionMode::None));
sys.burst(em, 1);
for _ in 0..20 {
sys.update_with_scene(0.05, &scene);
}
assert!(sys.particles()[0].pos[2] > 12.0);
}
#[test]
fn collision_bounce_reflects_and_damps() {
let scene = scene_with_floor();
let mut sys = ParticleSystem::new(32);
let em = sys.add_emitter(falling(CollisionMode::Bounce { restitution: 0.5 }));
sys.burst(em, 1);
let mut bounced = false;
for _ in 0..40 {
sys.update_with_scene(0.05, &scene);
let p = sys.particles()[0];
assert!(p.pos[2] < 10.5, "never sinks into the slab: {}", p.pos[2]);
if p.vel[2] < 0.0 {
bounced = true;
}
}
assert!(bounced, "velocity reflected upward");
let p = sys.particles()[0];
assert!(
p.vel[2].abs() <= 10.0 + 1e-3,
"restitution halves the speed: {}",
p.vel[2]
);
}
#[test]
fn carve_debris_samples_colours_and_carves() {
use glam::{DVec3, IVec3};
let mut scene = roxlap_scene::Scene::new();
let grid = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
scene.grid_mut(grid).expect("grid").set_rect(
IVec3::new(-8, -8, 10),
IVec3::new(8, 8, 12),
Some(roxlap_formats::VoxColor(0x80_12_34_56)),
);
let mut sys = ParticleSystem::new(40);
let centre = IVec3::new(0, 0, 11);
let spawned = sys.carve_debris(
&mut scene,
grid,
centre,
2,
4.0..6.0,
&ParticleEmitterDef {
lifetime: 100.0..100.0,
..base_def()
},
);
assert!(spawned > 0);
assert_eq!(sys.particle_count() as u32, spawned);
for p in sys.particles() {
assert_eq!(p.tint, Rgb(0x0012_3456), "tint is the voxel colour");
}
let g = scene.grid_mut(grid).expect("grid");
assert!(g.voxel_color(centre).is_none());
for p in sys.particles() {
let d = [p.pos[0] - 0.5, p.pos[1] - 0.5, p.pos[2] - 11.5];
let r2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if r2 > 0.5 {
let dot = d[0] * p.vel[0] + d[1] * p.vel[1] + d[2] * p.vel[2];
assert!(dot > 0.0, "debris flies outward: {d:?} vs {:?}", p.vel);
}
}
assert_eq!(sys.emitter_count(), 0);
sys.update(200.0);
assert_eq!(sys.particle_count(), 0);
assert!(sys.emitters.iter().all(Option::is_none));
}
#[test]
fn carve_debris_caps_big_carves_and_respects_budget() {
use glam::IVec3;
let mut scene = scene_with_floor();
let grid = scene.grids().next().expect("one grid").0;
let mut sys = ParticleSystem::new(41);
let spawned = sys.carve_debris(
&mut scene,
grid,
IVec3::new(0, 0, 11),
6,
1.0..2.0,
&ParticleEmitterDef {
lifetime: 100.0..100.0,
..base_def()
},
);
assert!(spawned as usize <= CARVE_DEBRIS_CAP);
assert!(
spawned as usize > CARVE_DEBRIS_CAP / 2,
"stride fills near the cap"
);
let mut low = ParticleSystem::new(44);
low.set_carve_debris_cap(16);
let mut scene3 = scene_with_floor();
let spawned = low.carve_debris(
&mut scene3,
grid,
IVec3::new(0, 0, 11),
6,
1.0..2.0,
&ParticleEmitterDef {
lifetime: 100.0..100.0,
..base_def()
},
);
assert!(spawned <= 16, "tuned cap respected: {spawned}");
assert!(spawned >= 8, "stride still fills near the tuned cap");
let mut tiny = ParticleSystem::new(42);
tiny.set_max_particles(5);
let mut scene2 = scene_with_floor();
let spawned = tiny.carve_debris(
&mut scene2,
grid,
IVec3::new(10, 10, 11),
4,
1.0..2.0,
&ParticleEmitterDef {
lifetime: 100.0..100.0,
..base_def()
},
);
assert_eq!(spawned, 5);
assert!(tiny.dropped_spawns() > 0);
}
#[test]
fn carve_debris_tint_end_lerps_from_voxel_colour() {
use glam::IVec3;
let mut scene = scene_with_floor();
let grid = scene.grids().next().expect("one grid").0;
let mut sys = ParticleSystem::new(43);
sys.carve_debris(
&mut scene,
grid,
IVec3::new(0, 0, 11),
1,
0.0..0.0,
&ParticleEmitterDef {
lifetime: 1.0..1.0,
tint_end: Some(Rgb(0x0000_0000)),
..base_def()
},
);
assert!(sys.particle_count() > 0);
sys.update(0.5);
for p in sys.particles() {
let r = (p.tint.0 >> 16) & 0xff;
assert!(
(120..=135).contains(&r),
"lerp starts at the voxel colour, not def.tint: {:#08x}",
p.tint.0
);
}
}
#[test]
fn stress_10k_particles_simulate_and_sync() {
let mut sys = ParticleSystem::new(50);
sys.set_max_particles(10_000);
let em = sys.add_emitter(ParticleEmitterDef {
lifetime: 2.0..2.0,
velocity: VelocityDef {
spread: 10.0,
..VelocityDef::default()
},
fade_in_frac: 0.5,
fade_out_frac: 0.5,
spin: -3.0..3.0,
scale_end: Some(2.0),
tint_end: Some(Rgb(0x0000_0000)),
gravity: [0.0, 0.0, 5.0],
..base_def()
});
assert_eq!(sys.burst(em, 10_000), 10_000);
let mut f = Mock::default();
for _ in 0..30 {
sys.update(1.0 / 60.0);
sys.sync_with(&mut f);
}
assert_eq!(sys.particle_count(), 10_000);
assert_eq!(f.spawns.len(), 10_000);
assert_eq!(*f.batch_sizes.last().expect("batches ran"), 10_000);
assert!(f.alphas.len() > 100_000, "alpha churns every frame");
}
#[test]
#[ignore = "manual perf probe — prints timings, asserts nothing"]
fn stress_10k_probe() {
let mut sys = ParticleSystem::new(51);
sys.set_max_particles(10_000);
let em = sys.add_emitter(ParticleEmitterDef {
lifetime: 8.0..8.0,
velocity: VelocityDef {
spread: 10.0,
..VelocityDef::default()
},
fade_in_frac: 0.5,
fade_out_frac: 0.5,
spin: -3.0..3.0,
scale_end: Some(2.0),
tint_end: Some(Rgb(0x0000_0000)),
..base_def()
});
sys.burst(em, 10_000);
let mut f = Mock::default();
sys.sync_with(&mut f); let t0 = std::time::Instant::now();
const FRAMES: u32 = 200;
for _ in 0..FRAMES {
sys.update(1.0 / 60.0);
sys.sync_with(&mut f);
}
let per_frame = t0.elapsed() / FRAMES;
eprintln!("10k particles: {per_frame:?}/frame (update + sync w/ alpha+tint churn)");
}
#[test]
fn moved_emitter_spawns_at_new_pos() {
let mut sys = ParticleSystem::new(8);
let em = sys.add_emitter(base_def());
assert!(sys.set_emitter_pos(em, [5.0, 6.0, 7.0]));
sys.burst(em, 1);
assert_eq!(sys.particles()[0].pos, [5.0, 6.0, 7.0]);
}
}