use embedded_graphics_core::pixelcolor::{Rgb565, RgbColor};
use nalgebra::{Point3, Vector3};
use crate::billboard::Billboard;
#[derive(Clone, Copy)]
pub struct Particle {
pub position: Point3<f32>,
pub velocity: Vector3<f32>,
pub acceleration: Vector3<f32>,
pub color_start: Rgb565,
pub color_end: Rgb565,
pub size_start: f32,
pub size_end: f32,
pub age: f32,
pub lifetime: f32,
pub(crate) active: bool,
}
impl Particle {
#[inline]
pub fn t(&self) -> f32 {
if self.lifetime > 0.0 {
(self.age / self.lifetime).clamp(0.0, 1.0)
} else {
1.0
}
}
#[inline]
pub fn color(&self) -> Rgb565 {
let t = self.t();
let r = lerp_ch(self.color_start.r(), self.color_end.r(), t, 31);
let g = lerp_ch(self.color_start.g(), self.color_end.g(), t, 63);
let b = lerp_ch(self.color_start.b(), self.color_end.b(), t, 31);
Rgb565::new(r, g, b)
}
#[inline]
pub fn size(&self) -> f32 {
self.size_start + self.t() * (self.size_end - self.size_start)
}
#[inline]
pub fn color_with_gradient(
&self,
gradient: &crate::color_gradient::ColorGradient<Rgb565>,
) -> Rgb565 {
gradient.sample(self.t())
}
#[inline]
pub fn size_with_curve(&self, curve: &crate::curve::Curve<f32>) -> f32 {
curve.sample(self.t())
}
}
#[inline]
fn lerp_ch(start: u8, end: u8, t: f32, max: u8) -> u8 {
(start as f32 + t * (end as f32 - start as f32)).clamp(0.0, max as f32) as u8
}
#[derive(Clone, Copy)]
pub struct ParticleSpawn {
pub position: Point3<f32>,
pub velocity: Vector3<f32>,
pub acceleration: Vector3<f32>,
pub color_start: Rgb565,
pub color_end: Rgb565,
pub size_start: f32,
pub size_end: f32,
pub lifetime: f32,
}
pub struct ParticleSystem<const N: usize> {
slots: [Particle; N],
active: usize,
}
impl<const N: usize> ParticleSystem<N> {
pub fn new() -> Self {
Self {
slots: core::array::from_fn(|_| Particle {
position: Point3::new(0.0, 0.0, 0.0),
velocity: Vector3::new(0.0, 0.0, 0.0),
acceleration: Vector3::new(0.0, 0.0, 0.0),
color_start: Rgb565::new(0, 0, 0),
color_end: Rgb565::new(0, 0, 0),
size_start: 0.0,
size_end: 0.0,
age: 0.0,
lifetime: 0.0,
active: false,
}),
active: 0,
}
}
pub fn spawn(&mut self, s: ParticleSpawn) -> bool {
for slot in &mut self.slots {
if !slot.active {
*slot = Particle {
position: s.position,
velocity: s.velocity,
acceleration: s.acceleration,
color_start: s.color_start,
color_end: s.color_end,
size_start: s.size_start,
size_end: s.size_end,
age: 0.0,
lifetime: s.lifetime,
active: true,
};
self.active += 1;
return true;
}
}
false
}
pub fn update(&mut self, dt: f32, gravity: Vector3<f32>) {
let mut live = 0usize;
for slot in &mut self.slots {
if !slot.active {
continue;
}
slot.velocity += (slot.acceleration + gravity) * dt;
slot.position += slot.velocity * dt;
slot.age += dt;
if slot.age >= slot.lifetime {
slot.active = false;
} else {
live += 1;
}
}
self.active = live;
}
#[inline]
pub fn active_count(&self) -> usize {
self.active
}
#[inline]
pub fn is_empty(&self) -> bool {
self.active == 0
}
pub fn iter_active(&self) -> impl Iterator<Item = &Particle> {
self.slots.iter().filter(|p| p.active)
}
pub fn record<const MAX: usize>(
&self,
engine: &crate::engine::K3dengine,
commands: &mut crate::command_buffer::CommandBuffer<MAX>,
) -> usize {
use crate::command_buffer::RenderCommand;
if self.active == 0 {
return 0;
}
let camera_up = Vector3::new(0.0, 1.0, 0.0);
let vp = engine.camera.vp_matrix;
let mut emitted = 0usize;
for particle in self.iter_active() {
let size = particle.size();
if size <= 0.0 {
continue;
}
let color = particle.color();
let billboard = Billboard::new(particle.position, size, color);
let quad = billboard.generate_quad(engine.camera.position, camera_up);
let Some((pts1, _)) = engine.transform_points_with_w(&[0usize, 1, 2], &quad, vp) else {
continue;
};
let Some((pts2, _)) = engine.transform_points_with_w(&[0usize, 2, 3], &quad, vp) else {
continue;
};
let _ = commands.push(RenderCommand::Draw(
crate::primitive::DrawPrimitive::ColoredTriangleWithDepth {
points: [pts1[0].xy(), pts1[1].xy(), pts1[2].xy()],
depths: [pts1[0].z as f32, pts1[1].z as f32, pts1[2].z as f32],
color,
},
));
let _ = commands.push(RenderCommand::Draw(
crate::primitive::DrawPrimitive::ColoredTriangleWithDepth {
points: [pts2[0].xy(), pts2[1].xy(), pts2[2].xy()],
depths: [pts2[0].z as f32, pts2[1].z as f32, pts2[2].z as f32],
color,
},
));
emitted += 1;
}
emitted
}
pub fn record_with_gradient<const MAX: usize>(
&self,
engine: &crate::engine::K3dengine,
commands: &mut crate::command_buffer::CommandBuffer<MAX>,
gradient: Option<&crate::color_gradient::ColorGradient<Rgb565>>,
size_curve: Option<&crate::curve::Curve<f32>>,
) -> usize {
use crate::command_buffer::RenderCommand;
if self.active == 0 {
return 0;
}
let camera_up = Vector3::new(0.0, 1.0, 0.0);
let vp = engine.camera.vp_matrix;
let mut emitted = 0usize;
for particle in self.iter_active() {
let size = if let Some(curve) = size_curve {
particle.size_with_curve(curve)
} else {
particle.size()
};
if size <= 0.0 {
continue;
}
let color = if let Some(grad) = gradient {
particle.color_with_gradient(grad)
} else {
particle.color()
};
let billboard = Billboard::new(particle.position, size, color);
let quad = billboard.generate_quad(engine.camera.position, camera_up);
let Some((pts1, _)) = engine.transform_points_with_w(&[0usize, 1, 2], &quad, vp) else {
continue;
};
let Some((pts2, _)) = engine.transform_points_with_w(&[0usize, 2, 3], &quad, vp) else {
continue;
};
let _ = commands.push(RenderCommand::Draw(
crate::primitive::DrawPrimitive::ColoredTriangleWithDepth {
points: [pts1[0].xy(), pts1[1].xy(), pts1[2].xy()],
depths: [pts1[0].z as f32, pts1[1].z as f32, pts1[2].z as f32],
color,
},
));
let _ = commands.push(RenderCommand::Draw(
crate::primitive::DrawPrimitive::ColoredTriangleWithDepth {
points: [pts2[0].xy(), pts2[1].xy(), pts2[2].xy()],
depths: [pts2[0].z as f32, pts2[1].z as f32, pts2[2].z as f32],
color,
},
));
emitted += 1;
}
emitted
}
}
impl<const N: usize> Default for ParticleSystem<N> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
extern crate std;
use super::*;
use embedded_graphics_core::pixelcolor::WebColors;
fn default_spawn(lifetime: f32) -> ParticleSpawn {
ParticleSpawn {
position: Point3::new(0.0, 0.0, 0.0),
velocity: Vector3::new(0.0, 1.0, 0.0),
acceleration: Vector3::zeros(),
color_start: Rgb565::CSS_RED,
color_end: Rgb565::CSS_BLUE,
size_start: 1.0,
size_end: 0.0,
lifetime,
}
}
#[test]
fn test_spawn_increments_count() {
let mut sys: ParticleSystem<4> = ParticleSystem::new();
assert_eq!(sys.active_count(), 0);
assert!(sys.spawn(default_spawn(2.0)));
assert_eq!(sys.active_count(), 1);
}
#[test]
fn test_pool_full_returns_false() {
let mut sys: ParticleSystem<2> = ParticleSystem::new();
assert!(sys.spawn(default_spawn(2.0)));
assert!(sys.spawn(default_spawn(2.0)));
assert!(!sys.spawn(default_spawn(2.0)));
}
#[test]
fn test_update_kills_expired_particles() {
let mut sys: ParticleSystem<4> = ParticleSystem::new();
sys.spawn(default_spawn(0.1));
sys.update(0.2, Vector3::zeros());
assert_eq!(sys.active_count(), 0);
}
#[test]
fn test_update_moves_particle_with_gravity() {
let mut sys: ParticleSystem<4> = ParticleSystem::new();
sys.spawn(default_spawn(5.0));
let y0 = sys.iter_active().next().unwrap().position.y;
sys.update(0.1, Vector3::new(0.0, -9.81, 0.0));
let y1 = sys.iter_active().next().unwrap().position.y;
assert!(y1 != y0);
}
#[test]
fn test_slot_reused_after_expiry() {
let mut sys: ParticleSystem<1> = ParticleSystem::new();
assert!(sys.spawn(default_spawn(0.05)));
sys.update(0.1, Vector3::zeros());
assert_eq!(sys.active_count(), 0);
assert!(sys.spawn(default_spawn(2.0)));
assert_eq!(sys.active_count(), 1);
}
#[test]
fn test_color_at_birth_equals_start() {
let mut sys: ParticleSystem<4> = ParticleSystem::new();
sys.spawn(default_spawn(2.0));
let p = sys.iter_active().next().unwrap();
assert_eq!(p.t(), 0.0);
assert_eq!(p.color(), Rgb565::CSS_RED);
}
#[test]
fn test_size_shrinks_over_time() {
let mut sys: ParticleSystem<4> = ParticleSystem::new();
sys.spawn(default_spawn(2.0));
let s0 = sys.iter_active().next().unwrap().size();
sys.update(1.0, Vector3::zeros());
let s1 = sys.iter_active().next().unwrap().size();
assert!(s1 < s0);
}
#[test]
fn test_empty_and_default() {
let mut sys: ParticleSystem<4> = ParticleSystem::default();
assert!(sys.is_empty());
assert_eq!(sys.active_count(), 0);
assert!(sys.spawn(default_spawn(1.0)));
assert!(!sys.is_empty());
}
#[test]
fn test_size_curve_and_color_gradient() {
use crate::color_gradient::{ColorGradient, GradientStop};
use crate::curve::{Curve, CurveInterpolation, CurveKey};
let mut sys: ParticleSystem<2> = ParticleSystem::new();
sys.spawn(default_spawn(2.0));
let p = sys.iter_active().next().unwrap();
let curve_keys = [
CurveKey::new(0.0, 10.0, CurveInterpolation::Linear),
CurveKey::new(1.0, 20.0, CurveInterpolation::Linear),
];
let curve = Curve::new(&curve_keys);
assert_eq!(p.size_with_curve(&curve), 10.0);
let stops = [
GradientStop::new(0.0, Rgb565::CSS_RED),
GradientStop::new(1.0, Rgb565::CSS_BLUE),
];
let grad = ColorGradient::new(&stops);
assert_eq!(p.color_with_gradient(&grad), Rgb565::CSS_RED);
}
#[test]
fn test_record_particles() {
use crate::color_gradient::{ColorGradient, GradientStop};
use crate::curve::{Curve, CurveInterpolation, CurveKey};
let mut sys: ParticleSystem<4> = ParticleSystem::new();
let mut spawn = default_spawn(2.0);
spawn.position = Point3::new(0.0, 0.0, 5.0);
sys.spawn(spawn);
let mut engine = crate::engine::K3dengine::new(320, 240);
engine.camera.set_position(Point3::new(0.0, 0.0, 0.0));
engine.camera.set_target(Point3::new(0.0, 0.0, 5.0));
let mut commands: crate::command_buffer::CommandBuffer<16> =
crate::command_buffer::CommandBuffer::new();
let emitted = sys.record(&engine, &mut commands);
assert!(emitted > 0);
assert!(!commands.is_empty());
let mut commands_grad: crate::command_buffer::CommandBuffer<16> =
crate::command_buffer::CommandBuffer::new();
let curve_keys = [
CurveKey::new(0.0, 1.0, CurveInterpolation::Linear),
CurveKey::new(1.0, 0.5, CurveInterpolation::Linear),
];
let curve = Curve::new(&curve_keys);
let stops = [
GradientStop::new(0.0, Rgb565::CSS_RED),
GradientStop::new(1.0, Rgb565::CSS_GREEN),
];
let grad = ColorGradient::new(&stops);
let emitted_grad =
sys.record_with_gradient(&engine, &mut commands_grad, Some(&grad), Some(&curve));
assert!(emitted_grad > 0);
assert!(!commands_grad.is_empty());
}
}