proof-engine 0.2.1

Real-time graphics from math: glyphs and particles moved by ODEs, strange attractors and force fields, drawn with HDR bloom on OpenGL.
Documentation

// ── 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" }
}