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}