Skip to main content

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