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}