// ── Particle Renderer ─────────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct ParticleRenderBatch {
pub emitter_id: u32,
pub material_id: u32,
pub blend_mode: BlendMode,
pub positions: Vec<Vec3>,
pub colors: Vec<Vec4>,
pub sizes: Vec<f32>,
pub rotations: Vec<f32>,
pub particle_count: u32,
pub sort_key: f32,
}
#[derive(Clone, Debug, PartialEq)]
pub enum BlendMode { Additive, Alpha, Multiply, Screen, Premultiplied }
impl ParticleRenderBatch {
pub fn new(emitter_id: u32, material_id: u32) -> Self {
Self { emitter_id, material_id, blend_mode: BlendMode::Alpha, positions: Vec::new(), colors: Vec::new(), sizes: Vec::new(), rotations: Vec::new(), particle_count: 0, sort_key: 0.0 }
}
pub fn add_particle(&mut self, pos: Vec3, color: Vec4, size: f32, rotation: f32) {
self.positions.push(pos); self.colors.push(color); self.sizes.push(size); self.rotations.push(rotation);
self.particle_count += 1;
}
pub fn clear(&mut self) { self.positions.clear(); self.colors.clear(); self.sizes.clear(); self.rotations.clear(); self.particle_count = 0; }
pub fn is_empty(&self) -> bool { self.particle_count == 0 }
pub fn vertex_count(&self) -> u32 { self.particle_count * 4 }
pub fn index_count(&self) -> u32 { self.particle_count * 6 }
}
#[derive(Clone, Debug)]
pub struct ParticleRenderer {
pub batches: Vec<ParticleRenderBatch>,
pub sort_transparent: bool,
pub camera_pos: Vec3,
pub camera_forward: Vec3,
pub draw_call_count: u32,
}
impl ParticleRenderer {
pub fn new() -> Self { Self { batches: Vec::new(), sort_transparent: true, camera_pos: Vec3::ZERO, camera_forward: Vec3::NEG_Z, draw_call_count: 0 } }
pub fn add_batch(&mut self, batch: ParticleRenderBatch) { self.batches.push(batch); }
pub fn clear(&mut self) { self.batches.clear(); self.draw_call_count = 0; }
pub fn sort_batches(&mut self) {
if self.sort_transparent { self.batches.sort_by(|a, b| b.sort_key.partial_cmp(&a.sort_key).unwrap_or(std::cmp::Ordering::Equal)); }
}
pub fn total_particles(&self) -> u32 { self.batches.iter().map(|b| b.particle_count).sum() }
pub fn batch_count(&self) -> usize { self.batches.len() }
pub fn set_camera(&mut self, pos: Vec3, forward: Vec3) { self.camera_pos = pos; self.camera_forward = forward; }
}
impl Default for ParticleRenderer {
fn default() -> Self { Self::new() }
}
// ── Particle Spawner Pool ─────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct ParticlePool {
pub capacity: u32,
pub free_indices: Vec<u32>,
pub used_count: u32,
pub peak_used: u32,
pub recycle_count: u64,
}
impl ParticlePool {
pub fn new(capacity: u32) -> Self {
let free_indices = (0..capacity).rev().collect();
Self { capacity, free_indices, used_count: 0, peak_used: 0, recycle_count: 0 }
}
pub fn allocate(&mut self) -> Option<u32> {
let idx = self.free_indices.pop()?;
self.used_count += 1;
if self.used_count > self.peak_used { self.peak_used = self.used_count; }
Some(idx)
}
pub fn free(&mut self, idx: u32) {
if idx < self.capacity { self.free_indices.push(idx); self.used_count = self.used_count.saturating_sub(1); self.recycle_count += 1; }
}
pub fn available(&self) -> u32 { self.free_indices.len() as u32 }
pub fn is_full(&self) -> bool { self.free_indices.is_empty() }
pub fn utilization(&self) -> f32 { if self.capacity == 0 { 0.0 } else { self.used_count as f32 / self.capacity as f32 } }
pub fn reset(&mut self) { self.free_indices = (0..self.capacity).rev().collect(); self.used_count = 0; }
}
// ── Particle Effect Asset ─────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct ParticleEffectAsset {
pub id: u32,
pub name: String,
pub file_path: String,
pub version: u32,
pub author: String,
pub tags: Vec<String>,
pub duration_secs: f32,
pub is_looping: bool,
pub peak_particle_count: u32,
pub texture_ids: Vec<u32>,
pub emitter_count: u32,
pub created_at: u64,
pub modified_at: u64,
}
impl ParticleEffectAsset {
pub fn new(id: u32, name: impl Into<String>) -> Self {
Self { id, name: name.into(), file_path: String::new(), version: 1, author: String::new(), tags: Vec::new(), duration_secs: 2.0, is_looping: false, peak_particle_count: 0, texture_ids: Vec::new(), emitter_count: 0, created_at: 0, modified_at: 0 }
}
pub fn with_path(mut self, path: impl Into<String>) -> Self { self.file_path = path.into(); self }
pub fn looping(mut self) -> Self { self.is_looping = true; self }
pub fn add_tag(&mut self, tag: impl Into<String>) { self.tags.push(tag.into()); }
pub fn add_texture(&mut self, id: u32) { self.texture_ids.push(id); }
pub fn is_short(&self) -> bool { self.duration_secs < 1.0 }
pub fn is_long(&self) -> bool { self.duration_secs > 10.0 }
}
#[derive(Clone, Debug)]
pub struct ParticleAssetLibrary {
pub assets: HashMap<u32, ParticleEffectAsset>,
pub next_id: u32,
pub search_index: HashMap<String, Vec<u32>>,
}
impl ParticleAssetLibrary {
pub fn new() -> Self { Self { assets: HashMap::new(), next_id: 1, search_index: HashMap::new() } }
pub fn add(&mut self, mut asset: ParticleEffectAsset) -> u32 {
let id = self.next_id; self.next_id += 1;
asset.id = id;
for tag in &asset.tags { self.search_index.entry(tag.clone()).or_default().push(id); }
self.assets.insert(id, asset);
id
}
pub fn get(&self, id: u32) -> Option<&ParticleEffectAsset> { self.assets.get(&id) }
pub fn find_by_tag(&self, tag: &str) -> Vec<&ParticleEffectAsset> {
self.search_index.get(tag).map(|ids| ids.iter().filter_map(|id| self.assets.get(id)).collect()).unwrap_or_default()
}
pub fn find_by_name(&self, name: &str) -> Option<&ParticleEffectAsset> { self.assets.values().find(|a| a.name == name) }
pub fn count(&self) -> usize { self.assets.len() }
}
impl Default for ParticleAssetLibrary {
fn default() -> Self { Self::new() }
}
// ── Particle Simulation State ─────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct ParticleSimState {
pub time: f32,
pub delta_time: f32,
pub frame: u64,
pub paused: bool,
pub time_scale: f32,
pub gravity: Vec3,
pub wind: Vec3,
pub random_seed: u64,
}
impl ParticleSimState {
pub fn new() -> Self {
Self { time: 0.0, delta_time: 0.016, frame: 0, paused: false, time_scale: 1.0, gravity: Vec3::new(0.0, -9.81, 0.0), wind: Vec3::ZERO, random_seed: 42 }
}
pub fn tick(&mut self, dt: f32) {
if self.paused { return; }
let scaled_dt = dt * self.time_scale;
self.time += scaled_dt;
self.delta_time = scaled_dt;
self.frame += 1;
}
pub fn pause(&mut self) { self.paused = true; }
pub fn resume(&mut self) { self.paused = false; }
pub fn set_time_scale(&mut self, scale: f32) { self.time_scale = scale.max(0.0); }
pub fn effective_gravity(&self) -> Vec3 { self.gravity + self.wind * 0.1 }
pub fn is_running(&self) -> bool { !self.paused }
}
impl Default for ParticleSimState {
fn default() -> Self { Self::new() }
}
// ── Particle Debug Visualizer ─────────────────────────────────────────────────
#[derive(Clone, Debug, Default)]
pub struct ParticleDebugSettings {
pub show_bounds: bool,
pub show_velocity: bool,
pub show_force_fields: bool,
pub show_attractors: bool,
pub show_colliders: bool,
pub show_trail_points: bool,
pub show_lod_regions: bool,
pub show_stats_overlay: bool,
pub highlight_emitter_id: Option<u32>,
pub velocity_scale: f32,
}
impl ParticleDebugSettings {
pub fn new() -> Self { Self { velocity_scale: 0.1, ..Default::default() } }
pub fn show_all(mut self) -> Self {
self.show_bounds = true; self.show_velocity = true; self.show_force_fields = true;
self.show_attractors = true; self.show_colliders = true; self.show_trail_points = true;
self.show_lod_regions = true; self.show_stats_overlay = true;
self
}
pub fn hide_all(mut self) -> Self {
self.show_bounds = false; self.show_velocity = false; self.show_force_fields = false;
self.show_attractors = false; self.show_colliders = false; self.show_trail_points = false;
self.show_lod_regions = false; self.show_stats_overlay = false;
self
}
pub fn any_debug_active(&self) -> bool {
self.show_bounds || self.show_velocity || self.show_force_fields || self.show_attractors
}
}
// ── Particle System Manager ───────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct ParticleSystemManager {
pub active_effects: HashMap<u32, u32>,
pub pool: ParticlePool,
pub sim_state: ParticleSimState,
pub stats: ParticleStats,
pub debug: ParticleDebugSettings,
pub lod: ParticleLodController,
pub renderer: ParticleRenderer,
pub asset_library: ParticleAssetLibrary,
pub next_effect_instance: u32,
pub max_concurrent_effects: u32,
}
impl ParticleSystemManager {
pub fn new(pool_size: u32) -> Self {
Self {
active_effects: HashMap::new(),
pool: ParticlePool::new(pool_size),
sim_state: ParticleSimState::new(),
stats: ParticleStats::new(),
debug: ParticleDebugSettings::new(),
lod: ParticleLodController::new(),
renderer: ParticleRenderer::new(),
asset_library: ParticleAssetLibrary::new(),
next_effect_instance: 1,
max_concurrent_effects: 64,
}
}
pub fn spawn_effect(&mut self, asset_id: u32, _position: Vec3) -> Option<u32> {
if self.active_effects.len() >= self.max_concurrent_effects as usize { return None; }
let instance_id = self.next_effect_instance; self.next_effect_instance += 1;
self.active_effects.insert(instance_id, asset_id);
Some(instance_id)
}
pub fn kill_effect(&mut self, instance_id: u32) -> bool { self.active_effects.remove(&instance_id).is_some() }
pub fn kill_all(&mut self) { self.active_effects.clear(); }
pub fn tick(&mut self, dt: f32) { self.sim_state.tick(dt); self.stats.begin_frame(); }
pub fn active_count(&self) -> usize { self.active_effects.len() }
pub fn pause(&mut self) { self.sim_state.pause(); }
pub fn resume(&mut self) { self.sim_state.resume(); }
pub fn pool_utilization(&self) -> f32 { self.pool.utilization() }
pub fn is_at_capacity(&self) -> bool { self.active_effects.len() >= self.max_concurrent_effects as usize }
}
impl Default for ParticleSystemManager {
fn default() -> Self { Self::new(100_000) }
}
// ── Additional helpers ────────────────────────────────────────────────────────
pub fn lerp_color(a: Vec4, b: Vec4, t: f32) -> Vec4 { a + (b - a) * t.clamp(0.0, 1.0) }
pub fn lerp_size(start: f32, end: f32, t: f32) -> f32 { start + (end - start) * t.clamp(0.0, 1.0) }
pub fn fade_in_out(t: f32, fade_in: f32, fade_out: f32) -> f32 {
if t < fade_in { t / fade_in.max(1e-5) }
else if t > 1.0 - fade_out { (1.0 - t) / fade_out.max(1e-5) }
else { 1.0 }
}
pub fn billboard_matrix(pos: Vec3, cam_pos: Vec3, up: Vec3) -> Mat4 {
let forward = (cam_pos - pos).normalize();
let right = up.cross(forward).normalize();
let actual_up = forward.cross(right);
Mat4::from_cols(right.extend(0.0), actual_up.extend(0.0), forward.extend(0.0), pos.extend(1.0))
}
pub fn velocity_from_angle(angle_deg: f32, speed: f32) -> Vec3 {
let rad = angle_deg.to_radians();
Vec3::new(rad.cos() * speed, 0.0, rad.sin() * speed)
}
pub fn random_on_sphere(u: f32, v: f32) -> Vec3 {
let theta = 2.0 * std::f32::consts::PI * u;
let phi = (1.0 - 2.0 * v).acos();
Vec3::new(phi.sin() * theta.cos(), phi.cos(), phi.sin() * theta.sin())
}
pub fn random_in_sphere(u: f32, v: f32, w: f32) -> Vec3 {
random_on_sphere(u, v) * w.cbrt()
}
pub fn particle_system_default_gravity() -> Vec3 { Vec3::new(0.0, -9.81, 0.0) }
pub fn blend_mode_name(mode: &BlendMode) -> &'static str {
match mode { BlendMode::Additive => "Additive", BlendMode::Alpha => "Alpha", BlendMode::Multiply => "Multiply", BlendMode::Screen => "Screen", BlendMode::Premultiplied => "Premultiplied" }
}