use std::f32::consts::{PI, TAU};
use std::time::Duration;
use bevy_ecs::entity::Entity;
use bevy_ecs::prelude::{
Commands, Component, Query, Res, Resource, With, Without, World,
};
use bevy_ecs::query::QueryItem;
use serde::{Deserialize, Serialize};
use super::sim_math;
use super::{FrameTime, GlobalTransform, RandomSeed, SceneId};
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum EmitterShape {
#[default]
Point,
Box,
Sphere,
Cone,
Circle,
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum ParticleSpace {
#[default]
World,
Local,
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum ParticleFacing {
#[default]
Billboard,
Velocity,
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum ParticleBlend {
#[default]
Alpha,
Additive,
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum ParticleSprite {
#[default]
Soft,
Disc,
Square,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ParticleBurst {
pub time: f32,
pub count: u32,
}
impl Default for ParticleBurst {
fn default() -> Self {
Self {
time: 0.0,
count: 10,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CurveKey {
pub t: f32,
pub value: f32,
}
impl Default for CurveKey {
fn default() -> Self {
Self { t: 0.0, value: 1.0 }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ColorKey {
pub t: f32,
pub color: [f32; 4],
}
impl Default for ColorKey {
fn default() -> Self {
Self {
t: 0.0,
color: [1.0; 4],
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ParticleCommand {
#[default]
None,
Play,
Pause,
Stop,
Restart,
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ParticleEmitter {
pub autoplay: bool,
pub rate: f32,
pub bursts: Vec<ParticleBurst>,
pub max_particles: u32,
pub prewarm: bool,
pub duration: f32,
pub looping: bool,
pub on_collision: bool,
pub shape: EmitterShape,
pub shape_size: [f32; 3],
pub lifetime: [f32; 2],
pub speed: [f32; 2],
pub size: [f32; 2],
pub rotation: [f32; 2],
pub spin: [f32; 2],
pub direction: [f32; 3],
pub spread: f32,
pub gravity: f32,
pub drag: f32,
pub wind: [f32; 3],
pub turbulence: f32,
pub turbulence_frequency: f32,
pub size_over_life: Vec<CurveKey>,
pub color_over_life: Vec<ColorKey>,
pub start_colors: Vec<[f32; 4]>,
pub emissive: f32,
pub fade_in: f32,
pub fade_out: f32,
pub space: ParticleSpace,
pub facing: ParticleFacing,
pub stretch: f32,
pub blend: ParticleBlend,
pub sprite: ParticleSprite,
#[serde(skip)]
pub command: ParticleCommand,
}
impl Default for ParticleEmitter {
fn default() -> Self {
Self {
autoplay: true,
rate: 20.0,
bursts: Vec::new(),
max_particles: 1000,
prewarm: false,
duration: 5.0,
looping: true,
on_collision: false,
shape: EmitterShape::Point,
shape_size: [0.5; 3],
lifetime: [1.0, 2.0],
speed: [1.0, 2.0],
size: [0.1, 0.2],
rotation: [0.0, 0.0],
spin: [0.0, 0.0],
direction: [0.0, 1.0, 0.0],
spread: 0.4,
gravity: 0.0,
drag: 0.0,
wind: [0.0; 3],
turbulence: 0.0,
turbulence_frequency: 1.0,
size_over_life: Vec::new(),
color_over_life: Vec::new(),
start_colors: Vec::new(),
emissive: 1.0,
fade_in: 0.1,
fade_out: 0.3,
space: ParticleSpace::World,
facing: ParticleFacing::Billboard,
stretch: 0.1,
blend: ParticleBlend::Alpha,
sprite: ParticleSprite::Soft,
command: ParticleCommand::None,
}
}
}
impl ParticleEmitter {
pub fn play(&mut self) {
self.command = ParticleCommand::Play;
}
pub fn pause(&mut self) {
self.command = ParticleCommand::Pause;
}
pub fn stop(&mut self) {
self.command = ParticleCommand::Stop;
}
pub fn restart(&mut self) {
self.command = ParticleCommand::Restart;
}
#[must_use]
pub fn size_at(&self, life: f32) -> f32 {
sample_keys(&self.size_over_life, life, |key| (key.t, key.value), 1.0)
}
#[must_use]
pub fn color_at(&self, life: f32) -> [f32; 4] {
let mut color = [0, 1, 2, 3].map(|channel| {
sample_keys(
&self.color_over_life,
life,
|key| (key.t, key.color[channel]),
1.0,
)
});
let fade_in = if self.fade_in > 0.0 {
(life / self.fade_in).min(1.0)
} else {
1.0
};
let fade_out = if self.fade_out > 0.0 {
((1.0 - life) / self.fade_out).min(1.0)
} else {
1.0
};
color[3] *= fade_in.max(0.0) * fade_out.max(0.0);
color
}
}
fn sample_keys<K>(
keys: &[K],
t: f32,
get: impl Fn(&K) -> (f32, f32),
empty: f32,
) -> f32 {
let Some(first) = keys.first() else {
return empty;
};
let mut previous = get(first);
if t <= previous.0 {
return previous.1;
}
for key in &keys[1..] {
let next = get(key);
if t <= next.0 {
let span = next.0 - previous.0;
if span <= 0.0 {
return next.1;
}
let f = (t - previous.0) / span;
return previous.1 + (next.1 - previous.1) * f;
}
previous = next;
}
previous.1
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Particle {
pub position: [f32; 3],
pub velocity: [f32; 3],
pub age: f32,
pub lifetime: f32,
pub size: f32,
pub rotation: f32,
pub spin: f32,
pub tint: [f32; 4],
}
#[derive(Component, Clone, Debug, Default, PartialEq)]
pub struct ParticleSystem {
pub particles: Vec<Particle>,
pub time: f32,
pub playing: bool,
pub paused: bool,
pub pending: f32,
pub spawned: u64,
pub started: bool,
}
impl ParticleSystem {
#[must_use]
pub fn alive(&self) -> usize {
self.particles.len()
}
}
#[derive(Clone, Copy)]
struct Frame {
matrix: [[f32; 4]; 4],
}
impl Frame {
fn point(&self, p: [f32; 3]) -> [f32; 3] {
let m = &self.matrix;
std::array::from_fn(|r| {
m[0][r] * p[0] + m[1][r] * p[1] + m[2][r] * p[2] + m[3][r]
})
}
fn direction(&self, v: [f32; 3]) -> [f32; 3] {
let m = &self.matrix;
let axis = |c: usize| sim_math::normalize([m[c][0], m[c][1], m[c][2]]);
let [x, y, z] = [axis(0), axis(1), axis(2)];
std::array::from_fn(|r| x[r] * v[0] + y[r] * v[1] + z[r] * v[2])
}
}
struct Draw<'a> {
seed: &'a RandomSeed,
tick: u64,
stream: u64,
spawn: u64,
}
impl Draw<'_> {
fn unit(&self, index: u64) -> f32 {
self.seed
.unit(self.tick, self.stream ^ (self.spawn << 4 | index))
}
fn range(&self, index: u64, [min, max]: [f32; 2]) -> f32 {
min + (max - min) * self.unit(index)
}
}
fn cone_direction(axis: [f32; 3], spread: f32, u: f32, v: f32) -> [f32; 3] {
let spread = spread.clamp(0.0, PI);
let cos_max = sim_math::sin_cos(spread).1;
let cos = 1.0 - u * (1.0 - cos_max);
let sin = (1.0 - cos * cos).max(0.0).sqrt();
let (sp, cp) = sim_math::sin_cos(v * TAU);
let helper = if axis[1].abs() < 0.99 {
[0.0, 1.0, 0.0]
} else {
[1.0, 0.0, 0.0]
};
let side = sim_math::normalize(cross(helper, axis));
let up = cross(axis, side);
std::array::from_fn(|i| axis[i] * cos + (side[i] * cp + up[i] * sp) * sin)
}
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn pick<T: Copy>(items: &[T], unit: f32) -> Option<T> {
let index = (unit * items.len() as f32) as usize;
items.get(index.min(items.len().saturating_sub(1))).copied()
}
fn spawn(emitter: &ParticleEmitter, frame: Frame, draw: &Draw) -> Particle {
let axis = {
let length = sim_math::length(emitter.direction);
if length > 0.0 {
emitter.direction.map(|v| v / length)
} else {
[0.0, 1.0, 0.0]
}
};
let size = emitter.shape_size;
let radius = size[0].max(0.0);
let (offset, direction) = match emitter.shape {
EmitterShape::Point => ([0.0; 3], None),
EmitterShape::Box => (
[0, 1, 2].map(|i| (draw.unit(i) * 2.0 - 1.0) * size[i as usize]),
None,
),
EmitterShape::Sphere => {
let dir = cone_direction(axis, PI, draw.unit(0), draw.unit(1));
let r = radius * draw.unit(2).cbrt();
(dir.map(|v| v * r), None)
}
EmitterShape::Cone | EmitterShape::Circle => {
let (s, c) = sim_math::sin_cos(draw.unit(0) * TAU);
let r = if emitter.shape == EmitterShape::Circle {
radius
} else {
radius * draw.unit(1).sqrt()
};
let offset = [c * r, 0.0, s * r];
let direction = (emitter.shape == EmitterShape::Cone).then(|| {
let lean = if radius > 0.0 {
emitter.spread.clamp(0.0, PI * 0.5) * r / radius
} else {
0.0
};
let (ls, lc) = sim_math::sin_cos(lean);
sim_math::normalize([c * ls, lc, s * ls])
});
(offset, direction)
}
};
let local_direction = direction.unwrap_or_else(|| {
cone_direction(axis, emitter.spread, draw.unit(3), draw.unit(4))
});
let speed = draw.range(5, emitter.speed);
let lifetime = draw.range(6, emitter.lifetime).max(1e-3);
let mut particle = Particle {
position: offset,
velocity: local_direction.map(|v| v * speed),
age: 0.0,
lifetime,
size: draw.range(7, emitter.size),
rotation: draw.range(8, emitter.rotation),
spin: draw.range(9, emitter.spin),
tint: pick(&emitter.start_colors, draw.unit(10)).unwrap_or([1.0; 4]),
};
if emitter.space == ParticleSpace::World {
particle.position = frame.point(offset);
particle.velocity = frame.direction(particle.velocity);
}
particle
}
fn integrate(
emitter: &ParticleEmitter,
particle: &mut Particle,
dt: f32,
time: f32,
) {
let mut accel = emitter.wind;
accel[1] -= emitter.gravity;
if emitter.turbulence != 0.0 {
let f = emitter.turbulence_frequency;
let p = particle.position;
let wave = |a: f32, b: f32, phase: f32| {
sim_math::sin_cos(a * f + time * 1.3 + phase).0
+ sim_math::sin_cos(b * f * 1.7 - time * 0.9 + phase).1
};
accel[0] += emitter.turbulence * 0.5 * wave(p[1], p[2], 0.0);
accel[1] += emitter.turbulence * 0.5 * wave(p[2], p[0], 2.1);
accel[2] += emitter.turbulence * 0.5 * wave(p[0], p[1], 4.2);
}
let damp = (1.0 - emitter.drag * dt).max(0.0);
for ((velocity, position), accel) in particle
.velocity
.iter_mut()
.zip(&mut particle.position)
.zip(accel)
{
*velocity = (*velocity + accel * dt) * damp;
*position += *velocity * dt;
}
particle.rotation += particle.spin * dt;
particle.age += dt;
}
fn step(
emitter: &ParticleEmitter,
system: &mut ParticleSystem,
frame: Frame,
seed: &RandomSeed,
tick: u64,
stream: u64,
dt: f32,
) {
if system.paused {
return;
}
let time = system.time;
system.particles.retain_mut(|particle| {
integrate(emitter, particle, dt, time);
particle.age < particle.lifetime
});
if !system.playing {
return;
}
let duration = emitter.duration.max(1e-3);
let before = system.time;
let after = before + dt;
system.time = after;
let mut count = 0_u64;
let emitting = emitter.looping || before < duration;
if emitting && emitter.rate > 0.0 {
let span = if emitter.looping {
dt
} else {
after.min(duration) - before
};
system.pending += emitter.rate * span;
let whole = system.pending.floor();
system.pending -= whole;
count += whole as u64;
}
for burst in &emitter.bursts {
let fires = if emitter.looping {
let cycle = (before / duration).floor();
let mut at = cycle * duration + burst.time;
if at < before {
at += duration;
}
at >= before && at < after
} else {
burst.time >= before && burst.time < after
};
if fires {
count += u64::from(burst.count);
}
}
if !emitter.looping && after >= duration && system.particles.is_empty() {
system.playing = false;
}
let room =
u64::from(emitter.max_particles).saturating_sub(system.alive() as u64);
for _ in 0..count.min(room) {
let draw = Draw {
seed,
tick,
stream,
spawn: system.spawned,
};
system.spawned += 1;
system.particles.push(spawn(emitter, frame, &draw));
}
}
pub(super) fn add_particle_systems(
mut commands: Commands,
emitters: Query<Entity, (With<ParticleEmitter>, Without<ParticleSystem>)>,
) {
for entity in &emitters {
commands.entity(entity).insert(ParticleSystem::default());
}
}
type EmitterParts = (
Entity,
&'static mut ParticleEmitter,
&'static mut ParticleSystem,
Option<&'static GlobalTransform>,
Option<&'static crate::Transform>,
Option<&'static SceneId>,
);
fn stream_key(entity: Entity, id: Option<&SceneId>) -> u64 {
let key = id.map_or(entity.to_bits(), |id| {
let bits = id.0.as_u128();
(bits as u64) ^ ((bits >> 64) as u64)
});
RandomSeed::stream("particles", key)
}
fn emitter_frame(
global: Option<&GlobalTransform>,
transform: Option<&crate::Transform>,
) -> Frame {
Frame {
matrix: global.map(|g| g.matrix).unwrap_or_else(|| {
let mut matrix = GlobalTransform::default().matrix;
if let Some(t) = transform {
matrix[3] = [t.position[0], t.position[1], t.position[2], 1.0];
}
matrix
}),
}
}
pub(super) fn update_particles(
time: Res<FrameTime>,
seed: Res<RandomSeed>,
mut emitters: Query<EmitterParts>,
) {
let dt = time.fixed_delta.as_secs_f32();
for parts in &mut emitters {
advance(parts, &seed, time.fixed_tick, dt);
}
}
fn advance(
(entity, mut emitter, mut system, global, transform, id): QueryItem<
'_,
'_,
EmitterParts,
>,
seed: &RandomSeed,
tick: u64,
dt: f32,
) {
let stream = stream_key(entity, id);
let frame = emitter_frame(global, transform);
let command = std::mem::take(&mut emitter.command);
apply_command(&mut system, command);
if !system.started {
system.started = true;
system.playing |= emitter.autoplay;
if emitter.prewarm && system.playing {
prewarm(&emitter, &mut system, frame, seed, tick, stream, dt);
}
}
step(&emitter, &mut system, frame, seed, tick, stream, dt);
}
#[derive(Resource, Default)]
pub struct ParticlePreview {
pending: Duration,
tick: u64,
}
pub fn preview_particles(world: &mut World, real: Duration) {
let missing = world
.query_filtered::<Entity, (With<ParticleEmitter>, Without<ParticleSystem>)>()
.iter(world)
.collect::<Vec<_>>();
for entity in missing {
world.entity_mut(entity).insert(ParticleSystem::default());
}
let fixed = world
.get_resource::<FrameTime>()
.map_or(FrameTime::default().fixed_delta, |time| time.fixed_delta);
let seed = world
.get_resource::<RandomSeed>()
.copied()
.unwrap_or_default();
let mut preview =
world.get_resource_or_insert_with(ParticlePreview::default);
preview.pending += real;
let mut steps = 0_u64;
while preview.pending >= fixed && !fixed.is_zero() {
preview.pending -= fixed;
steps += 1;
}
let steps = steps.min(4);
let first = preview.tick;
preview.tick += steps;
let mut emitters = world.query::<EmitterParts>();
for tick in first..first + steps {
for parts in emitters.iter_mut(world) {
advance(parts, &seed, tick, fixed.as_secs_f32());
}
}
}
pub fn reset_particles(world: &mut World) {
let live = world
.query_filtered::<Entity, With<ParticleSystem>>()
.iter(world)
.collect::<Vec<_>>();
for entity in live {
world.entity_mut(entity).remove::<ParticleSystem>();
}
}
fn apply_command(system: &mut ParticleSystem, command: ParticleCommand) {
match command {
ParticleCommand::None => {}
ParticleCommand::Play => {
if !system.playing {
system.time = 0.0;
}
system.playing = true;
system.paused = false;
system.started = true;
}
ParticleCommand::Pause => system.paused = true,
ParticleCommand::Stop => {
system.playing = false;
system.paused = false;
system.started = true;
}
ParticleCommand::Restart => {
system.particles.clear();
system.time = 0.0;
system.pending = 0.0;
system.playing = true;
system.paused = false;
system.started = true;
}
}
}
fn prewarm(
emitter: &ParticleEmitter,
system: &mut ParticleSystem,
frame: Frame,
seed: &RandomSeed,
tick: u64,
stream: u64,
dt: f32,
) {
if dt <= 0.0 {
return;
}
let seconds = emitter.duration.clamp(0.0, 10.0);
let steps = (seconds / dt) as u64;
let warm = RandomSeed::stream("particles_prewarm", stream);
for index in 0..steps {
let virtual_tick = tick.wrapping_sub(steps - index);
step(emitter, system, frame, seed, virtual_tick, warm, dt);
}
}
pub(super) fn restart_on_contact(
physics: Res<super::PhysicsWorld>,
mut emitters: Query<(Entity, &mut ParticleEmitter)>,
mut touching: bevy_ecs::prelude::Local<std::collections::BTreeSet<Entity>>,
) {
let now: std::collections::BTreeSet<Entity> = physics
.contacts()
.iter()
.flat_map(|contact| [contact.a, contact.b])
.collect();
for (entity, mut emitter) in &mut emitters {
if emitter.on_collision
&& now.contains(&entity)
&& !touching.contains(&entity)
{
emitter.restart();
}
}
*touching = now;
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ParticleInstance {
pub position: [f32; 3],
pub size: f32,
pub color: [f32; 4],
pub stretch: [f32; 3],
pub rotation: f32,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParticleBatch {
pub entity: Option<Entity>,
pub blend: ParticleBlend,
pub sprite: ParticleSprite,
pub instances: Vec<ParticleInstance>,
}
#[must_use]
pub fn particle_batch(
emitter: &ParticleEmitter,
system: &ParticleSystem,
global: Option<&GlobalTransform>,
) -> ParticleBatch {
let frame = emitter_frame(global, None);
let local = emitter.space == ParticleSpace::Local;
let instances = system
.particles
.iter()
.map(|particle| {
let life = (particle.age / particle.lifetime).clamp(0.0, 1.0);
let mut color = emitter.color_at(life);
for (channel, tint) in color.iter_mut().zip(particle.tint) {
*channel *= tint;
}
for channel in &mut color[..3] {
*channel *= emitter.emissive;
}
let (position, velocity) = if local {
(
frame.point(particle.position),
frame.direction(particle.velocity),
)
} else {
(particle.position, particle.velocity)
};
let stretch = match emitter.facing {
ParticleFacing::Billboard => [0.0; 3],
ParticleFacing::Velocity => {
velocity.map(|v| v * emitter.stretch)
}
};
ParticleInstance {
position,
size: particle.size * emitter.size_at(life),
color,
stretch,
rotation: particle.rotation,
}
})
.filter(|instance| instance.color[3] > 0.0 && instance.size > 0.0)
.collect();
ParticleBatch {
entity: None,
blend: emitter.blend,
sprite: emitter.sprite,
instances,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(emitter: &ParticleEmitter, ticks: u64, seed: u64) -> ParticleSystem {
let mut system = ParticleSystem {
playing: emitter.autoplay,
started: true,
..ParticleSystem::default()
};
let frame = emitter_frame(None, None);
let seed = RandomSeed(seed);
for tick in 0..ticks {
step(emitter, &mut system, frame, &seed, tick, 7, 1.0 / 60.0);
}
system
}
#[test]
fn rate_emits_and_lifetime_removes() {
let emitter = ParticleEmitter {
rate: 60.0,
lifetime: [0.5, 0.5],
..ParticleEmitter::default()
};
let system = run(&emitter, 60, 1);
assert!((29..=31).contains(&system.alive()), "{}", system.alive());
assert_eq!(system.spawned, 60);
}
#[test]
fn same_seed_same_particles_other_seed_differs() {
let emitter = ParticleEmitter {
spread: PI,
turbulence: 2.0,
..ParticleEmitter::default()
};
assert_eq!(run(&emitter, 90, 5), run(&emitter, 90, 5));
assert_ne!(
run(&emitter, 90, 5).particles,
run(&emitter, 90, 6).particles
);
}
#[test]
fn bursts_respect_max_and_one_shot_stops() {
let emitter = ParticleEmitter {
rate: 0.0,
bursts: vec![
ParticleBurst {
time: 0.0,
count: 30,
},
ParticleBurst {
time: 0.5,
count: 30,
},
],
max_particles: 40,
looping: false,
duration: 1.0,
lifetime: [0.8, 0.8],
..ParticleEmitter::default()
};
let mut system = run(&emitter, 2, 1);
assert_eq!(system.alive(), 30);
let frame = emitter_frame(None, None);
for tick in 2..40 {
step(
&emitter,
&mut system,
frame,
&RandomSeed(1),
tick,
7,
1.0 / 60.0,
);
}
assert_eq!(system.alive(), 40, "second burst capped");
for tick in 40..200 {
step(
&emitter,
&mut system,
frame,
&RandomSeed(1),
tick,
7,
1.0 / 60.0,
);
}
assert_eq!(system.alive(), 0);
assert!(!system.playing);
}
#[test]
fn looping_bursts_repeat_each_cycle() {
let emitter = ParticleEmitter {
rate: 0.0,
bursts: vec![ParticleBurst {
time: 0.25,
count: 5,
}],
duration: 0.5,
lifetime: [10.0, 10.0],
..ParticleEmitter::default()
};
assert_eq!(run(&emitter, 120, 1).spawned, 20);
}
#[test]
fn spawn_shapes_stay_inside_their_size() {
for shape in [
EmitterShape::Point,
EmitterShape::Box,
EmitterShape::Sphere,
EmitterShape::Cone,
EmitterShape::Circle,
] {
let emitter = ParticleEmitter {
shape,
shape_size: [1.0, 2.0, 3.0],
speed: [0.0, 0.0],
rate: 600.0,
..ParticleEmitter::default()
};
let system = run(&emitter, 2, 3);
assert!(!system.particles.is_empty());
for particle in &system.particles {
let [x, y, z] = particle.position;
let inside = match shape {
EmitterShape::Point => x == 0.0 && y == 0.0 && z == 0.0,
EmitterShape::Box => {
x.abs() <= 1.0 && y.abs() <= 2.0 && z.abs() <= 3.0
}
EmitterShape::Sphere => (x * x + y * y + z * z) <= 1.0001,
EmitterShape::Cone => y == 0.0 && x * x + z * z <= 1.0001,
EmitterShape::Circle => {
y == 0.0 && (x * x + z * z - 1.0).abs() < 1e-3
}
};
assert!(inside, "{shape:?} {:?}", particle.position);
}
}
}
#[test]
fn spread_zero_launches_along_direction_and_gravity_pulls() {
let emitter = ParticleEmitter {
direction: [1.0, 0.0, 0.0],
spread: 0.0,
speed: [2.0, 2.0],
gravity: 10.0,
rate: 60.0,
..ParticleEmitter::default()
};
let system = run(&emitter, 30, 1);
let oldest = system.particles[0];
assert!((oldest.velocity[0] - 2.0).abs() < 1e-4);
assert!(oldest.velocity[1] < -4.0);
assert!(oldest.velocity[2].abs() < 1e-4);
}
#[test]
fn curves_and_fades() {
let emitter = ParticleEmitter {
size_over_life: vec![
CurveKey { t: 0.0, value: 0.0 },
CurveKey { t: 1.0, value: 2.0 },
],
color_over_life: vec![
ColorKey {
t: 0.0,
color: [1.0, 0.0, 0.0, 1.0],
},
ColorKey {
t: 1.0,
color: [0.0, 0.0, 1.0, 1.0],
},
],
fade_in: 0.0,
fade_out: 0.5,
..ParticleEmitter::default()
};
assert!((emitter.size_at(0.25) - 0.5).abs() < 1e-6);
let color = emitter.color_at(0.75);
assert!((color[0] - 0.25).abs() < 1e-6);
assert!((color[2] - 0.75).abs() < 1e-6);
assert!((color[3] - 0.5).abs() < 1e-6);
assert_eq!(ParticleEmitter::default().size_at(0.3), 1.0);
}
#[test]
fn start_colors_tint_each_particle_with_one_of_them() {
let colors = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]];
let emitter = ParticleEmitter {
rate: 600.0,
start_colors: colors.clone(),
..ParticleEmitter::default()
};
let system = run(&emitter, 10, 3);
assert!(system.particles.len() > 50);
assert!(system.particles.iter().all(|p| colors.contains(&p.tint)));
for color in &colors {
assert!(system.particles.iter().any(|p| p.tint == *color));
}
assert_eq!(pick::<u8>(&[], 0.5), None);
assert_eq!(pick(&[1, 2], 0.999_999), Some(2));
}
#[test]
fn local_space_particles_follow_the_emitter() {
let emitter = ParticleEmitter {
space: ParticleSpace::Local,
speed: [0.0, 0.0],
rate: 60.0,
fade_in: 0.0,
..ParticleEmitter::default()
};
let system = run(&emitter, 5, 1);
let mut moved = GlobalTransform::default();
moved.matrix[3] = [5.0, 0.0, 0.0, 1.0];
let batch = particle_batch(&emitter, &system, Some(&moved));
assert!(!batch.instances.is_empty());
assert!(batch.instances.iter().all(|i| i.position[0] == 5.0));
}
#[test]
fn prewarm_fills_before_the_first_step() {
let emitter = ParticleEmitter {
prewarm: true,
rate: 30.0,
lifetime: [2.0, 2.0],
duration: 2.0,
..ParticleEmitter::default()
};
let mut system = ParticleSystem::default();
let frame = emitter_frame(None, None);
system.started = true;
system.playing = true;
prewarm(
&emitter,
&mut system,
frame,
&RandomSeed(1),
0,
7,
1.0 / 60.0,
);
assert!(system.alive() > 50, "{}", system.alive());
}
}