T.TAO
Back to Works
2024/graphics / rendering

BDPT + MIS Renderer

A bidirectional path tracer with multiple importance sampling, built on a full CPU renderer: microfacet BSDFs, volumetric media, MIS path tracing, and BVH acceleration.

ComputerGraphicsRenderingPathTracing
BDPT + MIS Renderer

This is a physically based offline renderer built on the Dartmouth Introductory Ray Tracer (DIRT) framework, written in C++, developed for CMU's Physically Based Rendering course. Over one semester I implemented the full light transport stack β€” from Whitted-style ray tracing to Monte Carlo path tracing with Multiple Importance Sampling (MIS), volumetric rendering, and finally Bidirectional Path Tracing (BDPT) with a balance-heuristic MIS weighting scheme as the final project.

Note on source access: this repository is private because it is coursework governed by academic integrity policy, so I cannot publish the full source. The core algorithms, code excerpts, and design decisions are documented below, and the full report is attached at the end of this page.

BDPT with MIS

Why Bidirectional Path Tracing

A unidirectional path tracer starts every path at the camera. This works well when light is easy to find by chance, but collapses for scenes dominated by indirect, hard-to-reach illumination β€” small light sources, light entering through a narrow opening, or caustic-like specular-diffuse-specular chains. Most camera paths terminate without ever touching a light, so almost every sample is wasted and the image stays noisy.

BDPT attacks the problem from both ends:

  1. Trace a camera subpath starting from the eye.
  2. Trace a light subpath starting from a sampled point on an emitter.
  3. Connect every prefix of one subpath to every prefix of the other, producing a whole family of estimators β€” each (s, t) pair is a different sampling strategy for the same path length.
  4. Combine all strategies with MIS weights, so each strategy contributes exactly where it has low variance.

The MIS step is not optional. Naively summing all connection strategies counts the same light transport multiple times and produces severe fireflies. The comparison below is the clearest illustration of why MIS matters:

Naive connections (no MIS)With MIS weightingPath-traced reference
No MISWith MISReference

Renderer Architecture

The renderer is a modular C++ system driven by JSON scene descriptions. Every subsystem is pluggable through a factory-based parser:

  • Integrators β€” normals / ambient occlusion debug views, material-only path tracing, next-event estimation, MIS path tracing (power heuristic), volumetric path tracing (uniform & NEE+MIS), and BDPT (balance heuristic).
  • BSDFs β€” Lambertian, metal (rough reflection), dielectric with Fresnel, Phong / Blinn-Phong lobes, Beckmann microfacet with importance-sampled normals, Oren-Nayar rough diffuse, and texture-driven blend materials. All BSDFs expose a unified sample / eval / pdf interface with a specular flag, which is exactly what makes one set of materials usable across PT, MIS-PT, and BDPT.
  • Lights β€” quad and sphere area lights with sampleOn / pdfOn solid-angle sampling, plus delta point and spot lights (with cone falloff) added for the final scenes.
  • Acceleration β€” a bounding-box hierarchy (BVH) with recursive midpoint splits.
  • Media β€” homogeneous and Perlin-noise heterogeneous volumes with Henyey-Greenstein phase functions, sampled via free-flight distance sampling and ratio tracking.
  • Samplers β€” independent, stratified, and Halton low-discrepancy sequences.
  • Camera β€” thin-lens model with configurable aperture and focal distance for depth of field.

MIS in the Unidirectional Path Tracer

Before BDPT, the MIS path tracer combines BSDF sampling and next-event estimation (light sampling) at every bounce using the power heuristic:

wlight(x)=plight(x)Ξ²plight(x)Ξ²+pbsdf(x)Ξ²,Ξ²=2w_{\text{light}}(x) = \frac{p_{\text{light}}(x)^{\beta}}{p_{\text{light}}(x)^{\beta} + p_{\text{bsdf}}(x)^{\beta}}, \qquad \beta = 2
C++float powerHeuristic(float pdf1, float pdf2, float power) {
    float pow1 = pow(pdf1, power);
    float pow2 = pow(pdf2, power);
    return pow1 / (pow1 + pow2);
}

Each strategy evaluates the other strategy's PDF for the direction it sampled, weights its contribution accordingly, and the weight is threaded through the recursion so that emitted radiance is never double counted. Specular bounces bypass the weighting entirely (a delta distribution cannot be sampled by the other strategy, so its weight is 1). The classic Veach scene renders correctly across the full roughness range:

Veach MIS scene

MIS path tracing

BDPT Deep Dive

Subpath generation

Both subpaths are random walks that record, at every vertex, the data MIS will need later: the forward PDF (the probability with which this walk actually generated the vertex) and the reverse PDF (the probability with which the opposite walk would have generated it), along with accumulated throughput.

C++struct Vertex {
    Vec3f wi, wo;            // incoming / outgoing directions
    HitInfo hit;             // surface interaction
    Color3f emitted;         // accumulated emission along the walk
    Color3f throughput;      // path throughput up to this vertex
    Color3f nextThroughput;  // throughput after scattering here
    float pdfFwd;            // cumulative forward sampling PDF
    float pdfRev;            // cumulative reverse sampling PDF
};

The camera walk scatters at each surface via the BSDF's importance sampler, accumulating both PDF products as it goes. Specular vertices multiply throughput by the delta attenuation without touching the PDFs; diffuse vertices track both directions:

C++// inside traceCameraPath(): per-bounce PDF bookkeeping
float pdfMat = hit.mat->pdf(currentRay.d, normalize(srec.scattered), hit);

vertex.pdfFwd = pdfFwd;
pdfFwd *= pdfMat;                                              // forward: eye -> scene
pdfRev *= hit.mat->pdf(-srec.scattered, normalize(-currentRay.d), hit); // reverse walk
vertex.pdfRev = pdfRev;

Color3f evalMat = hit.mat->eval(currentRay.d, normalize(srec.scattered), hit);
throughput *= evalMat / pdfMat;

The light walk starts by sampling a position and an outgoing direction on an emitter (initVertex.pdfFwd = pdfDir * lightPDF), then extends identically. Both walks terminate by Russian roulette on throughput luminance, which keeps the estimator unbiased while bounding path length.

Connecting subpaths

The main loop enumerates every valid (s, t) strategy within the bounce budget:

C++Color3f BDPTIntegrator::Li(const Scene &scene, Sampler &sampler, const Ray3f &ray) const {
    Color3f L(0.f);
    vector<Vertex> lightPath  = traceLightPath(scene, sampler);
    vector<Vertex> cameraPath = traceCameraPath(scene, sampler, ray);

    for (int s = 1; s <= cameraPath.size(); ++s) {
        for (int t = 1; t <= lightPath.size(); ++t) {
            int depth = t + s - 2;
            if ((s == 1 && t == 1) || depth < 0 || depth > m_maxBounces) continue;

            Color3f contribution = connect(scene, cameraPath, lightPath, s - 1, t - 1);
            float misWeight = calculateMIS(cameraPath, lightPath, s, t);
            L += contribution * misWeight;
        }
    }
    return L;
}

A connection casts a shadow ray between the two endpoint vertices, then multiplies the two subpath throughputs, the BSDF at each endpoint (evaluated toward the connection direction), the emitter term, and the geometry term that converts between solid-angle and area measures:

G(x↔y)=∣cos⁑θxβˆ£β€‰βˆ£cos⁑θy∣βˆ₯xβˆ’yβˆ₯2G(x \leftrightarrow y) = \frac{|\cos\theta_x| \, |\cos\theta_y|}{\lVert x - y \rVert^{2}}
C++float geometryTerm(const Vec3f &nx, const Vec3f &x, const Vec3f &ny, const Vec3f &y) {
    Vec3f d = y - x;
    float cosThetaI = abs(dot(normalize(nx), normalize(d)));
    float cosThetaO = abs(dot(normalize(ny), normalize(-d)));
    return cosThetaI * cosThetaO / length2(d);
}

// connect(): general (s, t) strategy
Color3f bsdfCam   = cameraVertex.hit.mat->eval(cameraVertex.wi, normalize(dir), cameraVertex.hit);
Color3f bsdfLight = lightVertex.hit.mat->eval(lightVertex.wo,  normalize(dir), lightVertex.hit);

Color3f result = lightVertex.throughput * cameraVertex.throughput;
result *= initLight.emitted / initLight.pdfFwd;   // emitter radiance / sampling PDF
result *= bsdfCam * bsdfLight;
result *= geometryTerm(cameraVertex.hit.sn, cameraVertex.hit.p,
                       lightVertex.hit.sn,  lightVertex.hit.p);

MIS weights across strategies

For a full path of a given length, every (s, t) split is a different way of sampling the same path. The balance heuristic weight for the chosen strategy is its PDF over the sum of all strategies' PDFs:

ws,t=ps,tβˆ‘kpkw_{s,t} = \frac{p_{s,t}}{\sum_{k} p_{k}}

Rather than materialising every hypothetical PDF, the implementation uses the recurrence from Veach's thesis (as popularised by PBRT): walk each subpath accumulating the ratio of reverse to forward PDFs, which expresses "how likely would the opposite walk have been to produce this vertex" relative to how it was actually produced:

C++float BDPTIntegrator::calculateMIS(const vector<Vertex> &camPath,
                                   const vector<Vertex> &lightPath,
                                   size_t s, size_t t) const {
    if (s == 1 && t == 1) return 1.f;

    float sumCamera = 1.f, sumLight = 1.f;
    for (int i = 1; i <= s; i++)
        sumCamera = (remap0(camPath[i].pdfRev) / remap0(camPath[i].pdfFwd)) * (sumCamera + 1.f);
    for (int i = 1; i <= t; i++)
        sumLight  = (remap0(lightPath[i].pdfRev) / remap0(lightPath[i].pdfFwd)) * (sumLight + 1.f);

    return 1.f / (sumLight + 1.f + sumCamera);
}

remap0() clamps near-zero PDFs (delta interactions) to 1 so they drop out of the ratio instead of dividing by zero β€” the standard treatment for specular vertices in the recurrence.

Delta lights

Point and spot lights (with configurable cone angle and falloff) were added for the final scenes. Delta emitters cannot be hit by a random walk, so their contribution flows exclusively through explicit connection strategies β€” a good stress test that the MIS bookkeeping treats non-sampleable strategies correctly.

Point lightSpot light with falloff
Point lightSpot light

The Road to BDPT: Feature Progression

The final project sits on top of a semester of renderer development:

Foundations β€” ray-primitive intersection (sphere, quad, MΓΆller-Trumbore triangles, OBJ meshes), instancing transforms, recursive Whitted ray tracing, procedural and image textures, BVH construction.

Cornell box

Monte Carlo & BSDFs β€” cosine-weighted, Phong-lobe, and Beckmann-normal importance sampling, each validated against analytic PDFs with chi-square-style histogram comparisons; stratified and Halton samplers; thin-lens depth of field.

Beckmann microfacetDepth of field
BeckmannDepth of field

Direct illumination & MIS β€” area-light sampling with solid-angle PDFs, next-event estimation, power-heuristic MIS, HDR environment lighting.

Volumetric rendering β€” Henyey-Greenstein phase function with analytic inversion sampling, homogeneous media via exponential free-flight sampling, heterogeneous Perlin media via ratio tracking, and MIS between phase-function and light sampling at medium interactions.

Isotropic volume, NEESpotlight through participating media
Volume Cornell boxVolume spotlight

What This Project Demonstrates

  • Light transport theory in practice β€” deriving and implementing estimators for the rendering equation across four solution strategies (Whitted, PT, MIS-PT, BDPT), and understanding precisely where each one's variance comes from.
  • Two MIS regimes β€” the power heuristic over two strategies (BSDF Γ— NEE), and the balance heuristic over an entire strategy family via forward/reverse PDF recurrences.
  • Sampling craftsmanship β€” importance sampling for every BSDF lobe, solid-angle light sampling, low-discrepancy sequences, unbiased Russian roulette, and PDF validation against ground truth.
  • Renderer engineering β€” a unified BSDF interface that lets one material library serve every integrator, factory-driven scene parsing, and a BVH that keeps final scenes tractable.

The full technical report, including derivations and additional comparisons:

BDPT Final ReportOpen β†—