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