Skip to main content

mirage_engine/
light.rs

1use bytemuck::{Pod, Zeroable};
2
3use crate::Color;
4use crate::math::Vec3;
5
6/// Light cap per frame; excess is ignored — warned the first frame, a
7/// debug log after.
8pub const MAX_LIGHTS: usize = 64;
9
10/// Cap on casting lights per frame (see [`Light::shadow`]); excess is
11/// ignored — warned the first frame, a debug log after.
12pub const MAX_SHADOWS: usize = 4;
13
14/// The value a light with no depth map of its own stores in place of one.
15pub(crate) const NO_SHADOW: i32 = -1;
16
17/// The least angle a cone may have; less becomes this value.
18const NARROWEST_CONE: f32 = 0.01;
19
20/// One light for one frame.
21///
22/// A frame is lit by exactly what it submits; submitting none keeps the
23/// default environment.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct Light {
26    pub(crate) position: Vec3,
27    pub(crate) kind: Kind,
28    pub(crate) direction: Vec3,
29    pub(crate) range: f32,
30    pub(crate) color: Vec3,
31    pub(crate) cone: f32,
32    pub(crate) casts: bool,
33}
34
35impl Light {
36    /// A sun: parallel light along `direction`.
37    pub fn directional(direction: Vec3, color: Color) -> Self {
38        Self {
39            position: Vec3::ZERO,
40            kind: Kind::Directional,
41            direction: direction.normalize_or_zero(),
42            range: 0.0,
43            color: rgb(color),
44            cone: 0.0,
45            casts: false,
46        }
47    }
48
49    /// A lamp at `position`, fading to nothing `range` meters out.
50    ///
51    /// A surface takes the square of the fraction of `range` left at its
52    /// distance from `position`: the whole of the light at `position`, a
53    /// quarter of it halfway out, and none of it at `range` or past it.
54    pub fn point(position: Vec3, color: Color, range: f32) -> Self {
55        Self {
56            position,
57            kind: Kind::Point,
58            direction: Vec3::ZERO,
59            range: range.max(f32::EPSILON),
60            color: rgb(color),
61            cone: 0.0,
62            casts: false,
63        }
64    }
65
66    /// A [point](Light::point) light within `spot`'s cone.
67    pub fn spot(spot: Spot) -> Self {
68        Self {
69            direction: spot.direction.normalize_or_zero(),
70            kind: Kind::Spot,
71            cone: spot.angle.max(NARROWEST_CONE).cos(),
72            ..Self::point(spot.position, spot.color, spot.range)
73        }
74    }
75
76    /// Draws what this light covers into a depth map of its own, and darkens
77    /// this light where the map holds a blocker in front of the surface.
78    ///
79    /// [`MAX_SHADOWS`] lights of a frame may cast, in submission order; a
80    /// point light counts as one and costs six maps. The rest are ignored —
81    /// warned the first frame. Only opaque draws block a light, and only this
82    /// light is darkened — the sky's own light and unlit materials are
83    /// untouched.
84    /// A shadow extends no further than the light: past its range there is
85    /// no light left to block. Both faces of a mesh cast, so a light placed
86    /// within an opaque mesh is blocked by its own caster.
87    #[must_use]
88    pub fn shadow(mut self) -> Self {
89        self.casts = true;
90        self
91    }
92}
93
94/// The values [`Light::spot`] builds one light from.
95#[derive(Clone, Copy, Debug, PartialEq)]
96pub struct Spot {
97    /// Where the light is positioned.
98    pub position: Vec3,
99    /// Where its cone points.
100    pub direction: Vec3,
101    /// The color of its light.
102    pub color: Color,
103    /// Meters out where its light fades to nothing.
104    pub range: f32,
105    /// How wide its cone is, in radians.
106    pub angle: f32,
107}
108
109/// This light's shading type, one of three, named the same in
110/// `forward.wgsl`.
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112#[repr(u32)]
113pub(crate) enum Kind {
114    Directional = 0,
115    Point = 1,
116    Spot = 2,
117}
118
119/// One light as the shader reads it, with the first depth map that darkens
120/// it, or [`NO_SHADOW`] where nothing does.
121#[repr(C)]
122#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)]
123pub(crate) struct GpuLight {
124    position: Vec3,
125    kind: u32,
126    direction: Vec3,
127    range: f32,
128    color: Vec3,
129    cone: f32,
130    pub(crate) shadow: i32,
131    /// Keeps this struct's size at WGSL's alignment; `bytemuck` needs padding
132    /// written out.
133    _padding: [u32; 3],
134}
135
136impl GpuLight {
137    /// Lays `light` out as the shader reads it, with `shadow` as the first
138    /// of its depth maps.
139    pub(crate) fn new(light: &Light, shadow: i32) -> Self {
140        Self {
141            position: light.position,
142            kind: light.kind as u32,
143            direction: light.direction,
144            range: light.range,
145            color: light.color,
146            cone: light.cone,
147            shadow,
148            _padding: [0; 3],
149        }
150    }
151}
152
153/// Default light for a frame that submits none, so that lit materials draw
154/// as more than flat color before a game has lights of its own.
155pub(crate) fn default_lights() -> [Light; 1] {
156    [Light::directional(
157        Vec3::new(-0.4, -1.0, -0.6),
158        Color::WHITE,
159    )]
160}
161
162/// A color as the shader reads a light's: three channels, no opacity.
163pub(crate) fn rgb(color: Color) -> Vec3 {
164    Vec3::new(color.red, color.green, color.blue)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn a_light_stays_the_size_the_shader_steps_by() {
173        assert_eq!(size_of::<GpuLight>(), 64);
174    }
175
176    #[test]
177    fn a_light_casts_nothing_until_it_is_asked_to() {
178        let sun = Light::directional(Vec3::NEG_Y, Color::WHITE);
179
180        assert!(!sun.casts);
181        assert!(sun.shadow().casts);
182        assert_eq!(
183            GpuLight::new(&sun, NO_SHADOW).shadow,
184            NO_SHADOW,
185            "and reads no map until a frame gives it one"
186        );
187        assert!(
188            default_lights().iter().all(|light| !light.casts),
189            "no default light casts, so a frame that submits no lights \
190             still builds no caster batches"
191        );
192    }
193}