CodeRigor

FREE LESSON

Shading and Monte Carlo light transport

Radiance and the rendering equation, Lambert and GGX BRDFs, Monte Carlo estimators with cosine-weighted importance sampling, a branchless frame around the normal, Russian roulette, and lighting in linear space before the sRGB encode.

A ray tracer that can find the closest hit (previous skill) still has to answer the question that makes images: how much light leaves this point towards the camera? The answer is an integral over every direction light can arrive from, and almost every production renderer — offline path tracers, real-time GI, and the "ground truth" renders that radiance fields and Gaussian splats are trained on — evaluates it the same way: by Monte Carlo sampling.

This lesson builds the kernel of a path tracer: the units of light, the rendering equation, BRDFs (Lambert, Blinn–Phong, GGX), Monte Carlo estimators and importance sampling, a local frame around a normal, Russian roulette, and the linear-vs-sRGB distinction that decides whether the final pixels are right.

1. Radiometry in five definitions

quantity symbol unit what it measures
radiant flux Φ W total power
irradiance E W/m² power arriving per unit area of a surface
radiance L W/(m²·sr) power per unit projected area per unit solid angle — what a pixel/eye sees
solid angle ω sr area on the unit sphere; the hemisphere is 2π sr
BRDF f(ωᵢ, ωₒ) 1/sr how much of the light arriving from ωᵢ leaves towards ωₒ

Radiance is the one to internalise: it is constant along a ray in empty space, it is what a camera measures, and it is what NeRF's MLP outputs. Irradiance gathers radiance from the hemisphere, weighting each direction by cosθ (Lambert's cosine law — light arriving at a slant spreads over more area):

E(x) = ∫_Ω L(x, ω) cosθ dω, with dω = sinθ dθ dφ.

For a uniform sky of radiance 1, E = ∫₀^{2π}∫₀^{π/2} cosθ sinθ dθ dφ = π. That π will keep showing up.

2. The rendering equation

Kajiya (1986) wrote down what every renderer approximates. Outgoing radiance is emitted radiance plus reflected radiance:

Lₒ(x, ωₒ) = Lₑ(x, ωₒ) + ∫_Ω f(x, ωᵢ, ωₒ) · Lᵢ(x, ωᵢ) · cosθᵢ dωᵢ

and Lᵢ is itself the Lₒ of whatever point the ray from x in direction ωᵢ hits — so the equation is recursive. A path tracer unrolls the recursion one random direction per bounce:

One path, bounce by bounce
Each bounce multiplies the path's throughput by f·cosθ/pdf. Emission found along the way is added, weighted by the throughput so far.
One path, bounce by bounce
Each bounce multiplies the path's throughput by f·cosθ/pdf. Emission found along the way is added, weighted by the throughput so far.

3. BRDFs

A BRDF must be non-negative, reciprocal (f(ωᵢ, ωₒ) = f(ωₒ, ωᵢ)) and energy conserving: the directional albedo ∫ f cosθᵢ dωᵢ ≤ 1 for every ωₒ — a surface can't reflect more than arrives.

Lambert. A perfectly matte surface reflects equally in all directions: f = albedo / π. The π is not decoration: ∫ (albedo/π) cosθ dω = albedo. Leave it out and a white wall under a sky of radiance 1 glows with radiance π.

Blinn–Phong (1977) is the classic real-time highlight: f = (n + 8)/(8π) · (n·h)ⁿ with the half vector h = normalize(l + v). The (n + 8)/(8π) factor is the approximate normalisation that keeps the highlight's energy constant as the exponent sharpens it; without it, shiny materials go dark.

Microfacet models treat the surface as tiny perfect mirrors whose normals follow a distribution D. Only facets with normal h reflect l into v; the Cook–Torrance form is

f(l, v) = D(h) · G(l, v) · F(v·h) / (4 (n·l)(n·v))

Cook–Torrance with GGX
Three factors over a Jacobian. α is the roughness parameter (engines usually expose perceptual roughness r and set α = r²).
Cook–Torrance with GGX
Three factors over a Jacobian. α is the roughness parameter (engines usually expose perceptual roughness r and set α = r²).
  • D — GGX / Trowbridge–Reitz (Walter et al. 2007): D(h) = α² / (π ((n·h)²(α² − 1) + 1)²). It is a distribution: projected onto the macro surface it integrates to one, ∫ D(h) (n·h) dω_h = 1. Its long tail is why GGX highlights have the soft glow real materials show.
  • G — Smith: G = G₁(l) G₁(v), G₁(v) = 2(n·v) / (n·v + √(α² + (1 − α²)(n·v)²)). 1 at normal incidence, falling to 0 at grazing angles, where facets hide each other.
  • F — Schlick (1994): F = F₀ + (1 − F₀)(1 − cosθ)⁵. F₀ ≈ 0.04 for dielectrics, the tinted base colour for metals. Everything reflects like a mirror at grazing angles.

The 4 (n·l)(n·v) converts from the half-vector domain to the light-direction domain (dω_h = dω_l / (4 (v·h))) and from projected to true facet area.

C++
#include <cmath>
#include <numbers>
#include <print>

const double pi = std::numbers::pi;

double ggx_d(double nh, double a) {
    double d = nh * nh * (a * a - 1) + 1;
    return nh > 0 ? a * a / (pi * d * d) : 0.0;
}

int main() {
    // Check the normalisation  2π ∫₀¹ D(μ) μ dμ = 1  numerically (μ = n·h).
    for (double alpha : {0.05, 0.2, 0.5, 1.0}) {
        const int steps = 200000;
        double sum = 0;
        for (int i = 0; i < steps; ++i) {
            double mu = (i + 0.5) / steps;
            sum += ggx_d(mu, alpha) * mu;
        }
        std::println("alpha {:4}: peak D = {:9.2f}   integral = {:.4f}",
                     alpha, ggx_d(1.0, alpha), 2 * pi * sum / steps);
    }
}

Small α means a tall narrow peak (a near-mirror); the area stays one. That is the property the hidden tests check, and the first thing to verify when you port a BRDF from a paper.

4. Monte Carlo integration

To estimate I = ∫ g(x) dx, draw xᵢ from any density p that is non-zero where g is, and average:

Î = (1/N) Σ g(xᵢ) / p(xᵢ), E[Î] = ∫ (g/p) p dx = I.

The estimator is unbiased, its variance is Var[g/p] / N, so the error (standard deviation) falls as 1/√N — in any dimension. Halving the noise costs 4× the samples. The only other lever is the variance per sample: make p look like g (importance sampling). If p ∝ g exactly, every sample is I and the variance is zero.

C++
#include <cmath>
#include <cstdint>
#include <numbers>
#include <print>

struct Rng {  // xorshift32: deterministic everywhere
    std::uint32_t s = 2463534242u;
    float next() {
        s ^= s << 13; s ^= s >> 17; s ^= s << 5;
        return static_cast<float>(s >> 8) * 0x1p-24f;
    }
};

int main() {
    const double pi = std::numbers::pi;
    // I = ∫₀^π sin x dx = 2. RMS error over 64 independent runs per N.
    Rng rng;
    for (int n : {16, 256, 4096, 65536}) {
        double se_uni = 0, se_imp = 0;
        for (int run = 0; run < 64; ++run) {
            double uni = 0, imp = 0;
            for (int i = 0; i < n; ++i) {
                double x = pi * rng.next();               // p(x) = 1/π
                uni += std::sin(x) / (1 / pi);
                double y = std::acos(1 - 2 * rng.next()); // p(y) = sin(y)/2  (inverse CDF)
                imp += std::sin(y) / (std::sin(y) / 2);   // exactly 2 every time
            }
            se_uni += (uni / n - 2) * (uni / n - 2);
            se_imp += (imp / n - 2) * (imp / n - 2);
        }
        std::println("N = {:6}: uniform RMS error {:.5f}   importance RMS error {:.5f}",
                     n, std::sqrt(se_uni / 64), std::sqrt(se_imp / 64));
    }
}

The uniform column's error shrinks by about 4× for every 16× more samples (√16 = 4); the importance-sampled column is exact from the first sample because p ∝ sin (up to float rounding in acos). (The acos(1 − 2u) is inverse-CDF sampling: if P(y) is the cumulative distribution, P⁻¹(u) with uniform u is distributed by p.)

Error falls as 1/√N
Standard error of the irradiance estimate for a sky L = cos θ, measured in units of the result. Both lines have slope −½ on log–log axes; cosine-weighted sampling sits 2.5× lower — equivalent to 6.4× more uniform samples.
Error falls as 1/√N
Standard error of the irradiance estimate for a sky L = cos θ, measured in units of the result. Both lines have slope −½ on log–log axes; cosine-weighted sampling sits 2.5× lower — equivalent to 6.4× more uniform samples.

5. Sampling the hemisphere

Work in a local frame where the normal is +z, so cosθ = z.

Uniform: p(ω) = 1/(2π). Since the area of a sphere band is proportional to its height (Archimedes), z = u₁ uniform and φ = 2π u₂ give uniform directions: (√(1 − z²) cos φ, √(1 − z²) sin φ, z).

Cosine-weighted: p(ω) = cosθ / π — it cancels the cosine in the integrand. Malley's method: pick a uniform point on the unit disk (r = √u₁, φ = 2π u₂; the √ because area grows as r²) and lift it onto the hemisphere, z = √(1 − r²). The disk's density 1/π per unit area, divided by the projection factor dA = cosθ dω, is exactly cosθ/π per steradian.

Two hemisphere pdfs
Density per steradian against the polar angle θ. Cosine sampling spends samples where cos θ (and hence the integrand) is large; both integrate to 1 over the hemisphere.
Two hemisphere pdfs
Density per steradian against the polar angle θ. Cosine sampling spends samples where cos θ (and hence the integrand) is large; both integrate to 1 over the hemisphere.

With cosine samples, a Lambertian sample is (albedo/π) · L · cosθ / (cosθ/π) = albedo · L. Under a uniform sky that is a constant — the estimator has zero variance. This is the white furnace test: put a white (albedo 1) object in a uniform white environment; with no absorption it must disappear (every pixel exactly 1). Any missing π, cosθ or mismatched pdf shows up immediately:

C++
#include <cmath>
#include <cstdint>
#include <numbers>
#include <print>

const float pi = std::numbers::pi_v<float>;

struct Rng {
    std::uint32_t s = 0x9E3779B9u;
    float next() {
        s ^= s << 13; s ^= s >> 17; s ^= s << 5;
        return static_cast<float>(s >> 8) * 0x1p-24f;
    }
};

int main() {
    Rng rng;
    const int n = 100000;
    double good = 0, wrong_pdf = 0, no_cos = 0, no_pi = 0, uniform = 0;
    for (int i = 0; i < n; ++i) {
        float u1 = rng.next(), u2 = rng.next();
        float z = std::sqrt(1 - u1);                   // Malley: cos θ
        float pdf = z / pi;
        float f = 1.0f / pi, L = 1.0f;
        good      += f * L * z / pdf;
        wrong_pdf += f * L * z / (1 / (2 * pi));       // uniform pdf, cosine samples
        no_cos    += f * L / pdf;
        no_pi     += 1.0f * L * z / pdf;
        float zu = u2;                                  // a uniform sample instead
        uniform   += f * L * zu / (1 / (2 * pi));
    }
    std::println("correct {:.4f}  wrong pdf {:.4f}  no cos {:.4f}  no 1/pi {:.4f}  uniform {:.4f}",
                 good / n, wrong_pdf / n, no_cos / n, no_pi / n, uniform / n);
}

Expected: 1, 4/3, 2, π, and ≈ 1 (uniform sampling is unbiased, just noisy).

6. A frame around the normal

Samples are generated around +z and must be rotated into world space around the surface normal n: find unit t, b with (t, b, n) orthonormal, then world = x·t + y·b + z·n. The classic recipe "cross n with whichever axis is least parallel" branches; Frisvad (2012) gave a branch-free formula with a = 1/(1 + n.z) — which is infinite at n = −z and inaccurate near it. Duff et al. (2017) fixed it with one sign:

C++
#include <cmath>
#include <print>

struct V { float x, y, z; };
float dot(V a, V b) { return a.x * b.x + a.y * b.y + a.z * b.z; }

void frisvad(V n, V& t, V& b) {
    float a = 1.0f / (1.0f + n.z);
    float c = -n.x * n.y * a;
    t = {1.0f - n.x * n.x * a, c, -n.x};
    b = {c, 1.0f - n.y * n.y * a, -n.y};
}

void duff(V n, V& t, V& b) {
    float sign = std::copysign(1.0f, n.z);
    float a = -1.0f / (sign + n.z);
    float c = n.x * n.y * a;
    t = {1.0f + sign * n.x * n.x * a, sign * c, -sign * n.x};
    b = {c, sign + n.y * n.y * a, -n.y};
}

int main() {
    for (float eps : {1e-1f, 1e-3f, 1e-4f}) {
        float len = std::sqrt(1.25f * eps * eps + 1);
        V n{eps / len, 0.5f * eps / len, -1 / len};   // almost straight down
        V t, b;
        frisvad(n, t, b);
        float e1 = std::fabs(dot(t, n)) + std::fabs(dot(t, b)) + std::fabs(dot(t, t) - 1);
        duff(n, t, b);
        float e2 = std::fabs(dot(t, n)) + std::fabs(dot(t, b)) + std::fabs(dot(t, t) - 1);
        std::println("eps {:g}: Frisvad error {:.2e}   Duff error {:.2e}", eps, e1, e2);
    }
}

Duff's sign + n.z is never smaller than 1 in magnitude, so there is no cancellation and no special case. (PBRT-v4's CoordinateSystem and most production renderers use it.) Normals near −z are not exotic: every floor seen from below, every inward-facing sphere and every back face hits them.

7. Russian roulette and the path tracer

A path could bounce forever; truncating at a fixed depth loses energy (bias). Russian roulette (Arvo & Kirk 1990) terminates paths at random: continue with probability q, and divide the survivor's throughput by q. The expected contribution is q · (C/q) + (1 − q) · 0 = C — unbiased, and dim paths (small throughput, so small q) rarely survive. Here it is in a complete, tiny path tracer: a camera inside a sphere whose walls emit Lₑ = 1 and reflect with albedo a. Light bounces forever, so the answer is the geometric series Σ aᵏ = 1/(1 − a) — a closed-form check for every piece above.

C++
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <numbers>
#include <print>

const float pi = std::numbers::pi_v<float>;
struct V { float x, y, z; };
V operator*(float s, V v) { return {s * v.x, s * v.y, s * v.z}; }
V operator+(V a, V b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; }
float dot(V a, V b) { return a.x * b.x + a.y * b.y + a.z * b.z; }

struct Rng {
    std::uint32_t s = 0x9E3779B9u;
    float next() { s ^= s << 13; s ^= s >> 17; s ^= s << 5; return float(s >> 8) * 0x1p-24f; }
};

// Leaving the unit sphere from inside: o + t d with |o| <= 1, d unit.
float exit_t(V o, V d) {
    float b = dot(o, d), c = dot(o, o) - 1;
    return -b + std::sqrt(std::max(0.0f, b * b - c));
}

int main() {
    const float albedo = 0.5f, Le = 1.0f;
    Rng rng;
    double total = 0;
    const int paths = 20000;
    for (int p = 0; p < paths; ++p) {
        V o{0, 0, 0}, d{0, 0, 1};
        float beta = 1, L = 0;
        for (int bounce = 0;; ++bounce) {
            V x = o + exit_t(o, d) * d;                 // hit the wall
            V n = -1.0f * x;                            // inward normal
            L += beta * Le;                             // emission
            // frame around n (Duff et al.), cosine-weighted direction
            float sign = std::copysign(1.0f, n.z), a = -1 / (sign + n.z), b = n.x * n.y * a;
            V t{1 + sign * n.x * n.x * a, sign * b, -sign * n.x}, bt{b, sign + n.y * n.y * a, -n.y};
            float u1 = rng.next(), u2 = rng.next(), r = std::sqrt(u1), phi = 2 * pi * u2;
            float z = std::sqrt(1 - u1);
            d = (r * std::cos(phi)) * t + (r * std::sin(phi)) * bt + z * n;
            beta *= (albedo / pi) * z / (z / pi);       // f cosθ / pdf = albedo
            if (bounce >= 3) {                          // Russian roulette
                float q = std::min(0.95f, beta);
                if (rng.next() >= q) break;
                beta /= q;
            }
            o = 0.9999f * x;                            // step off the surface
        }
        total += L;
    }
    std::println("estimate {:.3f}, exact {:.3f}", total / paths, Le / (1 - albedo));
}

Note o = 0.9999f * x: the next ray starts a hair inside the wall, the self-intersection ("shadow acne") guard from the ray-intersection skill.

8. Linear light, sRGB pixels

Radiance adds: two lights give the sum of their contributions, a pixel covered half by white and half by black receives half the light. All of that holds for linear values. Displays and 8-bit images instead store sRGB-encoded values, a curve close to x^(1/2.2) with a linear toe:

encode(x) = 12.92 x for x ≤ 0.0031308, else 1.055 x^(1/2.4) − 0.055.

The sRGB transfer function
Half the light (linear 0.5) is stored as 0.735 → 188/255. The curve spends more of the 256 codes on dark tones, where eyes tell levels apart best.
The sRGB transfer function
Half the light (linear 0.5) is stored as 0.735 → 188/255. The curve spends more of the 256 codes on dark tones, where eyes tell levels apart best.

So the pipeline is: decode sRGB inputs (textures, colour pickers) to linear → light, accumulate samples, blend, filter in linear → tone-map (compress HDR radiance into [0, 1], e.g. Reinhard x/(1 + x) or ACES) → encode once → quantise to 8 bits. Encoding before averaging is the classic bug: an anti-aliased edge between black and white comes out as 128 (which the display shows as 21 % of the light) instead of 188.

C++
#include <cmath>
#include <print>

float encode(float x) { return x <= 0.0031308f ? 12.92f * x : 1.055f * std::pow(x, 1 / 2.4f) - 0.055f; }
float decode(float y) { return y <= 0.04045f ? y / 12.92f : std::pow((y + 0.055f) / 1.055f, 2.4f); }

int main() {
    float samples[] = {0, 0, 0, 1};            // 25 % coverage of a white object
    float lin = 0, enc = 0;
    for (float s : samples) { lin += s / 4; enc += encode(s) / 4; }
    std::println("average then encode: {}", std::lround(encode(lin) * 255));
    std::println("encode then average: {}  (the display shows only {:.0f}% of the light)",
                 std::lround(enc * 255), 100 * decode(enc));
}

Training data for neural rendering raises the same question: NeRF and 3D Gaussian Splatting fit colours in whatever space the photos are in (usually sRGB-encoded LDR); RawNeRF (Mildenhall et al. 2022) trains on linear raw images instead to get correct HDR and blur. Know which space your loss sees.

In Python (NumPy)

Text
u1, u2 = rng.random(N), rng.random(N)
r, phi = np.sqrt(u1), 2 * np.pi * u2
d = np.stack([r * np.cos(phi), r * np.sin(phi), np.sqrt(1 - u1)], -1)   # cosine samples
E = np.mean(sky(d @ frame) * d[:, 2] / (d[:, 2] / np.pi))               # irradiance
srgb = np.where(x <= 0.0031308, 12.92 * x, 1.055 * x ** (1 / 2.4) - 0.055)

In an interview

  • "Why albedo/π?" — ∫ cosθ dω = π; it makes a white Lambertian surface reflect exactly what it receives.
  • "How do you know your path tracer is right?" — white furnace test (objects vanish), analytic cases like the emissive sphere above, convergence at 1/√N, reciprocity and energy checks on each BRDF.
  • "Variance reduction?" — importance sampling (BRDF, light, both with MIS — Veach 1997), stratification / low-discrepancy sequences, Russian roulette, next-event estimation, and finally a denoiser.

Recap

  • Radiance is what rays carry; irradiance integrates radiance × cosθ.
  • The rendering equation: Lₒ = Lₑ + ∫ f · Lᵢ · cosθ dω; path tracing unrolls it.
  • Lambert = albedo/π; GGX = D·G·F/(4 (n·l)(n·v)); BRDFs are reciprocal and conserve energy.
  • Monte Carlo: average g/p, unbiased iff p is the density you sampled; error ∝ 1/√N.
  • Cosine sampling (Malley) has pdf cosθ/π and makes Lambert samples constant.
  • Build frames with Duff et al.'s branchless ONB; terminate with Russian roulette and divide by q.
  • Light and average in linear space; tone-map and sRGB-encode once at the end.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Sampling disks and hemispheres (uniform and cosine-weighted), pdfs in solid angle, and importance sampling for path tracing. Lecture 3 before it covers the basic estimator.

Probability recap, the Monte Carlo estimator, why the error falls as 1/√N in any dimension.

A path tracer built from scratch on screen: random hemisphere bounces, cosine-weighted sampling, accumulating frames — the intuition for section 7.