1use crate::ray::{HitRecord, Photon, Ray};
8use nalgebra::{Unit, Vector3};
9use rand::Rng;
10use std::f64::consts::PI;
11
12#[derive(Debug, Clone)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22pub struct MaterialParams {
23 pub name: String,
25
26 pub reflectance_pct: f64,
33
34 pub ior: f64,
40
41 pub transmittance_pct: f64,
46
47 pub thickness_mm: f64,
52
53 pub diffusion_pct: f64,
64}
65
66impl MaterialParams {
67 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 Material::ClearTransmitter {
84 ior,
85 transmittance: self.transmittance_pct / 100.0,
86 min_reflectance: min_refl,
87 }
88 } else {
89 let thickness_m = self.thickness_mm / 1000.0;
91 let tau = (self.transmittance_pct / 100.0).max(0.001);
92
93 let mu_a = -(tau.ln()) / thickness_m;
103
104 let mu_s = (self.diffusion_pct / 100.0) * 2000.0; 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 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#[derive(Debug, Clone)]
148pub enum Material {
149 Absorber,
151
152 DiffuseReflector {
154 reflectance: f64,
156 },
157
158 SpecularReflector {
160 reflectance: f64,
162 },
163
164 MixedReflector {
166 reflectance: f64,
168 specular_fraction: f64,
170 },
171
172 ClearTransmitter {
174 ior: f64,
176 transmittance: f64,
178 min_reflectance: f64,
181 },
182
183 DiffuseTransmitter {
185 ior: f64,
187 scattering_coeff: f64,
189 absorption_coeff: f64,
191 asymmetry: f64,
193 thickness: f64,
195 min_reflectance: f64,
197 },
198}
199
200#[derive(Debug, Clone)]
202pub enum Interaction {
203 Absorbed,
205 Reflected { new_ray: Ray, attenuation: f64 },
207 Transmitted { new_ray: Ray, attenuation: f64 },
209}
210
211impl Material {
212 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 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, }
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
288fn 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
298fn 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
309fn 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; }
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
326fn 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 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
349fn 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 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
373fn 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
385fn 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 let fresnel_r = fresnel_schlick(cos_i.abs(), eta_ratio).max(min_reflectance);
415
416 if rng.random::<f64>() < fresnel_r {
417 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 match refract(&photon.ray.direction, &hit.normal, eta_ratio) {
426 Some(refracted) => {
427 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 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#[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 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 let transmittance = (-mu_a * thickness).exp();
502 if rng.random::<f64>() > transmittance {
503 return Interaction::Absorbed;
504 }
505
506 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 let exit_dir_internal = if mu_s > 0.0 {
521 sample_henyey_greenstein(&refracted, g, rng)
522 } else {
523 refracted
524 };
525
526 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 return Interaction::Absorbed;
534 }
535
536 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; }
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, }
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 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}