mirage-engine 0.1.0

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
use bytemuck::{Pod, Zeroable};

use crate::Color;
use crate::math::Vec3;

/// Light cap per frame; excess is ignored — warned the first frame, a
/// debug log after.
pub const MAX_LIGHTS: usize = 64;

/// Cap on casting lights per frame (see [`Light::shadow`]); excess is
/// ignored — warned the first frame, a debug log after.
pub const MAX_SHADOWS: usize = 4;

/// The value a light with no depth map of its own stores in place of one.
pub(crate) const NO_SHADOW: i32 = -1;

/// The least angle a cone may have; less becomes this value.
const NARROWEST_CONE: f32 = 0.01;

/// One light for one frame.
///
/// A frame is lit by exactly what it submits; submitting none keeps the
/// default environment.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Light {
    pub(crate) position: Vec3,
    pub(crate) kind: Kind,
    pub(crate) direction: Vec3,
    pub(crate) range: f32,
    pub(crate) color: Vec3,
    pub(crate) cone: f32,
    pub(crate) casts: bool,
}

impl Light {
    /// A sun: parallel light along `direction`.
    pub fn directional(direction: Vec3, color: Color) -> Self {
        Self {
            position: Vec3::ZERO,
            kind: Kind::Directional,
            direction: direction.normalize_or_zero(),
            range: 0.0,
            color: rgb(color),
            cone: 0.0,
            casts: false,
        }
    }

    /// A lamp at `position`, fading to nothing `range` meters out.
    ///
    /// A surface takes the square of the fraction of `range` left at its
    /// distance from `position`: the whole of the light at `position`, a
    /// quarter of it halfway out, and none of it at `range` or past it.
    pub fn point(position: Vec3, color: Color, range: f32) -> Self {
        Self {
            position,
            kind: Kind::Point,
            direction: Vec3::ZERO,
            range: range.max(f32::EPSILON),
            color: rgb(color),
            cone: 0.0,
            casts: false,
        }
    }

    /// A [point](Light::point) light within `spot`'s cone.
    pub fn spot(spot: Spot) -> Self {
        Self {
            direction: spot.direction.normalize_or_zero(),
            kind: Kind::Spot,
            cone: spot.angle.max(NARROWEST_CONE).cos(),
            ..Self::point(spot.position, spot.color, spot.range)
        }
    }

    /// Draws what this light covers into a depth map of its own, and darkens
    /// this light where the map holds a blocker in front of the surface.
    ///
    /// [`MAX_SHADOWS`] lights of a frame may cast, in submission order; a
    /// point light counts as one and costs six maps. The rest are ignored —
    /// warned the first frame. Only opaque draws block a light, and only this
    /// light is darkened — the sky's own light and unlit materials are
    /// untouched.
    /// A shadow extends no further than the light: past its range there is
    /// no light left to block. Both faces of a mesh cast, so a light placed
    /// within an opaque mesh is blocked by its own caster.
    #[must_use]
    pub fn shadow(mut self) -> Self {
        self.casts = true;
        self
    }
}

/// The values [`Light::spot`] builds one light from.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Spot {
    /// Where the light is positioned.
    pub position: Vec3,
    /// Where its cone points.
    pub direction: Vec3,
    /// The color of its light.
    pub color: Color,
    /// Meters out where its light fades to nothing.
    pub range: f32,
    /// How wide its cone is, in radians.
    pub angle: f32,
}

/// This light's shading type, one of three, named the same in
/// `forward.wgsl`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u32)]
pub(crate) enum Kind {
    Directional = 0,
    Point = 1,
    Spot = 2,
}

/// One light as the shader reads it, with the first depth map that darkens
/// it, or [`NO_SHADOW`] where nothing does.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)]
pub(crate) struct GpuLight {
    position: Vec3,
    kind: u32,
    direction: Vec3,
    range: f32,
    color: Vec3,
    cone: f32,
    pub(crate) shadow: i32,
    /// Keeps this struct's size at WGSL's alignment; `bytemuck` needs padding
    /// written out.
    _padding: [u32; 3],
}

impl GpuLight {
    /// Lays `light` out as the shader reads it, with `shadow` as the first
    /// of its depth maps.
    pub(crate) fn new(light: &Light, shadow: i32) -> Self {
        Self {
            position: light.position,
            kind: light.kind as u32,
            direction: light.direction,
            range: light.range,
            color: light.color,
            cone: light.cone,
            shadow,
            _padding: [0; 3],
        }
    }
}

/// Default light for a frame that submits none, so that lit materials draw
/// as more than flat color before a game has lights of its own.
pub(crate) fn default_lights() -> [Light; 1] {
    [Light::directional(
        Vec3::new(-0.4, -1.0, -0.6),
        Color::WHITE,
    )]
}

/// A color as the shader reads a light's: three channels, no opacity.
pub(crate) fn rgb(color: Color) -> Vec3 {
    Vec3::new(color.red, color.green, color.blue)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_light_stays_the_size_the_shader_steps_by() {
        assert_eq!(size_of::<GpuLight>(), 64);
    }

    #[test]
    fn a_light_casts_nothing_until_it_is_asked_to() {
        let sun = Light::directional(Vec3::NEG_Y, Color::WHITE);

        assert!(!sun.casts);
        assert!(sun.shadow().casts);
        assert_eq!(
            GpuLight::new(&sun, NO_SHADOW).shadow,
            NO_SHADOW,
            "and reads no map until a frame gives it one"
        );
        assert!(
            default_lights().iter().all(|light| !light.casts),
            "no default light casts, so a frame that submits no lights \
             still builds no caster batches"
        );
    }
}