CHAO BIAN / SELECTED WORKAll projects ↗
RENDERING / ENGINEERING STUDYC++ · CPU

CPU Path TracerBeyond the first image.

A CPU renderer extended from Ray Tracing in One Weekend with triangle meshes, SAH acceleration, GGX microfacet reflection, and multithreaded rendering.

Red dielectric and gold metallic spheres showing progressively rougher GGX reflections
FIG. 01 / MATERIAL RESPONSEGGX reflection · dielectric & metallic surfaces

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

A foundation to
build beyond.

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.

C++ / CPU PATH TRACING / MULTITHREADING / TRIANGLE MESHES / SAH BVH / GGX BRDF

01 / TRIANGLE MESH & ASSET PIPELINE

Geometry is more than
a list of triangles.

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.

Spot cow mesh rendered with barycentrically interpolated texture coordinates
FIG. 02 / SPOT · 5,856 TRIANGLES · DIFFUSE MTL TEXTURE

AT THE HIT POINT / SCHEMATIC

Barycentric surface interpolationA ray hits a triangle at P. The weights of the three vertices interpolate UV coordinates and vertex normals. Arrows show vertex normals and the interpolated shading normal.v₀ / UV₀v₁ / UV₁v₂ / UV₂Pn shadingincoming ray
w₀ = 1 − α − β · w₁ = α · w₂ = β

UV(P) = Σ wᵢ UVᵢ
n(P) = normalize(Σ wᵢ nᵢ)

Conceptual diagram of the implemented attribute interpolation; not a wireframe or normal pass of Spot.

01 / INGEST

OBJ & MTL loading

Read mesh faces and their vertex attributes; connect diffuse material textures to the resulting triangle primitives.

02 / INTERSECT

Triangle intersection

Intersect the triangle plane and test barycentric coordinates. Reject hits outside the face; retain the weights for shading attributes.

03 / INTERPOLATE

UVs & vertex normals

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.

04 / ACCELERATE

Per-triangle bounds & BVH

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

More triangles.
Fewer intersection tests.

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.

Green Stanford Dragon on a stone pedestal rendered by the path tracer
FIG. 03 / STANFORD DRAGON · 871,306 TRIANGLES
DRAGON / MEDIAN BVH → SAH21.8%

less render time

8.22 s → 6.43 s

64 SPP · one thread · median of five runs

The baseline

The existing median builder splits primitives by count along the longest bounding-box axis. Balanced counts do not necessarily produce low-cost traversal.

The extension

Evaluate 12 centroid bins per axis. Child surface areas and primitive counts estimate split cost, which is compared against keeping a leaf.

The tradeoff

Evaluating candidate partitions adds work during construction. I measured mesh build time separately from rendering to compare both sides of that decision.

Two scenes · median of five runs per builder
Scene / trianglesMedian renderSAH renderReductionMesh build: median / SAH
Spot5,856 triangles5.144 s4.466 s−13.2%3.42 / 3.78 ms
Dragon871,306 triangles8.221 s6.431 s−21.8%1467.17 / 1376.10 ms
Benchmark details

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

A material model is only
half the problem.

I extended the renderer with isotropic GGX reflection, roughness and metallic controls. Efficiently sampling that reflection lobe matters just as much as evaluating it.

D

Distribution

GGX describes microfacet orientations; roughness changes the width of the reflection lobe.

G

Geometry

Masking and shadowing account for microfacets hidden along the view and light directions.

F

Fresnel

Angle-dependent reflectance combines with the metallic control to shape the surface response.

Same BRDF. Different sampling.

Uniform hemisphere → GGX normal distribution → GGX visible normals

Uniform hemisphere render at 64 samples per pixel
Uniform hemisphere64 SPP
GGX NDF render at 64 samples per pixel
GGX NDF64 SPP
GGX VNDF render at 64 samples per pixel
GGX VNDF64 SPP

OBSERVATION / ONE SCENE, FIVE SEEDS

Why sampling strategy matters.

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

From visible-normal sampling to weighted paths.

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.

Sampling results & implementation notes

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.

64 SPP · five seeds · mean ± sample standard deviation
SamplerLinear RGB MSETime
Uniform hemisphere1.524e-1 ± 7.533e-20.254 ± 0.005 s
GGX NDF3.799e-3 ± 1.109e-40.289 ± 0.056 s
GGX VNDF3.481e-3 ± 2.487e-40.261 ± 0.009 s
Convergence chart showing mean error by sample count for three samplers

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

More threads.
Not always more speed.

I added workers that claim rows from an atomic counter, then compared thread counts against an identical workload.

2.92×speedup with four threads
1T
4.67 s
2T
2.65 s
4T
1.60 s
8T
1.77 s

Median of five runs · lower is better · deterministic pixel streams

Cornell Box render with a glass sphere and tall box
FIG. 04 / CORNELL BOX · 240 × 240 · 64 SPP
Illustrative render; benchmark scene differs slightly

Distribute the work

Workers claim rows, write disjoint pixels to a shared buffer, and join before ordered image output.

Keep the image fixed

Optional deterministic pixel-seeded random streams make output independent of worker count and scheduling. All compared images are byte-identical.

Report the limit

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.

Performance notes

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

One renderer.
Connected extensions.

Each extension changes a part of the path from camera sample to accumulated radiance.

  1. 01 →

    Camera rays

    Parallel workers claim rows from an atomic counter.

    MULTITHREADING
  2. 02 →

    Traversal & intersection

    SAH partitions imported triangles into a mesh hierarchy.

    MESHES + SAH
  3. 03 →

    BSDF & light sampling

    Evaluate reflection and sample the next path direction.

    GGX + NDF / VNDF
  4. 04 →

    Integration & film

    Accumulate weighted radiance and write the final image.

    PATH TRACING

WHAT 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.