Skip to main content

euv_engine/lighting/
impl.rs

1use super::*;
2
3/// Implements factory constructors and shading for [`Light`], [`Material`],
4/// and [`LightingUniforms`].
5impl Light {
6    /// Creates a new directional light pointing in `direction` with `color`.
7    ///
8    /// The `direction` is normalized internally; intensity defaults to 1.0.
9    /// Falloff and spot half-angle are unused for directional lights.
10    ///
11    /// # Arguments
12    ///
13    /// - `Vector3D` - The unit direction toward the light source.
14    /// - `Vector3D` - The RGB intensity multiplier.
15    ///
16    /// # Returns
17    ///
18    /// - `Light` - The new directional light.
19    pub fn new_directional(direction: Vector3D, color: Vector3D) -> Light {
20        Light::new(
21            LightType::Directional,
22            Vector3D::zero(),
23            direction.normalized(),
24            color,
25            1.0,
26            0.0,
27            0.0,
28        )
29    }
30
31    /// Creates a new point light at `position` with `color` and `intensity`.
32    ///
33    /// Falloff defaults to 1.0 (inverse-square). The `direction` field is
34    /// unused for point lights and is set to the zero vector.
35    ///
36    /// # Arguments
37    ///
38    /// - `Vector3D` - The world-space position of the light.
39    /// - `Vector3D` - The RGB intensity multiplier.
40    /// - `f64` - The intensity scalar.
41    ///
42    /// # Returns
43    ///
44    /// - `Light` - The new point light.
45    pub fn new_point(position: Vector3D, color: Vector3D, intensity: f64) -> Light {
46        Light::new(
47            LightType::Point,
48            position,
49            Vector3D::zero(),
50            color,
51            intensity,
52            1.0,
53            0.0,
54        )
55    }
56
57    /// Creates a new spotlight at `position` shining in `direction`.
58    ///
59    /// The cone is defined by `half_angle_rad`; the cosine of that angle
60    /// is stored for fast cone-test comparisons during shading.
61    ///
62    /// # Arguments
63    ///
64    /// - `Vector3D` - The world-space position of the light.
65    /// - `Vector3D` - The unit direction the cone opens along.
66    /// - `Vector3D` - The RGB intensity multiplier.
67    /// - `f64` - The intensity scalar.
68    /// - `f64` - The half-angle of the cone in radians.
69    ///
70    /// # Returns
71    ///
72    /// - `Light` - The new spotlight.
73    pub fn new_spot(
74        position: Vector3D,
75        direction: Vector3D,
76        color: Vector3D,
77        intensity: f64,
78        half_angle_rad: f64,
79    ) -> Light {
80        Light::new(
81            LightType::Spot,
82            position,
83            direction.normalized(),
84            color,
85            intensity,
86            1.0,
87            half_angle_rad.cos(),
88        )
89    }
90}
91
92/// Implements factory constructors for [`Material`].
93impl Material {
94    /// Creates a pure-Lambert material with the given albedo.
95    ///
96    /// # Arguments
97    ///
98    /// - `Vector3D` - The diffuse albedo color.
99    ///
100    /// # Returns
101    ///
102    /// - `Material` - A Lambertian material.
103    pub fn lambert(albedo: Vector3D) -> Material {
104        Material::new(
105            MaterialKind::Lambert,
106            albedo,
107            0.0,
108            LIGHTING_DEFAULT_SHININESS,
109            Vector3D::zero(),
110        )
111    }
112
113    /// Creates a Blinn-Phong material with the given albedo, specular
114    /// strength, and specular exponent.
115    ///
116    /// # Arguments
117    ///
118    /// - `Vector3D` - The diffuse albedo color.
119    /// - `f64` - The specular intensity in the range 0.0..=1.0.
120    /// - `f64` - The Phong specular exponent.
121    ///
122    /// # Returns
123    ///
124    /// - `Material` - A Phong material.
125    pub fn phong(albedo: Vector3D, specular: f64, shininess: f64) -> Material {
126        Material::new(
127            MaterialKind::Phong,
128            albedo,
129            specular,
130            shininess,
131            Vector3D::zero(),
132        )
133    }
134
135    /// Creates a purely emissive material (light source with no shading).
136    ///
137    /// # Arguments
138    ///
139    /// - `Vector3D` - The self-illumination color.
140    ///
141    /// # Returns
142    ///
143    /// - `Material` - An emissive material.
144    pub fn emissive(color: Vector3D) -> Material {
145        Material::new(MaterialKind::Lambert, Vector3D::zero(), 0.0, 0.0, color)
146    }
147}
148
149/// Implements [`LightingUniforms`] builders and the [`LightingUniforms::shade`]
150/// entry point used by the ray tracer.
151impl LightingUniforms {
152    /// Creates a uniform set with an empty light list, default ambient,
153    /// and the supplied eye position.
154    ///
155    /// # Arguments
156    ///
157    /// - `Vector3D` - The view position used for specular calculations.
158    ///
159    /// # Returns
160    ///
161    /// - `LightingUniforms` - The new uniform set.
162    pub fn with_eye(eye: Vector3D) -> LightingUniforms {
163        LightingUniforms::new(Vec::new(), LIGHTING_DEFAULT_AMBIENT, eye)
164    }
165
166    /// Adds a light to the uniform set.
167    ///
168    /// # Arguments
169    ///
170    /// - `Light` - The light to append.
171    pub fn add_light(&mut self, light: Light) {
172        self.get_mut_lights().push(light);
173    }
174
175    /// Shades a surface point by summing ambient, per-light Lambertian, and
176    /// per-light Phong contributions, gated by a soft shadow factor.
177    ///
178    /// # Arguments
179    ///
180    /// - `Vector3D` - The world-space position of the shaded point.
181    /// - `Vector3D` - The surface normal (unit length).
182    /// - `&Material` - The material at the shaded point.
183    /// - `&[(Vector3D, f64)]` - `(center, radius)` occluder tuples used by
184    ///   [`soft_shadow_factor`].
185    ///
186    /// # Returns
187    ///
188    /// - `Vector3D` - The final shaded color.
189    pub fn shade(
190        &self,
191        position: Vector3D,
192        normal: Vector3D,
193        material: &Material,
194        occluders: &[(Vector3D, f64)],
195    ) -> Vector3D {
196        let mut color: Vector3D = self.get_ambient();
197        let eye: Vector3D = self.get_eye();
198        let to_eye: Vector3D = eye - position;
199        let view_dist: f64 = to_eye.magnitude();
200        let view_dir: Vector3D = if view_dist > EPSILON {
201            to_eye.scaled(1.0 / view_dist)
202        } else {
203            Vector3D::zero()
204        };
205        for light in self.get_lights().iter() {
206            let kind: LightType = light.get_kind();
207            let shadow: f64 = match kind {
208                LightType::Directional => 1.0,
209                LightType::Point | LightType::Spot => {
210                    soft_shadow_factor(position, light.get_position(), occluders)
211                }
212            };
213            if shadow <= 0.0 {
214                continue;
215            }
216            let mut lambert_input: Light = light.clone();
217            match kind {
218                LightType::Directional => {}
219                LightType::Point | LightType::Spot => {
220                    let to_light: Vector3D = light.get_position() - position;
221                    let dist: f64 = to_light.magnitude().max(LIGHTING_POINT_LIGHT_MIN_DISTANCE);
222                    let dir: Vector3D = to_light.scaled(1.0 / dist);
223                    lambert_input.set_direction(dir);
224                }
225            }
226            let diffuse: Vector3D = compute_lambert(&lambert_input, normal, material);
227            let mut spec_input: Light = lambert_input.clone();
228            spec_input.set_intensity(
229                light.get_intensity() * apply_falloff(view_dist, light.get_falloff()),
230            );
231            let specular: Vector3D = compute_phong(&spec_input, normal, view_dir, material);
232            let mut contribution: Vector3D = diffuse + specular;
233            contribution = contribution.scaled(shadow);
234            color += contribution;
235        }
236        let emissive: Vector3D = material.get_emissive();
237        color += emissive;
238        color
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    /// Diffuse term is maximized when the normal aligns with the light
247    /// direction (cosine = 1) and equals `color * intensity * albedo`.
248    #[test]
249    fn lambert_diffuse_face_normal() {
250        let light: Light =
251            Light::new_directional(Vector3D::new(0.0, 1.0, 0.0), Vector3D::new(1.0, 0.0, 0.0));
252        let material: Material = Material::lambert(Vector3D::new(0.5, 0.5, 0.5));
253        let normal: Vector3D = Vector3D::new(0.0, 1.0, 0.0);
254        let result: Vector3D = compute_lambert(&light, normal, &material);
255        let expected: f64 = 1.0 * 1.0 * 1.0 * 0.5;
256        assert!(
257            (result.get_x() - expected).abs() < EPSILON,
258            "expected red channel {expected}, got {}",
259            result.get_x(),
260        );
261        assert!(
262            result.get_y().abs() < EPSILON,
263            "expected green channel 0.0, got {}",
264            result.get_y(),
265        );
266        assert!(
267            result.get_z().abs() < EPSILON,
268            "expected blue channel 0.0, got {}",
269            result.get_z(),
270        );
271    }
272
273    /// Specular term is maximized when the reflection vector aligns with
274    /// the view direction, producing the peak Phong highlight.
275    #[test]
276    fn phong_specular_peak() {
277        let normal: Vector3D = Vector3D::new(0.0, 1.0, 0.0);
278        let light_dir: Vector3D = Vector3D::new(0.0, -1.0, 0.0);
279        let view_dir: Vector3D = Vector3D::new(0.0, 1.0, 0.0);
280        let light: Light = Light::new(
281            LightType::Directional,
282            Vector3D::zero(),
283            light_dir,
284            Vector3D::new(1.0, 1.0, 1.0),
285            1.0,
286            0.0,
287            0.0,
288        );
289        let material: Material = Material::phong(Vector3D::new(1.0, 1.0, 1.0), 1.0, 32.0);
290        let result: Vector3D = compute_phong(&light, normal, view_dir, &material);
291        assert!(
292            (result.get_x() - 1.0).abs() < EPSILON,
293            "expected specular peak ~1.0, got {}",
294            result.get_x(),
295        );
296        assert!(
297            (result.get_y() - 1.0).abs() < EPSILON,
298            "expected specular peak ~1.0, got {}",
299            result.get_y(),
300        );
301        assert!(
302            (result.get_z() - 1.0).abs() < EPSILON,
303            "expected specular peak ~1.0, got {}",
304            result.get_z(),
305        );
306    }
307
308    /// Inverse-square falloff: at d=0 returns 1.0; at d=1 returns
309    /// 1/(1+falloff); at d=2 returns 1/(1+4*falloff).
310    #[test]
311    fn point_light_falloff_distance() {
312        let falloff: f64 = 1.0;
313        let f0: f64 = apply_falloff(0.0, falloff);
314        let f1: f64 = apply_falloff(1.0, falloff);
315        let f2: f64 = apply_falloff(2.0, falloff);
316        assert!((f0 - 1.0).abs() < EPSILON, "d=0 should yield 1.0, got {f0}");
317        assert!(
318            (f1 - 1.0 / (1.0 + 1.0)).abs() < EPSILON,
319            "d=1 should yield 0.5, got {f1}",
320        );
321        assert!(
322            (f2 - 1.0 / (1.0 + 4.0)).abs() < EPSILON,
323            "d=2 should yield 0.2, got {f2}",
324        );
325    }
326
327    /// Three ray-sphere cases: hit from outside, miss, origin inside sphere.
328    #[test]
329    fn ray_sphere_intersect_hit_miss_inside() {
330        // Hit from outside.
331        let origin: Vector3D = Vector3D::new(0.0, 0.0, 5.0);
332        let dir: Vector3D = Vector3D::new(0.0, 0.0, -1.0);
333        let center: Vector3D = Vector3D::zero();
334        let radius: f64 = 1.0;
335        let hit: Option<(f64, Vector3D)> = ray_sphere_intersect(origin, dir, center, radius);
336        assert!(hit.is_some(), "ray from outside should hit sphere");
337        let (t, normal): (f64, Vector3D) = hit.unwrap();
338        assert!((t - 4.0).abs() < EPSILON, "expected t=4, got {t}");
339        assert!(
340            (normal.get_z() - 1.0).abs() < EPSILON,
341            "expected normal (0,0,1), got (0,0,{})",
342            normal.get_z(),
343        );
344
345        // Miss.
346        let origin_miss: Vector3D = Vector3D::new(10.0, 0.0, 5.0);
347        let dir_miss: Vector3D = Vector3D::new(0.0, 0.0, -1.0);
348        let miss: Option<(f64, Vector3D)> =
349            ray_sphere_intersect(origin_miss, dir_miss, center, radius);
350        assert!(miss.is_none(), "ray far from sphere should miss");
351
352        // Origin inside sphere.
353        let origin_in: Vector3D = Vector3D::zero();
354        let dir_in: Vector3D = Vector3D::new(1.0, 0.0, 0.0);
355        let inside: Option<(f64, Vector3D)> =
356            ray_sphere_intersect(origin_in, dir_in, center, radius);
357        assert!(
358            inside.is_some(),
359            "ray from inside should still hit exit point"
360        );
361        let (t_in, normal_in): (f64, Vector3D) = inside.unwrap();
362        assert!(
363            (t_in - 1.0).abs() < EPSILON,
364            "expected t=1 (exit through +x), got {t_in}",
365        );
366        assert!(
367            (normal_in.get_x() - 1.0).abs() < EPSILON,
368            "expected exit normal (1,0,0), got ({},0,0)",
369            normal_in.get_x(),
370        );
371    }
372
373    /// Empty occluder list returns full visibility (1.0).
374    #[test]
375    fn soft_shadow_no_occluder_returns_one() {
376        let origin: Vector3D = Vector3D::zero();
377        let light_pos: Vector3D = Vector3D::new(0.0, 0.0, 10.0);
378        let occluders: [(Vector3D, f64); 0] = [];
379        let v: f64 = soft_shadow_factor(origin, light_pos, &occluders);
380        assert!(
381            (v - 1.0).abs() < EPSILON,
382            "empty occluders should yield 1.0, got {v}"
383        );
384    }
385}