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}