mod bvh; mod camera; mod medium; mod photons; mod scene; mod scenes; mod vec3; use std::f64::consts::PI; use std::time::Instant; use indicatif::{ProgressBar, ProgressStyle}; use rand::rngs::SmallRng; use rand::{Rng, SeedableRng}; use rayon::prelude::*; use camera::Camera; use medium::{hg_phase, sample_hg, MediumState}; use photons::CausticMaps; use scene::{spot_emission, Light, Material, Ray, Scene, EPSILON}; use vec3::Vec3; /// Shadow rays stop this fraction short of the light, so they don't hit the /// light's own surface. Relative, because scenes range from ~1 to ~1000 units. const SHADOW_RELATIVE_EPSILON: f64 = 1e-4; /// Where light sampled by next event estimation arrives. enum Receiver { /// A diffuse surface: Lambertian BRDF times the cosine at the surface. Surface { normal: Vec3, albedo: Vec3 }, /// A scattering point in a medium: the phase function for turning from /// the path's direction `dir` towards the light. Medium { dir: Vec3, g: f64 }, } /// Direct light at `point` from one uniformly sampled point on `light`'s /// surface, attenuated by whatever media the shadow ray passes through. fn sample_direct( scene: &Scene, state: MediumState, light: &Light, point: Vec3, receiver: Receiver, rng: &mut R, ) -> Vec3 { let (lp, light_normal) = light.sample(rng); let to_light = lp - point; let dist_sq = to_light.dot(to_light); if dist_sq < 1e-12 { return Vec3::ZERO; } let dist = dist_sq.sqrt(); let d_to_light = to_light / dist; let cos_at_light = -light_normal.dot(d_to_light); let (response, cos_at_rec, origin) = match receiver { Receiver::Surface { normal, albedo } => (albedo / PI, normal.dot(d_to_light), point + EPSILON * normal), Receiver::Medium { dir, g } => (Vec3::splat(hg_phase(dir.dot(d_to_light), g)), 1.0, point), }; if cos_at_rec <= 0.0 || cos_at_light <= 0.0 { return Vec3::ZERO; } let tr = scene.transmittance(state, origin, d_to_light, dist * (1.0 - SHADOW_RELATIVE_EPSILON)); if tr == Vec3::ZERO { return Vec3::ZERO; } // Uniform area sampling has pdf 1 / area. let geometry = cos_at_rec * cos_at_light / dist_sq; let emission = spot_emission(light.emission, light.spot, cos_at_light); (emission.mul_elem(response) * (geometry * light.area())).mul_elem(tr) } /// Bounces before Russian roulette may terminate a path. const RR_MIN_DEPTH: u32 = 3; /// Guard against paths that keep crossing invisible volume boundaries. const MAX_VOLUME_CROSSINGS: u32 = 256; /// Russian roulette: after RR_MIN_DEPTH bounces, end dim paths early, /// boosting survivors so the estimate stays unbiased. Glass/mirror paths /// (throughput ~1) survive. Returns false if the path ends. fn russian_roulette(throughput: &mut Vec3, bounces: u32, rng: &mut R) -> bool { if bounces >= RR_MIN_DEPTH { let survive = throughput.x.max(throughput.y).max(throughput.z).min(0.95); if rng.gen::() >= survive { return false; } *throughput = *throughput / survive; } true } /// With `caustics`, caustic light (light that went through glass or off /// mirrors before reaching fog or a diffuse surface) comes from the photon /// maps, and path tracing skips the matching paths (from a diffuse or medium /// interaction through mirrors/glass to a light): /// - normally (all lights emit photons), only at the path's first such /// interaction: the maps are gathered along fog segments and at the first /// diffuse surface until then, and later caustics are path traced; /// - with `direct_in_media` (only spotlights emit photons), for spotlights /// at every interaction: the volume map also holds their direct light, so /// points in fog don't sample spotlights, and every fog segment and /// diffuse surface of the path gathers the maps. Other lights are path /// traced as usual. fn trace(scene: &Scene, primary: &Ray, max_depth: u32, rng: &mut R, caustics: Option<&CausticMaps>) -> Vec3 { let mut radiance = Vec3::ZERO; // Product of the attenuations along the path so far (divided by the RR // survival probabilities), i.e. how much of any light found here reaches the camera. let mut throughput = Vec3::ONE; // False after a diffuse bounce or medium scattering: that light was // already gathered by next event estimation, so hitting it again would // double count. let mut count_emission = true; let mut ray = *primary; let mut state = MediumState::Outside; let mut bounces = 0; let mut crossings = 0; // Diffuse and medium interactions so far. let mut non_specular = 0; let direct_in_media = caustics.is_some_and(|m| m.direct_in_media); // Whether this interaction gathers from the photon maps. let maps_here = |non_specular: u32| caustics.filter(|_| direct_in_media || non_specular == 0); while bounces < max_depth { let hit = scene.hit(&ray, EPSILON, f64::INFINITY); // Travel through the medium (if any) up to the surface: either // scatter somewhere along the way, or arrive with the absorption // along the way applied. let t_hit = hit.as_ref().map_or(f64::INFINITY, |h| h.t); if let Some((medium, t0, t1)) = scene.medium_segment(state, &ray, t_hit) { if let Some(maps) = maps_here(non_specular) { radiance += throughput.mul_elem(maps.beam(&medium, &ray, t0, t1)); } let t = t0 + medium.sample_distance(rng); if t < t1 { throughput = throughput.mul_elem(medium.absorption(t - t0)); let p = ray.at(t); // Spotlights are left out when the photon map carries their // direct light into the fog. if let Some((light, pick_prob)) = scene.pick_light(p, direct_in_media, rng) { let receiver = Receiver::Medium { dir: ray.direction, g: medium.g }; let direct = sample_direct(scene, state, light, p, receiver, rng) / pick_prob; radiance += throughput.mul_elem(direct); } count_emission = false; non_specular += 1; ray = Ray::from_unit(p, sample_hg(ray.direction, medium.g, rng)); bounces += 1; if !russian_roulette(&mut throughput, bounces, rng) { break; } continue; } throughput = throughput.mul_elem(medium.absorption(t1 - t0)); } let Some(rec) = hit else { radiance += throughput.mul_elem(scene.background.radiance(ray.direction)); break; }; match rec.material { Material::Light { emission, spot } => { // Lights emit from their front side only (matters for one-sided rects). // Reached from a diffuse/medium interaction via mirrors or // glass: a caustic, which the photon maps may already cover. let from_maps = match caustics { Some(_) if non_specular == 0 => false, Some(m) if m.direct_in_media => spot.is_some(), Some(_) => non_specular == 1, None => false, }; if count_emission && rec.front_face && !from_maps { let emission = spot_emission(emission, spot, -ray.direction.dot(rec.normal)); radiance += throughput.mul_elem(emission); } break; } Material::Volume { medium } => { // Invisible boundary: carry on in the same direction, now // inside (or back outside) the volume. Not a bounce. state = if rec.front_face { MediumState::Inside(Some(medium)) } else { MediumState::Outside }; ray = Ray::from_unit(rec.point, ray.direction); crossings += 1; if crossings > MAX_VOLUME_CROSSINGS { break; } continue; } _ => {} } let Some((attenuation, scattered)) = rec.material.scatter(&ray, &rec, rng) else { break; }; match rec.material { Material::Diffuse { albedo } => { if let Some(maps) = maps_here(non_specular) { radiance += throughput.mul_elem(maps.surface(rec.point, rec.normal, albedo)); } if let Some((light, pick_prob)) = scene.pick_light(rec.point, false, rng) { let receiver = Receiver::Surface { normal: rec.normal, albedo }; let direct = sample_direct(scene, state, light, rec.point, receiver, rng) / pick_prob; radiance += throughput.mul_elem(direct); } count_emission = false; non_specular += 1; } Material::Dielectric { medium, .. } => { // Refracted through the surface (rather than reflected): // entering or leaving the object's interior medium. if scattered.direction.dot(rec.normal) < 0.0 { state = if rec.front_face { MediumState::Inside(medium) } else { MediumState::Outside }; } count_emission = true; } _ => count_emission = true, } throughput = throughput.mul_elem(attenuation); ray = scattered; bounces += 1; if !russian_roulette(&mut throughput, bounces, rng) { break; } } radiance } struct Settings { width: usize, height: usize, /// Maximum samples per pixel (adaptive sampling may stop earlier). spp: u32, max_depth: u32, output: String, /// Target standard error of a pixel in display (gamma) space, 0..1. /// 0 disables adaptive sampling. noise_threshold: f64, } /// Adaptive sampling: every pixel first takes this many samples... const ADAPTIVE_MIN_SPP: u32 = 32; /// ...then later passes grow each still-noisy pixel's count by this fraction. const ADAPTIVE_GROWTH: f64 = 0.5; /// Running mean and variance of a pixel's luminance (Welford's algorithm). #[derive(Default)] struct PixelStats { n: u32, mean: f64, m2: f64, } impl PixelStats { fn add(&mut self, x: f64) { self.n += 1; let delta = x - self.mean; self.mean += delta / self.n as f64; self.m2 += delta * (x - self.mean); } /// Standard error of the pixel mean after gamma (sqrt) encoding, using /// d(sqrt(x))/dx = 1 / (2 sqrt(x)) to map it from linear space. fn display_error(&self) -> f64 { if self.n < 2 { return f64::INFINITY; } let variance = self.m2 / (self.n - 1) as f64; let std_error = (variance / self.n as f64).sqrt(); std_error / (2.0 * self.mean.max(1e-4).sqrt()) } } struct Pixel { sum: Vec3, stats: PixelStats, rng: SmallRng, } struct Renderer<'a> { scene: &'a Scene, cam: Camera, s: &'a Settings, caustics: Option<&'a CausticMaps>, } impl Renderer<'_> { fn sample(&self, px: &mut Pixel, i: usize, j: usize, count: u32) { let (width, height) = (self.s.width, self.s.height); for _ in 0..count { let u = (i as f64 + px.rng.gen::()) / width as f64; let v = ((height - 1 - j) as f64 + px.rng.gen::()) / height as f64; let sample = trace(self.scene, &self.cam.get_ray(u, v), self.s.max_depth, &mut px.rng, self.caustics); px.sum += sample; px.stats.add(sample.luminance()); } } /// Per-pixel noise, taken as the maximum over each 3x3 neighbourhood. A /// pixel's own estimate is unreliable while it has only seen a few samples /// (a pixel that has not yet hit a rare bright path looks converged), so /// a neighbour that did keeps it sampling. fn neighbourhood_error(&self, pixels: &[Pixel]) -> Vec { let (width, height) = (self.s.width, self.s.height); let own: Vec = pixels.iter().map(|p| p.stats.display_error()).collect(); (0..width * height) .into_par_iter() .map(|idx| { let (i, j) = (idx % width, idx / width); let mut worst = 0.0_f64; for nj in j.saturating_sub(1)..(j + 2).min(height) { for ni in i.saturating_sub(1)..(i + 2).min(width) { worst = worst.max(own[nj * width + ni]); } } worst }) .collect() } } fn bar(len: u64, template: &str) -> ProgressBar { let pb = ProgressBar::new(len); pb.set_style( ProgressStyle::with_template(template) .unwrap() .progress_chars("=> "), ); pb } /// Returns the gamma-encoded image and the total number of samples taken. fn render(scene: &Scene, s: &Settings, caustics: Option<&CausticMaps>) -> (Vec, u64) { let (width, height) = (s.width, s.height); let view = scene.view; let r = Renderer { scene, cam: Camera::new( view.lookfrom, view.lookat, Vec3::new(0.0, 1.0, 0.0), view.vfov, width as f64 / height as f64, (view.lookfrom - view.lookat).length(), ), s, caustics, }; let mut pixels: Vec = (0..width * height) .map(|idx| Pixel { sum: Vec3::ZERO, stats: PixelStats::default(), rng: SmallRng::seed_from_u64(idx as u64 ^ 0x9E37_79B9_7F4A_7C15), }) .collect(); // Base pass: every pixel gets the full budget, or the adaptive minimum. let adaptive = s.noise_threshold > 0.0; let base_spp = if adaptive { s.spp.min(ADAPTIVE_MIN_SPP) } else { s.spp }; let pb = bar( height as u64, "{spinner:.green} Rendering [{bar:50.bright_green/blue}] {percent:>3}% {pos}/{len} rows {elapsed_precise} ETA {eta_precise}", ); pixels.par_chunks_mut(width).enumerate().for_each(|(j, row)| { for (i, px) in row.iter_mut().enumerate() { r.sample(px, i, j, base_spp); } pb.inc(1); }); pb.finish(); // Adaptive passes: keep sampling pixels whose neighbourhood is still noisy. if adaptive { let pb = bar( (width * height) as u64, "{spinner:.green} Refining [{bar:50.bright_green/blue}] {percent:>3}% {pos}/{len} pixels converged {msg}", ); for pass in 1.. { let error = r.neighbourhood_error(&pixels); let active: Vec = pixels .iter() .zip(&error) .map(|(p, &e)| p.stats.n < s.spp && e >= s.noise_threshold) .collect(); let remaining = active.iter().filter(|&&a| a).count(); pb.set_position((width * height - remaining) as u64); pb.set_message(format!("pass {pass}")); if remaining == 0 { break; } pixels .par_chunks_mut(width) .zip(active.par_chunks(width)) .enumerate() .for_each(|(j, (row, row_active))| { for (i, (px, &a)) in row.iter_mut().zip(row_active).enumerate() { if a { let n = px.stats.n; let extra = ((n as f64 * ADAPTIVE_GROWTH).ceil() as u32).clamp(1, s.spp - n); r.sample(px, i, j, extra); } } }); } pb.finish(); } let total_samples = pixels.iter().map(|p| p.stats.n as u64).sum(); let image = pixels .iter() .map(|p| (p.sum / p.stats.n as f64).map(|c| c.max(0.0).sqrt().min(1.0))) .collect(); (image, total_samples) } fn save_png(image: &[Vec3], width: usize, height: usize, path: &str) -> image::ImageResult<()> { let bytes: Vec = image .iter() .flat_map(|c| [c.x, c.y, c.z]) .map(|c| (c.clamp(0.0, 1.0) * 255.0) as u8) .collect(); image::save_buffer(path, &bytes, width as u32, height as u32, image::ColorType::Rgb8) } fn region_mean(image: &[Vec3], width: usize, rows: std::ops::Range, cols: std::ops::Range) -> f64 { let n = rows.len() * cols.len(); if n == 0 { return 0.0; } let sum: f64 = rows .flat_map(|j| cols.clone().map(move |i| image[j * width + i].mean())) .sum(); sum / n as f64 } fn print_region_table(image: &[Vec3], width: usize, height: usize) { let (h_start, h_end) = (height / 3, 2 * height / 3); let (w_start, w_end) = (width / 3, 2 * width / 3); let regions = [ ("Left Wall", 0..height, 0..width / 4), ("Right Wall", 0..height, 3 * width / 4..width), ("Floor", h_end..height, w_start..w_end), ("Ceiling", 0..h_start, w_start..w_end), ("Center", h_start..h_end, w_start..w_end), ]; println!("Region Brightness Analysis"); println!("{:<12} {:>10} Visual", "Region", "Brightness"); for (name, rows, cols) in regions { let brightness = region_mean(image, width, rows, cols) * 255.0; let filled = ((brightness / 255.0 * 20.0) as usize).min(20); let bar = format!("{}{}", "#".repeat(filled), "-".repeat(20 - filled)); let color = if brightness > 80.0 { 32 } else if brightness > 40.0 { 33 } else { 31 }; println!("{name:<12} {brightness:>10.1} \x1b[{color}m{bar}\x1b[0m"); } } const USAGE: &str = "usage: pathtracer [--scene NAME] [--list-scenes] [--photons N | --no-photons] \ [width] [height] [samples_per_pixel] [max_bounces] [output] [noise_threshold]"; fn list_scenes() { println!("Scenes:"); for s in scenes::SCENES { println!(" {:<10} {}", s.name, s.description); } } fn main() { // Pull out the --scene / --list-scenes options; the rest are positional. let mut scene_name = String::from("cornell"); // Override for the number of caustic photons (0: path trace only). let mut photons_override: Option = None; let mut args: Vec = Vec::new(); let mut raw = std::env::args().skip(1); while let Some(a) = raw.next() { if a == "--list-scenes" { list_scenes(); return; } else if a == "--scene" { scene_name = raw.next().unwrap_or_else(|| { eprintln!("{USAGE}"); std::process::exit(2); }); } else if let Some(name) = a.strip_prefix("--scene=") { scene_name = name.to_string(); } else if a == "--photons" { photons_override = raw.next().and_then(|n| n.parse().ok()); if photons_override.is_none() { eprintln!("{USAGE}"); std::process::exit(2); } } else if a == "--no-photons" { photons_override = Some(0); } else if a == "--help" || a == "-h" { println!("{USAGE}"); list_scenes(); return; } else { args.push(a); } } let Some(scene_info) = scenes::find(&scene_name) else { eprintln!("unknown scene '{scene_name}'"); list_scenes(); std::process::exit(2); }; let arg = |i: usize, default: &str| args.get(i).cloned().unwrap_or_else(|| default.to_string()); fn parse(s: String) -> T { s.parse().unwrap_or_else(|_| { eprintln!("{USAGE}"); std::process::exit(2); }) } let settings = Settings { width: parse(arg(0, "400")), height: parse(arg(1, "300")), spp: parse(arg(2, "50")), max_depth: parse(arg(3, "15")), output: arg(4, &format!("{scene_name}.png")), noise_threshold: parse(arg(5, "0")), }; if settings.width == 0 || settings.height == 0 || settings.spp == 0 || settings.noise_threshold < 0.0 { eprintln!("width, height and samples_per_pixel must be >= 1, noise_threshold >= 0"); std::process::exit(2); } let scene = (scene_info.build)(); println!("=== Path Tracer (Rust) ==="); println!("Features: reflections, refraction/caustics, multiple bounces, NEE, BVH"); println!(); println!("Scene: {} ({} objects, {} lights)", scene_name, scene.object_count(), scene.light_count()); println!("Resolution: {}x{}", settings.width, settings.height); println!("Samples per pixel: {} (max)", settings.spp); println!("Max bounces: {} (Russian roulette after {})", settings.max_depth, RR_MIN_DEPTH); if settings.noise_threshold > 0.0 { println!("Adaptive sampling: stop at noise < {}", settings.noise_threshold); } else { println!("Adaptive sampling: off"); } println!("Threads: {}", rayon::current_num_threads()); println!( "BVH: 8-wide, depth {}, {}", scene.bvh_depth(), if scene.uses_avx512() { "AVX-512" } else { "scalar" } ); println!("Output: {}", settings.output); println!(); let start = Instant::now(); let photon_settings = scene.caustic_photons.map(|mut p| { if let Some(n) = photons_override { p.emitted = n; } p }); let caustics = match photon_settings { Some(p) if p.emitted > 0 => { let t = Instant::now(); let maps = CausticMaps::build(&scene, p); let (volume, surface) = maps.counts(); println!( "Caustic photons: {} emitted, {} stored in media, {} on surfaces ({:.2}s)", p.emitted, volume, surface, t.elapsed().as_secs_f64() ); Some(maps) } _ => None, }; let (image, total_samples) = render(&scene, &settings, caustics.as_ref()); let elapsed = start.elapsed().as_secs_f64(); let pixels = (settings.width * settings.height) as f64; if let Err(e) = save_png(&image, settings.width, settings.height, &settings.output) { eprintln!("failed to write {}: {e}", settings.output); std::process::exit(1); } println!(); println!("Rendered {}", settings.output); println!( "Total time: {:.2}s | Speed: {:.2} MPix*samp/s | Avg samples/pixel: {:.1}", elapsed, total_samples as f64 / elapsed / 1e6, total_samples as f64 / pixels ); // The regions (left wall, floor, ...) only mean something for the Cornell box. if scene_name == "cornell" { println!(); print_region_table(&image, settings.width, settings.height); } } #[cfg(test)] mod tests { use super::*; use medium::Medium; use scene::{Background, SceneBuilder, View}; fn view() -> View { View { lookfrom: Vec3::new(0.0, 0.0, -10.0), lookat: Vec3::ZERO, vfov: 40.0 } } /// Average of `n` path samples along one ray. fn mean_radiance(scene: &Scene, ray: Ray, n: usize) -> Vec3 { let mut rng = SmallRng::seed_from_u64(1); let mut sum = Vec3::ZERO; for _ in 0..n { sum += trace(scene, &ray, 10_000, &mut rng, None); } sum / n as f64 } fn assert_close(v: Vec3, expected: Vec3, tol: f64) { for a in 0..3 { assert!((v[a] - expected[a]).abs() < tol, "got {v:?}, expected {expected:?}"); } } const INTO_CENTER: fn() -> Ray = || Ray::from_unit(Vec3::new(0.0, 0.0, -10.0), Vec3::new(0.0, 0.0, 1.0)); /// White furnace: with no absorption and a uniform background of 1, /// every path eventually leaves and sees 1, so the mean must be 1 however /// much the medium scatters. #[test] fn furnace_fog() { let mut b = SceneBuilder::new(view(), Background::Color(Vec3::ONE)); b.fog(Vec3::splat(-1.0), Vec3::splat(1.0), Medium::new(Vec3::ZERO, 3.0, 0.6)); assert_close(mean_radiance(&b.build(), INTO_CENTER(), 200_000), Vec3::ONE, 0.01); } #[test] fn furnace_volume_sphere() { let mut b = SceneBuilder::new(view(), Background::Color(Vec3::ONE)); let smoke = Medium::new(Vec3::ZERO, 4.0, -0.3); b.sphere(Vec3::ZERO, 1.0, Material::Volume { medium: smoke }); assert_close(mean_radiance(&b.build(), INTO_CENTER(), 200_000), Vec3::ONE, 0.01); } #[test] fn furnace_glass_with_scattering_interior() { let mut b = SceneBuilder::new(view(), Background::Color(Vec3::ONE)); let milk = Medium::new(Vec3::ZERO, 5.0, 0.2); b.sphere(Vec3::ZERO, 1.0, Material::Dielectric { ref_idx: 1.5, albedo: Vec3::ONE, medium: Some(milk) }); assert_close(mean_radiance(&b.build(), INTO_CENTER(), 200_000), Vec3::ONE, 0.01); } /// Henyey-Greenstein: sampled directions have mean cosine g, and the /// phase function (used by next event estimation) integrates to 1. #[test] fn henyey_greenstein() { let mut rng = SmallRng::seed_from_u64(2); let dir = Vec3::new(0.3, -0.5, 0.8).normalize(); for g in [-0.7, 0.0, 0.4, 0.9] { let n = 400_000; let mean_cos: f64 = (0..n).map(|_| sample_hg(dir, g, &mut rng).dot(dir)).sum::() / n as f64; assert!((mean_cos - g).abs() < 0.005, "g = {g}: mean cos {mean_cos}"); // Midpoint rule over cos_theta (the phase function doesn't depend on phi). let steps = 200_000; let integral: f64 = (0..steps) .map(|i| { let c = -1.0 + (i as f64 + 0.5) * 2.0 / steps as f64; hg_phase(c, g) * 2.0 * PI * (2.0 / steps as f64) }) .sum(); assert!((integral - 1.0).abs() < 1e-3, "g = {g}: integral {integral}"); } } /// A purely absorbing slab of thickness 2: Beer's law, exactly, per channel. #[test] fn beer_lambert_absorption() { let mut b = SceneBuilder::new(view(), Background::Color(Vec3::ONE)); let sigma_a = Vec3::new(0.1, 0.5, 1.5); b.fog(Vec3::splat(-1.0), Vec3::splat(1.0), Medium::new(sigma_a, 0.0, 0.0)); let expected = (sigma_a * -2.0).map(f64::exp); assert_close(mean_radiance(&b.build(), INTO_CENTER(), 10), expected, 1e-12); } }