Skip to main content

embedded_3dgfx/
lights.rs

1//! Dynamic point lights for runtime illumination.
2//!
3//! Point lights are evaluated at face/vertex granularity (not per-pixel) to
4//! keep cost tractable on microcontrollers.  A typical embedded scene uses
5//! 4–16 lights; the engine stores up to 16 internally.
6//!
7//! # Example
8//! ```
9//! use embedded_3dgfx::lights::{PointLight, PointLightSet};
10//! use nalgebra::Point3;
11//! use embedded_graphics_core::pixelcolor::Rgb565;
12//!
13//! let mut lights: PointLightSet<8> = PointLightSet::new();
14//! lights.add(PointLight::new(Point3::new(0.0, 3.0, 0.0), Rgb565::new(31, 63, 31), 5.0));
15//! let tint = lights.accumulate(Point3::new(0.0, 0.0, 0.0));
16//! ```
17
18use embedded_graphics_core::pixelcolor::{Rgb565, RgbColor};
19use nalgebra::Point3;
20
21/// A dynamic point light in world space.
22///
23/// Attenuation uses a squared-distance falloff that avoids a `sqrt` call:
24/// `factor = (1 − d²/r²) × intensity`
25/// giving a smooth curve from full brightness at the source to zero at
26/// `radius`.
27#[derive(Debug, Clone, Copy)]
28pub struct PointLight {
29    /// World-space position of the light source.
30    pub position: Point3<f32>,
31    /// Light colour.  Channels are scaled by `intensity` at sample time.
32    pub color: Rgb565,
33    /// Influence radius in world units.  Surfaces at or beyond this distance
34    /// receive no contribution.
35    pub radius: f32,
36    /// Brightness multiplier.  `1.0` = full colour at the source centre.
37    pub intensity: f32,
38}
39
40impl PointLight {
41    /// Construct a new point light with `intensity = 1.0`.
42    pub fn new(position: Point3<f32>, color: Rgb565, radius: f32) -> Self {
43        Self {
44            position,
45            color,
46            radius,
47            intensity: 1.0,
48        }
49    }
50
51    /// Builder-style intensity override.
52    pub fn with_intensity(mut self, intensity: f32) -> Self {
53        self.intensity = intensity;
54        self
55    }
56
57    /// Compute the additive RGB565 contribution of this light at `world_pos`.
58    ///
59    /// Returns `Rgb565::new(0, 0, 0)` when `world_pos` is outside the
60    /// influence radius.
61    #[inline]
62    pub fn contribution_at(&self, world_pos: Point3<f32>) -> Rgb565 {
63        let diff = world_pos - self.position;
64        let dist_sq = diff.dot(&diff);
65        let r_sq = self.radius * self.radius;
66        if dist_sq >= r_sq {
67            return Rgb565::new(0, 0, 0);
68        }
69        let t = 1.0 - dist_sq / r_sq;
70        let factor = t * self.intensity;
71        let r = ((self.color.r() as f32) * factor).min(31.0) as u8;
72        let g = ((self.color.g() as f32) * factor).min(63.0) as u8;
73        let b = ((self.color.b() as f32) * factor).min(31.0) as u8;
74        Rgb565::new(r, g, b)
75    }
76}
77
78/// A fixed-capacity collection of [`PointLight`]s.
79///
80/// `N` is the maximum number of simultaneous lights (8–16 is typical for
81/// embedded targets).
82pub struct PointLightSet<const N: usize> {
83    pub lights: heapless::Vec<PointLight, N>,
84}
85
86impl<const N: usize> PointLightSet<N> {
87    /// Create an empty set.
88    pub const fn new() -> Self {
89        Self {
90            lights: heapless::Vec::new(),
91        }
92    }
93
94    /// Add a light.  Returns `true` on success, `false` if the set is full.
95    pub fn add(&mut self, light: PointLight) -> bool {
96        self.lights.push(light).is_ok()
97    }
98
99    /// Remove all lights.
100    pub fn clear(&mut self) {
101        self.lights.clear();
102    }
103
104    /// Number of lights currently in the set.
105    pub fn len(&self) -> usize {
106        self.lights.len()
107    }
108
109    /// `true` if no lights are registered.
110    pub fn is_empty(&self) -> bool {
111        self.lights.is_empty()
112    }
113
114    /// Accumulate the additive RGB565 contribution of all lights at
115    /// `world_pos`.  Each channel is summed and saturated to its maximum
116    /// (R: 31, G: 63, B: 31).
117    pub fn accumulate(&self, world_pos: Point3<f32>) -> Rgb565 {
118        let mut r = 0u32;
119        let mut g = 0u32;
120        let mut b = 0u32;
121        for light in &self.lights {
122            let c = light.contribution_at(world_pos);
123            r += c.r() as u32;
124            g += c.g() as u32;
125            b += c.b() as u32;
126        }
127        Rgb565::new(r.min(31) as u8, g.min(63) as u8, b.min(31) as u8)
128    }
129}
130
131impl<const N: usize> Default for PointLightSet<N> {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    extern crate std;
140    use super::*;
141    use embedded_graphics_core::pixelcolor::WebColors;
142
143    #[test]
144    fn test_point_light_full_at_center() {
145        let light = PointLight::new(Point3::new(0.0, 0.0, 0.0), Rgb565::CSS_WHITE, 5.0);
146        let tint = light.contribution_at(Point3::new(0.0, 0.0, 0.0));
147        assert_eq!(tint.r(), 31);
148        assert_eq!(tint.g(), 63);
149        assert_eq!(tint.b(), 31);
150    }
151
152    #[test]
153    fn test_point_light_zero_outside_radius() {
154        let light = PointLight::new(Point3::new(0.0, 0.0, 0.0), Rgb565::CSS_WHITE, 1.0);
155        let tint = light.contribution_at(Point3::new(2.0, 0.0, 0.0));
156        assert_eq!(tint.r(), 0);
157        assert_eq!(tint.g(), 0);
158        assert_eq!(tint.b(), 0);
159    }
160
161    #[test]
162    fn test_point_light_falloff() {
163        let light = PointLight::new(Point3::new(0.0, 0.0, 0.0), Rgb565::CSS_WHITE, 10.0);
164        let near = light.contribution_at(Point3::new(1.0, 0.0, 0.0));
165        let far = light.contribution_at(Point3::new(5.0, 0.0, 0.0));
166        assert!(near.r() > far.r());
167    }
168
169    #[test]
170    fn test_point_light_set_accumulates() {
171        let mut set: PointLightSet<4> = PointLightSet::new();
172        set.add(PointLight::new(
173            Point3::new(0.0, 0.0, 0.0),
174            Rgb565::new(10, 20, 10),
175            5.0,
176        ));
177        set.add(PointLight::new(
178            Point3::new(0.0, 0.0, 0.0),
179            Rgb565::new(5, 10, 5),
180            5.0,
181        ));
182        let tint = set.accumulate(Point3::new(0.0, 0.0, 0.0));
183        assert!(tint.r() >= 10);
184    }
185
186    #[test]
187    fn test_point_light_set_empty() {
188        let set: PointLightSet<4> = PointLightSet::new();
189        let tint = set.accumulate(Point3::new(0.0, 0.0, 0.0));
190        assert_eq!(tint.r(), 0);
191        assert_eq!(tint.g(), 0);
192        assert_eq!(tint.b(), 0);
193    }
194
195    #[test]
196    fn test_point_light_set_saturation() {
197        let mut set: PointLightSet<4> = PointLightSet::new();
198        // Two max-brightness lights at the same point saturate all channels
199        set.add(PointLight::new(
200            Point3::new(0.0, 0.0, 0.0),
201            Rgb565::CSS_WHITE,
202            5.0,
203        ));
204        set.add(PointLight::new(
205            Point3::new(0.0, 0.0, 0.0),
206            Rgb565::CSS_WHITE,
207            5.0,
208        ));
209        let tint = set.accumulate(Point3::new(0.0, 0.0, 0.0));
210        assert_eq!(tint.r(), 31);
211        assert_eq!(tint.g(), 63);
212        assert_eq!(tint.b(), 31);
213    }
214
215    #[test]
216    fn test_point_light_analytical_falloff_math() {
217        let radius = 10.0f32;
218        let light = PointLight::new(Point3::new(0.0, 0.0, 0.0), Rgb565::new(31, 63, 31), radius)
219            .with_intensity(1.0);
220
221        // At d = 0.5 * radius (5.0 units away), d^2 / R^2 = 0.25 => factor = 0.75
222        let pos_half = Point3::new(5.0, 0.0, 0.0);
223        let tint = light.contribution_at(pos_half);
224
225        let expected_r = (31.0 * 0.75) as u8; // 23
226        let expected_g = (63.0 * 0.75) as u8; // 47
227        let expected_b = (31.0 * 0.75) as u8; // 23
228
229        assert_eq!(tint.r(), expected_r);
230        assert_eq!(tint.g(), expected_g);
231        assert_eq!(tint.b(), expected_b);
232    }
233}