Skip to main content

omp_tui/scene/
integrator.rs

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/// An analytic light evaluated without sampling noise.
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub enum Light {
13	/// An isotropic point source. `intensity` is radiant intensity and therefore
14	/// falls off with the inverse square of distance.
15	Point {
16		/// World-space source position.
17		position:  Vec3,
18		/// Unit-scale linear RGB source color.
19		color:     Vec3,
20		/// Non-negative radiant intensity multiplier.
21		intensity: f32,
22	},
23	/// A source at infinity. `direction` is the direction its rays travel.
24	Directional {
25		/// Unit direction from the light toward the scene.
26		direction: Vec3,
27		/// Unit-scale linear RGB source color.
28		color:     Vec3,
29		/// Non-negative incident irradiance multiplier.
30		intensity: f32,
31	},
32}
33
34impl Light {
35	/// Creates an inverse-square point light in linear color space.
36	pub const fn point(position: Vec3, color: Vec3, intensity: f32) -> Self {
37		Self::Point { position, color, intensity }
38	}
39
40	/// Creates a directional light whose rays travel along `direction`.
41	pub fn directional(direction: Vec3, color: Vec3, intensity: f32) -> Self {
42		Self::Directional { direction: direction.normalize(), color, intensity }
43	}
44}
45
46/// Geometry, analytic lights, and the linear radiance seen by rays that miss.
47///
48/// Four lights stay inline; larger scenes spill only while the world is built.
49pub struct World {
50	bvh:         Bvh,
51	environment: Vec3,
52	lights:      SmallVec<Light, INLINE_LIGHTS>,
53}
54
55impl World {
56	/// Builds a world from owned objects with no lights and a black environment.
57	pub fn new(objects: Vec<Object>) -> Self {
58		Self::from_bvh(Bvh::new(objects))
59	}
60
61	/// Creates a world from an already-built acceleration structure.
62	pub const fn from_bvh(bvh: Bvh) -> Self {
63		Self { bvh, environment: Vec3::ZERO, lights: SmallVec::new() }
64	}
65
66	/// Adds an analytic light and returns this world.
67	///
68	/// The first four lights remain inline; overflow storage is allocated only
69	/// while constructing the world, never during tracing.
70	pub fn with_light(mut self, light: Light) -> Self {
71		self.lights.push(light);
72		self
73	}
74
75	/// Sets the constant linear environment radiance and returns this world.
76	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/// Path-transport limits and deterministic sampling configuration.
87#[derive(Clone, Copy, Debug, PartialEq)]
88pub struct Integrator {
89	/// Maximum number of secondary surface bounces after the primary hit.
90	pub max_bounces:            u8,
91	/// Independent paths averaged for each primary ray; zero is treated as one.
92	pub samples_per_ray:        u16,
93	/// Secondary-bounce index at which Russian roulette begins.
94	pub russian_roulette_start: u8,
95	/// Positive distance used for ray origins and minimum intersections.
96	pub ray_epsilon:            f32,
97	/// Furthest distance considered by primary, secondary, and shadow rays.
98	pub max_distance:           f32,
99	/// User-controlled salt mixed with every primary ray's exact float bits.
100	pub seed:                   u64,
101}
102
103impl Integrator {
104	/// Returns this configuration with unsafe or non-finite limits repaired.
105	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
139/// Deterministic CPU path tracer over an owned [`World`].
140pub struct PathTracer {
141	world:      World,
142	integrator: Integrator,
143	camera:     Camera,
144}
145
146impl PathTracer {
147	/// Creates a tracer with the default camera and [`Integrator`]
148	/// configuration.
149	pub fn new(world: World) -> Self {
150		Self { world, integrator: Integrator::default(), camera: Camera::default() }
151	}
152
153	/// Selects the camera returned by [`Trace::advance`].
154	pub const fn with_camera(mut self, camera: Camera) -> Self {
155		self.camera = camera;
156		self
157	}
158
159	/// Selects transport limits and deterministic sampling configuration.
160	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}