#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ParticleLocomotion {
#[default]
Idle,
Moving,
Jumping,
Landing,
Sprinting,
}
impl ParticleLocomotion {
pub fn shape_params(self) -> ParticleShapeParams {
match self {
Self::Idle => ParticleShapeParams {
stretch: [1.0, 1.0, 1.0],
radius_scale: 1.0,
particle_scale: 0.08,
noise_amplitude: 0.06,
},
Self::Moving => ParticleShapeParams {
stretch: [1.0, 0.9, 1.3],
radius_scale: 1.2,
particle_scale: 0.07,
noise_amplitude: 0.10,
},
Self::Jumping => ParticleShapeParams {
stretch: [1.2, 1.5, 1.2],
radius_scale: 1.5,
particle_scale: 0.06,
noise_amplitude: 0.14,
},
Self::Landing => ParticleShapeParams {
stretch: [1.5, 0.5, 1.5],
radius_scale: 1.3,
particle_scale: 0.09,
noise_amplitude: 0.08,
},
Self::Sprinting => ParticleShapeParams {
stretch: [0.8, 0.85, 1.8],
radius_scale: 1.4,
particle_scale: 0.06,
noise_amplitude: 0.12,
},
}
}
pub fn label(self) -> &'static str {
match self {
Self::Idle => "Idle",
Self::Moving => "Moving",
Self::Jumping => "Jumping",
Self::Landing => "Landing",
Self::Sprinting => "Sprinting",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ParticleShapeParams {
pub stretch: [f32; 3],
pub radius_scale: f32,
pub particle_scale: f32,
pub noise_amplitude: f32,
}
#[derive(Clone)]
pub struct WaveFormationState {
pub phase_time: f32,
pub idle_time: f32,
pub fibonacci_blend: f32,
pub amplitude: f32,
pub frequency: f32,
pub wavelength: f32,
pub heading: [f32; 2],
pub offsets: Vec<[f32; 3]>,
}
impl WaveFormationState {
pub fn new(particle_count: u32) -> Self {
Self {
phase_time: 0.0,
idle_time: 0.0,
fibonacci_blend: 0.0,
amplitude: 0.3,
frequency: 2.0,
wavelength: 1.5,
heading: [0.0, 1.0],
offsets: vec![[0.0; 3]; particle_count as usize],
}
}
pub fn update(&mut self, speed: f32, heading: [f32; 2], yaw: f32, dt: f32, particle_count: u32, radius: f32) {
self.phase_time += dt;
let moving = speed > 0.5;
if moving {
self.idle_time = 0.0;
self.amplitude = (speed * 0.06).clamp(0.1, 0.5);
self.heading = if heading[0].abs() + heading[1].abs() > 0.01 {
heading
} else {
[yaw.sin(), yaw.cos()]
};
self.fibonacci_blend *= (-6.0 * dt).exp(); } else {
self.idle_time += dt;
self.fibonacci_blend = 1.0 - (-2.0 * self.idle_time).exp();
self.amplitude *= (-3.0 * dt).exp();
}
let n = particle_count as usize;
if self.offsets.len() != n {
self.offsets.resize(n, [0.0; 3]);
}
let k = std::f32::consts::TAU / self.wavelength; let omega = std::f32::consts::TAU * self.frequency; let t = self.phase_time;
let a = self.amplitude;
let golden_angle = std::f32::consts::TAU / (((1.0 + 5.0f32.sqrt()) / 2.0) * ((1.0 + 5.0f32.sqrt()) / 2.0));
let fib_blend = self.fibonacci_blend.clamp(0.0, 1.0);
for i in 0..n {
let frac = (i as f32 + 0.5) / n as f32;
let grid_side = (n as f32).sqrt().ceil() as usize;
let gx = (i % grid_side) as f32 / grid_side as f32 - 0.5;
let gz = (i / grid_side) as f32 / grid_side as f32 - 0.5;
let hx = self.heading[0];
let hz = self.heading[1];
let world_x = gx * hz - gz * hx; let world_z = gx * hx + gz * hz;
let wave_y = a * (k * world_z * radius * 2.0 - omega * t).sin() * (k * world_x * radius * 0.5).cos();
let wave_pos = [world_x * radius * 2.0, wave_y, world_z * radius * 2.0];
let fib_r = frac.sqrt() * radius * 1.2;
let fib_theta = i as f32 * golden_angle + t * 0.3; let fib_pos = [
fib_r * fib_theta.cos(),
a * 0.1 * (fib_theta * 3.0 + t).sin(), fib_r * fib_theta.sin(),
];
self.offsets[i] = [
wave_pos[0] * (1.0 - fib_blend) + fib_pos[0] * fib_blend,
wave_pos[1] * (1.0 - fib_blend) + fib_pos[1] * fib_blend,
wave_pos[2] * (1.0 - fib_blend) + fib_pos[2] * fib_blend,
];
}
}
}
pub struct ParticleController {
pub position: [f32; 3],
pub previous_position: [f32; 3],
pub velocity: [f32; 3],
pub move_speed: f32,
pub sprint_multiplier: f32,
pub locomotion: ParticleLocomotion,
pub gravity: f32,
pub grounded: bool,
pub landing_timer: f32,
pub facing_yaw: f32,
pub cloud_radius: f32,
pub particle_count: u32,
pub base_color: [f32; 4],
pub movement_force_direction: [f32; 3],
pub movement_force_magnitude: f32,
pub rheology_blend: f32,
pub rheology_target: f32,
pub wave_formation: WaveFormationState,
}
impl Default for ParticleController {
fn default() -> Self {
Self {
position: [0.0, 1.0, 0.0],
previous_position: [0.0, 1.0, 0.0],
velocity: [0.0; 3],
move_speed: 5.0,
sprint_multiplier: 2.0,
locomotion: ParticleLocomotion::Idle,
gravity: 1.2,
grounded: true,
landing_timer: 0.0,
facing_yaw: 0.0,
cloud_radius: 0.8,
particle_count: 256,
base_color: [0.4, 0.6, 1.0, 0.85],
movement_force_direction: [0.0; 3],
movement_force_magnitude: 0.0,
rheology_blend: 0.15,
rheology_target: 0.15,
wave_formation: WaveFormationState::new(256),
}
}
}
impl ParticleController {
pub fn new(position: [f32; 3], particle_count: u32) -> Self {
Self {
position,
particle_count,
wave_formation: WaveFormationState::new(particle_count),
..Default::default()
}
}
pub fn update(
&mut self,
forward: bool,
back: bool,
left: bool,
right: bool,
jump: bool,
sprint: bool,
camera_yaw: f32,
dt: f32,
) -> bool {
self.previous_position = self.position;
let mut dir_fwd = 0.0f32;
let mut dir_right = 0.0f32;
if forward {
dir_fwd += 1.0;
}
if back {
dir_fwd -= 1.0;
}
if left {
dir_right -= 1.0;
}
if right {
dir_right += 1.0;
}
let len = (dir_fwd * dir_fwd + dir_right * dir_right).sqrt();
let moving = len > 0.001;
if moving {
dir_fwd /= len;
dir_right /= len;
}
let cos_yaw = camera_yaw.cos();
let sin_yaw = camera_yaw.sin();
let world_x = dir_fwd * (-cos_yaw) + dir_right * sin_yaw;
let world_z = dir_fwd * (-sin_yaw) + dir_right * (-cos_yaw);
let speed = if sprint {
self.move_speed * self.sprint_multiplier
} else {
self.move_speed
};
self.velocity[0] = if moving { world_x * speed } else { 0.0 };
self.velocity[2] = if moving { world_z * speed } else { 0.0 };
if !self.grounded {
self.velocity[1] -= self.gravity * dt;
} else if jump {
self.velocity[1] = 3.5;
self.grounded = false;
}
self.position[0] += self.velocity[0] * dt;
self.position[1] += self.velocity[1] * dt;
self.position[2] += self.velocity[2] * dt;
let was_airborne = !self.grounded;
if self.position[1] <= 0.5 {
self.position[1] = 0.5;
self.velocity[1] = 0.0;
self.grounded = true;
if was_airborne {
self.landing_timer = 0.3;
}
}
if moving {
self.facing_yaw = world_z.atan2(world_x);
}
self.landing_timer = (self.landing_timer - dt).max(0.0);
self.locomotion = if self.landing_timer > 0.0 {
ParticleLocomotion::Landing
} else if !self.grounded {
ParticleLocomotion::Jumping
} else if sprint && moving {
ParticleLocomotion::Sprinting
} else if moving {
ParticleLocomotion::Moving
} else {
ParticleLocomotion::Idle
};
if moving {
self.movement_force_direction = [world_x, 0.0, world_z];
self.movement_force_magnitude = speed;
} else {
self.movement_force_direction = [0.0; 3];
self.movement_force_magnitude = 0.0;
}
self.rheology_target = match self.locomotion {
ParticleLocomotion::Idle => 0.15,
ParticleLocomotion::Moving => 0.5,
ParticleLocomotion::Sprinting => 0.7,
ParticleLocomotion::Jumping => 0.1,
ParticleLocomotion::Landing => 0.6,
};
let blend_rate = 1.0 - (-8.0 * dt).exp();
self.rheology_blend += (self.rheology_target - self.rheology_blend) * blend_rate;
let speed_mag = (self.velocity[0] * self.velocity[0] + self.velocity[2] * self.velocity[2]).sqrt();
let heading = if speed_mag > 0.01 {
[self.velocity[0] / speed_mag, self.velocity[2] / speed_mag]
} else {
[0.0, 0.0]
};
self.wave_formation.update(
speed_mag,
heading,
self.facing_yaw,
dt,
self.particle_count,
self.cloud_radius,
);
moving
}
pub fn wave_offsets(&self) -> &[[f32; 3]] {
&self.wave_formation.offsets
}
pub fn shape_params(&self) -> ParticleShapeParams {
self.locomotion.shape_params()
}
}
pub fn particle_sphere_offsets(count: u32) -> Vec<[f32; 3]> {
let golden_ratio = (1.0 + 5.0f32.sqrt()) / 2.0;
let angle_increment = std::f32::consts::TAU * golden_ratio;
let mut offsets = Vec::with_capacity(count as usize);
for i in 0..count {
let t = (i as f32 + 0.5) / count as f32;
let phi = (1.0 - 2.0 * t).acos();
let theta = angle_increment * i as f32;
let x = phi.sin() * theta.cos();
let y = phi.cos();
let z = phi.sin() * theta.sin();
offsets.push([x, y, z]);
}
offsets
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn golden_spiral_produces_correct_count() {
let pts = particle_sphere_offsets(128);
assert_eq!(pts.len(), 128);
for p in &pts {
let len = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
assert!((len - 1.0).abs() < 0.01, "point not on unit sphere: len={len}");
}
}
#[test]
fn particle_locomotion_transitions() {
let mut ctrl = ParticleController::default();
assert_eq!(ctrl.locomotion, ParticleLocomotion::Idle);
ctrl.update(true, false, false, false, false, false, 0.0, 0.016);
assert_eq!(ctrl.locomotion, ParticleLocomotion::Moving);
ctrl.update(true, false, false, false, false, true, 0.0, 0.016);
assert_eq!(ctrl.locomotion, ParticleLocomotion::Sprinting);
ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
assert_eq!(ctrl.locomotion, ParticleLocomotion::Idle);
}
#[test]
fn particle_jump_and_land() {
let mut ctrl = ParticleController::default();
ctrl.position = [0.0, 0.5, 0.0];
ctrl.update(false, false, false, false, true, false, 0.0, 0.016);
assert_eq!(ctrl.locomotion, ParticleLocomotion::Jumping);
assert!(!ctrl.grounded);
for _ in 0..400 {
ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
}
assert!(ctrl.grounded);
assert!(ctrl.locomotion == ParticleLocomotion::Idle || ctrl.locomotion == ParticleLocomotion::Landing);
}
#[test]
fn shape_params_vary_by_state() {
let idle = ParticleLocomotion::Idle.shape_params();
let moving = ParticleLocomotion::Moving.shape_params();
assert!(idle.radius_scale < moving.radius_scale);
assert!(idle.noise_amplitude < moving.noise_amplitude);
}
#[test]
fn particle_controller_previous_position_stored() {
let mut ctrl = ParticleController::new([3.0, 1.0, -2.0], 128);
let pos_before = ctrl.position;
ctrl.update(true, false, false, false, false, false, 0.0, 0.016);
assert_eq!(
ctrl.previous_position, pos_before,
"previous_position should equal position before update"
);
assert_ne!(ctrl.position, pos_before, "position should change when moving forward");
}
#[test]
fn particle_controller_force_direction_when_moving() {
let mut ctrl = ParticleController::default();
ctrl.update(true, false, false, false, false, false, 0.0, 0.016);
let dir = ctrl.movement_force_direction;
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
assert!(
len > 0.001,
"movement_force_direction should be nonzero when moving, got length {}",
len
);
}
#[test]
fn particle_controller_force_direction_when_idle() {
let mut ctrl = ParticleController::default();
ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
assert_eq!(
ctrl.movement_force_magnitude, 0.0,
"movement_force_magnitude should be 0 when idle"
);
assert_eq!(
ctrl.movement_force_direction, [0.0; 3],
"movement_force_direction should be zero when idle"
);
}
#[test]
fn particle_controller_rheology_idle_target() {
let mut ctrl = ParticleController::default();
ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
assert_eq!(ctrl.locomotion, ParticleLocomotion::Idle);
assert!(
(ctrl.rheology_target - 0.15).abs() < 1e-5,
"idle rheology_target should be 0.15, got {}",
ctrl.rheology_target
);
}
#[test]
fn particle_controller_rheology_sprinting_target() {
let mut ctrl = ParticleController::default();
ctrl.update(true, false, false, false, false, true, 0.0, 0.016);
assert_eq!(ctrl.locomotion, ParticleLocomotion::Sprinting);
assert!(
(ctrl.rheology_target - 0.7).abs() < 1e-5,
"sprinting rheology_target should be 0.7, got {}",
ctrl.rheology_target
);
}
#[test]
fn particle_controller_rheology_blend_approaches_target() {
let mut ctrl = ParticleController::default();
for _ in 0..600 {
ctrl.update(true, false, false, false, false, true, 0.0, 0.016);
}
assert!(
(ctrl.rheology_blend - ctrl.rheology_target).abs() < 0.01,
"after 600 frames, rheology_blend ({}) should approach target ({})",
ctrl.rheology_blend,
ctrl.rheology_target
);
}
#[test]
fn particle_controller_default_particle_count_256() {
let ctrl = ParticleController::default();
assert_eq!(
ctrl.particle_count, 256,
"default particle_count should be 256, got {}",
ctrl.particle_count
);
}
#[test]
fn particle_controller_default_cloud_radius_08() {
let ctrl = ParticleController::default();
assert!(
(ctrl.cloud_radius - 0.8).abs() < 1e-5,
"default cloud_radius should be 0.8, got {}",
ctrl.cloud_radius
);
}
}