roxlap_render/light.rs
1//! Dynamic lighting (stages DL + SL) — runtime sun + point + spot
2//! lights with stylized voxel shadows, on **both** backends (the GPU
3//! shaders since DL, the CPU renderer since CPU.1/CPU.2). See
4//! `PORTING-DYNLIGHT.md` + `PORTING-SPOTLIGHT.md`.
5//!
6//! Lighting is per-frame: a host builds a [`LightRig`] each frame and
7//! hands it to the renderer via [`crate::FrameParams::lights`]. There are
8//! deliberately no stateful lighting setters on [`crate::SceneRenderer`]
9//! (mirroring how sky / fog / side-shades already flow through
10//! [`crate::FrameParams`]). `lights: None` ⇒ exactly the pre-DL render.
11
12/// The sun — a single directional light for the whole scene. World space.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct DirectionalLight {
15 /// Direction the light **travels** (from the sun toward the scene),
16 /// world space. Need not be normalized — the backend normalizes. The
17 /// N·L term uses the negation (the direction *to* the sun).
18 pub direction: [f32; 3],
19 /// Linear RGB, `0..1` (may exceed 1 for extra punch; the output is
20 /// clamped). `[1.0; 3]` is neutral white.
21 pub color: [f32; 3],
22 /// Scalar brightness multiplier on `color`.
23 pub intensity: f32,
24 /// Whether this light casts stylized hard shadows (DL.3). The sun is
25 /// the natural first shadow caster.
26 pub casts_shadow: bool,
27}
28
29impl Default for DirectionalLight {
30 fn default() -> Self {
31 Self {
32 direction: [0.0, 0.0, 1.0],
33 color: [1.0; 3],
34 intensity: 1.0,
35 casts_shadow: false,
36 }
37 }
38}
39
40/// A colored point light. World space, with a hard radius cutoff (it
41/// contributes nothing beyond `radius`).
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub struct PointLight {
44 /// World-space position (voxel units).
45 pub position: [f32; 3],
46 /// Linear RGB, `0..1`.
47 pub color: [f32; 3],
48 /// Scalar brightness multiplier on `color`.
49 pub intensity: f32,
50 /// Hard cutoff distance in world units; past it the light is zero.
51 pub radius: f32,
52 /// Whether this light casts stylized hard shadows (DL.3). Only the
53 /// first few flagged lights actually cast (see the renderer's
54 /// shadow-caster cap); the rest are silently demoted to shadowless
55 /// with a log warning — never truncated.
56 pub casts_shadow: bool,
57}
58
59impl Default for PointLight {
60 fn default() -> Self {
61 Self {
62 position: [0.0; 3],
63 color: [1.0; 3],
64 intensity: 1.0,
65 radius: 32.0,
66 casts_shadow: false,
67 }
68 }
69}
70
71/// A spot (cone) light — a [`PointLight`] with a direction and a soft
72/// angular cutoff, so it lights only a cone. World space. Internally folded
73/// into the point-light path (a point light is the 180°-cone degenerate), so
74/// spots share the point-light count + shadow-caster budgets.
75#[derive(Clone, Copy, Debug, PartialEq)]
76pub struct SpotLight {
77 /// World-space position (voxel units).
78 pub position: [f32; 3],
79 /// Cone **axis** — the direction the light shines **along** (the way the
80 /// light travels). Need not be normalized; the backend normalizes.
81 pub direction: [f32; 3],
82 /// Linear RGB, `0..1`.
83 pub color: [f32; 3],
84 /// Scalar brightness multiplier on `color`.
85 pub intensity: f32,
86 /// Hard cutoff distance in world units; past it the light is zero.
87 pub radius: f32,
88 /// Inner half-angle in **degrees**: within it the cone is at full
89 /// brightness. Clamped to `0..=outer_angle_deg`.
90 pub inner_angle_deg: f32,
91 /// Outer half-angle in **degrees**: past it the cone is zero; between the
92 /// two it soft-falls off (`smoothstep`). `outer == inner` ⇒ a hard edge;
93 /// `>= 180` ⇒ omnidirectional (a plain point light). Clamped to `0..=180`.
94 pub outer_angle_deg: f32,
95 /// Whether this spot casts a stylized hard shadow (shares the
96 /// shadow-caster cap with the sun + point lights; excess demoted with a
97 /// warning, never truncated).
98 pub casts_shadow: bool,
99}
100
101impl Default for SpotLight {
102 fn default() -> Self {
103 Self {
104 position: [0.0; 3],
105 direction: [0.0, 0.0, 1.0],
106 color: [1.0; 3],
107 intensity: 1.0,
108 radius: 32.0,
109 inner_angle_deg: 20.0,
110 outer_angle_deg: 30.0,
111 casts_shadow: false,
112 }
113 }
114}
115
116impl SpotLight {
117 /// The clamped `(inner, outer)` half-angles in degrees (`0 <= inner <=
118 /// outer <= 180`). Shared by both backends so the cone is identical.
119 fn clamped_angles(&self) -> (f32, f32) {
120 let outer = self.outer_angle_deg.clamp(0.0, 180.0);
121 let inner = self.inner_angle_deg.clamp(0.0, outer);
122 (inner, outer)
123 }
124
125 /// The normalized world-space cone axis (travel direction). `[0,0,1]`
126 /// if `direction` is degenerate.
127 pub(crate) fn axis(&self) -> [f32; 3] {
128 let d = self.direction;
129 let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
130 if len > 1e-6 {
131 [d[0] / len, d[1] / len, d[2] / len]
132 } else {
133 [0.0, 0.0, 1.0]
134 }
135 }
136
137 /// Cosine of the clamped inner half-angle (`>= cos_outer`).
138 pub(crate) fn cos_inner(&self) -> f32 {
139 self.clamped_angles().0.to_radians().cos()
140 }
141
142 /// Cosine of the clamped outer half-angle. `<= -0.999` (a `>= ~177°`
143 /// cone) makes the shader/CPU skip the cone mask — an omnidirectional
144 /// point light.
145 pub(crate) fn cos_outer(&self) -> f32 {
146 self.clamped_angles().1.to_radians().cos()
147 }
148}
149
150/// The whole per-frame light environment, borrowed into
151/// [`crate::FrameParams`]. Applied by **both** backends (GPU since DL,
152/// CPU since CPU.1/CPU.2) — see the module docs.
153#[derive(Clone, Copy, Debug)]
154pub struct LightRig<'a> {
155 /// The sun. `None` ⇒ no directional light this frame.
156 pub sun: Option<DirectionalLight>,
157 /// Point lights. The backend caps the count (excess dropped with a
158 /// log warning), so this slice may be longer than the GPU honours.
159 pub points: &'a [PointLight],
160 /// Spot (cone) lights. Folded into the point-light path, so they share
161 /// the point-light count + shadow-caster budgets (points take priority).
162 pub spots: &'a [SpotLight],
163 /// Multiplier applied to the baked ambient byte — the global ambient
164 /// level / tint. `[1.0; 3]` uses the baked byte as-is.
165 pub ambient: [f32; 3],
166 /// Stylized-shadow strength `0..1`: the fraction of a caster's light
167 /// removed in shadow (`1.0` = full black, `0.0` = no visible shadow).
168 pub shadow_strength: f32,
169 /// Shadow-ray origin bias along the surface normal, in voxel units —
170 /// kills self-shadow acne. ~1.5 is a good default.
171 pub shadow_bias_voxels: f32,
172 /// Sun shadow-ray length cap, world units (point-light shadow rays
173 /// stop at the light instead).
174 pub shadow_max_dist: f32,
175 /// DL.6 — **stylized lighting**. `0` ⇒ smooth (physically-ish) diffuse
176 /// (the default). `≥1` ⇒ retro cel look: the sun's key term and each
177 /// point light's diffuse factor quantize to `bands + 1` discrete levels
178 /// (terraced light instead of a smooth gradient), and the banded sun key
179 /// drives a **gradient map** from [`shadow_tint`](Self::shadow_tint)
180 /// (cool, unlit) to the sun's colour (warm, lit) — hue-shifted shadows
181 /// rather than plain darkening. Avoids the "generic Phong" read that
182 /// flattens the voxel/retro identity.
183 pub bands: u32,
184 /// DL.6 — the cool shadow/ambient tint the stylized ramp starts from
185 /// (the unlit end). Multiplied by the baked ambient/AO byte. Ignored
186 /// when `bands == 0` (then [`ambient`](Self::ambient) is used instead).
187 pub shadow_tint: [f32; 3],
188}
189
190impl Default for LightRig<'_> {
191 fn default() -> Self {
192 Self {
193 sun: None,
194 points: &[],
195 spots: &[],
196 ambient: [1.0; 3],
197 shadow_strength: 0.7,
198 shadow_bias_voxels: 1.5,
199 shadow_max_dist: 512.0,
200 bands: 0,
201 shadow_tint: [0.12, 0.14, 0.2],
202 }
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn spot_angles_convert_and_clamp() {
212 // cos is decreasing in angle ⇒ inner (smaller angle) has the larger
213 // cosine; the two bracket the smoothstep the backends run.
214 let s = SpotLight {
215 inner_angle_deg: 20.0,
216 outer_angle_deg: 30.0,
217 ..SpotLight::default()
218 };
219 assert!(s.cos_inner() > s.cos_outer());
220 assert!((s.cos_inner() - 20.0f32.to_radians().cos()).abs() < 1e-6);
221
222 // inner > outer is clamped to outer ⇒ a hard edge (equal cosines).
223 let hard = SpotLight {
224 inner_angle_deg: 40.0,
225 outer_angle_deg: 25.0,
226 ..SpotLight::default()
227 };
228 assert!((hard.cos_inner() - hard.cos_outer()).abs() < 1e-6);
229
230 // A 180° outer cone reads as an omnidirectional point (mask skipped:
231 // the backends treat `cos_outer <= -0.999` as "not a spot").
232 let wide = SpotLight {
233 outer_angle_deg: 180.0,
234 ..SpotLight::default()
235 };
236 assert!(wide.cos_outer() <= -0.999);
237 }
238
239 #[test]
240 fn spot_axis_normalizes() {
241 let s = SpotLight {
242 direction: [0.0, 0.0, 5.0],
243 ..SpotLight::default()
244 };
245 assert_eq!(s.axis(), [0.0, 0.0, 1.0]);
246 // Degenerate direction falls back to a defined axis.
247 let z = SpotLight {
248 direction: [0.0; 3],
249 ..SpotLight::default()
250 };
251 assert_eq!(z.axis(), [0.0, 0.0, 1.0]);
252 }
253}