Learning project · ray tracing from first principles

Monte Carlo Path Tracer

A from-scratch, multithreaded C++17 renderer built to learn the fundamentals behind real-time ray tracing — global illumination, physically based materials, and acceleration structures — and then to carry them into the resampling techniques real-time renderers actually lean on.

Rendered scene: nearly 500 spheres with diffuse, metal, and glass materials on a ground plane, three large feature spheres in the foreground, soft depth-of-field background.
Resolution800 × 450
Samples / px100
Max depth12 bounces
Objects487 spheres
Render time28.1s
Hardware2 CPU threads

What this is

I wanted to actually understand ray tracing — not just the vocabulary — so I built a path tracer from first principles: rays, intersection math, Monte Carlo sampling, physically based materials, and a spatial acceleration structure, all measured and benchmarked rather than taken on faith.

Every pixel in the render above is the average of 100 independently sampled light paths, each bouncing up to 12 times through the scene — diffuse, metal, and glass surfaces all obey the same underlying light-transport integral, just with different scattering rules.

What it demonstrates

AreaWhere it shows up hereStatus
Light transport & physically based materials Ray–sphere intersection, recursive path tracing, Lambertian / metal / dielectric BRDFs, indirect light from pure ray bounces demonstrated
Data structures & performance Bounding volume hierarchy, multithreaded render loop, zero warnings under -Wall -Wextra demonstrated
Profiling & benchmarking Built-in --benchmark mode measuring BVH vs. linear-scan intersection cost demonstrated
GPU acceleration Not yet — next step is a HIP / ROCm port of this same ray gen → intersect → shade loop, running natively on an RX 9060 XT (RDNA4) next up
Resampled direct lighting (ReSTIR) Resampled importance sampling (weighted reservoir sampling, Bitterli et al. 2020) for direct lighting, plus spatial reuse of neighboring pixels' reservoirs — measured up to 1.96× lower MSE than uniform-pick NEE at equal sample counts. demonstrated
Neural rendering Not yet — plan to train a small NeRF / Gaussian Splatting reproduction on views rendered by this tracer next up

Why the BVH matters

Without an acceleration structure, testing a ray against a scene costs O(N) — every object, every ray. A bounding volume hierarchy lets a ray that misses a bounding box skip everything inside it, which is the same underlying idea GPU ray-tracing hardware accelerates in silicon. I measured it directly on this scene's 487 spheres:

Linear scan (no BVH)8.51s
BVH-accelerated1.36s
Same 487-sphere scene, same 300×168 / 32 spp / depth 8 settings — the BVH alone is a 6.24× speedup, and the gap widens as scene complexity grows.

Why RIS + spatial reuse matters

Direct lighting means picking a light and testing whether it's visible — but a scene with dozens of small lights makes blind uniform picking noisy, since most picks land on a light that barely matters from a given shading point. Resampled importance sampling (RIS) scores several candidate lights per hit and streams them through a weighted reservoir (Bitterli et al., ReSTIR 2020) so the one candidate that pays for a shadow ray is whichever actually looked promising — same one-shadow-ray-per-hit cost as before, just aimed better. Spatial reuse then lets a pixel fold in its already-built neighbors' reservoirs as free extra candidates, rejecting any neighbor across a normal or depth discontinuity so reuse can't leak across a silhouette. I measured both against a uniform-pick baseline with an MSE sweep across light counts and sample budgets (benchmarks/mse_sweep.py):

Baseline (uniform-pick NEE)MSE 39.4
Reservoir (RIS)MSE 20.1
Reservoir + spatial reuseMSE 19.1
32-light scene, 64 samples/pixel, mean of 3 runs against a 400-spp reference — RIS alone is 1.96× lower MSE than uniform picking at equal sample count, and the gap widens with light count: negligible at 1 light (nothing to pick between), up to that 1.96× at 32. Spatial reuse adds a further, smaller gain here — this scene's dense field of small, differently shaded spheres trips the normal/depth reject checks often, correctly declining to reuse across nearby discontinuities rather than pointing at a flaw in the technique.

Project architecture

vec3.h3D vector math + Monte Carlo sampling helpers
ray.hParametric ray: origin + t × direction
interval.h / aabb.hRange utility + axis-aligned bounding box (BVH primitive)
hittable.h / sphere.hIntersectable interface + ray–sphere intersection, plus solid-angle sampling of a sphere seen as a light
bvh.hBounding volume hierarchy for O(log N) intersection
material.hLambertian, Metal, and Dielectric BRDFs, and emissive lights
onb.hOrthonormal basis for local → world direction transforms
reservoir.hWeighted reservoir sampling — streaming selection, unbiased contribution weight, and the combine rule spatial reuse merges neighbours with
camera.hCamera model, two-phase threaded render loop, next-event estimation, RIS and spatial reuse
main.cppScene construction, CLI, benchmark mode

What's next

GPU port

Move the render loop onto the GPU with HIP

Port ray gen → intersect → shade into raw HIP kernels on an RX 9060 XT, hand-writing the BVH traversal first so the algorithm is mine rather than the API's, then profile it on a real GPU timeline.

Hardware RT

Compare hand-written traversal against HIPRT

Once the HIP port runs, bring in HIPRT — AMD's open-source ray tracing SDK — as a second traversal mode, and measure what hardware-accelerated BVH traversal actually buys over the kernels I wrote myself.

Neural rendering

Train a small NeRF or Gaussian Splatting reproduction

Generate the multi-view training data with this same path tracer — the renderer generating the training data the model learns from.