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    ///
15    /// # Returns
16    ///
17    /// - `Light` - The new directional light.
18    pub fn new_directional(direction: Vector3D, color: Vector3D) -> Light {
19        Light::new(
20            LightType::Directional,
21            Vector3D::zero(),
22            direction.normalized(),
23            color,
24            1.0,
25            0.0,
26            0.0,
27        )
28    }
29
30    /// Creates a new point light at `position` with `color` and `intensity`.
31    ///
32    /// Falloff defaults to 1.0 (inverse-square). The `direction` field is
33    /// unused for point lights and is set to the zero vector.
34    ///
35    /// # Arguments
36    ///
37    /// - `Vector3D` - The world-space position of the light.
38    /// - `f64` - The intensity scalar.
39    ///
40    /// # Returns
41    ///
42    /// - `Light` - The new point light.
43    pub fn new_point(position: Vector3D, color: Vector3D, intensity: f64) -> Light {
44        Light::new(
45            LightType::Point,
46            position,
47            Vector3D::zero(),
48            color,
49            intensity,
50            1.0,
51            0.0,
52        )
53    }
54
55    /// Creates a new spotlight at `position` shining in `direction`.
56    ///
57    /// The cone is defined by `half_angle_rad`; the cosine of that angle
58    /// is stored for fast cone-test comparisons during shading.
59    ///
60    /// # Arguments
61    ///
62    /// - `Vector3D` - The world-space position of the light.
63    /// - `f64` - The intensity scalar.
64    ///
65    /// # Returns
66    ///
67    /// - `Light` - The new spotlight.
68    pub fn new_spot(
69        position: Vector3D,
70        direction: Vector3D,
71        color: Vector3D,
72        intensity: f64,
73        half_angle_rad: f64,
74    ) -> Light {
75        Light::new(
76            LightType::Spot,
77            position,
78            direction.normalized(),
79            color,
80            intensity,
81            1.0,
82            half_angle_rad.cos(),
83        )
84    }
85}
86
87/// Implements factory constructors for [`Material`].
88impl Material {
89    /// Creates a pure-Lambert material with the given albedo.
90    ///
91    /// # Arguments
92    ///
93    /// - `Vector3D` - The diffuse albedo color.
94    ///
95    /// # Returns
96    ///
97    /// - `Material` - A Lambertian material.
98    pub fn lambert(albedo: Vector3D) -> Material {
99        Material::new(
100            MaterialKind::Lambert,
101            albedo,
102            0.0,
103            LIGHTING_DEFAULT_SHININESS,
104            Vector3D::zero(),
105        )
106    }
107
108    /// Creates a Phong material with the given albedo, specular
109    /// strength, and specular exponent.
110    ///
111    /// # Arguments
112    ///
113    /// - `Vector3D` - The diffuse albedo color.
114    /// - `f64` - The specular intensity in the range 0.0..=1.0.
115    ///
116    /// # Returns
117    ///
118    /// - `Material` - A Phong material.
119    pub fn phong(albedo: Vector3D, specular: f64, shininess: f64) -> Material {
120        Material::new(
121            MaterialKind::Phong,
122            albedo,
123            specular,
124            shininess,
125            Vector3D::zero(),
126        )
127    }
128
129    /// Creates a material using the [`MaterialKind::Pbr`] approximation.
130    ///
131    /// The resulting material is shaded as a Lambertian diffuse term scaled
132    /// by a Schlick fresnel factor; its `specular` and `shininess` fields are
133    /// not consulted on that path. See [`MaterialKind::Pbr`] for what this
134    /// model does and does not include — in particular, read it as
135    /// "diffuse plus a fresnel rim", not as a physical BRDF.
136    ///
137    /// # Arguments
138    ///
139    /// - `Vector3D` - The diffuse albedo color.
140    ///
141    /// # Returns
142    ///
143    /// - `Material` - A PBR-approximation material.
144    pub fn pbr(albedo: Vector3D) -> Material {
145        Material::new(
146            MaterialKind::Pbr,
147            albedo,
148            0.0,
149            LIGHTING_DEFAULT_SHININESS,
150            Vector3D::zero(),
151        )
152    }
153
154    /// Creates a purely emissive material (light source with no shading).
155    ///
156    /// # Arguments
157    ///
158    /// - `Vector3D` - The self-illumination color.
159    ///
160    /// # Returns
161    ///
162    /// - `Material` - An emissive material.
163    pub fn emissive(color: Vector3D) -> Material {
164        Material::new(MaterialKind::Lambert, Vector3D::zero(), 0.0, 0.0, color)
165    }
166}
167
168/// Implements [`LightingUniforms`] builders and the shading entry points
169/// used by the ray tracer and by any external visibility provider.
170impl LightingUniforms {
171    /// Creates a uniform set with an empty light list, default ambient,
172    /// and the supplied eye position.
173    ///
174    /// # Arguments
175    ///
176    /// - `Vector3D` - The view position used for specular calculations.
177    ///
178    /// # Returns
179    ///
180    /// - `LightingUniforms` - The new uniform set.
181    pub fn with_eye(eye: Vector3D) -> LightingUniforms {
182        LightingUniforms::new(Vec::new(), LIGHTING_DEFAULT_AMBIENT, eye)
183    }
184
185    /// Adds a light to the uniform set.
186    ///
187    /// # Arguments
188    ///
189    /// - `Light` - The light to append.
190    pub fn add_light(&mut self, light: Light) {
191        self.get_mut_lights().push(light);
192    }
193
194    /// Shades a surface point against `(center, radius)` shadow bounding
195    /// spheres, the pre-existing entry point.
196    ///
197    /// A compatibility shim over
198    /// [`LightingUniforms::shade_with_visibility`] that supplies a callback
199    /// backed by [`soft_shadow_factor`], retained so existing ray-tracing
200    /// callers keep compiling unchanged. New code holding real geometry
201    /// should call [`LightingUniforms::shade_with_visibility`] directly and
202    /// get exact shadows instead of bounding-sphere ones.
203    ///
204    /// Directional lights report full visibility here, unchanged from
205    /// before: a bounding sphere stores a position but no direction, so
206    /// there is no finite segment to cast along and a callback using one
207    /// would have to invent a far bound. That far bound is
208    /// [`LIGHTING_DIRECTIONAL_VISIBILITY_DISTANCE`] and belongs to
209    /// [`LightingUniforms::shade_with_visibility`], where the caller
210    /// supplies its own geometry.
211    ///
212    /// # Arguments
213    ///
214    /// - `Vector3D` - The world-space position of the shaded point.
215    /// - `&Material` - The material at the shaded point.
216    /// - `&[(Vector3D, f64)]` - `(center, radius)` occluder bounding
217    ///   spheres, one per occluder.
218    ///
219    /// # Returns
220    ///
221    /// - `Vector3D` - The final shaded color.
222    pub fn shade(
223        &self,
224        position: Vector3D,
225        normal: Vector3D,
226        material: &Material,
227        occluders: &[(Vector3D, f64)],
228    ) -> Vector3D {
229        self.shade_with_visibility(
230            position,
231            normal,
232            material,
233            |light: &Light, _dir: Vector3D, _max_distance: f64| -> f64 {
234                match light.get_kind() {
235                    LightType::Directional => 1.0,
236                    LightType::Point | LightType::Spot => {
237                        soft_shadow_factor(position, light.get_position(), occluders)
238                    }
239                }
240            },
241        )
242    }
243
244    /// Shades a surface point, asking `visibility` whether each light is
245    /// unoccluded along the segment to that light.
246    ///
247    /// The entry point for real shadow geometry. The callback receives the
248    /// light, the unit direction from the shaded point toward the light,
249    /// and the maximum distance to cast along that direction, and returns a
250    /// visibility factor that multiplies the light's whole contribution.
251    /// Returning `1.0` for every light reproduces the unshadowed result
252    /// exactly.
253    ///
254    /// ## The callback contract
255    ///
256    /// ```text
257    /// F: FnMut(&Light, surface_to_light_dir: Vector3D, max_distance: f64) -> f64
258    /// ```
259    ///
260    /// - `surface_to_light_dir` is the **normalized** direction from
261    ///   `position` toward the light. For a spot light this points *into*
262    ///   the lamp (it is the negation of the cone axis), matching the
263    ///   shading convention used for every other term — cast your shadow
264    ///   ray along this vector and it runs surface→light.
265    /// - `max_distance` is the far bound for that ray: the true
266    ///   surface→light distance for point and spot lights, so the ray can
267    ///   never reach past the lamp, and
268    ///   [`LIGHTING_DIRECTIONAL_VISIBILITY_DISTANCE`] (1000.0, matching
269    ///   the ray tracer's `RAYTRACE_DEFAULT_T_MAX`) for directional
270    ///   lights, which have no distance of their own.
271    /// - The returned factor is clamped to `0.0..=1.0`.
272    ///
273    /// ## When the callback runs
274    ///
275    /// At most once per light, and only for lights that can actually
276    /// contribute. Lights are skipped before any callback work when they
277    /// carry no intensity, fall outside their spot cone, or face away from
278    /// the surface (`dot(normal, light_dir) <= 0`). Those tests are free,
279    /// and skipping them is the point of the ordering: back-facing and
280    /// out-of-cone lights are the common reject path. A light that is fully
281    /// occluded still costs one callback — only the callback knows the
282    /// geometry.
283    ///
284    /// ## Evaluation order
285    ///
286    /// Per light: cone test → facing test → compute `light_dir`, `dist`,
287    /// and `view_dir` **once** → visibility callback → falloff →
288    /// [`MaterialKind`] dispatch. The [`Light`] itself is only read, never
289    /// cloned: a point or spot light's derived direction and attenuating
290    /// intensity live in locals for the duration of the iteration, so the
291    /// diffuse, specular, and fresnel terms all read the same values.
292    ///
293    /// [`MaterialKind`] selects which terms run per light:
294    /// [`MaterialKind::Lambert`] contributes diffuse only,
295    /// [`MaterialKind::Phong`] adds a Phong specular lobe that is zero
296    /// unless the surface faces the light, and [`MaterialKind::Pbr`]
297    /// scales the diffuse term by a Schlick fresnel factor and adds no
298    /// separate specular lobe.
299    ///
300    /// Ambient is added once and is never shadowed; the material's
301    /// emissive term is added last and is likewise unaffected by
302    /// visibility.
303    ///
304    /// # Arguments
305    ///
306    /// - `Vector3D` - The world-space position of the shaded point.
307    /// - `&Material` - The material at the shaded point.
308    /// - `F` - The visibility provider, called once per contributing
309    ///   light.
310    ///
311    /// # Returns
312    ///
313    /// - `Vector3D` - The final shaded color.
314    pub fn shade_with_visibility<F>(
315        &self,
316        position: Vector3D,
317        normal: Vector3D,
318        material: &Material,
319        mut visibility: F,
320    ) -> Vector3D
321    where
322        F: FnMut(&Light, Vector3D, f64) -> f64,
323    {
324        let mut color: Vector3D = self.get_ambient();
325        let view_dir: Vector3D = unit_vector(self.get_eye() - position);
326        let material_kind: MaterialKind = material.get_kind();
327        for light in self.get_lights().iter() {
328            let kind: LightType = light.get_kind();
329            // Derived once per light and shared by every term below. The
330            // magnitude is taken once, alongside the unit direction, since
331            // both come from the same vector. The `Light` itself is only
332            // read, never cloned or mutated.
333            let (light_dir, distance): (Vector3D, f64) = match kind {
334                LightType::Directional => (light.get_direction(), 0.0),
335                LightType::Point | LightType::Spot => {
336                    unit_vector_and_length(light.get_position() - position)
337                }
338            };
339            // Free rejects first: a light that cannot reach this surface
340            // costs no visibility callback at all.
341            if !light_reaches_position(light, light_dir) {
342                continue;
343            }
344            // A back-facing surface has zero Lambert and zero specular, so
345            // culling here saves a shadow ray per light.
346            if normal.dot(light_dir) <= 0.0 {
347                continue;
348            }
349            // `max_distance` is the **true** surface→light distance, so a
350            // shadow ray can never reach past the lamp. Only the falloff
351            // distance is floored at `LIGHTING_POINT_LIGHT_MIN_DISTANCE`,
352            // to keep a coincident surface from dividing by zero; a
353            // directional light attenuates not at all and its shadow ray
354            // runs to the ray tracer's far bound.
355            let (falloff_factor, shadow_distance): (f64, f64) = match kind {
356                LightType::Directional => (1.0, LIGHTING_DIRECTIONAL_VISIBILITY_DISTANCE),
357                LightType::Point | LightType::Spot => (
358                    apply_falloff(
359                        distance.max(LIGHTING_POINT_LIGHT_MIN_DISTANCE),
360                        light.get_falloff(),
361                    ),
362                    distance,
363                ),
364            };
365            let shadow: f64 = visibility(light, light_dir, shadow_distance).clamp(0.0, 1.0);
366            if shadow <= 0.0 {
367                continue;
368            }
369            // One resolved light, shared by every term below: the diffuse
370            // and specular lobes and the fresnel scale all read this one
371            // colour, the one attenuated intensity, and the one derived
372            // direction, instead of each re-reading the `Light`.
373            let sample: LightSample = LightSample {
374                color: light.get_color(),
375                intensity: light.get_intensity() * falloff_factor,
376                direction: light_dir,
377            };
378            let diffuse: Vector3D = match material_kind {
379                MaterialKind::Pbr => {
380                    let fresnel: f64 = apply_schlick_fresnel(light_dir, view_dir, normal);
381                    lambert_term(&sample, normal, material).scaled(
382                        LIGHTING_PBR_ENERGY_CONSERVATION
383                            + (1.0 - LIGHTING_PBR_ENERGY_CONSERVATION) * fresnel,
384                    )
385                }
386                MaterialKind::Lambert | MaterialKind::Phong => {
387                    lambert_term(&sample, normal, material)
388                }
389            };
390            let specular: Vector3D = match material_kind {
391                MaterialKind::Lambert | MaterialKind::Pbr => Vector3D::zero(),
392                MaterialKind::Phong => phong_specular_term(&sample, normal, view_dir, material),
393            };
394            color += (diffuse + specular).scaled(shadow);
395        }
396        color += material.get_emissive();
397        color
398    }
399}