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/// The returned color is computed as:
7///
8/// ```text
9/// color * intensity * max(0, dot(normal, light_dir)) * albedo
10/// ```
11///
12/// For directional lights, `light_dir` is the unit direction toward the
13/// light. For point and spot lights, `light_dir` is the unit direction from
14/// the surface position to the light position. The light's color and
15/// intensity are merged multiplicatively with the Lambertian cosine term
16/// and the material's albedo. The caller is expected to have already
17/// applied any falloff factor.
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 light_dir: Vector3D = light.get_direction();
30 let cos: f64 = normal.dot(light_dir).max(0.0);
31 let intensity: f64 = light.get_intensity();
32 let color: Vector3D = light.get_color();
33 let albedo: Vector3D = material.get_albedo();
34 let k: f64 = intensity * cos;
35 Vector3D::new(
36 color.get_x() * albedo.get_x() * k,
37 color.get_y() * albedo.get_y() * k,
38 color.get_z() * albedo.get_z() * k,
39 )
40}
41
42/// Computes the Blinn-Phong specular contribution of a single light.
43///
44/// Returns:
45///
46/// ```text
47/// light_color * intensity * pow(max(0, dot(reflect(-l, n), v)), shininess) * specular
48/// ```
49///
50/// where `reflect(-l, n)` is the mirror reflection of the light direction
51/// about the surface normal and `v` is the unit view direction from the
52/// shaded point toward the eye.
53///
54/// # Arguments
55///
56/// - `&Light` - The light source.
57/// - `Vector3D` - The surface normal (unit length).
58/// - `Vector3D` - The unit view direction (from the surface toward the eye).
59/// - `&Material` - The material at the shaded point.
60///
61/// # Returns
62///
63/// - `Vector3D` - The specular contribution of this light.
64pub fn compute_phong(
65 light: &Light,
66 normal: Vector3D,
67 view_dir: Vector3D,
68 material: &Material,
69) -> Vector3D {
70 let light_dir: Vector3D = light.get_direction();
71 let reflect: Vector3D = (light_dir - normal.scaled(2.0 * light_dir.dot(normal))).normalized();
72 let spec_factor: f64 = reflect
73 .dot(view_dir)
74 .max(0.0)
75 .powf(material.get_shininess());
76 let specular: f64 = material.get_specular();
77 let intensity: f64 = light.get_intensity();
78 let color: Vector3D = light.get_color();
79 let k: f64 = intensity * spec_factor * specular;
80 Vector3D::new(color.get_x() * k, color.get_y() * k, color.get_z() * k)
81}
82
83/// Applies the inverse-square falloff formula
84/// `1.0 / (1.0 + falloff * d²)`, clamped to a non-negative result.
85///
86/// # Arguments
87///
88/// - `f64` - The distance from the light source. Negative values are
89/// treated as zero.
90/// - `f64` - The falloff coefficient (0.0 disables falloff).
91///
92/// # Returns
93///
94/// - `f64` - A non-negative scalar in the range 0.0..=1.0.
95pub fn apply_falloff(distance: f64, falloff: f64) -> f64 {
96 let d: f64 = distance.abs();
97 let denom: f64 = 1.0 + falloff * d * d;
98 (1.0 / denom).max(0.0)
99}
100
101/// Computes a Schlick fresnel factor for the [`MaterialKind::Pbr`] branch of
102/// [`LightingUniforms::shade`].
103///
104/// Schlick's approximation is `F = F0 + (1 - F0) * (1 - cos)^5` where
105/// `cos` is the cosine between the surface normal and the half-vector
106/// between the light and the eye, and `F0` is the normal-incidence
107/// reflectance. Returns a scalar in the range
108/// [`LIGHTING_PBR_ENERGY_CONSERVATION`]..=1.0: it is minimal when the
109/// half-vector aligns with the normal and grows as the half-vector turns
110/// away from it, so a lit silhouette picks up a rim.
111///
112/// This is a single-lobe approximation, not a microfacet BRDF: it ignores
113/// roughness, multiple scattering, and per-channel Fresnel, so it should be
114/// read as "diffuse plus a fresnel rim" rather than as a physically based
115/// reflectance model.
116///
117/// # Arguments
118///
119/// - `Vector3D` - The unit direction from the surface toward the light.
120/// - `Vector3D` - The unit view direction (from the surface toward the eye).
121/// - `Vector3D` - The surface normal (unit length).
122///
123/// # Returns
124///
125/// - `f64` - The fresnel reflectance scalar.
126pub fn apply_schlick_fresnel(light_dir: Vector3D, view_dir: Vector3D, normal: Vector3D) -> f64 {
127 let half: Vector3D = light_dir + view_dir;
128 let half_len: f64 = half.magnitude();
129 let cos: f64 = if half_len > EPSILON {
130 normal.dot(half.scaled(1.0 / half_len)).abs().min(1.0)
131 } else {
132 1.0
133 };
134 let base: f64 = LIGHTING_PBR_ENERGY_CONSERVATION;
135 base + (1.0 - base) * (1.0 - cos).powi(5)
136}
137
138/// Intersects a ray with a sphere centered at `center` with radius `radius`.
139///
140/// Uses the standard quadratic-form ray-sphere test. Returns `Some((t, n))`
141/// where `t` is the nearest positive intersection distance along the ray
142/// and `n` is the outward unit normal at the hit point. Returns `None` if
143/// the ray misses.
144///
145/// # Arguments
146///
147/// - `Vector3D` - The ray origin.
148/// - `Vector3D` - The ray direction (expected to be unit length).
149/// - `Vector3D` - The sphere center.
150/// - `f64` - The sphere radius (must be positive).
151///
152/// # Returns
153///
154/// - `Option<(f64, Vector3D)>` - The hit distance and surface normal, or
155/// `None` on miss.
156pub fn ray_sphere_intersect(
157 origin: Vector3D,
158 dir: Vector3D,
159 center: Vector3D,
160 radius: f64,
161) -> Option<(f64, Vector3D)> {
162 let oc: Vector3D = origin - center;
163 let b: f64 = oc.dot(dir);
164 let c: f64 = oc.dot(oc) - radius * radius;
165 let disc: f64 = b * b - c;
166 if disc < 0.0 {
167 return None;
168 }
169 let sq: f64 = disc.sqrt();
170 let t1: f64 = -b - sq;
171 let t2: f64 = -b + sq;
172 let t: f64 = if t1 >= 0.0 {
173 t1
174 } else if t2 >= 0.0 {
175 t2
176 } else {
177 return None;
178 };
179 let hit: Vector3D = origin + dir.scaled(t);
180 let normal: Vector3D = (hit - center).normalized();
181 Some((t, normal))
182}
183
184/// Intersects a ray with an axis-aligned bounding box using the slab method.
185///
186/// # Arguments
187///
188/// - `Vector3D` - The ray origin.
189/// - `Vector3D` - The ray direction (expected to be unit length).
190/// - `Vector3D` - The AABB minimum corner.
191/// - `Vector3D` - The AABB maximum corner.
192///
193/// # Returns
194///
195/// - `Option<(f64, f64, Vector3D)>` - `(t_near, t_far, normal)` on hit, or
196/// `None` if the ray misses or is parallel to the slab.
197pub fn ray_aabb_intersect(
198 origin: Vector3D,
199 dir: Vector3D,
200 aabb_min: Vector3D,
201 aabb_max: Vector3D,
202) -> Option<(f64, f64, Vector3D)> {
203 let inv_dir: Vector3D = Vector3D::new(1.0 / dir.get_x(), 1.0 / dir.get_y(), 1.0 / dir.get_z());
204 let t1x: f64 = (aabb_min.get_x() - origin.get_x()) * inv_dir.get_x();
205 let t2x: f64 = (aabb_max.get_x() - origin.get_x()) * inv_dir.get_x();
206 let t1y: f64 = (aabb_min.get_y() - origin.get_y()) * inv_dir.get_y();
207 let t2y: f64 = (aabb_max.get_y() - origin.get_y()) * inv_dir.get_y();
208 let t1z: f64 = (aabb_min.get_z() - origin.get_z()) * inv_dir.get_z();
209 let t2z: f64 = (aabb_max.get_z() - origin.get_z()) * inv_dir.get_z();
210 let tmin_x: f64 = t1x.min(t2x);
211 let tmax_x: f64 = t1x.max(t2x);
212 let tmin_y: f64 = t1y.min(t2y);
213 let tmax_y: f64 = t1y.max(t2y);
214 let tmin_z: f64 = t1z.min(t2z);
215 let tmax_z: f64 = t1z.max(t2z);
216 let t_near: f64 = tmin_x.max(tmin_y).max(tmin_z);
217 let t_far: f64 = tmax_x.min(tmax_y).min(tmax_z);
218 if t_near > t_far || t_far < 0.0 {
219 return None;
220 }
221 let hit: Vector3D = origin + dir.scaled(t_near);
222 let cx: f64 = (aabb_min.get_x() + aabb_max.get_x()) * 0.5;
223 let cy: f64 = (aabb_min.get_y() + aabb_max.get_y()) * 0.5;
224 let cz: f64 = (aabb_min.get_z() + aabb_max.get_z()) * 0.5;
225 let dx: f64 = hit.get_x() - cx;
226 let dy: f64 = hit.get_y() - cy;
227 let dz: f64 = hit.get_z() - cz;
228 let ex: f64 = (aabb_max.get_x() - aabb_min.get_x()) * 0.5;
229 let ey: f64 = (aabb_max.get_y() - aabb_min.get_y()) * 0.5;
230 let ez: f64 = (aabb_max.get_z() - aabb_min.get_z()) * 0.5;
231 let ax: f64 = dx.abs() / ex.max(EPSILON);
232 let ay: f64 = dy.abs() / ey.max(EPSILON);
233 let az: f64 = dz.abs() / ez.max(EPSILON);
234 let normal: Vector3D = if ax >= ay && ax >= az {
235 Vector3D::new(dx.signum(), 0.0, 0.0)
236 } else if ay >= az {
237 Vector3D::new(0.0, dy.signum(), 0.0)
238 } else {
239 Vector3D::new(0.0, 0.0, dz.signum())
240 };
241 Some((t_near, t_far, normal))
242}
243
244/// Computes a soft-shadow visibility factor in the range 0.0..=1.0.
245///
246/// Casts a single ray from `origin` toward `light_pos` and checks whether
247/// any sphere in `occluders` blocks the path. Returns 1.0 when no occluder
248/// is intersected, otherwise returns 0.0 (binary shadow). A future
249/// refinement could sample multiple rays to approximate penumbra.
250///
251/// # Arguments
252///
253/// - `Vector3D` - The surface point casting the shadow ray.
254/// - `Vector3D` - The light position to test against.
255/// - `&[(Vector3D, f64)]` - A slice of `(center, radius)` occluder spheres.
256///
257/// # Returns
258///
259/// - `f64` - 1.0 if the light is visible, 0.0 if fully occluded.
260pub fn soft_shadow_factor(
261 origin: Vector3D,
262 light_pos: Vector3D,
263 occluders: &[(Vector3D, f64)],
264) -> f64 {
265 let to_light: Vector3D = light_pos - origin;
266 let dist: f64 = to_light.magnitude();
267 if dist < EPSILON {
268 return 1.0;
269 }
270 let dir: Vector3D = to_light.scaled(1.0 / dist);
271 for &(center, radius) in occluders.iter() {
272 if let Some((t, _)) = ray_sphere_intersect(origin, dir, center, radius)
273 && t > EPSILON
274 && t < dist - EPSILON
275 {
276 return 0.0;
277 }
278 }
279 1.0
280}