OBJ & MTL loading
Read mesh faces and their vertex attributes; connect diffuse material textures to the resulting triangle primitives.
A CPU renderer extended from Ray Tracing in One Weekend with triangle meshes, SAH acceleration, GGX microfacet reflection, and multithreaded rendering.

Graphics theory.
Systems performance.
Engineering implementation.
Starting from the Ray Tracing in One Weekend series, I used this project to explore how rendering algorithms work beyond their textbook implementations. I focused on integrating new features into a working renderer, comparing alternative approaches, and understanding the tradeoffs between image quality and performance.
Read the case study ↓THE STARTING POINT
The project began with Peter Shirley’s Ray Tracing in One Weekend series: recursive Monte Carlo integration, material scattering, emissive lighting, PDFs and importance sampling, and basic AABB/BVH traversal.
That working renderer became the testbed for my own extensions. The focus here is on the decisions that followed: handling imported geometry, improving spatial partitions, sampling rough reflections, and understanding CPU scaling.
01 / TRIANGLE MESH & ASSET PIPELINE
To render models beyond procedural primitives, I added triangle intersection and OBJ/MTL loading. Barycentric coordinates let me interpolate texture coordinates and vertex normals at each hit, while per-triangle bounds connect imported meshes to the existing BVH system.

AT THE HIT POINT / SCHEMATIC
UV(P) = Σ wᵢ UVᵢ
n(P) = normalize(Σ wᵢ nᵢ)
Conceptual diagram of the implemented attribute interpolation; not a wireframe or normal pass of Spot.
Read mesh faces and their vertex attributes; connect diffuse material textures to the resulting triangle primitives.
Intersect the triangle plane and test barycentric coordinates. Reject hits outside the face; retain the weights for shading attributes.
Interpolate UVs and optional vertex normals at the hit. Normalize the shading normal and align it with the geometric normal before orienting it to the ray.
Give each triangle an AABB, then build a mesh hierarchy. The same imported geometry becomes the workload for the median-versus-SAH study.
Next: acceleration results ↓02 / ACCELERATION STRUCTURES
With imported meshes working, dense models such as the 871,306-triangle Stanford Dragon made intersection cost a practical constraint. I implemented a binned surface area heuristic to choose partitions by estimated tracing cost.

less render time
64 SPP · one thread · median of five runs
The existing median builder splits primitives by count along the longest bounding-box axis. Balanced counts do not necessarily produce low-cost traversal.
Evaluate 12 centroid bins per axis. Child surface areas and primitive counts estimate split cost, which is compared against keeping a leaf.
Evaluating candidate partitions adds work during construction. I measured mesh build time separately from rendering to compare both sides of that decision.
| Scene / triangles | Median render | SAH render | Reduction | Mesh build: median / SAH |
|---|---|---|---|---|
| Spot5,856 triangles | 5.144 s | 4.466 s | −13.2% | 3.42 / 3.78 ms |
| Dragon871,306 triangles | 8.221 s | 6.431 s | −21.8% | 1467.17 / 1376.10 ms |
Tested on Apple M2 with a C++ Release build at 64 SPP using one thread. Render times are the median of five runs and exclude mesh loading and BVH construction.
The median and SAH builders use different leaf configurations, so these results compare the complete implementations rather than isolating the split heuristic. The SAH builder was informed by PBRT.
03 / MICROFACET REFLECTION
I extended the renderer with isotropic GGX reflection, roughness and metallic controls. Efficiently sampling that reflection lobe matters just as much as evaluating it.
GGX describes microfacet orientations; roughness changes the width of the reflection lobe.
Masking and shadowing account for microfacets hidden along the view and light directions.
Angle-dependent reflectance combines with the metallic control to shape the surface response.
Uniform hemisphere → GGX normal distribution → GGX visible normals



OBSERVATION / ONE SCENE, FIVE SEEDS
At 64 SPP, both GGX-based sampling strategies produced substantially less error than uniform-hemisphere sampling. VNDF achieved an additional 8.4% reduction in mean linear RGB MSE over NDF in this scene, though the difference was modest relative to variation across the five test seeds. These results do not establish a general ranking across scenes or view configurations.
IMPLEMENTATION / KEEPING THE ESTIMATOR CONSISTENT
I implemented GGX visible-normal sampling based on Eric Heitz’s Sampling the GGX Distribution of Visible Normals (2018).
Integrating the method into my path tracer, I converted the visible microfacet-normal density into a reflection-direction PDF using the half-vector Jacobian, 1 / (4 |ωo · h|). That PDF participates in the material’s specular/diffuse proposal and the integrator’s light/material mixture; path contributions use the full mixture density.
Below-surface reflected directions remain zero-contribution samples rather than being resampled. Keeping generation, PDF evaluation, and rejection behavior consistent was essential; numerical checks compare the sampler and PDF, and reflected energy against quadrature.
The three sampling strategies were compared using the same GGX BRDF and scene settings at 64 SPP. Each result summarizes five random seeds, with mean squared error measured against a high-sample reference image in unclamped linear RGB.
| Sampler | Linear RGB MSE | Time |
|---|---|---|
| Uniform hemisphere | 1.524e-1 ± 7.533e-2 | 0.254 ± 0.005 s |
| GGX NDF | 3.799e-3 ± 1.109e-4 | 0.289 ± 0.056 s |
| GGX VNDF | 3.481e-3 ± 2.487e-4 | 0.261 ± 0.009 s |
VNDF showed slightly lower average error than NDF in this scene, although the difference was modest. Numerical tests also checked sampling/PDF consistency and reflected energy against reference calculations. The reference image averages two independent 32,768-SPP renders.
04 / MULTITHREADING & PERFORMANCE
I added workers that claim rows from an atomic counter, then compared thread counts against an identical workload.
Median of five runs · lower is better · deterministic pixel streams

Workers claim rows, write disjoint pixels to a shared buffer, and join before ordered image output.
Optional deterministic pixel-seeded random streams make output independent of worker count and scheduling. All compared images are byte-identical.
Four threads achieved the best measured performance; eight workers were slightly slower. The measurements show a scaling limit, but do not isolate its cause.
The Apple M2 has four performance cores and four efficiency cores. This heterogeneous configuration may affect thread scaling, alongside scheduling overhead and resource contention. The current measurements do not isolate core placement or these other system-level effects.
Benchmarks were run on an Apple M2 using a C++ Release build. Each configuration was measured five times after a warmup run; the chart reports median render times.
A pixel-seeded random number generator keeps the rendered image identical across thread counts, so differences reflect execution time rather than changes in sampling.
05 / RENDERER ARCHITECTURE
Each extension changes a part of the path from camera sample to accumulated radiance.
Parallel workers claim rows from an atomic counter.
MULTITHREADINGSAH partitions imported triangles into a mesh hierarchy.
MESHES + SAHEvaluate reflection and sample the next path direction.
GGX + NDF / VNDFAccumulate weighted radiance and write the final image.
PATH TRACINGWHAT THIS PROJECT TAUGHT ME
Implementing a feature is the beginning. Understanding it means checking the image, isolating the workload, measuring the tradeoff, and being precise about what the result proves.