use std::f32::consts::{PI, TAU};
use std::time::Duration;
use bevy_ecs::entity::Entity;
use std::collections::BTreeSet;
use bevy_ecs::prelude::{
Commands, Component, Has, Local, 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 ParticleSimulation {
#[default]
Cpu,
Gpu,
}
pub const MAX_GPU_PARTICLES: u32 = 1 << 20;
#[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 impact_speed: f32,
pub impact_count: u32,
pub on_settle: bool,
pub on_fracture: 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 wind_factor: 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,
pub simulation: ParticleSimulation,
pub collide: bool,
pub trail: f32,
pub on_parent_death: u32,
pub blockers: bool,
#[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,
impact_speed: 0.0,
impact_count: 10,
on_settle: false,
on_fracture: 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,
wind_factor: 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,
simulation: ParticleSimulation::Cpu,
collide: false,
trail: 0.0,
on_parent_death: 0,
blockers: false,
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 launch_axis(emitter: &ParticleEmitter) -> [f32; 3] {
let length = sim_math::length(emitter.direction);
if length > 0.0 {
emitter.direction.map(|v| v / length)
} else {
[0.0, 1.0, 0.0]
}
}
fn spawn(
emitter: &ParticleEmitter,
frame: Frame,
draw: &Draw,
axis: [f32; 3],
) -> Particle {
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,
wind: Option<&super::Wind>,
) {
let mut accel = emitter.wind;
if let Some(wind) = wind {
let push = wind.push_at(particle.position, time);
for (accel, push) in accel.iter_mut().zip(push) {
*accel += push * emitter.wind_factor;
}
}
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;
}
#[allow(clippy::too_many_arguments)]
fn step(
emitter: &ParticleEmitter,
system: &mut ParticleSystem,
frame: Frame,
seed: &RandomSeed,
tick: u64,
stream: u64,
dt: f32,
wind: Option<&super::Wind>,
) {
if system.paused {
return;
}
let time = system.time;
system.particles.retain_mut(|particle| {
integrate(emitter, particle, dt, time, wind);
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;
}
if emitter.simulation == ParticleSimulation::Gpu {
system.spawned += count;
return;
}
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,
launch_axis(emitter),
));
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ParticleBlocker {
pub extents: [f32; 3],
}
impl Default for ParticleBlocker {
fn default() -> Self {
Self { extents: [1.0; 3] }
}
}
type BlockerParts = (
&'static ParticleBlocker,
Option<&'static GlobalTransform>,
Option<&'static crate::Transform>,
);
type GroundParts = (
&'static super::Heightfield,
Option<&'static GlobalTransform>,
Option<&'static crate::Transform>,
);
#[derive(Default)]
struct Blockers<'a> {
boxes: Vec<nalgebra::Matrix4<f32>>,
grounds: Vec<(nalgebra::Matrix4<f32>, &'a super::Heightfield)>,
}
impl<'a> Blockers<'a> {
fn new(
boxes: impl Iterator<Item = QueryItem<'a, 'a, BlockerParts>>,
grounds: impl Iterator<Item = QueryItem<'a, 'a, GroundParts>>,
) -> Self {
let inverse = |global, transform, scale: [f32; 3]| {
(nalgebra::Matrix4::from(emitter_frame(global, transform).matrix)
* nalgebra::Matrix4::new_nonuniform_scaling(&scale.into()))
.try_inverse()
};
Self {
boxes: boxes
.filter_map(|(blocker, global, transform)| {
inverse(global, transform, blocker.extents)
})
.collect(),
grounds: grounds
.filter_map(|(field, global, transform)| {
Some((inverse(global, transform, [1.0; 3])?, field))
})
.collect(),
}
}
fn kills(&self, point: [f32; 3]) -> bool {
let point = nalgebra::Point3::from(point);
self.boxes.iter().any(|to_box| {
to_box
.transform_point(&point)
.iter()
.all(|axis| axis.abs() <= 1.0)
}) || self.grounds.iter().any(|(to_local, field)| {
let local = to_local.transform_point(&point);
ground_height(field, local.x, local.z)
.is_some_and(|height| local.y < height)
})
}
}
fn ground_height(field: &super::Heightfield, x: f32, z: f32) -> Option<f32> {
let rows = field.heights.len();
let columns = field.heights.iter().map(Vec::len).max().unwrap_or(0);
if rows < 2 || columns < 2 || field.spacing <= 0.0 {
return None;
}
let u = x / field.spacing + 0.5 * (columns - 1) as f32;
let v = z / field.spacing + 0.5 * (rows - 1) as f32;
if !(0.0..=(columns - 1) as f32).contains(&u)
|| !(0.0..=(rows - 1) as f32).contains(&v)
{
return None;
}
let column = (u as usize).min(columns - 2);
let row = (v as usize).min(rows - 2);
let (across, along) = (u - column as f32, v - row as f32);
let height =
|r: usize, c: usize| field.heights[r].get(c).copied().unwrap_or(0.0);
let [a, b, c, d] = [
height(row, column),
height(row, column + 1),
height(row + 1, column),
height(row + 1, column + 1),
];
Some(if across + along <= 1.0 {
a + (b - a) * across + (c - a) * along
} else {
d + (c - d) * (1.0 - across) + (b - d) * (1.0 - along)
})
}
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>,
boxes: Query<BlockerParts>,
grounds: Query<GroundParts>,
winds: Query<(Entity, &super::Wind)>,
) {
let dt = time.fixed_delta.as_secs_f32();
let blockers = Blockers::new(boxes.iter(), grounds.iter());
let wind = super::Wind::first(winds);
for parts in &mut emitters {
advance(parts, &blockers, &seed, time.fixed_tick, dt, wind);
}
}
fn advance(
(entity, mut emitter, mut system, global, transform, id): QueryItem<
'_,
'_,
EmitterParts,
>,
blockers: &Blockers,
seed: &RandomSeed,
tick: u64,
dt: f32,
wind: Option<&super::Wind>,
) {
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, wind);
}
}
step(&emitter, &mut system, frame, seed, tick, stream, dt, wind);
if emitter.blockers {
let local = emitter.space == ParticleSpace::Local;
system.particles.retain(|particle| {
!blockers.kills(if local {
frame.point(particle.position)
} else {
particle.position
})
});
}
}
#[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 state = bevy_ecs::system::SystemState::<(
Query<EmitterParts>,
Query<BlockerParts>,
Query<GroundParts>,
Query<(Entity, &super::Wind)>,
)>::new(world);
let Ok((mut emitters, boxes, grounds, winds)) = state.get_mut(world) else {
return;
};
let blockers = Blockers::new(boxes.iter(), grounds.iter());
let wind = super::Wind::first(winds);
for tick in first..first + steps {
for parts in &mut emitters {
let dt = fixed.as_secs_f32();
advance(parts, &blockers, &seed, tick, dt, wind);
}
}
}
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;
}
}
}
#[allow(clippy::too_many_arguments)]
fn prewarm(
emitter: &ParticleEmitter,
system: &mut ParticleSystem,
frame: Frame,
seed: &RandomSeed,
tick: u64,
stream: u64,
dt: f32,
wind: Option<&super::Wind>,
) {
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, wind);
}
}
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;
}
type ImpactParts = (
Entity,
&'static ParticleEmitter,
&'static mut ParticleSystem,
Option<&'static GlobalTransform>,
Option<&'static crate::Transform>,
Option<&'static SceneId>,
Has<super::Sleeping>,
);
pub(super) fn emit_on_impacts(
physics: Res<super::PhysicsWorld>,
seed: Res<RandomSeed>,
time: Res<FrameTime>,
mut emitters: Query<ImpactParts>,
mut asleep: Local<BTreeSet<Entity>>,
) {
let mut now_asleep = BTreeSet::new();
for (entity, emitter, mut system, global, transform, id, sleeping) in
&mut emitters
{
if sleeping {
now_asleep.insert(entity);
}
if system.paused {
continue;
}
let frame = emitter_frame(global, transform);
let mut impacts = Vec::new();
if emitter.impact_speed > 0.0 {
for contact in physics.contacts() {
if contact.sensor || contact.speed < emitter.impact_speed {
continue;
}
let away = if contact.a == entity {
contact.normal.map(|v| -v)
} else if contact.b == entity {
contact.normal
} else {
continue;
};
impacts.push((contact.point, away));
}
}
if emitter.on_settle && sleeping && !asleep.contains(&entity) {
impacts.push((frame.point([0.0; 3]), [0.0, 1.0, 0.0]));
}
let stream = stream_key(entity, id);
spawn_impacts(emitter, &mut system, &impacts, &seed, &time, stream);
}
*asleep = now_asleep;
}
fn spawn_impacts(
emitter: &ParticleEmitter,
system: &mut ParticleSystem,
impacts: &[([f32; 3], [f32; 3])],
seed: &RandomSeed,
time: &FrameTime,
key: u64,
) {
let count = u64::from(emitter.impact_count);
if emitter.simulation == ParticleSimulation::Gpu {
system.spawned += count * impacts.len() as u64;
return;
}
let stream = RandomSeed::stream("particle_impacts", key);
for &(point, away) in impacts {
let at = if emitter.space == ParticleSpace::World {
impact_frame(point, away)
} else {
Frame {
matrix: GlobalTransform::default().matrix,
}
};
let axis = if emitter.space == ParticleSpace::World {
[0.0, 1.0, 0.0]
} else {
launch_axis(emitter)
};
let room = u64::from(emitter.max_particles)
.saturating_sub(system.alive() as u64);
for _ in 0..count.min(room) {
let draw = Draw {
seed,
tick: time.fixed_tick,
stream,
spawn: system.spawned,
};
system.spawned += 1;
system.particles.push(spawn(emitter, at, &draw, axis));
}
}
}
pub(super) fn emit_on_fractures(
breaks: Res<super::EventQueue<super::Fractured>>,
seed: Res<RandomSeed>,
time: Res<FrameTime>,
mut emitters: Query<(ImpactParts, &super::Parent)>,
) {
for event in breaks.iter() {
for ((entity, emitter, mut system, global, transform, id, _), parent) in
&mut emitters
{
if !emitter.on_fracture
|| system.paused
|| !event.pieces.contains(&parent.0)
{
continue;
}
let center = emitter_frame(global, transform).point([0.0; 3]);
let mut away = sim_math::normalize(std::array::from_fn(|i| {
center[i] - event.point[i]
}));
if away == [0.0; 3] {
away = [0.0, 1.0, 0.0];
}
let stream = stream_key(entity, id);
let impacts = [(event.point, away)];
spawn_impacts(emitter, &mut system, &impacts, &seed, &time, stream);
}
}
}
fn impact_frame(point: [f32; 3], up: [f32; 3]) -> Frame {
let helper = if up[1].abs() < 0.9 {
[0.0, 1.0, 0.0]
} else {
[1.0, 0.0, 0.0]
};
let x = sim_math::normalize(cross(up, helper));
let z = cross(x, up);
Frame {
matrix: [
[x[0], x[1], x[2], 0.0],
[up[0], up[1], up[2], 0.0],
[z[0], z[1], z[2], 0.0],
[point[0], point[1], point[2], 1.0],
],
}
}
#[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(
Component, Clone, Debug, Default, PartialEq, Serialize, Deserialize,
)]
#[serde(default)]
pub struct ParticleShader {
pub source: String,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParticleBatch {
pub entity: Option<Entity>,
pub blend: ParticleBlend,
pub sprite: ParticleSprite,
pub instances: Vec<ParticleInstance>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct GpuParticleBatch {
pub entity: Entity,
pub stream: u64,
pub spawned: u64,
pub max_particles: u32,
pub blend: ParticleBlend,
pub sprite: ParticleSprite,
pub shape: u32,
pub trail_points: u32,
pub parent: Option<(Entity, u32)>,
pub rows: Vec<[f32; 4]>,
}
pub const GPU_PARTICLE_CURVE: usize = 8;
pub const GPU_TRAIL_POINTS: u32 = 8;
#[must_use]
pub fn gpu_particle_batch(
entity: Entity,
emitter: &ParticleEmitter,
system: &ParticleSystem,
global: Option<&GlobalTransform>,
id: Option<&SceneId>,
) -> GpuParticleBatch {
let matrix = emitter_frame(global, None).matrix;
let direction = sim_math::normalize(emitter.direction);
let mut rows = matrix.to_vec();
rows.extend([
[direction[0], direction[1], direction[2], emitter.spread],
[
emitter.wind[0],
emitter.wind[1],
emitter.wind[2],
emitter.gravity,
],
[
emitter.lifetime[0],
emitter.lifetime[1],
emitter.speed[0],
emitter.speed[1],
],
[
emitter.size[0],
emitter.size[1],
emitter.drag,
emitter.emissive,
],
[
emitter.shape_size[0],
emitter.shape_size[1],
emitter.shape_size[2],
f32::from(u8::from(emitter.collide)),
],
]);
let trail_points = if emitter.trail > 0.0 {
GPU_TRAIL_POINTS
} else {
0
};
rows.push([emitter.trail.max(0.0), trail_points as f32, 0.0, 0.0]);
let life = |sample: usize| sample as f32 / (GPU_PARTICLE_CURVE - 1) as f32;
rows.extend((0..GPU_PARTICLE_CURVE).map(|i| emitter.color_at(life(i))));
rows.extend(
(0..GPU_PARTICLE_CURVE)
.map(|i| [emitter.size_at(life(i)), 0.0, 0.0, 0.0]),
);
GpuParticleBatch {
entity,
stream: stream_key(entity, id),
spawned: system.spawned,
max_particles: emitter.max_particles.clamp(1, MAX_GPU_PARTICLES),
blend: emitter.blend,
sprite: emitter.sprite,
shape: emitter.shape as u32,
trail_points,
parent: None,
rows,
}
}
#[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 {
run_in(emitter, ticks, seed, None)
}
fn run_in(
emitter: &ParticleEmitter,
ticks: u64,
seed: u64,
wind: Option<&super::super::Wind>,
) -> 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,
wind,
);
}
system
}
#[test]
fn scene_wind_pushes_particles_by_their_wind_factor() {
let wind = super::super::Wind {
direction: [0.0, 0.0, 2.0],
strength: 4.0,
gusts: 0.0,
};
let drift = |wind_factor: f32, wind: Option<&super::super::Wind>| {
let emitter = ParticleEmitter {
wind_factor,
..ParticleEmitter::default()
};
run_in(&emitter, 30, 1, wind).particles
};
let still = drift(1.0, None);
assert!(!still.is_empty());
assert_eq!(drift(0.0, Some(&wind)), still, "a zero factor ignores it");
let blown = drift(1.0, Some(&wind));
assert_eq!(blown.len(), still.len());
for (blown, still) in blown.iter().zip(&still) {
let gained = blown.velocity[2] - still.velocity[2];
assert!(
(gained - 4.0 * blown.age).abs() < 1e-4,
"{gained} after {} s",
blown.age
);
}
}
#[test]
fn blocked_rain_stops_at_roofs_and_the_ground() {
let field = crate::runtime::Heightfield {
heights: vec![vec![0.0, 2.0], vec![0.0, 2.0]],
spacing: 2.0,
..Default::default()
};
assert_eq!(ground_height(&field, 0.0, 0.5), Some(1.0));
assert_eq!(ground_height(&field, 1.5, 0.0), None);
let mut world = World::new();
world.spawn((
crate::Transform::default(),
GlobalTransform::default(),
field,
));
let mut roof = GlobalTransform::default();
roof.matrix[3] = [5.0, 3.0, 0.0, 1.0];
world.spawn((
roof,
ParticleBlocker {
extents: [2.0, 0.5, 2.0],
},
));
let rain = |x: f32, blockers| {
let mut frame = GlobalTransform::default();
frame.matrix[3] = [x, 6.0, 0.0, 1.0];
(
frame,
ParticleEmitter {
rate: 60.0,
lifetime: [3.0, 3.0],
speed: [0.0, 0.0],
gravity: 9.81,
blockers,
..ParticleEmitter::default()
},
)
};
let hill = world.spawn(rain(0.0, true)).id();
let roofed = world.spawn(rain(5.0, true)).id();
let open = world.spawn(rain(5.0, false)).id();
for _ in 0..30 {
preview_particles(&mut world, Duration::from_secs_f32(1.0 / 15.0));
}
let lowest = |entity| {
let system = world.get::<ParticleSystem>(entity).unwrap();
assert!(system.alive() > 0);
system
.particles
.iter()
.map(|particle| particle.position[1])
.fold(f32::INFINITY, f32::min)
};
assert!(lowest(hill) >= 0.9, "{}", lowest(hill));
assert!(lowest(roofed) >= 3.5, "{}", lowest(roofed));
assert!(lowest(open) < 0.0, "{}", lowest(open));
}
#[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 gpu_emitters_only_count_spawns_and_pack_their_settings() {
let emitter = ParticleEmitter {
rate: 60.0,
simulation: ParticleSimulation::Gpu,
collide: true,
trail: 0.5,
max_particles: u32::MAX,
shape: EmitterShape::Sphere,
..ParticleEmitter::default()
};
let system = run(&emitter, 60, 1);
assert_eq!(system.alive(), 0, "the renderer owns the particles");
assert_eq!(system.spawned, 60);
let global = GlobalTransform {
matrix: [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[3.0, 4.0, 5.0, 1.0],
],
};
let entity = Entity::from_raw_u32(3).unwrap();
let batch =
gpu_particle_batch(entity, &emitter, &system, Some(&global), None);
assert_eq!(batch.spawned, 60);
assert_eq!(batch.max_particles, MAX_GPU_PARTICLES);
assert_eq!(batch.shape, 2);
assert_eq!(batch.rows.len(), 10 + 2 * GPU_PARTICLE_CURVE);
assert_eq!(batch.rows[9], [0.5, 8.0, 0.0, 0.0], "trail");
assert_eq!(batch.trail_points, GPU_TRAIL_POINTS);
assert_eq!(batch.rows[3], [3.0, 4.0, 5.0, 1.0]);
assert_eq!(batch.rows[6], [1.0, 2.0, 1.0, 2.0], "lifetime, speed");
assert_eq!(batch.rows[8][3], 1.0, "collide");
assert_eq!(batch.rows[10][3], 0.0);
assert_eq!(batch.rows[10 + 3], [1.0; 4]);
assert_eq!(batch.rows[10 + GPU_PARTICLE_CURVE], [1.0, 0.0, 0.0, 0.0]);
}
#[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,
None,
);
}
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,
None,
);
}
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,
None,
);
assert!(system.alive() > 50, "{}", system.alive());
}
}