euv_engine/lighting/fn.rs
1use super::*;
2
3/// Computes the Lambertian (diffuse) contribution of a single light at a
4/// shaded surface point.
5///
6/// ```text
7/// color * intensity * albedo * max(0, dot(normal, light_dir))
8/// ```
9///
10/// `light_dir` is the unit direction from the surface toward the light;
11/// any falloff must already be folded into a scaled intensity by the
12/// caller.
13///
14/// A thin wrapper over [`lambert_term`], which takes the same quantities
15/// unpacked instead of through a [`Light`], so a direct call and the same
16/// light routed through [`LightingUniforms::shade_with_visibility`] share
17/// one implementation.
18///
19/// # Arguments
20///
21/// - `&Light` - The light source being evaluated.
22/// - `Vector3D` - The surface normal (expected to be unit length).
23/// - `&Material` - The material at the shaded point.
24///
25/// # Returns
26///
27/// - `Vector3D` - The diffuse contribution of this light.
28pub fn compute_lambert(light: &Light, normal: Vector3D, material: &Material) -> Vector3D {
29 lambert_term(
30 light.get_color(),
31 light.get_intensity(),
32 light.get_direction(),
33 normal,
34 material.get_albedo(),
35 )
36}
37
38/// The Lambertian term shared by [`compute_lambert`] and
39/// [`LightingUniforms::shade_with_visibility`].
40///
41/// Unpacking the light into its colour, intensity, and direction is what
42/// lets the shading loop evaluate a light once — one distance, one
43/// direction, one falloff — instead of building a mutated copy of the
44/// [`Light`] per term.
45///
46/// # Arguments
47///
48/// - `Vector3D` - The light's RGB color.
49/// - `f64` - The light's intensity, already scaled by any falloff.
50/// - `Vector3D` - The unit direction from the surface toward the light.
51/// - `Vector3D` - The surface normal (unit length).
52/// - `Vector3D` - The material's diffuse albedo.
53///
54/// # Returns
55///
56/// - `Vector3D` - `color * albedo * intensity * max(0, dot(normal, light_dir))`.
57pub(crate) fn lambert_term(
58 light_color: Vector3D,
59 light_intensity: f64,
60 light_dir: Vector3D,
61 normal: Vector3D,
62 albedo: Vector3D,
63) -> Vector3D {
64 let cos: f64 = normal.dot(light_dir).max(0.0);
65 let k: f64 = light_intensity * cos;
66 Vector3D::new(
67 light_color.get_x() * albedo.get_x() * k,
68 light_color.get_y() * albedo.get_y() * k,
69 light_color.get_z() * albedo.get_z() * k,
70 )
71}
72
73/// Computes the Phong specular contribution of a single light.
74///
75/// ```text
76/// color * intensity * specular * pow(max(0, dot(reflect(-l, n), v)), shininess)
77/// ```
78///
79/// where `l` is the unit direction from the surface toward the light, `n`
80/// the surface normal, and `v` the unit view direction from the shaded
81/// point toward the eye. A thin wrapper over [`phong_specular_term`], so
82/// a direct call and the same light routed through
83/// [`LightingUniforms::shade_with_visibility`] share one implementation.
84///
85/// # Arguments
86///
87/// - `&Light` - The light source. Its `direction` must point from the
88/// surface toward the light.
89/// - `Vector3D` - The surface normal (unit length).
90/// - `Vector3D` - The unit view direction (from the surface toward the eye).
91/// - `&Material` - The material at the shaded point.
92///
93/// # Returns
94///
95/// - `Vector3D` - The specular contribution of this light.
96pub fn compute_phong(
97 light: &Light,
98 normal: Vector3D,
99 view_dir: Vector3D,
100 material: &Material,
101) -> Vector3D {
102 phong_specular_term(
103 light.get_color(),
104 light.get_intensity(),
105 light.get_direction(),
106 normal,
107 view_dir,
108 material.get_specular(),
109 material.get_shininess(),
110 )
111}
112
113/// The Phong specular term shared by [`compute_phong`] and
114/// [`LightingUniforms::shade_with_visibility`]. Returns the zero vector
115/// unless the surface faces the light, so the lobe cannot survive on the
116/// back side of a surface.
117///
118/// # Arguments
119///
120/// - `Vector3D` - The light's RGB color.
121/// - `f64` - The light's intensity, already scaled by any falloff.
122/// - `Vector3D` - The unit direction from the surface toward the light.
123/// - `Vector3D` - The surface normal (unit length).
124/// - `Vector3D` - The unit view direction (from the surface toward the eye).
125/// - `f64` - The material's specular intensity.
126/// - `f64` - The Phong specular exponent.
127///
128/// # Returns
129///
130/// - `Vector3D` - `color * intensity * specular * spec_factor`.
131pub(crate) fn phong_specular_term(
132 light_color: Vector3D,
133 light_intensity: f64,
134 light_dir: Vector3D,
135 normal: Vector3D,
136 view_dir: Vector3D,
137 material_specular: f64,
138 shininess: f64,
139) -> Vector3D {
140 let spec_factor: f64 = phong_specular_factor(light_dir, normal, view_dir, shininess);
141 let k: f64 = light_intensity * spec_factor * material_specular;
142 Vector3D::new(
143 light_color.get_x() * k,
144 light_color.get_y() * k,
145 light_color.get_z() * k,
146 )
147}
148
149/// Computes the Phong specular factor in the range 0.0..=1.0.
150///
151/// The factor is `pow(max(0, dot(reflect(-l, n), v)), shininess)`.
152///
153/// ## Why `-l` is reflected, not `l`
154///
155/// `reflect(i, n) = i - 2 * dot(i, n) * n` mirrors an **incident** ray `i`
156/// about the normal. The incident ray travels from the light into the
157/// surface, so it is `-l`, and the expansion is
158///
159/// ```text
160/// reflect(-l, n) = -l + 2 * dot(n, l) * n
161/// ```
162///
163/// Reflecting `l` instead yields a vector pointing back at the lamp, so
164/// `dot(reflect, v)` peaks only where the eye is *behind* the surface and
165/// the highlight lands on the unlit side.
166///
167/// With `n` and `l` both unit length the reflection is already unit
168/// length — `|2 * n * d - l|² = 4d² - 4d² + 1 = 1` for `d = dot(n, l)` —
169/// so this needs no normalization, no division, and has no degenerate
170/// case when `l` opposes `n`. The lobe peaks at `v == 2 * n * dot(n, l) -
171/// l`, i.e. off-axis unless the light is exactly head-on.
172///
173/// Returns `0.0` — never `NaN` — when `dot(n, l) <= 0.0`: a surface lit
174/// from behind must not flash a highlight.
175///
176/// # Arguments
177///
178/// - `Vector3D` - The unit direction from the surface toward the light.
179/// - `Vector3D` - The surface normal (unit length).
180/// - `Vector3D` - The unit view direction (from the surface toward the eye).
181/// - `f64` - The Phong specular exponent.
182///
183/// # Returns
184///
185/// - `f64` - The specular factor.
186pub(crate) fn phong_specular_factor(
187 light_dir: Vector3D,
188 normal: Vector3D,
189 view_dir: Vector3D,
190 shininess: f64,
191) -> f64 {
192 if normal.dot(light_dir) <= 0.0 {
193 return 0.0;
194 }
195 let reflected: Vector3D = normal.scaled(2.0 * normal.dot(light_dir)) - light_dir;
196 reflected.dot(view_dir).max(0.0).powf(shininess)
197}
198
199/// Applies the inverse-square falloff formula
200/// `1.0 / (1.0 + falloff * d²)`, clamped to a non-negative result.
201///
202/// # Arguments
203///
204/// - `f64` - The distance from the light source. Negative values are
205/// treated as zero.
206/// - `f64` - The falloff coefficient (0.0 disables falloff).
207///
208/// # Returns
209///
210/// - `f64` - A non-negative scalar in the range 0.0..=1.0.
211pub fn apply_falloff(distance: f64, falloff: f64) -> f64 {
212 let d: f64 = distance.abs();
213 let denom: f64 = 1.0 + falloff * d * d;
214 (1.0 / denom).max(0.0)
215}
216
217/// Computes a Schlick fresnel factor for the [`MaterialKind::Pbr`] branch of
218/// [`LightingUniforms::shade`].
219///
220/// Schlick's approximation is `F = F0 + (1 - F0) * (1 - cos)^5` where
221/// `cos` is the cosine between the surface normal and the half-vector
222/// between the light and the eye, and `F0` is the normal-incidence
223/// reflectance. Returns a scalar in the range
224/// [`LIGHTING_PBR_ENERGY_CONSERVATION`]..=1.0: it is minimal when the
225/// half-vector aligns with the normal and grows as the half-vector turns
226/// away from it, so a lit silhouette picks up a rim.
227///
228/// This is a single-lobe approximation, not a microfacet BRDF: it ignores
229/// roughness, multiple scattering, and per-channel Fresnel, so it should be
230/// read as "diffuse plus a fresnel rim" rather than as a physically based
231/// reflectance model.
232///
233/// # Arguments
234///
235/// - `Vector3D` - The unit direction from the surface toward the light.
236/// - `Vector3D` - The unit view direction (from the surface toward the eye).
237/// - `Vector3D` - The surface normal (unit length).
238///
239/// # Returns
240///
241/// - `f64` - The fresnel reflectance scalar.
242pub fn apply_schlick_fresnel(light_dir: Vector3D, view_dir: Vector3D, normal: Vector3D) -> f64 {
243 let half: Vector3D = light_dir + view_dir;
244 let half_len: f64 = half.magnitude();
245 let cos: f64 = if half_len > EPSILON {
246 normal.dot(half.scaled(1.0 / half_len)).abs().min(1.0)
247 } else {
248 1.0
249 };
250 let base: f64 = LIGHTING_PBR_ENERGY_CONSERVATION;
251 base + (1.0 - base) * (1.0 - cos).powi(5)
252}
253
254/// Returns the unit-length form of `vector`, or the zero vector when it is
255/// too short to have a meaningful direction.
256///
257/// The threshold is [`EPSILON`], so a degenerate vector normalises to
258/// zero rather than to `NaN` from a division by a vanishing magnitude.
259/// A zero direction is safe downstream: it fails both the facing test and
260/// the cone test.
261///
262/// # Arguments
263///
264/// - `Vector3D` - The vector to normalize.
265///
266/// # Returns
267///
268/// - `Vector3D` - The unit vector, or the zero vector at or below [`EPSILON`].
269pub(crate) fn unit_vector(vector: Vector3D) -> Vector3D {
270 unit_vector_and_length(vector).0
271}
272
273/// Returns `(unit(vector), length)` while taking the magnitude **once**.
274///
275/// Shading needs both the unit direction and the original length of the
276/// same vector; measuring it twice would duplicate the square root on the
277/// hot path. A vector at or below [`EPSILON`] yields
278/// `(zero, 0.0)`.
279///
280/// # Arguments
281///
282/// - `Vector3D` - The vector to normalize.
283///
284/// # Returns
285///
286/// - `(Vector3D, f64)` - The unit vector and the vector's own length.
287pub(crate) fn unit_vector_and_length(vector: Vector3D) -> (Vector3D, f64) {
288 let length: f64 = vector.magnitude();
289 if length <= EPSILON {
290 return (Vector3D::zero(), 0.0);
291 }
292 (vector.scaled(1.0 / length), length)
293}
294
295/// Returns whether `light` can reach a surface point at all, before any
296/// visibility query: the spot cone test plus a zero-intensity reject.
297///
298/// These are the only two rejects a caller can make without geometry, so
299/// [`LightingUniforms::shade_with_visibility`] applies them before
300/// invoking the visibility callback.
301///
302/// ## Why the cone test compares against `-spot_cos`
303///
304/// A spot light stores `direction` as the cone axis pointing **outward**
305/// along the beam, i.e. the way the light travels. `light_dir` points the
306/// other way, from the surface **into** the lamp, so for a surface on the
307/// axis `dot(light_dir, axis)` is `-1.0`, not `+1.0`. Being inside the cone
308/// is therefore
309///
310/// ```text
311/// dot(light_dir, axis) <= -cos(half_angle) == -spot_cos
312/// ```
313///
314/// Testing `dot(light_dir, axis) >= spot_cos` instead would invert the
315/// cone: every point on the beam is rejected and every point behind the
316/// lamp is lit.
317///
318/// When the surface sits exactly on the lamp `light_dir` is zero, whose dot
319/// product with any axis is `0.0`; for a cone of half-angle 90° or narrower
320/// that would read as out of cone. The degenerate case is accepted
321/// explicitly — a surface coincident with a lamp sits at the cone's apex
322/// and is inside it by definition — and the zero `light_dir` makes the
323/// facing test reject it regardless.
324///
325/// # Arguments
326///
327/// - `&Light` - The light being tested.
328/// - `Vector3D` - The normalized direction from the surface toward the light.
329///
330/// # Returns
331///
332/// - `bool` - `false` when the light is out of cone or carries no intensity.
333pub(crate) fn light_reaches_position(light: &Light, light_dir: Vector3D) -> bool {
334 if light.get_intensity() <= 0.0 {
335 return false;
336 }
337 if light.get_kind() != LightType::Spot {
338 return true;
339 }
340 if light_dir == Vector3D::zero() {
341 return true;
342 }
343 light_dir.dot(light.get_direction()) <= -light.get_spot_cos()
344}
345
346/// Intersects a ray with a sphere centered at `center` with radius `radius`.
347///
348/// Uses the standard quadratic-form ray-sphere test.
349///
350/// # Arguments
351///
352/// - `Vector3D` - The ray origin.
353/// - `Vector3D` - The ray direction (expected to be unit length).
354/// - `Vector3D` - The sphere center.
355/// - `f64` - The sphere radius (must be positive).
356///
357/// # Returns
358///
359/// - `Option<(f64, Vector3D)>` - The hit distance and surface normal, or
360/// `None` on miss.
361pub fn ray_sphere_intersect(
362 origin: Vector3D,
363 dir: Vector3D,
364 center: Vector3D,
365 radius: f64,
366) -> Option<(f64, Vector3D)> {
367 let oc: Vector3D = origin - center;
368 let b: f64 = oc.dot(dir);
369 let c: f64 = oc.dot(oc) - radius * radius;
370 let disc: f64 = b * b - c;
371 if disc < 0.0 {
372 return None;
373 }
374 let sq: f64 = disc.sqrt();
375 let t1: f64 = -b - sq;
376 let t2: f64 = -b + sq;
377 let t: f64 = if t1 >= 0.0 {
378 t1
379 } else if t2 >= 0.0 {
380 t2
381 } else {
382 return None;
383 };
384 let hit: Vector3D = origin + dir.scaled(t);
385 let normal: Vector3D = (hit - center).normalized();
386 Some((t, normal))
387}
388
389/// Intersects a ray with an axis-aligned bounding box using the slab method.
390///
391/// # Arguments
392///
393/// - `Vector3D` - The ray origin.
394/// - `Vector3D` - The ray direction (expected to be unit length).
395/// - `Vector3D` - The AABB minimum corner.
396/// - `Vector3D` - The AABB maximum corner.
397///
398/// # Returns
399///
400/// - `Option<(f64, f64, Vector3D)>` - `(t_near, t_far, normal)` on hit, or
401/// `None` if the ray misses or is parallel to the slab.
402pub fn ray_aabb_intersect(
403 origin: Vector3D,
404 dir: Vector3D,
405 aabb_min: Vector3D,
406 aabb_max: Vector3D,
407) -> Option<(f64, f64, Vector3D)> {
408 let inv_dir: Vector3D = Vector3D::new(1.0 / dir.get_x(), 1.0 / dir.get_y(), 1.0 / dir.get_z());
409 let t1x: f64 = (aabb_min.get_x() - origin.get_x()) * inv_dir.get_x();
410 let t2x: f64 = (aabb_max.get_x() - origin.get_x()) * inv_dir.get_x();
411 let t1y: f64 = (aabb_min.get_y() - origin.get_y()) * inv_dir.get_y();
412 let t2y: f64 = (aabb_max.get_y() - origin.get_y()) * inv_dir.get_y();
413 let t1z: f64 = (aabb_min.get_z() - origin.get_z()) * inv_dir.get_z();
414 let t2z: f64 = (aabb_max.get_z() - origin.get_z()) * inv_dir.get_z();
415 let tmin_x: f64 = t1x.min(t2x);
416 let tmax_x: f64 = t1x.max(t2x);
417 let tmin_y: f64 = t1y.min(t2y);
418 let tmax_y: f64 = t1y.max(t2y);
419 let tmin_z: f64 = t1z.min(t2z);
420 let tmax_z: f64 = t1z.max(t2z);
421 let t_near: f64 = tmin_x.max(tmin_y).max(tmin_z);
422 let t_far: f64 = tmax_x.min(tmax_y).min(tmax_z);
423 if t_near > t_far || t_far < 0.0 {
424 return None;
425 }
426 let hit: Vector3D = origin + dir.scaled(t_near);
427 let cx: f64 = (aabb_min.get_x() + aabb_max.get_x()) * 0.5;
428 let cy: f64 = (aabb_min.get_y() + aabb_max.get_y()) * 0.5;
429 let cz: f64 = (aabb_min.get_z() + aabb_max.get_z()) * 0.5;
430 let dx: f64 = hit.get_x() - cx;
431 let dy: f64 = hit.get_y() - cy;
432 let dz: f64 = hit.get_z() - cz;
433 let ex: f64 = (aabb_max.get_x() - aabb_min.get_x()) * 0.5;
434 let ey: f64 = (aabb_max.get_y() - aabb_min.get_y()) * 0.5;
435 let ez: f64 = (aabb_max.get_z() - aabb_min.get_z()) * 0.5;
436 let ax: f64 = dx.abs() / ex.max(EPSILON);
437 let ay: f64 = dy.abs() / ey.max(EPSILON);
438 let az: f64 = dz.abs() / ez.max(EPSILON);
439 let normal: Vector3D = if ax >= ay && ax >= az {
440 Vector3D::new(dx.signum(), 0.0, 0.0)
441 } else if ay >= az {
442 Vector3D::new(0.0, dy.signum(), 0.0)
443 } else {
444 Vector3D::new(0.0, 0.0, dz.signum())
445 };
446 Some((t_near, t_far, normal))
447}
448
449/// Computes a visibility factor in the range 0.0..=1.0 for the segment from
450/// `origin` to `light_pos`.
451///
452/// Despite the historical name this is **binary**, not soft: `1.0` when
453/// nothing blocks the segment, `0.0` when anything does. No penumbra is
454/// sampled. Callers that hold real geometry should use
455/// [`LightingUniforms::shade_with_visibility`] with a callback instead;
456/// this is the bounded fallback for callers holding only
457/// `(center, radius)` bounding spheres, which necessarily over-shadow a
458/// ray passing through empty space beside an occluder.
459///
460/// # Arguments
461///
462/// - `Vector3D` - The surface point casting the shadow ray.
463/// - `Vector3D` - The light position to test against.
464/// - `&[(Vector3D, f64)]` - A slice of `(center, radius)` occluder spheres.
465///
466/// # Returns
467///
468/// - `f64` - 1.0 if the light is visible, 0.0 if fully occluded.
469pub fn soft_shadow_factor(
470 origin: Vector3D,
471 light_pos: Vector3D,
472 occluders: &[(Vector3D, f64)],
473) -> f64 {
474 let to_light: Vector3D = light_pos - origin;
475 let dist: f64 = to_light.magnitude();
476 if dist < EPSILON {
477 return 1.0;
478 }
479 let dir: Vector3D = to_light.scaled(1.0 / dist);
480 for &(center, radius) in occluders.iter() {
481 if segment_crosses_sphere(origin, dir, dist, center, radius) {
482 return 0.0;
483 }
484 }
485 1.0
486}
487
488/// Returns whether either ray-sphere root lands strictly inside the open
489/// segment `(EPSILON, dist - EPSILON)`.
490///
491/// **Both roots are tested, not just the near one.** A shadow ray is very
492/// often cast from the surface point of one of the occluders themselves:
493/// for that ray the near root sits at `t ≈ 0`, inside the [`EPSILON`]
494/// self-hit threshold, while the far root sits at `t ≈ 2r`. Testing only
495/// the nearest root would report the light as visible through the very
496/// sphere the ray starts inside.
497///
498/// # Arguments
499///
500/// - `Vector3D` - The segment start (the shaded point).
501/// - `Vector3D` - The unit direction toward the light.
502/// - `f64` - The distance from the segment start to the light.
503/// - `Vector3D` - The sphere center.
504/// - `f64` - The sphere radius.
505///
506/// # Returns
507///
508/// - `bool` - `true` when the segment passes through the sphere's interior.
509pub(crate) fn segment_crosses_sphere(
510 origin: Vector3D,
511 dir: Vector3D,
512 dist: f64,
513 center: Vector3D,
514 radius: f64,
515) -> bool {
516 let oc: Vector3D = origin - center;
517 let b: f64 = oc.dot(dir);
518 let c: f64 = oc.dot(oc) - radius * radius;
519 let disc: f64 = b * b - c;
520 if disc < 0.0 {
521 return false;
522 }
523 let sq: f64 = disc.sqrt();
524 let near: f64 = -b - sq;
525 let far: f64 = -b + sq;
526 let inside: f64 = EPSILON;
527 let outside: f64 = dist - EPSILON;
528 (near > inside && near < outside) || (far > inside && far < outside)
529}