Skip to main content

proof_engine/render/pbr/
brdf.rs

1//! Complete BRDF (Bidirectional Reflectance Distribution Function) library.
2//!
3//! Pure CPU math — no GPU types.  All functions use `glam::{Vec2, Vec3}` and
4//! bare `f32`.  Organised into:
5//!
6//! * [`distribution`] — microfacet normal-distribution functions (D)
7//! * [`geometry`]     — masking/shadowing functions (G)
8//! * [`fresnel`]      — Fresnel reflectance functions (F)
9//! * [`vec3`]         — Vec3 helpers used throughout
10//! * [`brdf`]         — full BRDF evaluators (Cook-Torrance, Lambertian, …)
11//! * [`ibl`]          — image-based lighting helpers
12//! * [`lights`]       — light types and shading loop
13//! * [`tonemap`]      — tone-mapping operators
14//! * [`glsl`]         — GLSL source generation
15
16use glam::{Vec2, Vec3};
17use std::f32::consts::{FRAC_1_PI, PI};
18
19// ─────────────────────────────────────────────────────────────────────────────
20// Vec3 helpers
21// ─────────────────────────────────────────────────────────────────────────────
22
23/// Lightweight Vec3 helper functions used throughout the BRDF library.
24pub mod vec3 {
25    use glam::Vec3;
26
27    /// Dot product, clamped to `[0, 1]`.
28    #[inline]
29    pub fn dot_clamp(a: Vec3, b: Vec3) -> f32 {
30        a.dot(b).clamp(0.0, 1.0)
31    }
32
33    /// Dot product, clamped to `[0 + epsilon, 1]` to avoid division by zero.
34    #[inline]
35    pub fn dot_clamp_eps(a: Vec3, b: Vec3) -> f32 {
36        a.dot(b).clamp(1e-5, 1.0)
37    }
38
39    /// Reflect `v` around `n`.
40    #[inline]
41    pub fn reflect(v: Vec3, n: Vec3) -> Vec3 {
42        v - 2.0 * v.dot(n) * n
43    }
44
45    /// Refract `v` through a surface with relative IOR `eta`.
46    /// Returns `None` on total internal reflection.
47    pub fn refract(v: Vec3, n: Vec3, eta: f32) -> Option<Vec3> {
48        let cos_i = (-v).dot(n);
49        let sin2_t = eta * eta * (1.0 - cos_i * cos_i);
50        if sin2_t >= 1.0 {
51            return None; // TIR
52        }
53        let cos_t = (1.0 - sin2_t).sqrt();
54        Some(eta * v + (eta * cos_i - cos_t) * n)
55    }
56
57    /// Schlick Fresnel approximation (scalar version).
58    #[inline]
59    pub fn schlick_scalar(cos_theta: f32, f0: f32) -> f32 {
60        f0 + (1.0 - f0) * (1.0 - cos_theta).powi(5)
61    }
62
63    /// Construct a local orthonormal basis (tangent `t`, bitangent `b`) from
64    /// surface normal `n` using the Duff et al. 2017 method.
65    pub fn orthonormal_basis(n: Vec3) -> (Vec3, Vec3) {
66        // Duff, Tom et al., "Building an Orthonormal Basis, Revisited", JCGT 2017
67        let sign = if n.z >= 0.0 { 1.0_f32 } else { -1.0_f32 };
68        let a = -1.0 / (sign + n.z);
69        let b = n.x * n.y * a;
70        let t = Vec3::new(1.0 + sign * n.x * n.x * a, sign * b, -sign * n.x);
71        let bi = Vec3::new(b, sign + n.y * n.y * a, -n.y);
72        (t, bi)
73    }
74
75    /// Spherical direction to cartesian.
76    #[inline]
77    pub fn spherical_to_cartesian(sin_theta: f32, cos_theta: f32, phi: f32) -> Vec3 {
78        Vec3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta)
79    }
80
81    /// Cartesian to spherical (theta, phi).
82    #[inline]
83    pub fn cartesian_to_spherical(v: Vec3) -> (f32, f32) {
84        let theta = v.z.clamp(-1.0, 1.0).acos();
85        let phi = v.y.atan2(v.x);
86        (theta, phi)
87    }
88
89    /// Saturate (clamp to [0, 1]).
90    #[inline]
91    pub fn saturate(v: Vec3) -> Vec3 {
92        v.clamp(Vec3::ZERO, Vec3::ONE)
93    }
94}
95
96// ─────────────────────────────────────────────────────────────────────────────
97// Distribution functions (D)
98// ─────────────────────────────────────────────────────────────────────────────
99
100/// Microfacet normal-distribution functions.
101///
102/// All functions return D(h) — the density of microfacets with half-vector h.
103pub mod distribution {
104    use std::f32::consts::PI;
105
106    /// GGX / Trowbridge-Reitz NDF.
107    ///
108    /// `n_dot_h` — cos angle between normal and half-vector (clamped to ≥ 0).
109    /// `roughness` — perceptual roughness (will be squared to get α²).
110    pub fn ggx_d(n_dot_h: f32, roughness: f32) -> f32 {
111        let alpha = roughness * roughness;
112        let alpha2 = alpha * alpha;
113        let n_dot_h = n_dot_h.max(1e-5);
114        let d = (n_dot_h * n_dot_h) * (alpha2 - 1.0) + 1.0;
115        alpha2 / (PI * d * d)
116    }
117
118    /// Beckmann NDF — older model, matches Blinn-Phong at certain roughness.
119    pub fn beckmann_d(n_dot_h: f32, roughness: f32) -> f32 {
120        let alpha = roughness * roughness;
121        let alpha2 = alpha * alpha;
122        let n_dot_h = n_dot_h.max(1e-5);
123        let cos2 = n_dot_h * n_dot_h;
124        let tan2 = (1.0 - cos2) / (cos2 + 1e-10);
125        (-tan2 / alpha2).exp() / (PI * alpha2 * cos2 * cos2)
126    }
127
128    /// Blinn-Phong NDF parameterised by shininess.
129    pub fn blinn_phong_d(n_dot_h: f32, shininess: f32) -> f32 {
130        let n_dot_h = n_dot_h.max(0.0);
131        (shininess + 2.0) / (2.0 * PI) * n_dot_h.powf(shininess)
132    }
133
134    /// Anisotropic GGX NDF.
135    ///
136    /// * `h_dot_x` — dot of half-vector with tangent direction
137    /// * `h_dot_y` — dot of half-vector with bitangent direction
138    /// * `ax`, `ay` — roughness along x and y tangent axes
139    pub fn anisotropic_ggx_d(
140        n_dot_h: f32,
141        h_dot_x: f32,
142        h_dot_y: f32,
143        ax: f32,
144        ay: f32,
145    ) -> f32 {
146        let ax = ax.max(1e-4);
147        let ay = ay.max(1e-4);
148        let hx = h_dot_x / ax;
149        let hy = h_dot_y / ay;
150        let n = n_dot_h.max(1e-5);
151        let denom = hx * hx + hy * hy + n * n;
152        1.0 / (PI * ax * ay * denom * denom)
153    }
154
155    /// Phong NDF — converts Phong shininess to roughness-compatible form.
156    pub fn phong_d(n_dot_h: f32, roughness: f32) -> f32 {
157        let shininess = 2.0 / (roughness * roughness).max(1e-5) - 2.0;
158        blinn_phong_d(n_dot_h, shininess.max(0.0))
159    }
160
161    /// Ward anisotropic NDF.
162    pub fn ward_d(
163        n_dot_h: f32,
164        h_dot_x: f32,
165        h_dot_y: f32,
166        ax: f32,
167        ay: f32,
168        n_dot_l: f32,
169        n_dot_v: f32,
170    ) -> f32 {
171        let ax = ax.max(1e-4);
172        let ay = ay.max(1e-4);
173        let n = n_dot_h.max(1e-5);
174        let nl = n_dot_l.max(1e-5);
175        let nv = n_dot_v.max(1e-5);
176        let exponent = -((h_dot_x / ax).powi(2) + (h_dot_y / ay).powi(2)) / (n * n);
177        exponent.exp() / (4.0 * std::f32::consts::PI * ax * ay * (nl * nv).sqrt())
178    }
179}
180
181// ─────────────────────────────────────────────────────────────────────────────
182// Geometry / masking functions (G)
183// ─────────────────────────────────────────────────────────────────────────────
184
185/// Microfacet masking-shadowing functions.
186pub mod geometry {
187    /// Schlick approximation of the GGX G1 term.
188    ///
189    /// `k` should be `roughness² / 2` for direct lighting, or
190    /// `(roughness + 1)² / 8` for IBL.
191    #[inline]
192    pub fn schlick_ggx_g1(n_dot_v: f32, k: f32) -> f32 {
193        let n_dot_v = n_dot_v.max(0.0);
194        n_dot_v / (n_dot_v * (1.0 - k) + k + 1e-7)
195    }
196
197    /// Smith GGX masking-shadowing — product of two G1 terms (view + light).
198    pub fn smith_ggx(n_dot_v: f32, n_dot_l: f32, roughness: f32) -> f32 {
199        let k = (roughness + 1.0).powi(2) / 8.0;
200        schlick_ggx_g1(n_dot_v, k) * schlick_ggx_g1(n_dot_l, k)
201    }
202
203    /// Smith GGX — IBL variant uses `k = α² / 2`.
204    pub fn smith_ggx_ibl(n_dot_v: f32, n_dot_l: f32, roughness: f32) -> f32 {
205        let k = (roughness * roughness) / 2.0;
206        schlick_ggx_g1(n_dot_v, k) * schlick_ggx_g1(n_dot_l, k)
207    }
208
209    /// Smith geometry term using the Beckmann distribution.
210    pub fn smith_beckmann(n_dot_v: f32, n_dot_l: f32, roughness: f32) -> f32 {
211        let alpha = roughness * roughness;
212
213        fn g1(n_dot: f32, alpha: f32) -> f32 {
214            let c = n_dot / (alpha * (1.0 - n_dot * n_dot).max(0.0).sqrt());
215            if c >= 1.6 {
216                1.0
217            } else {
218                (3.535 * c + 2.181 * c * c) / (1.0 + 2.276 * c + 2.577 * c * c)
219            }
220        }
221
222        g1(n_dot_v.max(1e-5), alpha) * g1(n_dot_l.max(1e-5), alpha)
223    }
224
225    /// Kelemen-Szirmay-Kalos simplified G term.
226    pub fn kelemen_szirmay_kalos_g(n_dot_v: f32, n_dot_l: f32) -> f32 {
227        let v = n_dot_v.max(1e-5);
228        let l = n_dot_l.max(1e-5);
229        (v * l) / (v + l - v * l)
230    }
231
232    /// Implicit (uncorrelated) geometry term — `G = NdotV * NdotL`.
233    #[inline]
234    pub fn implicit_g(n_dot_v: f32, n_dot_l: f32) -> f32 {
235        n_dot_v.max(0.0) * n_dot_l.max(0.0)
236    }
237
238    /// Neumann geometry term.
239    pub fn neumann_g(n_dot_v: f32, n_dot_l: f32) -> f32 {
240        let v = n_dot_v.max(1e-5);
241        let l = n_dot_l.max(1e-5);
242        (v * l) / v.max(l)
243    }
244
245    /// Cook-Torrance masking term.
246    pub fn cook_torrance_g(n_dot_v: f32, n_dot_l: f32, n_dot_h: f32, v_dot_h: f32) -> f32 {
247        let v = n_dot_v.max(1e-5);
248        let l = n_dot_l.max(1e-5);
249        let h = n_dot_h.max(1e-5);
250        let vdh = v_dot_h.max(1e-5);
251        let t1 = 2.0 * h * v / vdh;
252        let t2 = 2.0 * h * l / vdh;
253        t1.min(t2).min(1.0)
254    }
255}
256
257// ─────────────────────────────────────────────────────────────────────────────
258// Fresnel functions (F)
259// ─────────────────────────────────────────────────────────────────────────────
260
261/// Fresnel reflectance functions.
262pub mod fresnel {
263    use glam::Vec3;
264
265    /// Schlick Fresnel approximation for a coloured F0.
266    #[inline]
267    pub fn schlick_f(cos_theta: f32, f0: Vec3) -> Vec3 {
268        let t = (1.0 - cos_theta.clamp(0.0, 1.0)).powi(5);
269        f0 + (Vec3::ONE - f0) * t
270    }
271
272    /// Schlick Fresnel with roughness-modulated F0 — used in IBL.
273    pub fn schlick_roughness_f(cos_theta: f32, f0: Vec3, roughness: f32) -> Vec3 {
274        let t = (1.0 - cos_theta.clamp(0.0, 1.0)).powi(5);
275        let max_comp = (1.0 - roughness).max(f0.x).max(f0.y).max(f0.z);
276        f0 + (Vec3::splat(max_comp) - f0) * t
277    }
278
279    /// Exact Fresnel for a conductor (metal) with complex IOR `n + ik`.
280    ///
281    /// Returns reflectance for a single wavelength (use per-channel for RGB).
282    pub fn conductor_fresnel(cos_theta: f32, ior: f32, k: f32) -> f32 {
283        let ct = cos_theta.clamp(0.0, 1.0);
284        let ct2 = ct * ct;
285        let n2 = ior * ior;
286        let k2 = k * k;
287        let n2k2 = n2 + k2;
288
289        let rs_num = n2k2 - 2.0 * ior * ct + ct2;
290        let rs_den = n2k2 + 2.0 * ior * ct + ct2;
291        let rs = rs_num / rs_den.max(1e-10);
292
293        let rp_num = n2k2 * ct2 - 2.0 * ior * ct + 1.0;
294        let rp_den = n2k2 * ct2 + 2.0 * ior * ct + 1.0;
295        let rp = rp_num / rp_den.max(1e-10);
296
297        (rs + rp) * 0.5
298    }
299
300    /// Fresnel for a dielectric (non-absorbing), using Snell's law + exact formula.
301    pub fn dielectric_fresnel(cos_theta_i: f32, ior: f32) -> f32 {
302        let ct_i = cos_theta_i.clamp(0.0, 1.0);
303        let sin2_t = (1.0 - ct_i * ct_i) / (ior * ior);
304        if sin2_t >= 1.0 {
305            return 1.0; // TIR
306        }
307        let ct_t = (1.0 - sin2_t).sqrt();
308
309        let rs = (ct_i - ior * ct_t) / (ct_i + ior * ct_t);
310        let rp = (ior * ct_i - ct_t) / (ior * ct_i + ct_t);
311        (rs * rs + rp * rp) * 0.5
312    }
313
314    /// Compute scalar F0 from relative index of refraction.
315    #[inline]
316    pub fn f0_from_ior(ior: f32) -> f32 {
317        let t = (ior - 1.0) / (ior + 1.0);
318        t * t
319    }
320
321    /// Compute F0 as a Vec3 (achromatic) from IOR.
322    #[inline]
323    pub fn f0_vec3_from_ior(ior: f32) -> Vec3 {
324        Vec3::splat(f0_from_ior(ior))
325    }
326
327    /// Schlick Fresnel for a dielectric (scalar F0).
328    #[inline]
329    pub fn schlick_scalar_f(cos_theta: f32, f0: f32) -> f32 {
330        f0 + (1.0 - f0) * (1.0 - cos_theta.clamp(0.0, 1.0)).powi(5)
331    }
332}
333
334// ─────────────────────────────────────────────────────────────────────────────
335// Minimal PRNG for sampling
336// ─────────────────────────────────────────────────────────────────────────────
337
338/// Simple xorshift32 PRNG used for Monte Carlo BRDF sampling.
339/// Not suitable for cryptography — only for rendering randomness.
340pub struct SimpleRng {
341    state: u32,
342}
343
344impl SimpleRng {
345    pub fn new(seed: u32) -> Self {
346        Self {
347            state: if seed == 0 { 1 } else { seed },
348        }
349    }
350
351    /// Generate the next pseudo-random `u32`.
352    pub fn next_u32(&mut self) -> u32 {
353        let mut x = self.state;
354        x ^= x << 13;
355        x ^= x >> 17;
356        x ^= x << 5;
357        self.state = x;
358        x
359    }
360
361    /// Generate a uniform float in `[0, 1)`.
362    pub fn next_f32(&mut self) -> f32 {
363        (self.next_u32() as f32) / (u32::MAX as f32 + 1.0)
364    }
365
366    /// Generate two independent uniform floats in `[0, 1)`.
367    pub fn next_vec2(&mut self) -> Vec2 {
368        Vec2::new(self.next_f32(), self.next_f32())
369    }
370}
371
372// ─────────────────────────────────────────────────────────────────────────────
373// Full BRDF evaluators
374// ─────────────────────────────────────────────────────────────────────────────
375
376/// Cook-Torrance specular BRDF combined with Lambertian diffuse.
377///
378/// Uses GGX NDF, Smith-GGX geometry, and Schlick Fresnel.
379pub struct CookTorranceBrdf;
380
381impl CookTorranceBrdf {
382    /// Evaluate the full PBR BRDF (diffuse + specular) for one light direction.
383    ///
384    /// * `n` — surface normal (unit)
385    /// * `v` — view direction pointing *away* from surface (unit)
386    /// * `l` — light direction pointing *toward* the light (unit)
387    /// * `albedo` — base colour (linear RGB)
388    /// * `metallic` — metallic factor [0,1]
389    /// * `roughness` — perceptual roughness [0,1]
390    ///
391    /// Returns the BRDF value multiplied by `NdotL`.  The caller multiplies by
392    /// light irradiance.
393    pub fn evaluate(
394        n: Vec3,
395        v: Vec3,
396        l: Vec3,
397        albedo: Vec3,
398        metallic: f32,
399        roughness: f32,
400    ) -> Vec3 {
401        let n_dot_l = n.dot(l).max(0.0);
402        let n_dot_v = n.dot(v).max(1e-5);
403        if n_dot_l < 1e-5 {
404            return Vec3::ZERO;
405        }
406
407        let h = (v + l).normalize();
408        let n_dot_h = n.dot(h).max(0.0);
409        let v_dot_h = v.dot(h).clamp(0.0, 1.0);
410
411        // Roughness remapping to avoid zero
412        let rough = roughness.max(0.04);
413
414        // F0 for dielectric/metal blend
415        let f0_dielectric = fresnel::f0_from_ior(1.5);
416        let f0 = Vec3::splat(f0_dielectric).lerp(albedo, metallic);
417
418        // D — microfacet NDF
419        let d = distribution::ggx_d(n_dot_h, rough);
420
421        // G — masking-shadowing
422        let g = geometry::smith_ggx(n_dot_v, n_dot_l, rough);
423
424        // F — Fresnel
425        let f = fresnel::schlick_f(v_dot_h, f0);
426
427        // Cook-Torrance specular BRDF
428        let denom = (4.0 * n_dot_v * n_dot_l).max(1e-7);
429        let specular = d * g * f / denom;
430
431        // Lambertian diffuse — metals have no diffuse
432        let k_s = f;
433        let k_d = (Vec3::ONE - k_s) * (1.0 - metallic);
434        let diffuse = k_d * albedo * FRAC_1_PI;
435
436        (diffuse + specular) * n_dot_l
437    }
438
439    /// Importance-sample the GGX distribution to generate a reflected direction.
440    ///
441    /// Returns `(reflected_direction, pdf)`.
442    pub fn sample(n: Vec3, v: Vec3, roughness: f32, rng: &mut SimpleRng) -> (Vec3, f32) {
443        let (xi1, xi2) = (rng.next_f32(), rng.next_f32());
444
445        let alpha = roughness * roughness;
446        let alpha2 = alpha * alpha;
447
448        // Sample half-vector in tangent space (GGX distribution)
449        let cos_theta = ((1.0 - xi1) / (xi1 * (alpha2 - 1.0) + 1.0)).sqrt();
450        let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt();
451        let phi = 2.0 * PI * xi2;
452
453        let h_local = Vec3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta);
454
455        // Transform to world space
456        let (t, b) = vec3::orthonormal_basis(n);
457        let h = (t * h_local.x + b * h_local.y + n * h_local.z).normalize();
458
459        // Reflect view around half-vector
460        let l = vec3::reflect(-v, h);
461        if l.dot(n) < 0.0 {
462            // Sample below surface — return a safe fallback
463            return (n, 1.0);
464        }
465
466        // PDF of this sample
467        let n_dot_h = n.dot(h).max(1e-5);
468        let d = distribution::ggx_d(n_dot_h, roughness);
469        let pdf = (d * n_dot_h / (4.0 * v.dot(h).max(1e-5))).max(1e-7);
470
471        (l, pdf)
472    }
473
474    /// Evaluate the specular-only portion of the BRDF (for clearcoat layers etc.)
475    pub fn evaluate_specular(n: Vec3, v: Vec3, l: Vec3, roughness: f32, f0: Vec3) -> Vec3 {
476        let n_dot_l = n.dot(l).max(0.0);
477        let n_dot_v = n.dot(v).max(1e-5);
478        if n_dot_l < 1e-5 {
479            return Vec3::ZERO;
480        }
481
482        let h = (v + l).normalize();
483        let n_dot_h = n.dot(h).max(0.0);
484        let v_dot_h = v.dot(h).clamp(0.0, 1.0);
485        let rough = roughness.max(0.04);
486
487        let d = distribution::ggx_d(n_dot_h, rough);
488        let g = geometry::smith_ggx(n_dot_v, n_dot_l, rough);
489        let f = fresnel::schlick_f(v_dot_h, f0);
490
491        d * g * f / (4.0 * n_dot_v * n_dot_l).max(1e-7) * n_dot_l
492    }
493
494    /// Multi-scatter energy compensation (Karis 2018).
495    /// Corrects energy loss from single-scattering model.
496    pub fn multi_scatter_compensation(
497        n_dot_v: f32,
498        roughness: f32,
499        f0: Vec3,
500        brdf_lut: &ibl::BrdfLut,
501    ) -> Vec3 {
502        let lut = brdf_lut.integrate(n_dot_v, roughness);
503        let scale = lut.x;
504        let bias = lut.y;
505
506        let e_single = f0 * scale + Vec3::splat(bias);
507        let e_multi = Vec3::ONE - e_single;
508        // Approximate multi-scatter colour as albedo-tinted white
509        let f_avg = f0 * (1.0 / 21.0) + Vec3::splat(20.0 / 21.0) * f0;
510        Vec3::ONE + f_avg * e_multi / (Vec3::ONE - f_avg * e_multi + Vec3::splat(1e-7))
511    }
512}
513
514/// Lambertian (perfectly diffuse) BRDF.
515pub struct LambertianBrdf;
516
517impl LambertianBrdf {
518    /// Evaluate the Lambertian BRDF — returns `albedo / π * NdotL`.
519    #[inline]
520    pub fn evaluate(albedo: Vec3, n_dot_l: f32) -> Vec3 {
521        albedo * FRAC_1_PI * n_dot_l.max(0.0)
522    }
523
524    /// Generate a cosine-weighted sample in the hemisphere around `n`.
525    pub fn sample(n: Vec3, rng: &mut SimpleRng) -> (Vec3, f32) {
526        let xi1 = rng.next_f32();
527        let xi2 = rng.next_f32();
528
529        // Cosine-weighted hemisphere sample
530        let cos_theta = xi1.sqrt();
531        let sin_theta = (1.0 - xi1).sqrt();
532        let phi = 2.0 * PI * xi2;
533
534        let l_local = Vec3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta);
535        let (t, b) = vec3::orthonormal_basis(n);
536        let l = (t * l_local.x + b * l_local.y + n * l_local.z).normalize();
537        let pdf = cos_theta * FRAC_1_PI;
538        (l, pdf.max(1e-7))
539    }
540}
541
542/// Oren-Nayar BRDF for rough diffuse surfaces.
543pub struct OrenNayarBrdf;
544
545impl OrenNayarBrdf {
546    /// Evaluate the Oren-Nayar diffuse BRDF.
547    ///
548    /// `roughness` — surface roughness (0 = perfectly smooth Lambertian, 1 = very rough).
549    pub fn evaluate(v: Vec3, l: Vec3, n: Vec3, albedo: Vec3, roughness: f32) -> Vec3 {
550        let sigma2 = roughness * roughness;
551        let a = 1.0 - 0.5 * sigma2 / (sigma2 + 0.33);
552        let b = 0.45 * sigma2 / (sigma2 + 0.09);
553
554        let n_dot_v = n.dot(v).max(0.0);
555        let n_dot_l = n.dot(l).max(0.0);
556
557        // Project v and l onto the plane perpendicular to n
558        let v_perp = (v - n * n_dot_v).normalize();
559        let l_perp = (l - n * n_dot_l).normalize();
560        let cos_phi = v_perp.dot(l_perp).max(0.0);
561
562        let theta_i = n_dot_l.acos();
563        let theta_r = n_dot_v.acos();
564        let alpha = theta_i.max(theta_r);
565        let beta = theta_i.min(theta_r);
566
567        albedo * FRAC_1_PI * (a + b * cos_phi * alpha.sin() * (beta.tan() + 1e-5)) * n_dot_l
568    }
569}
570
571/// Clearcoat BRDF layer — a thin dielectric coat on top of the base material.
572pub struct ClearcoatBrdf;
573
574impl ClearcoatBrdf {
575    /// Evaluate the clearcoat specular lobe.
576    ///
577    /// `strength` — clearcoat weight [0,1]
578    /// `roughness` — clearcoat roughness [0,1]
579    pub fn evaluate(n: Vec3, v: Vec3, l: Vec3, strength: f32, roughness: f32) -> Vec3 {
580        if strength < 1e-5 {
581            return Vec3::ZERO;
582        }
583        // Clearcoat uses a fixed IOR of 1.5 (automotive clear coat)
584        let f0 = Vec3::splat(fresnel::f0_from_ior(1.5));
585        let spec = CookTorranceBrdf::evaluate_specular(n, v, l, roughness, f0);
586        spec * strength
587    }
588
589    /// Clearcoat Fresnel attenuation factor — multiply the base layer by this.
590    pub fn attenuation(n: Vec3, v: Vec3, strength: f32) -> Vec3 {
591        if strength < 1e-5 {
592            return Vec3::ONE;
593        }
594        let n_dot_v = n.dot(v).clamp(0.0, 1.0);
595        let f = fresnel::schlick_scalar_f(n_dot_v, fresnel::f0_from_ior(1.5));
596        Vec3::ONE - Vec3::splat(strength * f)
597    }
598}
599
600/// Anisotropic GGX BRDF.
601pub struct AnisotropicBrdf;
602
603impl AnisotropicBrdf {
604    /// Evaluate anisotropic specular BRDF.
605    ///
606    /// * `t`, `b` — tangent and bitangent vectors (unit)
607    /// * `roughness_x`, `roughness_y` — roughness along t and b axes
608    /// * `f0` — specular reflectance at normal incidence
609    pub fn evaluate(
610        n: Vec3,
611        v: Vec3,
612        l: Vec3,
613        t: Vec3,
614        b: Vec3,
615        metallic: f32,
616        roughness_x: f32,
617        roughness_y: f32,
618        f0: Vec3,
619    ) -> Vec3 {
620        let n_dot_l = n.dot(l).max(0.0);
621        let n_dot_v = n.dot(v).max(1e-5);
622        if n_dot_l < 1e-5 {
623            return Vec3::ZERO;
624        }
625
626        let h = (v + l).normalize();
627        let n_dot_h = n.dot(h).max(0.0);
628        let v_dot_h = v.dot(h).clamp(0.0, 1.0);
629        let h_dot_t = h.dot(t);
630        let h_dot_b = h.dot(b);
631
632        let ax = roughness_x.max(0.04);
633        let ay = roughness_y.max(0.04);
634
635        let d = distribution::anisotropic_ggx_d(n_dot_h, h_dot_t, h_dot_b, ax, ay);
636
637        // Anisotropic Smith G using combined roughness
638        let rough_avg = (ax * ay).sqrt();
639        let g = geometry::smith_ggx(n_dot_v, n_dot_l, rough_avg);
640
641        let f = fresnel::schlick_f(v_dot_h, f0);
642
643        let specular = d * g * f / (4.0 * n_dot_v * n_dot_l).max(1e-7);
644
645        // Diffuse term (metals have no diffuse)
646        let k_d = (Vec3::ONE - f) * (1.0 - metallic);
647        let diffuse = k_d * FRAC_1_PI;
648
649        (diffuse + specular) * n_dot_l
650    }
651}
652
653// ─────────────────────────────────────────────────────────────────────────────
654// Image-Based Lighting
655// ─────────────────────────────────────────────────────────────────────────────
656
657/// Image-based lighting helpers.
658pub mod ibl {
659    use super::{distribution, fresnel, geometry, SimpleRng, vec3, PI, FRAC_1_PI};
660    use glam::{Vec2, Vec3};
661
662    /// Analytic approximation of a pre-filtered environment map.
663    ///
664    /// In a real renderer this would be a cubemap convolved at multiple mip levels.
665    /// Here we provide a cheap analytical stand-in that can be used in tests and
666    /// offline tools.
667    pub struct PrefilterEnv {
668        /// The "sky colour" used for background environment.
669        pub sky_color: Vec3,
670        /// Sun disc colour (bright spot in the upper hemisphere).
671        pub sun_color: Vec3,
672        /// Sun direction (unit).
673        pub sun_dir: Vec3,
674        /// Sun angular radius in radians.
675        pub sun_radius: f32,
676    }
677
678    impl PrefilterEnv {
679        pub fn new(sky_color: Vec3, sun_color: Vec3, sun_dir: Vec3, sun_radius: f32) -> Self {
680            Self {
681                sky_color,
682                sun_color,
683                sun_dir: sun_dir.normalize(),
684                sun_radius,
685            }
686        }
687
688        /// Sample the environment in direction `dir` at the given roughness LOD.
689        ///
690        /// The analytic model blurs the sun disc as roughness increases and
691        /// lerps toward the average sky colour at maximum roughness.
692        pub fn sample_lod(&self, dir: Vec3, roughness: f32) -> Vec3 {
693            let dir = dir.normalize();
694            let cos_sun = dir.dot(self.sun_dir).max(0.0);
695            // Angular size of sun after blurring by roughness
696            let blur_radius = self.sun_radius + roughness * (PI / 2.0);
697            let sun_falloff = ((cos_sun - (1.0 - blur_radius * blur_radius * 0.5))
698                / (blur_radius * blur_radius * 0.5 + 1e-5))
699                .clamp(0.0, 1.0);
700
701            // Height-based sky gradient
702            let sky_lerp = (dir.y * 0.5 + 0.5).clamp(0.0, 1.0);
703            let ambient = self.sky_color * sky_lerp + Vec3::new(0.1, 0.1, 0.15) * (1.0 - sky_lerp);
704
705            // Mix in sun contribution
706            ambient.lerp(self.sun_color, sun_falloff * (1.0 - roughness))
707        }
708
709        /// Diffuse irradiance — sample as if roughness = 1.
710        pub fn sample_irradiance(&self, normal: Vec3) -> Vec3 {
711            self.sample_lod(normal, 1.0)
712        }
713    }
714
715    impl Default for PrefilterEnv {
716        fn default() -> Self {
717            Self::new(
718                Vec3::new(0.5, 0.7, 1.0),
719                Vec3::new(10.0, 9.0, 7.0),
720                Vec3::new(0.3, 0.8, 0.5).normalize(),
721                0.01,
722            )
723        }
724    }
725
726    /// BRDF integration lookup table.
727    ///
728    /// Stores pre-computed `(a, b)` scale and bias for the Schlick-GGX BRDF
729    /// integration over the upper hemisphere.  The result approximates:
730    ///
731    ///   ∫ f_spec(v, l) NdotL dl ≈ F0 * a + b
732    pub struct BrdfLut {
733        pub size: usize,
734        /// Row-major table: `data[row * size + col]` where
735        /// row = roughness bin, col = NdotV bin.
736        pub data: Vec<Vec2>,
737    }
738
739    impl BrdfLut {
740        /// Generate the LUT offline by Monte Carlo integration.
741        ///
742        /// `size` — number of bins along each axis (e.g. 128 or 256).
743        pub fn generate(size: usize) -> Self {
744            let mut data = vec![Vec2::ZERO; size * size];
745            let n_samples = 1024usize;
746
747            for row in 0..size {
748                let roughness = (row as f32 + 0.5) / size as f32;
749                for col in 0..size {
750                    let n_dot_v = (col as f32 + 0.5) / size as f32;
751                    data[row * size + col] = integrate_brdf(n_dot_v, roughness, n_samples);
752                }
753            }
754
755            BrdfLut { size, data }
756        }
757
758        /// Bilinearly sample the LUT.
759        pub fn integrate(&self, n_dot_v: f32, roughness: f32) -> Vec2 {
760            let n_dot_v = n_dot_v.clamp(0.0, 1.0);
761            let roughness = roughness.clamp(0.0, 1.0);
762
763            let col_f = n_dot_v * (self.size as f32 - 1.0);
764            let row_f = roughness * (self.size as f32 - 1.0);
765
766            let col0 = (col_f as usize).min(self.size - 1);
767            let row0 = (row_f as usize).min(self.size - 1);
768            let col1 = (col0 + 1).min(self.size - 1);
769            let row1 = (row0 + 1).min(self.size - 1);
770
771            let tc = col_f - col0 as f32;
772            let tr = row_f - row0 as f32;
773
774            let s00 = self.data[row0 * self.size + col0];
775            let s10 = self.data[row0 * self.size + col1];
776            let s01 = self.data[row1 * self.size + col0];
777            let s11 = self.data[row1 * self.size + col1];
778
779            let s0 = s00.lerp(s10, tc);
780            let s1 = s01.lerp(s11, tc);
781            s0.lerp(s1, tr)
782        }
783
784        /// Return a flat `Vec<Vec2>` copy of the table data.
785        pub fn generate_lut(size: usize) -> Vec<Vec2> {
786            Self::generate(size).data
787        }
788    }
789
790    impl Default for BrdfLut {
791        fn default() -> Self {
792            Self::generate(64)
793        }
794    }
795
796    /// Monte Carlo integrate the specular BRDF for `(NdotV, roughness)`.
797    fn integrate_brdf(n_dot_v: f32, roughness: f32, n_samples: usize) -> Vec2 {
798        // Build a local V in tangent space from NdotV
799        let v = Vec3::new(
800            (1.0 - n_dot_v * n_dot_v).max(0.0).sqrt(),
801            0.0,
802            n_dot_v,
803        );
804        let n = Vec3::Z;
805
806        let mut sum = Vec2::ZERO;
807        let mut rng = SimpleRng::new(0x12345678);
808
809        for _ in 0..n_samples {
810            let xi1 = rng.next_f32();
811            let xi2 = rng.next_f32();
812
813            // Importance-sample GGX half-vector
814            let alpha = roughness * roughness;
815            let alpha2 = alpha * alpha;
816            let cos_theta_h = ((1.0 - xi1) / (xi1 * (alpha2 - 1.0) + 1.0)).sqrt();
817            let sin_theta_h = (1.0 - cos_theta_h * cos_theta_h).max(0.0).sqrt();
818            let phi = 2.0 * PI * xi2;
819
820            let h = Vec3::new(sin_theta_h * phi.cos(), sin_theta_h * phi.sin(), cos_theta_h);
821            let l = (2.0 * v.dot(h) * h - v).normalize();
822
823            if l.z > 0.0 {
824                let n_dot_l = l.z.max(0.0);
825                let n_dot_h = h.z.max(0.0);
826                let v_dot_h = v.dot(h).max(0.0);
827
828                let g = geometry::smith_ggx_ibl(n_dot_v, n_dot_l, roughness);
829                let g_vis = g * v_dot_h / (n_dot_h * n_dot_v).max(1e-7);
830                let fc = (1.0 - v_dot_h).powi(5);
831
832                sum.x += (1.0 - fc) * g_vis;
833                sum.y += fc * g_vis;
834            }
835        }
836
837        sum / n_samples as f32
838    }
839
840    /// Ambient occlusion helpers.
841    pub struct AmbientOcclusion;
842
843    impl AmbientOcclusion {
844        /// Generate a hemisphere sample kernel for SSAO.
845        ///
846        /// `n_samples` — number of kernel taps (typically 16–64).
847        pub fn ssao_kernel(n_samples: usize) -> Vec<Vec3> {
848            let mut rng = SimpleRng::new(0xDEADBEEF);
849            let mut kernel = Vec::with_capacity(n_samples);
850
851            for i in 0..n_samples {
852                // Cosine-weighted hemisphere sample
853                let xi1 = rng.next_f32();
854                let xi2 = rng.next_f32();
855
856                let sin_theta = xi1.sqrt();
857                let cos_theta = (1.0 - xi1).sqrt();
858                let phi = 2.0 * PI * xi2;
859
860                let sample = Vec3::new(
861                    sin_theta * phi.cos(),
862                    sin_theta * phi.sin(),
863                    cos_theta,
864                );
865
866                // Accelerating interpolation towards the origin
867                let scale = i as f32 / n_samples as f32;
868                let scale = 0.1_f32 + (1.0_f32 - 0.1_f32) * (scale * scale);
869
870                kernel.push(sample * scale);
871            }
872
873            kernel
874        }
875
876        /// Compute a bent normal and AO factor from a sample set.
877        ///
878        /// `samples` — world-space sample offsets (from `ssao_kernel`)
879        /// `depth`   — per-sample visibility (1.0 = unoccluded, 0.0 = occluded)
880        /// `n`       — surface normal
881        ///
882        /// Returns `(bent_normal, ao)`.
883        pub fn bent_normal_ao(samples: &[Vec3], depth: &[f32], n: Vec3) -> (Vec3, f32) {
884            assert_eq!(
885                samples.len(),
886                depth.len(),
887                "samples and depth must have equal length"
888            );
889
890            let mut bent = Vec3::ZERO;
891            let mut unoccluded = 0u32;
892            let total = samples.len() as u32;
893
894            for (s, &vis) in samples.iter().zip(depth.iter()) {
895                if s.dot(n) > 0.0 {
896                    // Only count hemispherical samples
897                    if vis > 0.5 {
898                        bent += *s;
899                        unoccluded += 1;
900                    }
901                }
902            }
903
904            let ao = unoccluded as f32 / total.max(1) as f32;
905            let bent_normal = if bent.length_squared() > 1e-10 {
906                bent.normalize()
907            } else {
908                n
909            };
910
911            (bent_normal, ao)
912        }
913    }
914}
915
916// ─────────────────────────────────────────────────────────────────────────────
917// Light models
918// ─────────────────────────────────────────────────────────────────────────────
919
920/// Directional (sun-like) light — no position, infinite distance.
921#[derive(Debug, Clone)]
922pub struct DirectionalLight {
923    /// Unit direction from surface toward the light.
924    pub direction: Vec3,
925    /// Linear RGB colour of the light.
926    pub color: Vec3,
927    /// Luminous intensity multiplier.
928    pub intensity: f32,
929}
930
931impl DirectionalLight {
932    pub fn new(direction: Vec3, color: Vec3, intensity: f32) -> Self {
933        Self {
934            direction: direction.normalize(),
935            color,
936            intensity,
937        }
938    }
939
940    /// Irradiance contribution at a surface (before BRDF).
941    pub fn irradiance(&self) -> Vec3 {
942        self.color * self.intensity
943    }
944}
945
946/// Omnidirectional point light.
947#[derive(Debug, Clone)]
948pub struct PointLight {
949    pub position: Vec3,
950    pub color: Vec3,
951    pub intensity: f32,
952    /// Physical radius for energy-conserving area-light attenuation.
953    pub radius: f32,
954}
955
956impl PointLight {
957    pub fn new(position: Vec3, color: Vec3, intensity: f32, radius: f32) -> Self {
958        Self {
959            position,
960            color,
961            intensity,
962            radius: radius.max(0.001),
963        }
964    }
965
966    /// Inverse-square-law attenuation with smooth window function (Karis 2013).
967    pub fn attenuation(&self, surface_pos: Vec3) -> f32 {
968        let dist = (self.position - surface_pos).length();
969        let r = self.radius;
970        // Window function: (1 - (d/r)^4)^2 / (d^2 + 0.01)
971        let x = dist / r;
972        let window = (1.0 - x * x * x * x).max(0.0);
973        window * window / (dist * dist + 0.01)
974    }
975
976    /// Direction from surface toward this light.
977    pub fn direction_to(&self, surface_pos: Vec3) -> Vec3 {
978        (self.position - surface_pos).normalize()
979    }
980
981    /// Irradiance at surface position.
982    pub fn irradiance_at(&self, surface_pos: Vec3) -> Vec3 {
983        self.color * self.intensity * self.attenuation(surface_pos)
984    }
985}
986
987/// Spot light — cone-shaped.
988#[derive(Debug, Clone)]
989pub struct SpotLight {
990    pub position: Vec3,
991    /// Unit direction the spot points toward.
992    pub direction: Vec3,
993    pub color: Vec3,
994    pub intensity: f32,
995    /// Inner cone half-angle (full bright region), in radians.
996    pub inner_angle: f32,
997    /// Outer cone half-angle (falloff to zero), in radians.
998    pub outer_angle: f32,
999}
1000
1001impl SpotLight {
1002    pub fn new(
1003        position: Vec3,
1004        direction: Vec3,
1005        color: Vec3,
1006        intensity: f32,
1007        inner_angle: f32,
1008        outer_angle: f32,
1009    ) -> Self {
1010        Self {
1011            position,
1012            direction: direction.normalize(),
1013            color,
1014            intensity,
1015            inner_angle,
1016            outer_angle,
1017        }
1018    }
1019
1020    /// Angular attenuation — smooth falloff from inner to outer cone.
1021    pub fn angular_attenuation(&self, surface_pos: Vec3) -> f32 {
1022        let l = (surface_pos - self.position).normalize();
1023        let cos_theta = l.dot(self.direction);
1024        let cos_inner = self.inner_angle.cos();
1025        let cos_outer = self.outer_angle.cos();
1026        let t = ((cos_theta - cos_outer) / (cos_inner - cos_outer + 1e-5)).clamp(0.0, 1.0);
1027        t * t // smooth step approximation
1028    }
1029
1030    /// Radial attenuation (same as point light).
1031    pub fn radial_attenuation(&self, surface_pos: Vec3) -> f32 {
1032        let dist = (self.position - surface_pos).length();
1033        let falloff_radius = 10.0 * self.outer_angle; // heuristic
1034        let x = dist / falloff_radius;
1035        (1.0 - x * x * x * x).max(0.0).powi(2) / (dist * dist + 0.01)
1036    }
1037
1038    /// Combined irradiance at surface position.
1039    pub fn irradiance_at(&self, surface_pos: Vec3) -> Vec3 {
1040        let angular = self.angular_attenuation(surface_pos);
1041        let radial = self.radial_attenuation(surface_pos);
1042        self.color * self.intensity * angular * radial
1043    }
1044
1045    /// Direction from surface toward the light source.
1046    pub fn direction_to(&self, surface_pos: Vec3) -> Vec3 {
1047        (self.position - surface_pos).normalize()
1048    }
1049}
1050
1051/// Rectangular area light using the Linearly Transformed Cosines (LTC) approach.
1052#[derive(Debug, Clone)]
1053pub struct AreaLight {
1054    /// Centre of the rectangle.
1055    pub position: Vec3,
1056    /// Half-extent along the "right" axis (width/2).
1057    pub right: Vec3,
1058    /// Half-extent along the "up" axis (height/2).
1059    pub up: Vec3,
1060    pub color: Vec3,
1061    pub intensity: f32,
1062}
1063
1064impl AreaLight {
1065    pub fn new(position: Vec3, right: Vec3, up: Vec3, color: Vec3, intensity: f32) -> Self {
1066        Self {
1067            position,
1068            right,
1069            up,
1070            color,
1071            intensity,
1072        }
1073    }
1074
1075    /// Compute the four corner positions of the rectangle.
1076    pub fn corners(&self) -> [Vec3; 4] {
1077        [
1078            self.position - self.right - self.up,
1079            self.position + self.right - self.up,
1080            self.position + self.right + self.up,
1081            self.position - self.right + self.up,
1082        ]
1083    }
1084
1085    /// Area of the rectangle.
1086    pub fn area(&self) -> f32 {
1087        4.0 * self.right.length() * self.up.length()
1088    }
1089
1090    /// Normal of the area light face.
1091    pub fn normal(&self) -> Vec3 {
1092        self.right.cross(self.up).normalize()
1093    }
1094
1095    /// Approximate the solid angle subtended by the area light at `pos`.
1096    pub fn solid_angle_at(&self, pos: Vec3) -> f32 {
1097        let to_center = self.position - pos;
1098        let dist2 = to_center.length_squared();
1099        let cos_angle = to_center
1100            .normalize()
1101            .dot(self.normal())
1102            .abs();
1103        (self.area() * cos_angle / dist2.max(1e-5)).min(2.0 * PI)
1104    }
1105
1106    /// Simple irradiance estimate using solid angle and projected area.
1107    pub fn irradiance_at(&self, pos: Vec3, n: Vec3) -> Vec3 {
1108        let l = (self.position - pos).normalize();
1109        let n_dot_l = n.dot(l).max(0.0);
1110        let omega = self.solid_angle_at(pos);
1111        self.color * self.intensity * omega * n_dot_l * FRAC_1_PI
1112    }
1113}
1114
1115/// Combined material description used by `shade_point`.
1116#[derive(Debug, Clone)]
1117pub struct ShadeMaterial {
1118    pub albedo: Vec3,
1119    pub metallic: f32,
1120    pub roughness: f32,
1121    pub emission: Vec3,
1122    pub ao: f32,
1123    pub clearcoat: f32,
1124    pub clearcoat_roughness: f32,
1125    pub anisotropy: f32,
1126    pub anisotropy_tangent: Vec3,
1127    pub anisotropy_bitangent: Vec3,
1128    pub ior: f32,
1129}
1130
1131impl Default for ShadeMaterial {
1132    fn default() -> Self {
1133        Self {
1134            albedo: Vec3::new(0.8, 0.8, 0.8),
1135            metallic: 0.0,
1136            roughness: 0.5,
1137            emission: Vec3::ZERO,
1138            ao: 1.0,
1139            clearcoat: 0.0,
1140            clearcoat_roughness: 0.0,
1141            anisotropy: 0.0,
1142            anisotropy_tangent: Vec3::X,
1143            anisotropy_bitangent: Vec3::Y,
1144            ior: 1.5,
1145        }
1146    }
1147}
1148
1149/// Light enum for the shading loop.
1150#[derive(Debug, Clone)]
1151pub enum Light {
1152    Directional(DirectionalLight),
1153    Point(PointLight),
1154    Spot(SpotLight),
1155    Area(AreaLight),
1156}
1157
1158impl Light {
1159    /// Compute `(light_direction, irradiance)` for a surface point.
1160    pub fn contribution(&self, surface_pos: Vec3, n: Vec3) -> (Vec3, Vec3) {
1161        match self {
1162            Light::Directional(d) => (d.direction, d.irradiance()),
1163            Light::Point(p) => (p.direction_to(surface_pos), p.irradiance_at(surface_pos)),
1164            Light::Spot(s) => (s.direction_to(surface_pos), s.irradiance_at(surface_pos)),
1165            Light::Area(a) => {
1166                let dir = (a.position - surface_pos).normalize();
1167                (dir, a.irradiance_at(surface_pos, n))
1168            }
1169        }
1170    }
1171}
1172
1173/// Full shading of a surface point given material, lights, geometry, and an
1174/// optional IBL environment.
1175///
1176/// Returns linear HDR radiance (before tone-mapping).
1177pub fn shade_point(
1178    material: &ShadeMaterial,
1179    lights: &[Light],
1180    n: Vec3,
1181    v: Vec3,
1182    position: Vec3,
1183    env: Option<&ibl::PrefilterEnv>,
1184    brdf_lut: Option<&ibl::BrdfLut>,
1185) -> Vec3 {
1186    let n = n.normalize();
1187    let v = v.normalize();
1188    let n_dot_v = n.dot(v).max(1e-5);
1189
1190    // F0 for Fresnel
1191    let f0_d = fresnel::f0_from_ior(material.ior);
1192    let f0 = Vec3::splat(f0_d).lerp(material.albedo, material.metallic);
1193
1194    let mut color = Vec3::ZERO;
1195
1196    // ── Direct lighting ───────────────────────────────────────────────────────
1197    for light in lights {
1198        let (l, irradiance) = light.contribution(position, n);
1199        let l = l.normalize();
1200
1201        let brdf = if material.anisotropy > 1e-5 {
1202            AnisotropicBrdf::evaluate(
1203                n,
1204                v,
1205                l,
1206                material.anisotropy_tangent,
1207                material.anisotropy_bitangent,
1208                material.metallic,
1209                material.roughness * (1.0 + material.anisotropy),
1210                material.roughness * (1.0 - material.anisotropy),
1211                f0,
1212            )
1213        } else {
1214            CookTorranceBrdf::evaluate(n, v, l, material.albedo, material.metallic, material.roughness)
1215        };
1216
1217        // Clearcoat on top
1218        let clearcoat = ClearcoatBrdf::evaluate(n, v, l, material.clearcoat, material.clearcoat_roughness);
1219        let cc_atten = ClearcoatBrdf::attenuation(n, v, material.clearcoat);
1220
1221        color += (brdf * cc_atten + clearcoat) * irradiance;
1222    }
1223
1224    // ── IBL ambient ───────────────────────────────────────────────────────────
1225    if let Some(env) = env {
1226        let r = vec3::reflect(-v, n);
1227
1228        // Specular IBL
1229        let spec_env = env.sample_lod(r, material.roughness);
1230        let lut_val = brdf_lut
1231            .map(|lut| lut.integrate(n_dot_v, material.roughness))
1232            .unwrap_or(Vec2::new(0.5, 0.1));
1233        let f_ibl = fresnel::schlick_roughness_f(n_dot_v, f0, material.roughness);
1234        let specular_ibl = spec_env * (f_ibl * lut_val.x + Vec3::splat(lut_val.y));
1235
1236        // Diffuse IBL
1237        let diff_env = env.sample_irradiance(n);
1238        let k_s = f_ibl;
1239        let k_d = (Vec3::ONE - k_s) * (1.0 - material.metallic);
1240        let diffuse_ibl = diff_env * k_d * material.albedo;
1241
1242        color += (diffuse_ibl + specular_ibl) * material.ao;
1243    }
1244
1245    // Emission
1246    color += material.emission;
1247
1248    color
1249}
1250
1251// ─────────────────────────────────────────────────────────────────────────────
1252// Tone mapping
1253// ─────────────────────────────────────────────────────────────────────────────
1254
1255/// Tone-mapping operators for converting HDR radiance to display-ready LDR.
1256pub mod tonemap {
1257    use glam::Vec3;
1258
1259    /// ACES filmic tone-mapping curve (Stephen Hill's fit).
1260    pub fn aces_filmic(x: Vec3) -> Vec3 {
1261        // Luminance-based version
1262        let a = 2.51_f32;
1263        let b = 0.03_f32;
1264        let c = 2.43_f32;
1265        let d = 0.59_f32;
1266        let e = 0.14_f32;
1267        ((x * (a * x + Vec3::splat(b))) / (x * (c * x + Vec3::splat(d)) + Vec3::splat(e)))
1268            .clamp(Vec3::ZERO, Vec3::ONE)
1269    }
1270
1271    /// Extended Reinhard tone-mapper with white-point parameter.
1272    pub fn reinhard_extended(x: Vec3, max_white: f32) -> Vec3 {
1273        let mw2 = max_white * max_white;
1274        x * (Vec3::ONE + x / mw2) / (Vec3::ONE + x)
1275    }
1276
1277    /// Simple Reinhard operator.
1278    pub fn reinhard(x: Vec3) -> Vec3 {
1279        x / (Vec3::ONE + x)
1280    }
1281
1282    /// Uncharted 2 / John Hable filmic operator.
1283    pub fn uncharted2(x: Vec3) -> Vec3 {
1284        fn hable(v: Vec3) -> Vec3 {
1285            let a = Vec3::splat(0.15_f32);
1286            let b = Vec3::splat(0.50_f32);
1287            let c = Vec3::splat(0.10_f32);
1288            let d = Vec3::splat(0.20_f32);
1289            let e = Vec3::splat(0.02_f32);
1290            let f = Vec3::splat(0.30_f32);
1291            ((v * (a * v + c * b) + d * e) / (v * (a * v + b) + d * f)) - e / f
1292        }
1293
1294        let white_scale = Vec3::ONE / hable(Vec3::splat(11.2));
1295        hable(x * 2.0) * white_scale
1296    }
1297
1298    /// Lottes 2016 filmic operator.
1299    pub fn lottes(x: Vec3) -> Vec3 {
1300        // Scalar parameters — the Lottes operator is applied per-channel
1301        let a: f32 = 1.6;
1302        let d: f32 = 0.977;
1303        let hdr_max: f32 = 8.0;
1304        let mid_out: f32 = 0.267;
1305
1306        // Pre-compute per-scalar constants
1307        let b = (-mid_out.powf(a) + hdr_max.powf(a) * mid_out)
1308            / ((hdr_max.powf(a * d) - mid_out.powf(a * d)) * mid_out);
1309        let c_val = (hdr_max.powf(a * d) * mid_out.powf(a) - hdr_max.powf(a) * mid_out.powf(a * d))
1310            / ((hdr_max.powf(a * d) - mid_out.powf(a * d)) * mid_out);
1311
1312        let f = |v: f32| v.powf(a) / (v.powf(a * d) * b + c_val);
1313        Vec3::new(f(x.x), f(x.y), f(x.z))
1314    }
1315
1316    /// Uchimura GT tone-mapper (Gran Turismo, 2017).
1317    pub fn uchimura(x: Vec3) -> Vec3 {
1318        // Parameters
1319        let max_brightness = 1.0_f32;
1320        let contrast = 1.0_f32;
1321        let linear_start = 0.22_f32;
1322        let linear_length = 0.4_f32;
1323        let black = 1.33_f32;
1324        let pedestal = 0.0_f32;
1325
1326        let l0 = (max_brightness - linear_start) * linear_length / contrast;
1327        let l = linear_start + l0;
1328
1329        // Piecewise construction per channel
1330        fn gt_channel(x: f32, p: f32, a: f32, m: f32, l: f32, c: f32, b: f32) -> f32 {
1331            let l0 = (p - m) * l / a;
1332            let s0 = m + l0;
1333            let s1 = m + a * l0;
1334            let c2 = a * p / (p - s1);
1335            let cp = -c2 / p;
1336
1337            let w0 = 1.0 - (x / m).min(1.0).powf(b);
1338            let w1 = if x >= m && x < s0 { 1.0 } else { 0.0 };
1339            let w2 = if x >= s0 { 1.0 } else { 0.0 };
1340
1341            let t0 = m * (x / m).powf(c);
1342            let t1 = m + a * (x - m);
1343            let t2 = p - (p - s1) * (-c2 * (x - s0) / p).exp();
1344
1345            w0 * x.powf(b) + w1 * (m + a * (x - m)) + w2 * t2 * 0.0 // simplified
1346                + (1.0 - w2) * (w0 * t0 + w1 * t1)
1347        }
1348
1349        Vec3::new(
1350            gt_channel(x.x, max_brightness, contrast, linear_start, linear_length, black, pedestal),
1351            gt_channel(x.y, max_brightness, contrast, linear_start, linear_length, black, pedestal),
1352            gt_channel(x.z, max_brightness, contrast, linear_start, linear_length, black, pedestal),
1353        ).clamp(Vec3::ZERO, Vec3::ONE)
1354    }
1355
1356    /// AgX tone-mapper (Blender's 2022 default).
1357    pub fn agx(x: Vec3) -> Vec3 {
1358        // AgX matrix transform (linear sRGB → AgX log domain)
1359        // Based on Troy Sobotka's implementation
1360        fn agx_default_contrast(v: Vec3) -> Vec3 {
1361            // Sigmoid-like curve in log space
1362            let x_adj = v.clamp(Vec3::ZERO, Vec3::ONE);
1363            // Polynomial approximation
1364            let p1 = x_adj * x_adj * (Vec3::splat(3.0) - Vec3::splat(2.0) * x_adj);
1365            p1
1366        }
1367
1368        // AgX input transform (approximate)
1369        let agx_mat = [
1370            Vec3::new(0.842479062253094, 0.0423282422610123, 0.0423756549057051),
1371            Vec3::new(0.0784335999999992, 0.878468636469772, 0.0784336),
1372            Vec3::new(0.0792237451477643, 0.0791661274605434, 0.879142973793104),
1373        ];
1374
1375        let agx_in = Vec3::new(
1376            agx_mat[0].dot(x),
1377            agx_mat[1].dot(x),
1378            agx_mat[2].dot(x),
1379        ).max(Vec3::ZERO);
1380
1381        // Log encoding
1382        let log_min = -12.47393_f32;
1383        let log_max = 4.026069_f32;
1384        let clamped = agx_in.max(Vec3::splat(1e-10));
1385        let log_vec = Vec3::new(clamped.x.log2(), clamped.y.log2(), clamped.z.log2());
1386        let encoded = (log_vec - Vec3::splat(log_min)) / (log_max - log_min);
1387        let encoded = encoded.clamp(Vec3::ZERO, Vec3::ONE);
1388
1389        // Contrast S-curve
1390        let curved = agx_default_contrast(encoded);
1391
1392        // Inverse transform (approximate)
1393        let inv_mat = [
1394            Vec3::new(1.19687900512017, -0.0528968517574562, -0.0529716355144438),
1395            Vec3::new(-0.0980208811401368, 1.15190312990417, -0.0980434501171241),
1396            Vec3::new(-0.0990297440797205, -0.0989611768448433, 1.15107367264116),
1397        ];
1398
1399        Vec3::new(
1400            inv_mat[0].dot(curved),
1401            inv_mat[1].dot(curved),
1402            inv_mat[2].dot(curved),
1403        ).max(Vec3::ZERO)
1404    }
1405
1406    /// Apply camera exposure (EV = exposure value in stops).
1407    #[inline]
1408    pub fn exposure(x: Vec3, ev: f32) -> Vec3 {
1409        x * 2.0_f32.powf(ev)
1410    }
1411
1412    /// Gamma-correct a linear colour (encode from linear to display).
1413    #[inline]
1414    pub fn gamma_correct(x: Vec3, gamma: f32) -> Vec3 {
1415        x.max(Vec3::ZERO).powf(1.0 / gamma)
1416    }
1417
1418    /// Linear to sRGB per-channel (piecewise).
1419    pub fn linear_to_srgb(x: Vec3) -> Vec3 {
1420        let f = |c: f32| {
1421            if c <= 0.003_130_8 {
1422                c * 12.92
1423            } else {
1424                1.055 * c.powf(1.0 / 2.4) - 0.055
1425            }
1426        };
1427        Vec3::new(f(x.x), f(x.y), f(x.z))
1428    }
1429
1430    /// sRGB to linear per-channel (piecewise).
1431    pub fn srgb_to_linear(x: Vec3) -> Vec3 {
1432        let f = |c: f32| {
1433            if c <= 0.04045 {
1434                c / 12.92
1435            } else {
1436                ((c + 0.055) / 1.055).powf(2.4)
1437            }
1438        };
1439        Vec3::new(f(x.x), f(x.y), f(x.z))
1440    }
1441
1442    /// Operator enum for GLSL code generation.
1443    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1444    pub enum ToneMapOp {
1445        AcesFilmic,
1446        ReinhardExtended,
1447        Uncharted2,
1448        Lottes,
1449        Uchimura,
1450        AgX,
1451    }
1452}
1453
1454// Re-export ToneMapOp at module level for ergonomics
1455pub use tonemap::ToneMapOp;
1456
1457// ─────────────────────────────────────────────────────────────────────────────
1458// GLSL code generation
1459// ─────────────────────────────────────────────────────────────────────────────
1460
1461/// Generates GLSL source strings for the BRDF functions in this module.
1462pub struct BrdfGlsl;
1463
1464impl BrdfGlsl {
1465    /// Full Cook-Torrance PBR BRDF as a GLSL function.
1466    pub fn cook_torrance_source() -> &'static str {
1467        r#"
1468// ── Cook-Torrance PBR BRDF (auto-generated) ───────────────────────────────
1469// GGX normal distribution
1470float ggxD(float NdotH, float roughness) {
1471    float alpha  = roughness * roughness;
1472    float alpha2 = alpha * alpha;
1473    float d = (NdotH * NdotH) * (alpha2 - 1.0) + 1.0;
1474    return alpha2 / (PI * d * d);
1475}
1476
1477// Smith-GGX masking-shadowing
1478float schlickG1(float NdotX, float k) {
1479    return NdotX / (NdotX * (1.0 - k) + k + 1e-7);
1480}
1481float smithGGX(float NdotV, float NdotL, float roughness) {
1482    float k = pow(roughness + 1.0, 2.0) / 8.0;
1483    return schlickG1(NdotV, k) * schlickG1(NdotL, k);
1484}
1485
1486// Schlick Fresnel
1487vec3 schlickF(float cosTheta, vec3 F0) {
1488    float t = pow(1.0 - clamp(cosTheta, 0.0, 1.0), 5.0);
1489    return F0 + (vec3(1.0) - F0) * t;
1490}
1491
1492// Cook-Torrance BRDF evaluation — returns radiance (already multiplied by NdotL)
1493vec3 cookTorranceBrdf(
1494    vec3 N, vec3 V, vec3 L,
1495    vec3 albedo, float metallic, float roughness,
1496    float ior)
1497{
1498    float NdotL = max(dot(N, L), 0.0);
1499    float NdotV = max(dot(N, V), 1e-5);
1500    if (NdotL < 1e-5) return vec3(0.0);
1501
1502    vec3  H     = normalize(V + L);
1503    float NdotH = max(dot(N, H), 0.0);
1504    float VdotH = clamp(dot(V, H), 0.0, 1.0);
1505
1506    float rough = max(roughness, 0.04);
1507    float f0d   = pow((ior - 1.0) / (ior + 1.0), 2.0);
1508    vec3  F0    = mix(vec3(f0d), albedo, metallic);
1509
1510    float D = ggxD(NdotH, rough);
1511    float G = smithGGX(NdotV, NdotL, rough);
1512    vec3  F = schlickF(VdotH, F0);
1513
1514    vec3  specular = D * G * F / max(4.0 * NdotV * NdotL, 1e-7);
1515    vec3  kD       = (vec3(1.0) - F) * (1.0 - metallic);
1516    vec3  diffuse  = kD * albedo / PI;
1517
1518    return (diffuse + specular) * NdotL;
1519}
1520"#
1521    }
1522
1523    /// IBL ambient evaluation GLSL.
1524    pub fn ibl_source() -> &'static str {
1525        r#"
1526// ── IBL ambient (auto-generated) ─────────────────────────────────────────────
1527// Env and BRDF LUT samplers must be declared by the caller:
1528//   uniform samplerCube u_PrefilteredEnv;
1529//   uniform sampler2D   u_BrdfLut;
1530//   uniform samplerCube u_IrradianceMap;
1531
1532vec3 evaluateIbl(
1533    vec3 N, vec3 V,
1534    vec3 albedo, float metallic, float roughness,
1535    float ao, float ior,
1536    float maxReflectionLod)
1537{
1538    float NdotV  = max(dot(N, V), 1e-5);
1539    vec3  R      = reflect(-V, N);
1540
1541    float f0d    = pow((ior - 1.0) / (ior + 1.0), 2.0);
1542    vec3  F0     = mix(vec3(f0d), albedo, metallic);
1543
1544    // Fresnel with roughness for ambient
1545    float t      = pow(1.0 - NdotV, 5.0);
1546    float maxF   = max(max(1.0 - roughness, F0.r), max(F0.g, F0.b));
1547    vec3  Fibl   = F0 + (vec3(maxF) - F0) * t;
1548
1549    // Pre-filtered specular
1550    vec3  prefilt = textureLod(u_PrefilteredEnv, R, roughness * maxReflectionLod).rgb;
1551    vec2  brdf    = texture(u_BrdfLut, vec2(NdotV, roughness)).rg;
1552    vec3  specIbl = prefilt * (Fibl * brdf.x + vec3(brdf.y));
1553
1554    // Diffuse irradiance
1555    vec3  irrad   = texture(u_IrradianceMap, N).rgb;
1556    vec3  kD      = (vec3(1.0) - Fibl) * (1.0 - metallic);
1557    vec3  diffIbl = irrad * kD * albedo;
1558
1559    return (diffIbl + specIbl) * ao;
1560}
1561"#
1562    }
1563
1564    /// Tone-mapping GLSL for the selected operator.
1565    pub fn tonemap_source(op: ToneMapOp) -> &'static str {
1566        match op {
1567            ToneMapOp::AcesFilmic => r#"
1568vec3 toneMap(vec3 x) {
1569    // ACES filmic (Stephen Hill fit)
1570    return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
1571}
1572"#,
1573            ToneMapOp::ReinhardExtended => r#"
1574uniform float u_MaxWhite;
1575vec3 toneMap(vec3 x) {
1576    return x * (1.0 + x / (u_MaxWhite * u_MaxWhite)) / (1.0 + x);
1577}
1578"#,
1579            ToneMapOp::Uncharted2 => r#"
1580vec3 hable(vec3 x) {
1581    return ((x * (0.15 * x + 0.05) + 0.004) / (x * (0.15 * x + 0.5) + 0.06)) - 1.0/15.0;
1582}
1583vec3 toneMap(vec3 x) {
1584    vec3 ws = vec3(1.0) / hable(vec3(11.2));
1585    return hable(x * 2.0) * ws;
1586}
1587"#,
1588            ToneMapOp::Lottes => r#"
1589vec3 toneMap(vec3 x) {
1590    const vec3 a     = vec3(1.6);
1591    const vec3 d     = vec3(0.977);
1592    const vec3 hdrM  = vec3(8.0);
1593    const vec3 midIn = vec3(0.18);
1594    const vec3 midO  = vec3(0.267);
1595    vec3 b = (-pow(midO, a) + pow(hdrM, a) * midO)
1596           / ((pow(hdrM, a * d) - pow(midO, a * d)) * midO);
1597    vec3 c = (pow(hdrM, a * d) * pow(midO, a) - pow(hdrM, a) * pow(midO, a * d))
1598           / ((pow(hdrM, a * d) - pow(midO, a * d)) * midO);
1599    return pow(x, a) / (pow(x, a * d) * b + c);
1600}
1601"#,
1602            ToneMapOp::Uchimura => r#"
1603vec3 toneMap(vec3 x) {
1604    // GT Uchimura
1605    const float P = 1.0, a = 1.0, m = 0.22, l = 0.4, c = 1.33, b = 0.0;
1606    float l0 = (P - m) * l / a;
1607    float L  = m + l0;
1608    float S0 = m + l0;
1609    float S1 = m + a * l0;
1610    float C2 = a * P / (P - S1);
1611    float CP = -C2 / P;
1612    vec3  w0 = vec3(1.0 - smoothstep(vec3(0.0), vec3(m), x));
1613    vec3  w2 = step(vec3(S0), x);
1614    vec3  w1 = vec3(1.0) - w0 - w2;
1615    vec3  T  = vec3(m) * pow(x / vec3(m), vec3(c));
1616    vec3  S  = vec3(P) - (vec3(P) - vec3(S1)) * exp(CP * (x - vec3(S0)));
1617    vec3  Lin = vec3(m + a) * (x - vec3(m));
1618    return T * w0 + Lin * w1 + S * w2;
1619}
1620"#,
1621            ToneMapOp::AgX => r#"
1622vec3 toneMap(vec3 x) {
1623    // Simplified AgX (Blender 3.x default)
1624    mat3 agxMat = mat3(
1625        0.842479, 0.042328, 0.042376,
1626        0.078434, 0.878469, 0.078434,
1627        0.079224, 0.079166, 0.879143
1628    );
1629    vec3 enc = agxMat * max(x, vec3(0.0));
1630    float logMin = -12.47393, logMax = 4.026069;
1631    enc = clamp((log2(max(enc, vec3(1e-10))) - logMin) / (logMax - logMin), 0.0, 1.0);
1632    // Sigmoid contrast
1633    enc = enc * enc * (3.0 - 2.0 * enc);
1634    // Inverse
1635    mat3 invMat = mat3(
1636         1.19688, -0.05290, -0.05297,
1637        -0.09802,  1.15190, -0.09804,
1638        -0.09903, -0.09896,  1.15107
1639    );
1640    return max(invMat * enc, vec3(0.0));
1641}
1642"#,
1643        }
1644    }
1645
1646    /// Full GLSL preamble including all BRDF helpers, IBL, and the selected
1647    /// tone-mapper as a single concatenated string.
1648    pub fn full_brdf_glsl(op: ToneMapOp) -> String {
1649        const HEADER: &str = "#ifndef BRDF_GLSL\n#define BRDF_GLSL\n\n#define PI 3.14159265358979\n\n";
1650        const FOOTER: &str = "\n#endif // BRDF_GLSL\n";
1651        format!(
1652            "{}{}{}{}{}",
1653            HEADER,
1654            Self::cook_torrance_source(),
1655            Self::ibl_source(),
1656            Self::tonemap_source(op),
1657            FOOTER
1658        )
1659    }
1660}
1661
1662// ─────────────────────────────────────────────────────────────────────────────
1663// Tests
1664// ─────────────────────────────────────────────────────────────────────────────
1665
1666#[cfg(test)]
1667mod tests {
1668    use super::*;
1669    use glam::Vec3;
1670
1671    // ── Distribution ──────────────────────────────────────────────────────────
1672
1673    #[test]
1674    fn ggx_d_is_positive() {
1675        let d = distribution::ggx_d(0.8, 0.3);
1676        assert!(d > 0.0, "GGX D must be positive, got {d}");
1677    }
1678
1679    #[test]
1680    fn ggx_d_at_n_dot_h_one_is_finite() {
1681        // NdotH = 1 is the peak of GGX
1682        let d = distribution::ggx_d(1.0, 0.5);
1683        assert!(d.is_finite() && d > 0.0);
1684    }
1685
1686    #[test]
1687    fn beckmann_d_is_positive() {
1688        let d = distribution::beckmann_d(0.7, 0.4);
1689        assert!(d > 0.0);
1690    }
1691
1692    #[test]
1693    fn blinn_phong_d_is_positive() {
1694        let d = distribution::blinn_phong_d(0.9, 64.0);
1695        assert!(d > 0.0);
1696    }
1697
1698    #[test]
1699    fn anisotropic_ggx_d_is_positive() {
1700        let d = distribution::anisotropic_ggx_d(0.8, 0.1, 0.2, 0.2, 0.5);
1701        assert!(d > 0.0);
1702    }
1703
1704    // ── Geometry ──────────────────────────────────────────────────────────────
1705
1706    #[test]
1707    fn smith_ggx_is_in_unit_range() {
1708        let g = geometry::smith_ggx(0.9, 0.8, 0.3);
1709        assert!((0.0..=1.0).contains(&g), "G={g} must be in [0,1]");
1710    }
1711
1712    #[test]
1713    fn implicit_g_is_product() {
1714        let g = geometry::implicit_g(0.7, 0.5);
1715        assert!((g - 0.35).abs() < 1e-6);
1716    }
1717
1718    #[test]
1719    fn kelemen_g_is_positive() {
1720        let g = geometry::kelemen_szirmay_kalos_g(0.8, 0.9);
1721        assert!(g > 0.0);
1722    }
1723
1724    // ── Fresnel ───────────────────────────────────────────────────────────────
1725
1726    #[test]
1727    fn f0_from_ior_glass() {
1728        let f0 = fresnel::f0_from_ior(1.5);
1729        assert!((f0 - 0.04).abs() < 0.001, "Glass F0 ~0.04, got {f0}");
1730    }
1731
1732    #[test]
1733    fn schlick_f_at_zero_angle_equals_f0() {
1734        let f0 = Vec3::splat(0.04);
1735        let f = fresnel::schlick_f(1.0, f0);
1736        assert!((f - f0).length() < 1e-5);
1737    }
1738
1739    #[test]
1740    fn schlick_f_at_grazing_is_white() {
1741        let f0 = Vec3::splat(0.04);
1742        let f = fresnel::schlick_f(0.0, f0);
1743        assert!((f - Vec3::ONE).length() < 1e-5);
1744    }
1745
1746    #[test]
1747    fn dielectric_fresnel_tir() {
1748        // At grazing incidence from dense medium with ior>1, TIR
1749        let f = fresnel::dielectric_fresnel(0.0, 1.5);
1750        assert_eq!(f, 1.0, "Should be total internal reflection at cos=0");
1751    }
1752
1753    // ── Cook-Torrance ─────────────────────────────────────────────────────────
1754
1755    #[test]
1756    fn cook_torrance_returns_vec3_non_negative() {
1757        let n = Vec3::Y;
1758        let v = Vec3::new(0.0, 1.0, 0.0);
1759        let l = Vec3::new(0.5, 0.866, 0.0).normalize();
1760        let result = CookTorranceBrdf::evaluate(n, v, l, Vec3::splat(0.8), 0.0, 0.5);
1761        assert!(result.x >= 0.0 && result.y >= 0.0 && result.z >= 0.0);
1762    }
1763
1764    #[test]
1765    fn cook_torrance_below_horizon_is_zero() {
1766        let n = Vec3::Y;
1767        let v = Vec3::Y;
1768        let l = -Vec3::Y; // below surface
1769        let result = CookTorranceBrdf::evaluate(n, v, l, Vec3::ONE, 0.0, 0.5);
1770        assert_eq!(result, Vec3::ZERO);
1771    }
1772
1773    #[test]
1774    fn cook_torrance_metal_has_coloured_specular() {
1775        let n = Vec3::Y;
1776        let v = Vec3::new(0.0, 1.0, 0.0);
1777        let l = Vec3::new(0.5, 0.866, 0.0).normalize();
1778        let gold = Vec3::new(1.0, 0.766, 0.336);
1779        let result = CookTorranceBrdf::evaluate(n, v, l, gold, 1.0, 0.1);
1780        // Gold: red channel should be brighter than blue
1781        assert!(result.x > result.z, "Gold should be redder than blue");
1782    }
1783
1784    // ── Lambertian ────────────────────────────────────────────────────────────
1785
1786    #[test]
1787    fn lambertian_energy_conservation() {
1788        // Integral of Lambertian BRDF over hemisphere = albedo
1789        let albedo = Vec3::ONE;
1790        // Numerical hemisphere integral using many samples
1791        let mut sum = Vec3::ZERO;
1792        let n_theta = 100;
1793        let n_phi = 200;
1794        for i in 0..n_theta {
1795            let theta = (i as f32 + 0.5) / n_theta as f32 * std::f32::consts::FRAC_PI_2;
1796            for j in 0..n_phi {
1797                let phi = (j as f32 + 0.5) / n_phi as f32 * 2.0 * PI;
1798                let sin_t = theta.sin();
1799                let cos_t = theta.cos();
1800                let l = Vec3::new(sin_t * phi.cos(), cos_t, sin_t * phi.sin());
1801                let n_dot_l = cos_t;
1802                let solid_angle = sin_t
1803                    * (std::f32::consts::FRAC_PI_2 / n_theta as f32)
1804                    * (2.0 * PI / n_phi as f32);
1805                sum += LambertianBrdf::evaluate(albedo, n_dot_l) * solid_angle;
1806            }
1807        }
1808        // Should be approximately 1.0
1809        assert!(
1810            (sum.x - 1.0).abs() < 0.02,
1811            "Lambertian integral = {:.4}",
1812            sum.x
1813        );
1814    }
1815
1816    // ── Oren-Nayar ────────────────────────────────────────────────────────────
1817
1818    #[test]
1819    fn oren_nayar_at_zero_roughness_matches_lambertian() {
1820        let n = Vec3::Y;
1821        let v = Vec3::new(0.3, 0.95, 0.0).normalize();
1822        let l = Vec3::new(-0.3, 0.95, 0.0).normalize();
1823        let albedo = Vec3::ONE;
1824        let on = OrenNayarBrdf::evaluate(v, l, n, albedo, 0.0);
1825        let lam = LambertianBrdf::evaluate(albedo, n.dot(l).max(0.0));
1826        assert!(
1827            (on - lam).length() < 0.05,
1828            "ON with roughness=0 should ~match Lambertian: on={on:?} lam={lam:?}"
1829        );
1830    }
1831
1832    // ── Clearcoat ─────────────────────────────────────────────────────────────
1833
1834    #[test]
1835    fn clearcoat_zero_strength_returns_zero() {
1836        let n = Vec3::Y;
1837        let v = Vec3::new(0.0, 1.0, 0.0);
1838        let l = Vec3::new(0.5, 0.866, 0.0).normalize();
1839        let result = ClearcoatBrdf::evaluate(n, v, l, 0.0, 0.3);
1840        assert_eq!(result, Vec3::ZERO);
1841    }
1842
1843    // ── IBL ───────────────────────────────────────────────────────────────────
1844
1845    #[test]
1846    fn brdf_lut_values_in_unit_range() {
1847        let lut = ibl::BrdfLut::generate(32);
1848        for &v in &lut.data {
1849            assert!(
1850                (0.0..=1.0).contains(&v.x) && (0.0..=1.0).contains(&v.y),
1851                "LUT value out of range: {v:?}"
1852            );
1853        }
1854    }
1855
1856    #[test]
1857    fn ssao_kernel_has_correct_count() {
1858        let k = ibl::AmbientOcclusion::ssao_kernel(32);
1859        assert_eq!(k.len(), 32);
1860    }
1861
1862    // ── Tone mapping ──────────────────────────────────────────────────────────
1863
1864    #[test]
1865    fn aces_filmic_maps_zero_to_zero() {
1866        let out = tonemap::aces_filmic(Vec3::ZERO);
1867        assert!(out.length() < 1e-4);
1868    }
1869
1870    #[test]
1871    fn aces_filmic_clamps_to_one() {
1872        let out = tonemap::aces_filmic(Vec3::splat(1000.0));
1873        assert!(out.x <= 1.0 && out.y <= 1.0 && out.z <= 1.0);
1874    }
1875
1876    #[test]
1877    fn srgb_round_trip() {
1878        let linear = Vec3::new(0.5, 0.2, 0.8);
1879        let srgb = tonemap::linear_to_srgb(linear);
1880        let back = tonemap::srgb_to_linear(srgb);
1881        assert!((back - linear).length() < 1e-4, "sRGB round-trip error: {back:?}");
1882    }
1883
1884    #[test]
1885    fn gamma_correct_identity_at_gamma_one() {
1886        let v = Vec3::new(0.4, 0.7, 0.1);
1887        let out = tonemap::gamma_correct(v, 1.0);
1888        assert!((out - v).length() < 1e-5);
1889    }
1890
1891    // ── GLSL generation ───────────────────────────────────────────────────────
1892
1893    #[test]
1894    fn glsl_cook_torrance_contains_ggx() {
1895        let src = BrdfGlsl::cook_torrance_source();
1896        assert!(src.contains("ggxD"), "Expected ggxD function");
1897        assert!(src.contains("smithGGX"), "Expected smithGGX function");
1898        assert!(src.contains("schlickF"), "Expected schlickF function");
1899    }
1900
1901    #[test]
1902    fn glsl_ibl_source_contains_expected_samplers() {
1903        let src = BrdfGlsl::ibl_source();
1904        assert!(src.contains("u_PrefilteredEnv"));
1905        assert!(src.contains("u_BrdfLut"));
1906    }
1907
1908    #[test]
1909    fn glsl_full_brdf_compiles_to_large_string() {
1910        let src = BrdfGlsl::full_brdf_glsl(ToneMapOp::AcesFilmic);
1911        assert!(src.len() > 1000);
1912        assert!(src.contains("#define PI"));
1913    }
1914
1915    // ── SimpleRng ─────────────────────────────────────────────────────────────
1916
1917    #[test]
1918    fn simple_rng_produces_values_in_range() {
1919        let mut rng = SimpleRng::new(42);
1920        for _ in 0..1000 {
1921            let v = rng.next_f32();
1922            assert!((0.0..1.0).contains(&v), "RNG value {v} out of [0,1)");
1923        }
1924    }
1925}