nightshade 0.48.0

A cross-platform data-oriented game engine.
Documentation
//! Per-frame advancement for visual effects.
//!
//! A single [`update_vfx_system`] entry in the frame schedule advances every
//! beam, lightning arc, trail, and animated mesh effect.

use crate::ecs::generational_registry::registry_entry_by_name_mut;
use crate::ecs::lines::components::Line;
use crate::ecs::transform::commands::mark_local_transform_dirty;
use crate::ecs::vfx::components::{Beam, LightningBolt, Trail};
use crate::ecs::world::commands::{EcsCommand, queue_ecs_command};
use crate::ecs::world::{
    BEAM, LIGHTNING_BOLT, LINES, LOCAL_TRANSFORM, MATERIAL_REF, TRAIL, VFX_ANIMATOR, World,
};
use freecs::Entity;
use nalgebra_glm::{Vec3, vec4};

pub fn update_vfx_system(world: &mut World) {
    let delta_time = world.resources.window.timing.delta_time;
    update_beams(world, delta_time);
    update_lightning(world, delta_time);
    update_trails(world, delta_time);
    update_animators(world, delta_time);
}

fn perpendicular_basis(direction: Vec3) -> (Vec3, Vec3) {
    let reference = if direction.y.abs() < 0.95 {
        Vec3::new(0.0, 1.0, 0.0)
    } else {
        Vec3::new(1.0, 0.0, 0.0)
    };
    let first = direction.cross(&reference).normalize();
    let second = direction.cross(&first).normalize();
    (first, second)
}

fn update_beams(world: &mut World, delta_time: f32) {
    let entities: Vec<Entity> = world.core.query_entities(BEAM | LINES).collect();
    for entity in entities {
        let Some(mut beam) = world.core.get_beam(entity).copied() else {
            continue;
        };
        beam.age += delta_time;
        let segments = build_beam_lines(&beam);
        if let Some(lines) = world.core.get_lines_mut(entity) {
            lines.lines = segments;
            lines.mark_dirty();
        }
        if let Some(stored) = world.core.get_beam_mut(entity) {
            *stored = beam;
        }
    }
}

fn build_beam_lines(beam: &Beam) -> Vec<Line> {
    let axis = beam.end - beam.start;
    let length = axis.magnitude();
    if length <= f32::EPSILON {
        return Vec::new();
    }
    let direction = axis / length;
    let (first, second) = perpendicular_basis(direction);

    let flicker = 1.0 - beam.flicker * (0.5 - 0.5 * (beam.age * beam.flicker_speed).cos());
    let core = beam.color * beam.intensity * flicker;

    let strands = beam.strands.max(1);
    let mut lines = Vec::with_capacity(strands as usize);
    for strand in 0..strands {
        let (offset, falloff) = if strand == 0 {
            (Vec3::zeros(), 1.0)
        } else {
            let angle =
                (strand as f32 - 1.0) / (strands as f32 - 1.0).max(1.0) * std::f32::consts::TAU;
            let offset = (first * angle.cos() + second * angle.sin()) * beam.width;
            (offset, 0.45)
        };
        lines.push(Line {
            start: beam.start + offset,
            end: beam.end + offset,
            color: vec4(core.x, core.y, core.z, beam.alpha * falloff),
        });
    }
    lines
}

fn update_lightning(world: &mut World, delta_time: f32) {
    let entities: Vec<Entity> = world.core.query_entities(LIGHTNING_BOLT | LINES).collect();
    for entity in entities {
        let Some(mut bolt) = world.core.get_lightning_bolt(entity).copied() else {
            continue;
        };
        bolt.timer += delta_time;
        let regenerate = bolt.timer >= bolt.regen_interval;
        if regenerate {
            bolt.timer = 0.0;
            bolt.seed = bolt.seed.wrapping_mul(1664525).wrapping_add(1013904223);
            let segments = build_lightning_lines(&bolt);
            if let Some(lines) = world.core.get_lines_mut(entity) {
                lines.lines = segments;
                lines.mark_dirty();
            }
        }
        if let Some(stored) = world.core.get_lightning_bolt_mut(entity) {
            *stored = bolt;
        }
    }
}

fn next_random(state: &mut u32) -> f32 {
    *state = state.wrapping_mul(1664525).wrapping_add(1013904223);
    (*state >> 8) as f32 / 16_777_216.0
}

fn build_lightning_lines(bolt: &LightningBolt) -> Vec<Line> {
    let axis = bolt.end - bolt.start;
    let length = axis.magnitude();
    if length <= f32::EPSILON {
        return Vec::new();
    }
    let direction = axis / length;
    let (first, second) = perpendicular_basis(direction);
    let color = bolt.color * bolt.intensity;
    let line_color = vec4(color.x, color.y, color.z, bolt.alpha);

    let mut state = bolt.seed | 1;
    let segments = bolt.segments.max(2);
    let mut path = Vec::with_capacity(segments as usize + 1);
    for index in 0..=segments {
        let fraction = index as f32 / segments as f32;
        let base = bolt.start + axis * fraction;
        let taper = (fraction * (1.0 - fraction) * 4.0).clamp(0.0, 1.0);
        let lateral = if index == 0 || index == segments {
            Vec3::zeros()
        } else {
            let displace_first = (next_random(&mut state) - 0.5) * 2.0;
            let displace_second = (next_random(&mut state) - 0.5) * 2.0;
            (first * displace_first + second * displace_second) * bolt.jaggedness * taper
        };
        path.push(base + lateral);
    }

    let mut lines = Vec::new();
    for window in path.windows(2) {
        lines.push(Line {
            start: window[0],
            end: window[1],
            color: line_color,
        });
    }

    for _ in 0..bolt.branch_count {
        let anchor = 1 + (next_random(&mut state) * (segments as f32 - 2.0)) as usize;
        let anchor = anchor.min(path.len().saturating_sub(2));
        let origin = path[anchor];
        let branch_direction = (direction
            + first * (next_random(&mut state) - 0.5) * 2.0 * bolt.branch_spread
            + second * (next_random(&mut state) - 0.5) * 2.0 * bolt.branch_spread)
            .normalize();
        let branch_length = length * 0.25 * (0.5 + next_random(&mut state));
        let branch_segments = 4u32;
        let mut previous = origin;
        for index in 1..=branch_segments {
            let fraction = index as f32 / branch_segments as f32;
            let jitter = (first * (next_random(&mut state) - 0.5)
                + second * (next_random(&mut state) - 0.5))
                * bolt.jaggedness
                * 0.6;
            let next = origin + branch_direction * branch_length * fraction + jitter;
            let fade = bolt.alpha * (1.0 - fraction) * 0.7;
            lines.push(Line {
                start: previous,
                end: next,
                color: vec4(color.x, color.y, color.z, fade),
            });
            previous = next;
        }
    }

    lines
}

fn update_trails(world: &mut World, delta_time: f32) {
    let entities: Vec<Entity> = world.core.query_entities(TRAIL | LINES).collect();
    for entity in entities {
        let segments = {
            let Some(trail) = world.core.get_trail_mut(entity) else {
                continue;
            };
            trail.age += delta_time;
            let angle = trail.age * trail.speed;
            let (first, second) = perpendicular_basis(trail.axis.normalize());
            let position =
                trail.center + (first * angle.cos() + second * angle.sin()) * trail.radius;
            trail.points.push(position);
            let max_points = trail.max_points.max(2) as usize;
            while trail.points.len() > max_points {
                trail.points.remove(0);
            }
            build_trail_lines(trail)
        };
        if let Some(lines) = world.core.get_lines_mut(entity) {
            lines.lines = segments;
            lines.mark_dirty();
        }
    }
}

fn build_trail_lines(trail: &Trail) -> Vec<Line> {
    if trail.points.len() < 2 {
        return Vec::new();
    }
    let color = trail.color * trail.intensity;
    let count = trail.points.len();
    let mut lines = Vec::with_capacity(count - 1);
    for index in 0..count - 1 {
        let head_fraction = (index + 1) as f32 / count as f32;
        let alpha = trail.alpha * head_fraction;
        lines.push(Line {
            start: trail.points[index],
            end: trail.points[index + 1],
            color: vec4(color.x, color.y, color.z, alpha),
        });
    }
    lines
}

fn update_animators(world: &mut World, delta_time: f32) {
    let entities: Vec<Entity> = world
        .core
        .query_entities(VFX_ANIMATOR | LOCAL_TRANSFORM | MATERIAL_REF)
        .collect();
    let mut to_despawn = Vec::new();
    for entity in entities {
        let Some(mut animator) = world.core.get_vfx_animator(entity).copied() else {
            continue;
        };
        animator.age += delta_time;
        let progress = if animator.lifetime > 0.0 {
            (animator.age / animator.lifetime).clamp(0.0, 1.0)
        } else {
            0.0
        };
        let scale = animator.start_scale + (animator.end_scale - animator.start_scale) * progress;
        let pulse = 1.0
            + animator.pulse_amplitude
                * (animator.age * animator.pulse_frequency * std::f32::consts::TAU).sin();
        let fade = if animator.fade_alpha {
            1.0 - progress
        } else {
            1.0
        };
        let alpha = (animator.base_alpha * fade * pulse).clamp(0.0, 1.0);

        let orbit_position = if animator.orbit_radius > 0.0 {
            let (first, second) = perpendicular_basis(animator.orbit_axis.normalize());
            let angle = animator.age * animator.orbit_speed;
            Some(
                animator.orbit_center
                    + (first * angle.cos() + second * angle.sin()) * animator.orbit_radius,
            )
        } else {
            None
        };

        if let Some(transform) = world.core.get_local_transform_mut(entity) {
            transform.scale = scale;
            if let Some(position) = orbit_position {
                transform.translation = position;
            }
            if animator.spin_speed != 0.0 {
                transform.rotation = nalgebra_glm::quat_angle_axis(
                    animator.age * animator.spin_speed,
                    &animator.spin_axis.normalize(),
                );
            }
        }
        mark_local_transform_dirty(world, entity);

        let material_name = world
            .core
            .get_material_ref(entity)
            .map(|material_ref| material_ref.name.clone());
        if let Some(name) = material_name {
            if let Some(material) = registry_entry_by_name_mut(
                &mut world.resources.assets.material_registry.registry,
                &name,
            ) {
                let color = animator.peak_color;
                material.base_color = [color.x, color.y, color.z, alpha];
                material.emissive_factor = [color.x, color.y, color.z];
                material.emissive_strength = animator.emissive_strength;
            }
            world
                .resources
                .mesh_render_state
                .mark_material_dirty(entity);
        }

        if let Some(stored) = world.core.get_vfx_animator_mut(entity) {
            *stored = animator;
        }

        if animator.lifetime > 0.0 && animator.age >= animator.lifetime && animator.despawn_on_end {
            to_despawn.push(entity);
        }
    }

    for entity in to_despawn {
        queue_ecs_command(world, EcsCommand::DespawnRecursive { entity });
    }
}