# Cornell Box Raytracer A pure Python raytracer that renders the Cornell Box scene with: - **Reflections** - mirror/chrome spheres reflect surrounding geometry - **Caustics & Refraction** - glass spheres bend light (dielectric material with Schlick approximation) - **Multiple Bounces** - recursive path tracing up to configurable depth - **Next Event Estimation (NEE)** - explicit light sampling for fast convergence - **Anti-aliasing** - supersampled pixels with jittered rays - **Multiprocessing** - parallel rendering across CPU cores ## Requirements - Python 3.8+ - numpy - Pillow - rich (progress bars, colored tables) - matplotlib (render metrics graphs) ```bash pip install numpy Pillow rich matplotlib ``` ## Features - **Rich Progress Bar** - animated progress with ETA, speed, and row count - **Scene Configuration Table** - formatted parameter display - **Region Brightness Analysis** - color-coded brightness per region (left/right wall, floor, ceiling, center) - **Render Metrics Graph** - saved as `render_metrics.png`, shows brightness convergence and render preview - **Live Preview** - `preview.png` updated during rendering - **Reflections** - mirror/chrome spheres reflect surrounding geometry - **Caustics & Refraction** - glass spheres bend light (dielectric material with Schlick approximation) - **Multiple Bounces** - recursive path tracing up to configurable depth - **Next Event Estimation (NEE)** - explicit light sampling for fast convergence - **Anti-aliasing** - supersampled pixels with jittered rays - **Multiprocessing** - parallel rendering across CPU cores ## Usage ```bash python raytracer.py [width] [height] [samples_per_pixel] [max_bounces] [output] ``` ### Examples ```bash # Quick preview (4 min) python raytracer.py 200 150 20 10 preview.png # Good quality (~10 min) python raytracer.py 400 300 50 15 cornell.png # High quality (~40 min) python raytracer.py 800 600 100 15 cornell_hq.png # Custom camera and scene parameters can be set by editing the __main__ block ``` ### Parameters | Parameter | Default | Description | |-----------|---------|-------------| | width | 400 | Image width in pixels | | height | 300 | Image height in pixels | | samples_per_pixel | 50 | Anti-aliasing samples per pixel | | max_bounces | 15 | Maximum ray bounce depth | | output | cornell_box.png | Output PNG filename | ### Workers Multiprocessing uses one worker per CPU core by default. Pass `workers=N` to the `render()` call to change it. ## Rust version A much faster port lives in [`rust/`](rust/) (renders 800x600 at 100 spp in seconds). It takes the same arguments, plus an optional `--scene`: ```bash cd rust cargo build --release ./target/release/pathtracer 800 600 100 15 cornell_hq.png ./target/release/pathtracer --scene caustics 800 600 1024 15 caustics.png ./target/release/pathtracer --scene beam 800 600 1024 15 beam.png ./target/release/pathtracer --list-scenes ``` It uses all cores via rayon, shows a progress bar and (for the `cornell` scene) the region brightness table, but does not write `preview.png` or `render_metrics.png`. Without an output name it writes `.png`; an output name ending in `.jpg` or `.jpeg` writes a JPEG (quality 90) instead, which for noisy renders is several times smaller. PNG files are ignored by git (see `.gitignore`), so full-size renders stay local. To add a render to the repo, commit a downscaled JPEG preview to [`renders/`](renders/) (the gallery below uses 1280x720, quality 85, about 100 KB each). ### Scenes | Scene | What it shows | |-------|---------------| | `cornell` (default) | The scene from `raytracer.py` | | `caustics` | Clear, amber and blue glass spheres and a mirror under one light, placed so each sphere focuses a sharp caustic into its shadow. Caustics are the hardest light paths for a path tracer, so use ~1000+ spp | | `spheres` | Large scene: ~470 random diffuse/mirror/glass spheres under a sky (after *Ray Tracing in One Weekend*). The BVH makes it ~11x faster than testing every object | | `night` | The same field at night, lit by 61 glowing spheres. Each shading point picks one light, weighted by power / distance², so many lights cost no more shadow rays than one | | `classic` | The classic Cornell box: one-sided ceiling panel light, two boxes, colour bleeding from the walls | | `fog` | The `caustics` scene in light fog: lit haze, a glow around the light and caustics under the spheres, from caustic photon maps | | `beam` | After the classic photon-mapping image of a volume caustic: a spotlight shines down through a glass sphere in dense fog, and the sphere focuses its beam into a cone that narrows to a focus and widens again onto the floor. Photon mapped; ~6 min at 800x600, 1024 spp | | `media` | Media inside objects: jade (glass with a green scattering interior), smoke, amber liquid (pure absorption) and a cloud | | `haze` | The `city` under a layer of atmospheric haze that fades the horizon and glows around the sun | | `city` | Massive scale: a procedural 400x400-lot city of ~124,000 objects (buildings with stepped tiers, mirror-glass towers, parks with trees) under a low sun. Renders 800x600 at 64 spp in ~1 s; without the BVH it is ~1000x slower | ### Gallery | | | |---|---| | ![beam](renders/beam.jpg) `beam` | ![fog](renders/fog.jpg) `fog` | | ![caustics](renders/caustics.jpg) `caustics` | ![media](renders/media.jpg) `media` | | ![city](renders/city.jpg) `city` | ![haze](renders/haze.jpg) `haze` | | ![spheres](renders/spheres.jpg) `spheres` | ![night](renders/night.jpg) `night` | | ![classic](renders/classic.jpg) `classic` | ![cornell (Python)](renders/cornell_python.jpg) `cornell`, rendered by `raytracer.py` | Scenes are defined in [`rust/src/scenes.rs`](rust/src/scenes.rs) with a small builder (`plane`, `sphere`, `cuboid`, `box_light`, `sphere_light`, `rect_light`, `spot_light`, `fog`, `caustic_photons`, `media_photons`). Every emitter must be added with a `*_light` method so light sampling knows about it. ### Volumetrics Homogeneous participating media ([`rust/src/medium.rs`](rust/src/medium.rs)), each with a coloured absorption coefficient `sigma_a`, a grey scattering coefficient `sigma_s` and a Henyey-Greenstein anisotropy `g`, can be placed: - as **fog** filling an axis-aligned region outside all objects (`SceneBuilder::fog`, e.g. a haze layer below some height, so the sky stays visible above it); - **inside glass**: `Material::Dielectric { medium: Some(..), .. }` (jade, milk, coloured liquids); - inside an **invisible boundary**: `Material::Volume { medium }` (smoke, clouds). Paths sample a scattering distance from `sigma_s` and apply absorption as an exact `exp(-sigma_a * d)` weight, so coloured absorption needs no spectral tricks. At a scattering point they sample a light like a diffuse surface does (with the phase function instead of the BRDF) and continue in a Henyey-Greenstein direction. Shadow rays are attenuated by the media they cross and pass through volume boundaries. `cargo test --release` runs white-furnace tests (a non-absorbing medium in front of a uniform background must render exactly 1), a Beer-Lambert test and phase function tests. Scenes without media render exactly as before. ### Photon mapping for caustics Path tracing finds a caustic by starting at the camera: a point in fog or on the floor has to pick a direction that happens to pass through the glass and hit the light. With a small light, and especially a spotlight, that almost never happens, so volumetric caustics come out as fireflies or not at all. Scenes can opt in to photon maps ([`rust/src/photons.rs`](rust/src/photons.rs)): before rendering, photons are shot from the lights, and those that pass through glass or off mirrors are stored where they first land in fog or on a diffuse surface. Camera paths gather them along their fog segments (the beam radiance estimate of Jarosz et al.) and at diffuse surfaces, and path tracing skips exactly those caustic paths so nothing is counted twice. - `caustic_photons(emitted, volume_radius, surface_radius)`: all lights emit photons; the maps replace caustics at a path's first fog or diffuse interaction. - `media_photons(...)`: only spotlights emit photons, and their photons also carry their direct light into the fog (much smoother beams than sampling the spotlight from each fog point). Other lights are path traced as usual. `--photons N` overrides the photon count and `--no-photons` renders with path tracing alone. Gathering blurs by the radius, so photon-mapped images converge to a slightly blurred result; checked against pure path tracing at 4096-8192 spp, mean brightness agrees to within 0.3%. Spotlights (`spot_light`) emit within a cone with a soft edge, and light selection gives a spotlight zero probability for points outside its cone. ### Beyond the Python version - **8-wide BVH with AVX-512** - bounded objects live in a bounding volume hierarchy (median split, collapsed from binary to 8 children per node); planes are tested separately. Each node stores its children's boxes as structure-of-arrays, so one AVX-512 sequence tests a ray against all 8. Leaves hold up to 8 spheres, also intersected 8 at a time, but at most one other shape (boxes and rects are still tested one by one, so tight leaves are faster for them). The CPU is checked at runtime; without AVX-512, or with `PATHTRACER_NO_AVX512=1` set, the same traversal runs with scalar kernels and renders bit-identical images. Compared with the earlier binary BVH on a Ryzen 7 9800X3D: `city` 2.6x, `spheres` 2.1x, `night` 1.8x faster; the small scenes are unchanged within ±10%. - **Many-light sampling** - box, sphere and one-sided rectangle lights, one picked per shading point in proportion to its estimated contribution. - **Russian roulette** - after 3 bounces, dim paths are terminated randomly (survivors are re-weighted, so the image stays unbiased). About 2.5x more image quality per second of rendering in this scene. - **Cheap cosine-weighted diffuse sampling** - two uniform random numbers and an orthonormal basis instead of Gaussian samples. - **Adaptive sampling** (optional 6th argument, off by default) - after 32 samples per pixel, keeps sampling only pixels whose 3x3 neighbourhood still has a display-space standard error above the threshold, up to `samples_per_pixel`: ```bash ./target/release/pathtracer 800 600 4096 15 cornell.png 0.03 ``` In this scene noise is spread fairly evenly (caustic fireflies reach almost every pixel), so adaptive sampling roughly breaks even with uniform sampling and can darken the image slightly at loose thresholds. It pays off in scenes with large easy regions. ## Scene Description The scene is the classic **Cornell Box**: - 5 walls (floor, ceiling, left=red, right=green, back=white) with the front face open - A small bright light source on the left wall - A **glass sphere** (refractive, IOR=1.5) - demonstrates refraction and caustics - A **mirror sphere** (perfect reflection) - demonstrates reflections - A **red diffuse sphere** - demonstrates diffuse scattering ## Camera - Position: (278, 278, -800) - Look-at: (278, 278, 278) (box center) - FOV: 40° vertical (earlier versions effectively used half of this, zooming the image in 2x) - Focus distance: 1078 ## Materials | Material | Implementation | |----------|---------------| | Diffuse (Lambertian) | Cosine-distributed random scattering + NEE | | Mirror | Perfect specular reflection | | Dielectric | Snell refraction + Schlick fresnel + random bounce | | Light | Emissive surface, sampled via NEE | ## Progress Indicators The renderer uses **Rich** for terminal UI and **matplotlib** for metrics graphs: - **Progress Bar** - animated with spinner, bar, percentage, ETA, and row count - **Settings Table** - formatted parameter display with colored values - **Region Brightness** - color-coded ASCII bar chart showing brightness per region - **Metrics Graph** - saved as `render_metrics.png`, shows brightness convergence curve and live render preview - **Preview Image** - saved as `preview.png` and updated during rendering ## Performance Measured on 4 workers, single machine: | Resolution | spp | Time | |------------|-----|------| | 200x150 | 20 | ~40s | | 400x300 | 50 | ~10 min | | 800x600 | 50 | ~40 min | ## How It Works 1. **Primary rays** are cast from the camera through each pixel (with supersampling jitter) 2. **Ray-object intersection** tests against planes (walls), spheres, and boxes (light) 3. **Surface scattering**: - Diffuse: random hemisphere direction + explicit light sampling (NEE) - Mirror: perfect reflection vector - Dielectric: refraction with Snell's law, Schlick approximation for total internal reflection 4. **Recursive bouncing**: each scattered ray continues tracing up to `max_bounces` 5. **Radiance estimation**: `L = Le + ∫ f_r * L_i * cos θ dω` approximated via Monte Carlo integration ### Next Event Estimation For diffuse surfaces, the direct light contribution is computed by explicitly sampling points on the light source, testing visibility via shadow rays, and weighting by the BRDF and light PDF. This dramatically reduces variance compared to pure path tracing. ## Output Renders save as PNG with gamma correction (sqrt/gamma 2.0 applied).