Skip to main content

eulumdat_goniosim/
material.rs

1//! Two-layer material system: user-facing `MaterialParams` and internal `Material`.
2//!
3//! Users work with `MaterialParams` (datasheet values like reflectance %, IOR,
4//! transmittance %, thickness, diffusion %). The tracer converts them to the
5//! internal `Material` enum with physics coefficients automatically.
6
7use crate::ray::{HitRecord, Photon, Ray};
8use nalgebra::{Unit, Vector3};
9use rand::Rng;
10use std::f64::consts::PI;
11
12// ---------------------------------------------------------------------------
13// User-facing: MaterialParams
14// ---------------------------------------------------------------------------
15
16/// Material description using manufacturer datasheet values.
17///
18/// These are the values you find on a material datasheet. A lighting designer
19/// can fill these in without understanding Monte Carlo internals.
20#[derive(Debug, Clone)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22pub struct MaterialParams {
23    /// Human-readable name, e.g. "PMMA opal 3mm".
24    pub name: String,
25
26    /// Reflexionsgrad \[%\], 0-100.
27    ///
28    /// How much light is reflected at the surface.
29    /// For opaque materials: total reflectance (diffuse + specular combined).
30    /// For transparent materials: Fresnel reflectance is computed from IOR,
31    /// this field is ignored (set to 0).
32    pub reflectance_pct: f64,
33
34    /// Brechungsindex (index of refraction).
35    ///
36    /// How much light bends when entering the material.
37    /// PMMA: 1.49, glass: 1.52, polycarbonate: 1.585.
38    /// Set to 0.0 for opaque materials (metal, paint).
39    pub ior: f64,
40
41    /// Lichtdurchlässigkeit \[%\], 0-100 at the given thickness.
42    ///
43    /// How much light passes through (measured at normal incidence).
44    /// 0 = fully opaque, 92 = clear PMMA 3mm, 50 = heavy opal PMMA 3mm.
45    pub transmittance_pct: f64,
46
47    /// Dicke \[mm\].
48    ///
49    /// Material thickness. Affects volume scattering path length
50    /// and Beer-Lambert absorption. Ignored for opaque materials.
51    pub thickness_mm: f64,
52
53    /// Streuungsgrad \[%\], 0-100.
54    ///
55    /// Degree of light diffusion/scattering.
56    /// - 0 = perfectly clear (or mirror-specular for opaque)
57    /// - 25 = satin/frosted
58    /// - 60 = light opal
59    /// - 95 = heavy opal (near-Lambertian exit distribution)
60    /// - 100 = fully diffuse (matte paint for opaque)
61    ///
62    /// Maps directly to haze values in datasheets (e.g. Evonik Plexiglas).
63    pub diffusion_pct: f64,
64}
65
66impl MaterialParams {
67    /// Convert user-facing parameters to internal physics `Material`.
68    pub fn to_material(&self) -> Material {
69        let is_transparent = self.transmittance_pct > 0.0;
70        let is_near_absorber = self.reflectance_pct < 2.0 && !is_transparent;
71
72        if is_near_absorber {
73            return Material::Absorber;
74        }
75
76        if is_transparent {
77            let ior = if self.ior > 0.0 { self.ior } else { 1.49 };
78
79            let min_refl = self.reflectance_pct / 100.0;
80
81            if self.diffusion_pct < 5.0 {
82                // Clear transmitter: Fresnel + Snell, Beer-Lambert absorption
83                Material::ClearTransmitter {
84                    ior,
85                    transmittance: self.transmittance_pct / 100.0,
86                    min_reflectance: min_refl,
87                }
88            } else {
89                // Diffuse transmitter: volume scattering
90                let thickness_m = self.thickness_mm / 1000.0;
91                let tau = (self.transmittance_pct / 100.0).max(0.001);
92
93                // The transmittance specifies the total fraction of light that
94                // passes through the material. We need absorption to enforce this.
95                //
96                // Beer-Lambert gives us the absorption coefficient directly:
97                // tau = exp(-mu_a * d)  →  mu_a = -ln(tau) / d
98                //
99                // Scattering (diffusion) redistributes light direction but does
100                // not remove energy. The scattering coefficient controls how
101                // diffuse the output is, independent of transmittance.
102                let mu_a = -(tau.ln()) / thickness_m;
103
104                // Scattering coefficient: controls angular spread, not loss.
105                // Higher diffusion = more scattering events = more diffuse output.
106                // Scale so 100% diffusion gives heavy scattering.
107                let mu_s = (self.diffusion_pct / 100.0) * 2000.0; // [1/m]
108
109                // Henyey-Greenstein asymmetry:
110                // Low diffusion = forward-biased (g near 0.9)
111                // High diffusion = near-isotropic (g near 0.0)
112                let g = 0.9 * (1.0 - self.diffusion_pct / 100.0);
113
114                Material::DiffuseTransmitter {
115                    ior,
116                    scattering_coeff: mu_s,
117                    absorption_coeff: mu_a,
118                    asymmetry: g,
119                    thickness: thickness_m,
120                    min_reflectance: min_refl,
121                }
122            }
123        } else {
124            // Opaque material
125            let rho = self.reflectance_pct / 100.0;
126
127            if self.diffusion_pct < 1.0 {
128                Material::SpecularReflector { reflectance: rho }
129            } else if self.diffusion_pct > 99.0 {
130                Material::DiffuseReflector { reflectance: rho }
131            } else {
132                Material::MixedReflector {
133                    reflectance: rho,
134                    specular_fraction: 1.0 - self.diffusion_pct / 100.0,
135                }
136            }
137        }
138    }
139}
140
141// ---------------------------------------------------------------------------
142// Internal: Material enum (physics)
143// ---------------------------------------------------------------------------
144
145/// Internal material representation with physics coefficients.
146/// Users don't construct these directly — they are derived from `MaterialParams`.
147#[derive(Debug, Clone)]
148pub enum Material {
149    /// Perfect absorber — photon dies.
150    Absorber,
151
152    /// Lambertian (diffuse) reflector.
153    DiffuseReflector {
154        /// Reflectance rho, 0..1.
155        reflectance: f64,
156    },
157
158    /// Specular (mirror) reflector.
159    SpecularReflector {
160        /// Reflectance rho, 0..1.
161        reflectance: f64,
162    },
163
164    /// Mixed reflector (partly specular, partly diffuse).
165    MixedReflector {
166        /// Total reflectance rho, 0..1.
167        reflectance: f64,
168        /// Fraction of reflected light that is specular (0 = fully diffuse, 1 = fully specular).
169        specular_fraction: f64,
170    },
171
172    /// Clear dielectric (glass, clear PMMA).
173    ClearTransmitter {
174        /// Index of refraction.
175        ior: f64,
176        /// Transmittance at normal incidence for the given thickness.
177        transmittance: f64,
178        /// Minimum reflectance at normal incidence (user-specified, 0..1).
179        /// Used as max(fresnel, this) to allow coated surfaces.
180        min_reflectance: f64,
181    },
182
183    /// Diffuse transmitter (opal/satin PMMA) with volume scattering.
184    DiffuseTransmitter {
185        /// Index of refraction.
186        ior: f64,
187        /// Scattering coefficient mu_s \[1/m\].
188        scattering_coeff: f64,
189        /// Absorption coefficient mu_a \[1/m\].
190        absorption_coeff: f64,
191        /// Henyey-Greenstein asymmetry parameter g (-1..1).
192        asymmetry: f64,
193        /// Slab thickness \[m\].
194        thickness: f64,
195        /// Minimum reflectance at normal incidence (user-specified, 0..1).
196        min_reflectance: f64,
197    },
198}
199
200/// Result of a photon-material interaction.
201#[derive(Debug, Clone)]
202pub enum Interaction {
203    /// Photon was absorbed.
204    Absorbed,
205    /// Photon was reflected with a new ray and energy attenuation.
206    Reflected { new_ray: Ray, attenuation: f64 },
207    /// Photon was transmitted through the material.
208    Transmitted { new_ray: Ray, attenuation: f64 },
209}
210
211impl Material {
212    /// Compute the interaction of a photon with this material at a hit point.
213    pub fn interact<R: Rng>(&self, photon: &Photon, hit: &HitRecord, rng: &mut R) -> Interaction {
214        match self {
215            Material::Absorber => Interaction::Absorbed,
216
217            Material::DiffuseReflector { reflectance } => {
218                // Russian roulette on reflectance
219                if rng.random::<f64>() > *reflectance {
220                    return Interaction::Absorbed;
221                }
222                let new_dir = random_cosine_hemisphere(&hit.normal, rng);
223                Interaction::Reflected {
224                    new_ray: Ray::new(hit.point + new_dir.as_ref() * 1e-6, new_dir),
225                    attenuation: 1.0, // reflectance handled by RR above
226                }
227            }
228
229            Material::SpecularReflector { reflectance } => {
230                if rng.random::<f64>() > *reflectance {
231                    return Interaction::Absorbed;
232                }
233                let reflected = reflect(&photon.ray.direction, &hit.normal);
234                Interaction::Reflected {
235                    new_ray: Ray::new(hit.point + reflected.as_ref() * 1e-6, reflected),
236                    attenuation: 1.0,
237                }
238            }
239
240            Material::MixedReflector {
241                reflectance,
242                specular_fraction,
243            } => {
244                if rng.random::<f64>() > *reflectance {
245                    return Interaction::Absorbed;
246                }
247                let new_dir = if rng.random::<f64>() < *specular_fraction {
248                    reflect(&photon.ray.direction, &hit.normal)
249                } else {
250                    random_cosine_hemisphere(&hit.normal, rng)
251                };
252                Interaction::Reflected {
253                    new_ray: Ray::new(hit.point + new_dir.as_ref() * 1e-6, new_dir),
254                    attenuation: 1.0,
255                }
256            }
257
258            Material::ClearTransmitter {
259                ior,
260                transmittance,
261                min_reflectance,
262            } => {
263                interact_clear_transmitter(photon, hit, *ior, *transmittance, *min_reflectance, rng)
264            }
265
266            Material::DiffuseTransmitter {
267                ior,
268                scattering_coeff,
269                absorption_coeff,
270                asymmetry,
271                thickness,
272                min_reflectance,
273            } => interact_diffuse_transmitter(
274                photon,
275                hit,
276                *ior,
277                *scattering_coeff,
278                *absorption_coeff,
279                *asymmetry,
280                *thickness,
281                *min_reflectance,
282                rng,
283            ),
284        }
285    }
286}
287
288// ---------------------------------------------------------------------------
289// Fresnel equations
290// ---------------------------------------------------------------------------
291
292/// Fresnel reflectance for unpolarized light (Schlick approximation).
293fn fresnel_schlick(cos_theta: f64, ior_ratio: f64) -> f64 {
294    let r0 = ((1.0 - ior_ratio) / (1.0 + ior_ratio)).powi(2);
295    r0 + (1.0 - r0) * (1.0 - cos_theta).powi(5)
296}
297
298// ---------------------------------------------------------------------------
299// Reflection / refraction helpers
300// ---------------------------------------------------------------------------
301
302/// Reflect a direction vector around a normal.
303fn reflect(incoming: &Unit<Vector3<f64>>, normal: &Unit<Vector3<f64>>) -> Unit<Vector3<f64>> {
304    let d = incoming.as_ref();
305    let n = normal.as_ref();
306    Unit::new_normalize(d - 2.0 * d.dot(n) * n)
307}
308
309/// Refract a direction vector through a surface (Snell's law).
310/// Returns None for total internal reflection.
311fn refract(
312    incoming: &Unit<Vector3<f64>>,
313    normal: &Unit<Vector3<f64>>,
314    eta_ratio: f64,
315) -> Option<Unit<Vector3<f64>>> {
316    let cos_i = (-incoming.as_ref()).dot(normal.as_ref()).min(1.0);
317    let sin2_t = eta_ratio * eta_ratio * (1.0 - cos_i * cos_i);
318    if sin2_t > 1.0 {
319        return None; // total internal reflection
320    }
321    let cos_t = (1.0 - sin2_t).sqrt();
322    let refracted = eta_ratio * incoming.as_ref() + (eta_ratio * cos_i - cos_t) * normal.as_ref();
323    Some(Unit::new_normalize(refracted))
324}
325
326// ---------------------------------------------------------------------------
327// Random direction sampling
328// ---------------------------------------------------------------------------
329
330/// Sample a direction from cosine-weighted hemisphere around a normal.
331fn random_cosine_hemisphere<R: Rng>(
332    normal: &Unit<Vector3<f64>>,
333    rng: &mut R,
334) -> Unit<Vector3<f64>> {
335    let u1: f64 = rng.random();
336    let u2: f64 = rng.random();
337    let r = u1.sqrt();
338    let theta = 2.0 * PI * u2;
339    let x = r * theta.cos();
340    let y = r * theta.sin();
341    let z = (1.0 - u1).sqrt();
342
343    // Build orthonormal basis from normal
344    let (tangent, bitangent) = build_onb(normal);
345    let dir = x * tangent.as_ref() + y * bitangent.as_ref() + z * normal.as_ref();
346    Unit::new_normalize(dir)
347}
348
349/// Sample a direction from the Henyey-Greenstein phase function.
350fn sample_henyey_greenstein<R: Rng>(
351    incoming: &Unit<Vector3<f64>>,
352    g: f64,
353    rng: &mut R,
354) -> Unit<Vector3<f64>> {
355    let xi: f64 = rng.random();
356    let cos_theta = if g.abs() < 1e-6 {
357        // Isotropic: uniform on sphere
358        1.0 - 2.0 * xi
359    } else {
360        let term = (1.0 - g * g) / (1.0 - g + 2.0 * g * xi);
361        (1.0 + g * g - term * term) / (2.0 * g)
362    };
363    let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt();
364    let phi = 2.0 * PI * rng.random::<f64>();
365
366    let (tangent, bitangent) = build_onb(incoming);
367    let dir = sin_theta * phi.cos() * tangent.as_ref()
368        + sin_theta * phi.sin() * bitangent.as_ref()
369        + cos_theta * incoming.as_ref();
370    Unit::new_normalize(dir)
371}
372
373/// Build an orthonormal basis from a single vector.
374fn build_onb(n: &Unit<Vector3<f64>>) -> (Unit<Vector3<f64>>, Unit<Vector3<f64>>) {
375    let a = if n.x.abs() > 0.9 {
376        Vector3::y_axis()
377    } else {
378        Vector3::x_axis()
379    };
380    let t = Unit::new_normalize(n.cross(a.as_ref()));
381    let b = Unit::new_normalize(n.cross(t.as_ref()));
382    (t, b)
383}
384
385// ---------------------------------------------------------------------------
386// Clear transmitter interaction
387// ---------------------------------------------------------------------------
388
389fn interact_clear_transmitter<R: Rng>(
390    photon: &Photon,
391    hit: &HitRecord,
392    ior: f64,
393    transmittance: f64,
394    min_reflectance: f64,
395    rng: &mut R,
396) -> Interaction {
397    let (eta_ratio, cos_i) = if hit.front_face {
398        (
399            1.0 / ior,
400            (-photon.ray.direction.as_ref())
401                .dot(hit.normal.as_ref())
402                .min(1.0),
403        )
404    } else {
405        (
406            ior,
407            (-photon.ray.direction.as_ref())
408                .dot(hit.normal.as_ref())
409                .min(1.0),
410        )
411    };
412
413    // Use the higher of Fresnel or user-specified reflectance
414    let fresnel_r = fresnel_schlick(cos_i.abs(), eta_ratio).max(min_reflectance);
415
416    if rng.random::<f64>() < fresnel_r {
417        // Reflect
418        let reflected = reflect(&photon.ray.direction, &hit.normal);
419        Interaction::Reflected {
420            new_ray: Ray::new(hit.point + reflected.as_ref() * 1e-6, reflected),
421            attenuation: 1.0,
422        }
423    } else {
424        // Transmit
425        match refract(&photon.ray.direction, &hit.normal, eta_ratio) {
426            Some(refracted) => {
427                // Apply Beer-Lambert absorption for one surface pass.
428                // Full transmittance is for both surfaces, so per-surface ~ sqrt(tau).
429                let per_surface_tau = transmittance.sqrt();
430                Interaction::Transmitted {
431                    new_ray: Ray::new(hit.point + refracted.as_ref() * 1e-6, refracted),
432                    attenuation: per_surface_tau,
433                }
434            }
435            None => {
436                // Total internal reflection
437                let reflected = reflect(&photon.ray.direction, &hit.normal);
438                Interaction::Reflected {
439                    new_ray: Ray::new(hit.point + reflected.as_ref() * 1e-6, reflected),
440                    attenuation: 1.0,
441                }
442            }
443        }
444    }
445}
446
447// ---------------------------------------------------------------------------
448// Diffuse transmitter interaction (volume scattering)
449// ---------------------------------------------------------------------------
450
451/// Diffuse transmitter: simplified thin-sheet model.
452///
453/// Instead of a full volume random walk, this treats the cover as a thin sheet:
454/// 1. Fresnel reflection at entry surface (from IOR)
455/// 2. Absorption: photon survives with probability = transmittance
456/// 3. Diffusion: direction is scattered based on diffusion strength (HG parameter g)
457/// 4. Fresnel at exit surface
458///
459/// This guarantees the specified transmittance is respected exactly,
460/// while the scattering coefficient controls angular spread independently.
461#[allow(clippy::too_many_arguments)]
462fn interact_diffuse_transmitter<R: Rng>(
463    photon: &Photon,
464    hit: &HitRecord,
465    ior: f64,
466    mu_s: f64,
467    mu_a: f64,
468    g: f64,
469    thickness: f64,
470    min_reflectance: f64,
471    rng: &mut R,
472) -> Interaction {
473    let (eta_ratio, cos_i) = if hit.front_face {
474        (
475            1.0 / ior,
476            (-photon.ray.direction.as_ref())
477                .dot(hit.normal.as_ref())
478                .min(1.0),
479        )
480    } else {
481        (
482            ior,
483            (-photon.ray.direction.as_ref())
484                .dot(hit.normal.as_ref())
485                .min(1.0),
486        )
487    };
488
489    // Entry surface: use the higher of Fresnel or user-specified reflectance
490    let fresnel_r = fresnel_schlick(cos_i.abs(), eta_ratio).max(min_reflectance);
491    if rng.random::<f64>() < fresnel_r {
492        let reflected = reflect(&photon.ray.direction, &hit.normal);
493        return Interaction::Reflected {
494            new_ray: Ray::new(hit.point + reflected.as_ref() * 1e-6, reflected),
495            attenuation: 1.0,
496        };
497    }
498
499    // Absorption: transmittance = exp(-mu_a * thickness)
500    // Photon survives with this probability
501    let transmittance = (-mu_a * thickness).exp();
502    if rng.random::<f64>() > transmittance {
503        return Interaction::Absorbed;
504    }
505
506    // Refract into the material
507    let refracted = match refract(&photon.ray.direction, &hit.normal, eta_ratio) {
508        Some(r) => r,
509        None => {
510            let reflected = reflect(&photon.ray.direction, &hit.normal);
511            return Interaction::Reflected {
512                new_ray: Ray::new(hit.point + reflected.as_ref() * 1e-6, reflected),
513                attenuation: 1.0,
514            };
515        }
516    };
517
518    // Apply angular diffusion: scatter the direction
519    // mu_s > 0 means diffusion is active; g controls forward bias
520    let exit_dir_internal = if mu_s > 0.0 {
521        sample_henyey_greenstein(&refracted, g, rng)
522    } else {
523        refracted
524    };
525
526    // Exit surface: Fresnel
527    let exit_eta = if hit.front_face { ior } else { 1.0 / ior };
528    let cos_exit = exit_dir_internal.dot(hit.normal.as_ref()).abs().min(1.0);
529    let exit_fresnel = fresnel_schlick(cos_exit, exit_eta);
530    if rng.random::<f64>() < exit_fresnel {
531        // Reflected back — treat as absorbed for simplicity
532        // (in reality it would bounce around, but for a thin sheet this is rare)
533        return Interaction::Absorbed;
534    }
535
536    // Refract out
537    let exit_normal = if hit.front_face {
538        Unit::new_unchecked(-hit.normal.into_inner())
539    } else {
540        hit.normal
541    };
542    let exit_dir = match refract(&exit_dir_internal, &exit_normal, exit_eta) {
543        Some(d) => d,
544        None => {
545            return Interaction::Absorbed; // TIR
546        }
547    };
548
549    let exit_point = hit.point + exit_normal.as_ref() * thickness + exit_dir.as_ref() * 1e-6;
550    Interaction::Transmitted {
551        new_ray: Ray::new(exit_point, exit_dir),
552        attenuation: 1.0, // absorption already handled above
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::catalog;
560
561    #[test]
562    fn clear_pmma_produces_clear_transmitter() {
563        let params = catalog::clear_pmma_3mm();
564        let mat = params.to_material();
565        match mat {
566            Material::ClearTransmitter {
567                ior,
568                transmittance,
569                min_reflectance,
570            } => {
571                assert!((ior - 1.49).abs() < 0.01);
572                assert!((transmittance - 0.92).abs() < 0.01);
573                assert!((min_reflectance - 0.04).abs() < 0.01);
574            }
575            _ => panic!("Expected ClearTransmitter, got {:?}", mat),
576        }
577    }
578
579    #[test]
580    fn opal_pmma_produces_diffuse_transmitter() {
581        let params = catalog::opal_pmma_3mm();
582        let mat = params.to_material();
583        match mat {
584            Material::DiffuseTransmitter {
585                ior,
586                scattering_coeff,
587                absorption_coeff,
588                asymmetry,
589                thickness,
590                min_reflectance,
591            } => {
592                assert!((ior - 1.49).abs() < 0.01);
593                assert!(scattering_coeff > 0.0);
594                assert!(absorption_coeff > 0.0);
595                assert!(asymmetry < 0.1, "High diffusion should give low asymmetry");
596                assert!((thickness - 0.003).abs() < 0.0001);
597                assert!((min_reflectance - 0.04).abs() < 0.01);
598            }
599            _ => panic!("Expected DiffuseTransmitter, got {:?}", mat),
600        }
601    }
602
603    #[test]
604    fn white_paint_produces_diffuse_reflector() {
605        let params = catalog::white_paint();
606        let mat = params.to_material();
607        match mat {
608            Material::DiffuseReflector { reflectance } => {
609                assert!((reflectance - 0.85).abs() < 0.01);
610            }
611            _ => panic!("Expected DiffuseReflector, got {:?}", mat),
612        }
613    }
614
615    #[test]
616    fn mirror_produces_specular_reflector() {
617        let params = catalog::mirror_aluminum();
618        let mat = params.to_material();
619        match mat {
620            Material::SpecularReflector { reflectance } => {
621                assert!((reflectance - 0.95).abs() < 0.01);
622            }
623            _ => panic!("Expected SpecularReflector, got {:?}", mat),
624        }
625    }
626
627    #[test]
628    fn matte_black_near_absorber() {
629        let params = catalog::matte_black();
630        let mat = params.to_material();
631        // 5% reflectance is above the 2% absorber threshold
632        match mat {
633            Material::DiffuseReflector { reflectance } => {
634                assert!((reflectance - 0.05).abs() < 0.01);
635            }
636            _ => panic!("Expected DiffuseReflector, got {:?}", mat),
637        }
638    }
639
640    #[test]
641    fn anodized_aluminum_produces_mixed_reflector() {
642        let params = catalog::anodized_aluminum();
643        let mat = params.to_material();
644        match mat {
645            Material::MixedReflector {
646                reflectance,
647                specular_fraction,
648            } => {
649                assert!((reflectance - 0.80).abs() < 0.01);
650                assert!((specular_fraction - 0.30).abs() < 0.01);
651            }
652            _ => panic!("Expected MixedReflector, got {:?}", mat),
653        }
654    }
655}