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