1use std::time::Duration;
2
3use smallvec::SmallVec;
4
5use super::{Bvh, Camera, Hit, Material, Object, Ray, Trace, Vec3, vec3};
6
7const PI: f32 = std::f32::consts::PI;
8const INLINE_LIGHTS: usize = 4;
9
10#[derive(Clone, Copy, Debug, PartialEq)]
12pub enum Light {
13 Point {
16 position: Vec3,
18 color: Vec3,
20 intensity: f32,
22 },
23 Directional {
25 direction: Vec3,
27 color: Vec3,
29 intensity: f32,
31 },
32}
33
34impl Light {
35 pub const fn point(position: Vec3, color: Vec3, intensity: f32) -> Self {
37 Self::Point { position, color, intensity }
38 }
39
40 pub fn directional(direction: Vec3, color: Vec3, intensity: f32) -> Self {
42 Self::Directional { direction: direction.normalize(), color, intensity }
43 }
44}
45
46pub struct World {
50 bvh: Bvh,
51 environment: Vec3,
52 lights: SmallVec<Light, INLINE_LIGHTS>,
53}
54
55impl World {
56 pub fn new(objects: Vec<Object>) -> Self {
58 Self::from_bvh(Bvh::new(objects))
59 }
60
61 pub const fn from_bvh(bvh: Bvh) -> Self {
63 Self { bvh, environment: Vec3::ZERO, lights: SmallVec::new() }
64 }
65
66 pub fn with_light(mut self, light: Light) -> Self {
71 self.lights.push(light);
72 self
73 }
74
75 pub const fn with_environment(mut self, environment: Vec3) -> Self {
77 self.environment = positive_color(environment);
78 self
79 }
80
81 fn lights(&self) -> impl ExactSizeIterator<Item = &Light> {
82 self.lights.iter()
83 }
84}
85
86#[derive(Clone, Copy, Debug, PartialEq)]
88pub struct Integrator {
89 pub max_bounces: u8,
91 pub samples_per_ray: u16,
93 pub russian_roulette_start: u8,
95 pub ray_epsilon: f32,
97 pub max_distance: f32,
99 pub seed: u64,
101}
102
103impl Integrator {
104 pub fn sanitized(self) -> Self {
106 let ray_epsilon = if self.ray_epsilon.is_finite() {
107 self.ray_epsilon.clamp(1.0e-6, 0.1)
108 } else {
109 1.0e-4
110 };
111 Self {
112 max_bounces: self.max_bounces.min(64),
113 samples_per_ray: self.samples_per_ray.max(1),
114 russian_roulette_start: self.russian_roulette_start.min(64),
115 ray_epsilon,
116 max_distance: if self.max_distance.is_finite() {
117 self.max_distance.max(ray_epsilon * 2.0)
118 } else {
119 1.0e30
120 },
121 seed: self.seed,
122 }
123 }
124}
125
126impl Default for Integrator {
127 fn default() -> Self {
128 Self {
129 max_bounces: 6,
130 samples_per_ray: 1,
131 russian_roulette_start: 3,
132 ray_epsilon: 1.0e-4,
133 max_distance: 1.0e30,
134 seed: 0,
135 }
136 }
137}
138
139pub struct PathTracer {
141 world: World,
142 integrator: Integrator,
143 camera: Camera,
144}
145
146impl PathTracer {
147 pub fn new(world: World) -> Self {
150 Self { world, integrator: Integrator::default(), camera: Camera::default() }
151 }
152
153 pub const fn with_camera(mut self, camera: Camera) -> Self {
155 self.camera = camera;
156 self
157 }
158
159 pub fn with_integrator(mut self, integrator: Integrator) -> Self {
161 self.integrator = integrator.sanitized();
162 self
163 }
164}
165
166impl Trace for PathTracer {
167 fn advance(&mut self, _now: Duration) -> Camera {
168 self.camera
169 }
170
171 fn shade(&self, ray: Ray) -> (Vec3, f32) {
172 let config = self.integrator.sanitized();
173 let Some(primary_hit) = self
174 .world
175 .bvh
176 .hit(ray, config.ray_epsilon, config.max_distance)
177 else {
178 return (display_color(self.world.environment), 0.0);
179 };
180
181 let mut radiance = Vec3::ZERO;
182 for sample in 0..config.samples_per_ray {
183 let mut rng = Rng::for_ray(ray, config.seed, sample);
184 radiance += self.trace_path(ray, primary_hit, config, &mut rng);
185 }
186 (display_color(radiance * (1.0 / f32::from(config.samples_per_ray))), 1.0)
187 }
188}
189
190impl PathTracer {
191 fn trace_path<'w>(
192 &'w self,
193 mut ray: Ray,
194 mut hit: Hit<'w>,
195 config: Integrator,
196 rng: &mut Rng,
197 ) -> Vec3 {
198 let mut radiance = Vec3::ZERO;
199 let mut throughput = vec3(1.0, 1.0, 1.0);
200
201 for depth in 0..=config.max_bounces {
202 let material = hit.material.sanitized();
203 radiance = bounded_color(radiance + throughput * material.emission);
204 radiance = bounded_color(
205 radiance + throughput * self.direct_lighting(&hit, ray, material, config),
206 );
207
208 if depth == config.max_bounces || is_non_reflecting_emitter(material) {
209 break;
210 }
211 let Some(scatter) = sample_surface(ray, &hit, material, rng) else {
212 break;
213 };
214 throughput = bounded_color(throughput * scatter.weight);
215 if throughput.max_component() <= 0.0 {
216 break;
217 }
218
219 let secondary = depth.saturating_add(1);
220 if secondary >= config.russian_roulette_start {
221 let survive = throughput.max_component().clamp(0.05, 0.95);
222 if rng.next_f32() >= survive {
223 break;
224 }
225 throughput *= 1.0 / survive;
226 }
227
228 ray = Ray {
229 origin: offset_origin(
230 hit.point,
231 hit.geometric_normal,
232 scatter.direction,
233 config.ray_epsilon,
234 ),
235 dir: scatter.direction.normalize(),
236 };
237 let Some(next_hit) = self
238 .world
239 .bvh
240 .hit(ray, config.ray_epsilon, config.max_distance)
241 else {
242 radiance =
243 bounded_color(radiance + throughput * positive_color(self.world.environment));
244 break;
245 };
246 hit = next_hit;
247 }
248 bounded_color(radiance)
249 }
250
251 fn direct_lighting(
252 &self,
253 hit: &Hit<'_>,
254 ray: Ray,
255 material: Material,
256 config: Integrator,
257 ) -> Vec3 {
258 if is_non_reflecting_emitter(material) {
259 return Vec3::ZERO;
260 }
261 let mut result = Vec3::ZERO;
262 let view = ray.dir * -1.0;
263 for light in self.world.lights() {
264 let Some(incident) = incident_light(*light, hit.point, config.max_distance) else {
265 continue;
266 };
267 let n_dot_l = hit.normal.dot(incident.direction).max(0.0);
268 if n_dot_l <= 0.0 {
269 continue;
270 }
271 let shadow = Ray {
272 origin: offset_origin(
273 hit.point,
274 hit.geometric_normal,
275 incident.direction,
276 config.ray_epsilon,
277 ),
278 dir: incident.direction,
279 };
280 let shadow_max = (incident.distance - config.ray_epsilon).min(config.max_distance);
281 if shadow_max > config.ray_epsilon
282 && self
283 .world
284 .bvh
285 .occluded(shadow, config.ray_epsilon, shadow_max)
286 {
287 continue;
288 }
289 let brdf = opaque_brdf(material, hit.normal, view, incident.direction);
290 result = bounded_color(result + brdf * incident.radiance * n_dot_l);
291 }
292 bounded_color(result)
293 }
294}
295
296#[derive(Clone, Copy)]
297struct IncidentLight {
298 direction: Vec3,
299 distance: f32,
300 radiance: Vec3,
301}
302
303fn incident_light(light: Light, point: Vec3, max_distance: f32) -> Option<IncidentLight> {
304 match light {
305 Light::Point { position, color, intensity } => {
306 let offset = position - point;
307 let distance_squared = offset.dot(offset);
308 if !distance_squared.is_finite() || distance_squared <= 1.0e-12 {
309 return None;
310 }
311 let distance = distance_squared.sqrt();
312 if distance > max_distance {
313 return None;
314 }
315 Some(IncidentLight {
316 direction: offset * (1.0 / distance),
317 distance,
318 radiance: positive_color(color) * (positive(intensity) / distance_squared),
319 })
320 },
321 Light::Directional { direction, color, intensity } => {
322 let direction = (direction * -1.0).normalize();
323 if direction.dot(direction) < 0.5 {
324 return None;
325 }
326 Some(IncidentLight {
327 direction,
328 distance: max_distance,
329 radiance: positive_color(color) * positive(intensity),
330 })
331 },
332 }
333}
334
335fn opaque_brdf(material: Material, normal: Vec3, view: Vec3, light: Vec3) -> Vec3 {
336 let n_dot_v = normal.dot(view).max(0.0);
337 let n_dot_l = normal.dot(light).max(0.0);
338 if n_dot_v <= 0.0 || n_dot_l <= 0.0 {
339 return Vec3::ZERO;
340 }
341 let half = (view + light).normalize();
342 let n_dot_h = normal.dot(half).max(0.0);
343 let v_dot_h = view.dot(half).max(0.0);
344 let f0 = material_f0(material);
345 let fresnel = fresnel_schlick(f0, v_dot_h);
346 let distribution = ggx_distribution(n_dot_h, material.roughness);
347 let geometry = smith_geometry(n_dot_v, n_dot_l, material.roughness);
348 let specular = fresnel * (distribution * geometry / (4.0 * n_dot_v * n_dot_l).max(1.0e-8));
349 let diffuse_weight = (1.0 - material.metallic) * (1.0 - material.transmission);
350 let diffuse =
351 material.base_color * (Vec3::ONE - fresnel) * (diffuse_weight / PI);
352 diffuse + specular
353}
354
355fn material_f0(material: Material) -> Vec3 {
356 let dielectric = ((material.ior - 1.0) / (material.ior + 1.0)).powi(2);
357 vec3(dielectric, dielectric, dielectric).lerp(material.base_color, material.metallic)
358}
359
360fn fresnel_schlick(f0: Vec3, cosine: f32) -> Vec3 {
361 f0 + (Vec3::ONE - f0) * (1.0 - cosine.clamp(0.0, 1.0)).powi(5)
362}
363
364fn fresnel_dielectric(cosine: f32, ior: f32) -> f32 {
365 let f0 = ((ior - 1.0) / (ior + 1.0)).powi(2);
366 f0 + (1.0 - f0) * (1.0 - cosine.clamp(0.0, 1.0)).powi(5)
367}
368
369fn ggx_distribution(n_dot_h: f32, roughness: f32) -> f32 {
370 let alpha = roughness * roughness;
371 let alpha_squared = alpha * alpha;
372 let n_squared = n_dot_h.clamp(0.0, 1.0).powi(2);
373 let denominator = n_squared.mul_add(alpha_squared, 1.0 - n_squared);
374 alpha_squared / (PI * denominator * denominator).max(f32::MIN_POSITIVE)
375}
376
377fn smith_geometry(n_dot_v: f32, n_dot_l: f32, roughness: f32) -> f32 {
378 let alpha = roughness * roughness;
379 let alpha_squared = alpha * alpha;
380 let g1 = |n_dot_x: f32| {
381 2.0 * n_dot_x
382 / (n_dot_x + (alpha_squared + (1.0 - alpha_squared) * n_dot_x * n_dot_x).sqrt())
383 .max(1.0e-8)
384 };
385 g1(n_dot_v) * g1(n_dot_l)
386}
387
388#[derive(Clone, Copy)]
389struct Scatter {
390 direction: Vec3,
391 weight: Vec3,
392}
393
394fn sample_surface(ray: Ray, hit: &Hit<'_>, material: Material, rng: &mut Rng) -> Option<Scatter> {
395 let transmission = material.transmission;
396 if transmission > 0.0 && rng.next_f32() < transmission {
397 return Some(sample_dielectric(ray, hit, material, rng));
398 }
399
400 let mut opaque = material;
401 opaque.transmission = 0.0;
402 if opaque.metallic >= 0.999 && opaque.roughness <= 0.021 {
403 return Some(Scatter {
404 direction: ray.dir.reflect(hit.normal).normalize(),
405 weight: opaque.base_color,
406 });
407 }
408 sample_opaque(ray, hit.normal, opaque, rng)
409}
410
411fn sample_dielectric(ray: Ray, hit: &Hit<'_>, material: Material, rng: &mut Rng) -> Scatter {
412 let incident_cosine = (-ray.dir.dot(hit.normal)).clamp(0.0, 1.0);
413 let eta = if hit.front_face {
414 1.0 / material.ior
415 } else {
416 material.ior
417 };
418 let reflected = ray.dir.reflect(hit.normal).normalize();
419 let Some(refracted) = ray.dir.refract(hit.normal, eta) else {
420 return Scatter { direction: reflected, weight: vec3(1.0, 1.0, 1.0) };
421 };
422 if rng.next_f32() < fresnel_dielectric(incident_cosine, material.ior) {
423 Scatter { direction: reflected, weight: vec3(1.0, 1.0, 1.0) }
424 } else {
425 Scatter { direction: refracted, weight: material.base_color * (eta * eta) }
426 }
427}
428
429fn sample_opaque(ray: Ray, normal: Vec3, material: Material, rng: &mut Rng) -> Option<Scatter> {
430 let view = ray.dir * -1.0;
431 let f0 = material_f0(material);
432 let specular_probability = luminance(f0).clamp(0.1, 0.9);
433 let choose_specular = rng.next_f32() < specular_probability;
434 let direction = if choose_specular {
435 let half = sample_ggx_half(normal, material.roughness, rng);
436 let candidate = half * (2.0 * view.dot(half)) - view;
437 if normal.dot(candidate) <= 0.0 {
438 return None;
439 }
440 candidate.normalize()
441 } else {
442 sample_cosine_hemisphere(normal, rng)
443 };
444
445 let half = (view + direction).normalize();
446 let n_dot_l = normal.dot(direction).max(0.0);
447 let n_dot_h = normal.dot(half).max(0.0);
448 let v_dot_h = view.dot(half).abs().max(1.0e-8);
449 let diffuse_pdf = n_dot_l / PI;
450 let specular_pdf = ggx_distribution(n_dot_h, material.roughness) * n_dot_h / (4.0 * v_dot_h);
451 let pdf = (1.0 - specular_probability) * diffuse_pdf + specular_probability * specular_pdf;
452 if !pdf.is_finite() || pdf <= 1.0e-10 {
453 return None;
454 }
455 let brdf = opaque_brdf(material, normal, view, direction);
456 Some(Scatter { direction, weight: bounded_color(brdf * (n_dot_l / pdf)) })
457}
458
459fn sample_cosine_hemisphere(normal: Vec3, rng: &mut Rng) -> Vec3 {
460 let radius = rng.next_f32().sqrt();
461 let phi = 2.0 * PI * rng.next_f32();
462 let local_x = radius * phi.cos();
463 let local_y = radius * phi.sin();
464 let local_z = (1.0 - radius * radius).sqrt();
465 let (tangent, bitangent) = basis(normal);
466 (tangent * local_x + bitangent * local_y + normal * local_z).normalize()
467}
468
469fn sample_ggx_half(normal: Vec3, roughness: f32, rng: &mut Rng) -> Vec3 {
470 let alpha = roughness * roughness;
471 let alpha_squared = alpha * alpha;
472 let u = rng.next_f32().min(1.0 - f32::EPSILON);
473 let one_minus_u = 1.0 - u;
474 let cos_theta = (one_minus_u / alpha_squared.mul_add(u, one_minus_u)).sqrt();
475 let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt();
476 let phi = 2.0 * PI * rng.next_f32();
477 let (tangent, bitangent) = basis(normal);
478 (tangent * (sin_theta * phi.cos()) + bitangent * (sin_theta * phi.sin()) + normal * cos_theta)
479 .normalize()
480}
481
482fn basis(normal: Vec3) -> (Vec3, Vec3) {
483 let helper = if normal.z.abs() < 0.999 {
484 vec3(0.0, 0.0, 1.0)
485 } else {
486 vec3(0.0, 1.0, 0.0)
487 };
488 let tangent = helper.cross(normal).normalize();
489 (tangent, normal.cross(tangent))
490}
491
492fn offset_origin(point: Vec3, normal: Vec3, direction: Vec3, epsilon: f32) -> Vec3 {
493 let side = if normal.dot(direction) >= 0.0 {
494 1.0
495 } else {
496 -1.0
497 };
498 point + normal * (epsilon * side)
499}
500
501fn is_non_reflecting_emitter(material: Material) -> bool {
502 material.emission.max_component() > 0.0 && material.base_color.max_component() <= 0.0
503}
504
505fn luminance(color: Vec3) -> f32 {
506 0.2126 * color.x + 0.7152 * color.y + 0.0722 * color.z
507}
508
509const fn positive(value: f32) -> f32 {
510 if value.is_finite() {
511 value.max(0.0)
512 } else {
513 0.0
514 }
515}
516
517const fn positive_color(color: Vec3) -> Vec3 {
518 vec3(positive(color.x), positive(color.y), positive(color.z))
519}
520
521fn bounded_color(color: Vec3) -> Vec3 {
522 let channel = |value: f32| {
523 if value.is_finite() {
524 value.clamp(0.0, 1.0e6)
525 } else {
526 0.0
527 }
528 };
529 vec3(channel(color.x), channel(color.y), channel(color.z))
530}
531
532fn display_color(color: Vec3) -> Vec3 {
533 bounded_color(color).clamp01()
534}
535
536struct Rng {
537 state: u64,
538}
539
540impl Rng {
541 fn for_ray(ray: Ray, seed: u64, sample: u16) -> Self {
542 let mut state = seed ^ 0xa076_1d64_78bd_642f;
543 for bits in [
544 ray.origin.x.to_bits(),
545 ray.origin.y.to_bits(),
546 ray.origin.z.to_bits(),
547 ray.dir.x.to_bits(),
548 ray.dir.y.to_bits(),
549 ray.dir.z.to_bits(),
550 ] {
551 state = mix64(state ^ u64::from(bits));
552 }
553 Self { state: mix64(state ^ u64::from(sample)) }
554 }
555
556 fn next_f32(&mut self) -> f32 {
557 self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
558 let value = mix64(self.state);
559 ((value >> 40) as u32) as f32 * (1.0 / 16_777_216.0)
560 }
561}
562
563const fn mix64(mut value: u64) -> u64 {
564 value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
565 value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
566 value ^ (value >> 31)
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use crate::scene::{Object, Primitive, Sphere};
573
574 fn sphere(center: Vec3, radius: f32, material: Material) -> Object {
575 Object::new(Primitive::Sphere(Sphere::new(center, radius)), material)
576 }
577
578 fn primary_ray() -> Ray {
579 Ray { origin: vec3(0.0, 0.0, 3.0), dir: vec3(0.0, 0.0, -1.0) }
580 }
581
582 fn direct_tracer(objects: Vec<Object>) -> PathTracer {
583 let world = World::new(objects).with_light(Light::point(
584 vec3(0.0, 2.0, 2.0),
585 vec3(1.0, 1.0, 1.0),
586 30.0,
587 ));
588 PathTracer::new(world).with_integrator(Integrator {
589 max_bounces: 0,
590 samples_per_ray: 1,
591 ..Integrator::default()
592 })
593 }
594
595 #[test]
596 fn direct_light_respects_nearest_hit_shadows() {
597 let target = || sphere(Vec3::ZERO, 0.5, Material::diffuse(vec3(0.8, 0.8, 0.8)));
598 let clear = direct_tracer(vec![target()]).shade(primary_ray()).0;
599 let blocker = sphere(vec3(0.0, 1.0, 1.25), 0.35, Material::diffuse(vec3(0.2, 0.2, 0.2)));
600 let shadowed = direct_tracer(vec![target(), blocker])
601 .shade(primary_ray())
602 .0;
603 assert!(luminance(clear) > luminance(shadowed) + 0.05);
604 }
605
606 #[test]
607 fn minimum_trace_distance_keeps_directional_light_visible() {
608 let epsilon = 1.0e-4;
609 let world = World::new(vec![sphere(
610 Vec3::ZERO,
611 1.0,
612 Material::diffuse(Vec3::ONE),
613 )])
614 .with_light(Light::directional(
615 vec3(0.0, 0.0, -1.0),
616 Vec3::ONE,
617 1.0,
618 ));
619 let tracer = PathTracer::new(world).with_integrator(Integrator {
620 max_bounces: 0,
621 ray_epsilon: epsilon,
622 max_distance: epsilon * 2.0,
623 ..Integrator::default()
624 });
625 let ray = Ray {
626 origin: vec3(0.0, 0.0, 1.000_15),
627 dir: vec3(0.0, 0.0, -1.0),
628 };
629 assert!(luminance(tracer.shade(ray).0) > 0.0);
630 }
631
632 #[test]
633 fn schlick_fresnel_preserves_normal_and_grazing_limits() {
634 let f0 = vec3(0.04, 0.25, 0.81);
635 assert_eq!(fresnel_schlick(f0, 1.0), f0);
636 let grazing = fresnel_schlick(f0, 0.0);
637 assert!((grazing.x - 1.0).abs() < 1.0e-6);
638 assert!((grazing.y - 1.0).abs() < 1.0e-6);
639 assert!((grazing.z - 1.0).abs() < 1.0e-6);
640 }
641
642 #[test]
643 fn normal_incidence_brdf_conserves_fresnel_split() {
644 let base = vec3(0.8, 0.6, 0.4);
645 let material = Material::diffuse(base);
646 let normal = vec3(0.0, 0.0, 1.0);
647 let actual = opaque_brdf(material, normal, normal, normal);
648 let f0 = Vec3::splat(0.04);
649 let expected =
650 base * (Vec3::ONE - f0) * (1.0 / PI) + f0 * (1.0 / (4.0 * PI));
651 assert!((actual.x - expected.x).abs() < 1.0e-6);
652 assert!((actual.y - expected.y).abs() < 1.0e-6);
653 assert!((actual.z - expected.z).abs() < 1.0e-6);
654 }
655
656 #[test]
657 fn ggx_distribution_normalizes_and_preserves_smooth_peaks() {
658 let roughness = 0.2;
659 let steps = 100_000;
660 let mut integral = 0.0_f64;
661 for index in 0..steps {
662 let cosine = (index as f32 + 0.5) / steps as f32;
663 let density = ggx_distribution(cosine, roughness);
664 integral += f64::from(density * cosine) * (2.0 * f64::from(PI) / f64::from(steps));
665 }
666 assert!((integral - 1.0).abs() < 1.0e-4, "GGX projected-area integral: {integral}");
667
668 let smooth_roughness = 0.02_f32;
669 let peak = ggx_distribution(1.0, smooth_roughness);
670 let expected = 1.0 / (PI * smooth_roughness.powi(4));
671 assert!(((peak - expected) / expected).abs() < 1.0e-5);
672 }
673
674 #[test]
675 fn mirror_reflection_collects_environment_radiance() {
676 let world =
677 World::new(vec![sphere(Vec3::ZERO, 1.0, Material::metal(vec3(0.9, 0.8, 0.7), 0.0))])
678 .with_environment(vec3(0.3, 0.4, 0.5));
679 let tracer = PathTracer::new(world).with_integrator(Integrator {
680 max_bounces: 1,
681 russian_roulette_start: 2,
682 ..Integrator::default()
683 });
684 let (color, coverage) = tracer.shade(primary_ray());
685 assert_eq!(coverage, 1.0);
686 assert!(color.x > 0.2 && color.y > 0.2 && color.z > 0.2);
687 }
688
689 #[test]
690 fn dielectric_refraction_and_total_internal_reflection_are_distinct() {
691 let entering = vec3(0.0, 0.0, -1.0).refract(vec3(0.0, 0.0, 1.0), 1.0 / 1.5);
692 assert!(entering.is_some());
693 let grazing_inside = vec3(0.9, 0.0, 0.435_889_9).normalize();
694 assert!(grazing_inside.refract(vec3(0.0, 0.0, -1.0), 1.5).is_none());
695 }
696
697 #[test]
698 fn dielectric_path_refracts_to_emissive_geometry_behind_it() {
699 let glass = sphere(Vec3::ZERO, 1.0, Material::dielectric(Vec3::ONE, 1.5));
700 let emitter = sphere(vec3(0.0, 0.0, -3.0), 0.75, Material::emissive(Vec3::ONE, 1.0));
701 let tracer = PathTracer::new(World::new(vec![glass, emitter])).with_integrator(Integrator {
702 max_bounces: 4,
703 samples_per_ray: 32,
704 russian_roulette_start: 5,
705 seed: 11,
706 ..Integrator::default()
707 });
708 let (color, coverage) = tracer.shade(primary_ray());
709 assert_eq!(coverage, 1.0);
710 assert!(luminance(color) > 0.25, "glass must transmit the emitter: {color:?}");
711 }
712
713 #[test]
714 fn indirect_path_collects_emissive_geometry() {
715 let diffuse = sphere(Vec3::ZERO, 1.0, Material::diffuse(vec3(0.8, 0.8, 0.8)));
716 let emitter = sphere(Vec3::ZERO, 8.0, Material::emissive(vec3(0.7, 0.5, 0.3), 1.0));
717 let tracer =
718 PathTracer::new(World::new(vec![diffuse, emitter])).with_integrator(Integrator {
719 max_bounces: 1,
720 samples_per_ray: 4,
721 russian_roulette_start: 2,
722 seed: 7,
723 ..Integrator::default()
724 });
725 let (color, _) = tracer.shade(primary_ray());
726 assert!(luminance(color) > 0.05);
727 }
728
729 #[test]
730 fn seeded_sampling_is_bitwise_deterministic() {
731 let tracer = PathTracer::new(
732 World::new(vec![sphere(Vec3::ZERO, 1.0, Material::diffuse(vec3(0.8, 0.7, 0.6)))])
733 .with_environment(vec3(0.2, 0.3, 0.4)),
734 )
735 .with_integrator(Integrator {
736 max_bounces: 3,
737 samples_per_ray: 8,
738 seed: 42,
739 ..Integrator::default()
740 });
741 assert_eq!(tracer.shade(primary_ray()), tracer.shade(primary_ray()));
742 }
743
744 #[test]
745 fn output_stays_finite_and_energy_bounded() {
746 let world = World::new(vec![sphere(Vec3::ZERO, 1.0, Material::diffuse(vec3(1.0, 1.0, 1.0)))])
747 .with_light(Light::point(
748 vec3(0.0, 0.0, 2.0),
749 vec3(f32::INFINITY, 1.0e30, -1.0),
750 f32::INFINITY,
751 ));
752 let (color, _) = PathTracer::new(world).shade(primary_ray());
753 assert!(color.x.is_finite() && color.y.is_finite() && color.z.is_finite());
754 assert!(color.max_component() <= 1.0);
755 }
756
757 #[test]
758 fn primary_miss_has_zero_coverage_even_with_environment() {
759 let tracer = PathTracer::new(World::new(Vec::new()).with_environment(vec3(0.2, 0.3, 0.4)));
760 let (color, coverage) = tracer.shade(primary_ray());
761 assert_eq!(color, vec3(0.2, 0.3, 0.4));
762 assert_eq!(coverage, 0.0);
763 }
764}