Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Sidequest 2-2: Dynamic Lighting Grid

As I was playing around with the grid and our boring, flat colored player and enemy, I also thought it'd be neat if they gave off pseudo-lighting. Currently the engine does not do lighting, so this is a fun way to show what you could do instead. It also just looks super cool.

The Concept

We'll have multiple LightSource components in our game, like on players and enemies and projectiles and whatnot. They will be configurable, like GravitySource. GridDot will also act as a sort of battery with a "bucket" for each color light that affects them. These buckets get charged up by light being near, and then slowly discharge. That way there's essentially dynamic lighting that mixes and has memory. It's not that simple, but it's also not that complicated.

Components and Config

Like with gravity, we need some consts for the player and enemy to make setup easier, and then some universal ones too

srcconfig.rs
pub const PLAYER_LIGHT_RANGE: f32 = 1000.0;
pub const PLAYER_LIGHT_INTENSITY: f32 = 6.0; // Controls the strength of the light
pub const PLAYER_LIGHT_FALLOFF_POWER: f32 = 3.5; // Distance falloff curve shape (gentler)
pub const ENEMY_LIGHT_RANGE: f32 = 400.0;
pub const ENEMY_LIGHT_INTENSITY: f32 = 10.;
pub const ENEMY_LIGHT_FALLOFF_POWER: f32 = 2.5; // Distance falloff curve shape (gentler)

pub const CHARGE_RATE: f32 = 3.; // How quickly color updates
pub const DISCHARGE_RATE: f32 = 0.5; // How quickly stored color fades
pub const LIGHT_ALPHA_BOOST: f32 = 0.75; // How much brighter our dots get when they have charge.
pub const MAX_LIT_ALPHA: f32 = 1.0; // Max alpha for charged dots
pub const MAX_BUCKETS: usize = 8; // How many different colors we can support at once.

Unsurprisingly, we need to add a component. But also a struct to represent our color buckets, and of course good ol' GridDot gets expanded yet again. And while we're at it, we can go ahead and put in the system for our lighting calls now since we're editing lib.rs anyway.

srclib.rs
#[component]
struct LightSource {
    falloff_distance: f32,
    strength: f32,
    falloff_power: f32,
}

#[derive(Clone, Copy, serde::Serialize, serde::Deserialize)]
struct ColorBucket {
    h: f32,
    s: f32,
    l: f32,
    current_strength: f32,
    target_strength: f32,
}

#[derive(Default)]
#[component]
struct GridDot {
    home_position: Vec2,
    base_alpha: f32,
    current_displacement: Vec2,
    current_height: f32,
    lit_base_alpha: f32,
    color_buckets: [Option<ColorBucket>; MAX_BUCKETS],
}


//Place BEFORE apply_grid_gravity_system
#[system]
fn apply_grid_lighting_system(
    grid_dots: Query<(Mut<Color>, Mut<GridDot>)>,
    light_sources: Query<(Ref<Transform>, Ref<Color>, Ref<LightSource>)>,
    frame_constants: Ref<FrameConstants>,
) {
    apply_grid_lighting(grid_dots, light_sources, frame_constants);
}

You may have been tempted to use a Vec to store our buckets, but components must implement the Copy trait. By using a fixed size array we can have a predictable number of bits needed. Technically it is inefficient to have more buckets than colors declared in your project - so at the moment we should probably put MAX_BUCKETS to two. But I have a feeling we'll expand on that shortly.

As always you'll need to expand your spawning systems to accomodate these changes. I don't believe I need to tell you how to do this at this point.

The Lighting System

Now we implement the actual lighting system.

srcgrid_systems.rs
use crate::{Color, ColorBucket, GravitySource, GridDot, LightSource, Transform};
pub fn apply_grid_lighting(
    mut grid_dots: Query<(Mut<Color>, Mut<GridDot>)>,
    light_sources: Query<(Ref<Transform>, Ref<Color>, Ref<LightSource>)>,
    frame_constants: Ref<FrameConstants>,
) {
    let delta_time = frame_constants.delta_time;

    // Pre-process light sources
    let lights: Vec<_> = light_sources
        .iter()
        .map(|(transform, color, light)| {
            let (h, s, l, _) = color_to_hsla(&color);
            (
                transform.position,
                h,
                s,
                l,
                light.falloff_distance,
                light.strength,
                light.falloff_power,
            )
        })
        .collect();

    grid_dots.for_each(|(mut color, mut grid_dot)| {
        let dot_pos = grid_dot.home_position + grid_dot.current_displacement;

        // === CALCULATE CURRENT LIGHTING ===
        let mut current_colors = [(0.0, 0.0, 0.0, 0.0); MAX_BUCKETS];
        let mut current_count = 0;

        for (light_pos, h, s, l, range, intensity, falloff_power) in &lights {
            let distance = (*light_pos - dot_pos).length();

            let falloff = (1.0 - (distance / range).min(1.0)).powf(*falloff_power); // We use the same formula as gravity for consistency...

            let strength = falloff * intensity;

            if strength > INFLUENCE_CUTOFF && current_count < MAX_BUCKETS { //we can reuse the influence cutoff const as it represents the same idea.
                current_colors[current_count] = (*h, *s, *l, strength);
                current_count += 1;
            }
        }

        // === MATCH EXISTING BUCKETS WITH CURRENT COLORS ===
        // Track which current colors have been handled
        let mut handled_colors = [false; MAX_BUCKETS];

        // First, iterate over all existing buckets
        for i in 0..MAX_BUCKETS {
            if let Some(ref mut bucket) = grid_dot.color_buckets[i] {
                let mut found_match = false;

                // Check if this bucket matches any current color
                for j in 0..current_count {
                    if !handled_colors[j] {
                        let (h, s, l, strength) = current_colors[j];

                        if (bucket.h - h).abs() < 1.0 && // Slight amount of tolerance
                           (bucket.s - s).abs() < 0.1 &&
                           (bucket.l - l).abs() < 0.1
                        {
                            bucket.target_strength = strength;
                            handled_colors[j] = true;
                            found_match = true;
                            break;
                        }
                    }
                }

                // If no match found, this bucket should start to discharge.
                if !found_match {
                    bucket.target_strength = 0.0;
                }
            }
        }

        // If any of our current lights didn't already get assigned a bucket, we do so now.
        for i in 0..current_count {
            if !handled_colors[i] {
                let (h, s, l, strength) = current_colors[i];

                for j in 0..MAX_BUCKETS {
                    if grid_dot.color_buckets[j].is_none() {
                        grid_dot.color_buckets[j] = Some(ColorBucket {
                            h,
                            s,
                            l,
                            current_strength: 0.0,
                            target_strength: strength,
                        });
                        break;
                    }
                }
            }
        }

        // === UPDATE BUCKETS ===
        for i in 0..MAX_BUCKETS {
            if let Some(ref mut bucket) = grid_dot.color_buckets[i] {
                if bucket.current_strength < bucket.target_strength {
                    // Charge up towards target
                    let charge_amount = (bucket.target_strength - bucket.current_strength)
                        * CHARGE_RATE
                        * delta_time;
                    bucket.current_strength =
                        (bucket.current_strength + charge_amount).min(bucket.target_strength);
                } else if bucket.current_strength > bucket.target_strength {
                    // Discharge down towards target
                    let discharge_amount = (bucket.current_strength - bucket.target_strength)
                        * DISCHARGE_RATE
                        * delta_time;
                    bucket.current_strength =
                        (bucket.current_strength - discharge_amount).max(bucket.target_strength);
                }

                // Clear if too weak
                if bucket.current_strength < 0.00001 {
                    grid_dot.color_buckets[i] = None;
                }
            }
        }

        // === BLEND BUCKETS FOR DISPLAY ===
        let mut total_strength = 0.0;
        for i in 0..MAX_BUCKETS {
            if let Some(bucket) = &grid_dot.color_buckets[i] {
                total_strength += bucket.current_strength;
            }
        }

        if total_strength > 0.01 {
            // Weighted blend of all buckets
            let mut blended_h = 0.0;
            let mut blended_s = 0.0;
            let mut blended_l = 0.0;

            for i in 0..MAX_BUCKETS {
                if let Some(bucket) = &grid_dot.color_buckets[i] {
                    let weight = bucket.current_strength / total_strength;
                    blended_h += bucket.h * weight;
                    blended_s += bucket.s * weight;
                    blended_l += bucket.l * weight;
                }
            }

            // Mix with white based on total strength
            let intensity = total_strength.min(1.0);
            let final_s = blended_s * intensity;
            let final_l = blended_l * intensity + (1.0 - intensity);

            *color = Color::from_hsla(blended_h, final_s, final_l, color.a());

            // Alpha boost
            let alpha_boost = 1.0 + intensity * LIGHT_ALPHA_BOOST;
            grid_dot.lit_base_alpha = (grid_dot.base_alpha * alpha_boost).min(MAX_LIT_ALPHA);
        } else {
            // No buckets - pure white
            *color = Color::new(1.0, 1.0, 1.0, color.a());
            grid_dot.lit_base_alpha = grid_dot.base_alpha;
        }
    });
}

I would make sure you really understand what the above code does before adding it in! You may even come up with a better approach. This was just what made sense to me, looked good, and din't hurt performance too much.

Look at the colors!

It's way cooler in motion, too!

Now we can return to functionality and add in aiming.