Skip to content
Copied!
published on 2026-09-17

4. Monte Carlo Integration on the Sphere of Directions

Section 3.3 estimated an integral over a one-dimensional interval. I now integrate over the space of directions—the unit sphere S2—which connects directly to ray tracing.

This chapter covers two topics:

  • Uniform sampling on a sphere and its corresponding PDF
  • Empirical verification using the spherical integral of cos2θ

The Exact Value of the Integral

The integral of interest is

I=S2cos2θdω

where θ is the angle from the z axis and dω is an element of solid angle. In spherical coordinates, dω=sinθdθdϕ, so

I=02π0πcos2θsinθdθdϕ=2π0πcos2θsinθdθ

Substituting u=cosθ and du=sinθdθ gives

I=2π11u2du=2π23=4π3

Uniform Sampling on the Sphere

Generating a uniform distribution over the unit sphere uses the fact that dω=sinθdθdϕ is its area element. Because this element depends on θ, choosing both θ and ϕ uniformly does not cover the sphere uniformly.

The correct approach is to choose z=cosθ uniformly. A uniform distribution over z[1,1], combined with a uniform distribution over ϕ[0,2π), covers the sphere uniformly. Given u,vU[0,1), set

z=12u,ϕ=2πv,r=1z2

to construct the direction (rcosϕ,rsinϕ,z). The PDF for this direction is the reciprocal of the sphere's surface area:

p(ω)=14π

Rust Implementation

I add the r304-sphere-directions-mc crate for this chapter, with another local implementation of XorShift64.

The spherical sampling function and PDF constant are as follows.

r304-sphere-directions-mc/src/lib.rs
rust
fn sample_unit_vector(rng: &mut XorShift64) -> (f64, f64, f64) {
    let z = 1.0 - 2.0 * rng.next_f64();
    let phi = 2.0 * PI * rng.next_f64();
    let r = (1.0 - z * z).max(0.0).sqrt();
    (r * phi.cos(), r * phi.sin(), z)
}

fn sphere_pdf() -> f64 {
    1.0 / (4.0 * PI)
}

The .max(0.0) guard prevents floating-point error from making 1z2 slightly negative.

The estimator divides cos2θ=z2 by the PDF and averages the result over N samples.

r304-sphere-directions-mc/src/lib.rs
rust
fn estimate_integral(samples: usize, rng: &mut XorShift64) -> f64 {
    let pdf = sphere_pdf();
    let mut sum = 0.0;
    for _ in 0..samples {
        let (_, _, z) = sample_unit_vector(rng);
        let cosine_squared = z * z;
        sum += cosine_squared / pdf;
    }
    sum / samples as f64
}

generate_report constructs its output in two stages. It first reports a single estimate with N=106, then estimates each checkpoint from N=100 through 500000 using an independent RNG.

r304-sphere-directions-mc/src/lib.rs
rust
    let checkpoints = [100usize, 1_000, 10_000, 100_000, 500_000];
    for (index, n) in checkpoints.iter().copied().enumerate() {
        let mut rng = XorShift64::new(0x3040_1000u64 + index as u64);
        let est = estimate_integral(n, &mut rng);
        output.push_str(&format!(
            "{},{:.12},{:.12}\n",
            n,
            est,
            (est - EXACT_INTEGRAL).abs()
        ));
    }

Because each checkpoint has an independent seed, estimates at small N do not affect the results at larger N, unlike the cumulative-counting approach used in Section 3.2.

Differences Between C++ and Rust

The original C++ random_unit_vector has the same structure.

cpp
inline vec3 random_unit_vector() {
    auto z = random_double(-1, 1);
    auto a = random_double(0, 2*pi);
    auto r = sqrt(1 - z*z);
    return vec3(r*cos(a), r*sin(a), z);
}

C++ calculates sqrt(1 - z*z) without the .max(0.0) guard. The Rust implementation adds .max(0.0) as protection against floating-point error.

r304-sphere-directions-mc/src/lib.rs
rust
const PI: f64 = std::f64::consts::PI;
const EXACT_INTEGRAL: f64 = 4.0 * PI / 3.0;

struct XorShift64 {
    state: u64,
}

impl XorShift64 {
    fn new(seed: u64) -> Self {
        let state = if seed == 0 { 0x9e3779b97f4a7c15 } else { seed };
        Self { state }
    }

    fn next_u64(&mut self) -> u64 {
        let mut x = self.state;
        x ^= x << 13;
        x ^= x >> 7;
        x ^= x << 17;
        self.state = x;
        x
    }

    fn next_f64(&mut self) -> f64 {
        (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
    }
}

fn sample_unit_vector(rng: &mut XorShift64) -> (f64, f64, f64) {
    let z = 1.0 - 2.0 * rng.next_f64();
    let phi = 2.0 * PI * rng.next_f64();
    let r = (1.0 - z * z).max(0.0).sqrt();
    (r * phi.cos(), r * phi.sin(), z)
}

fn sphere_pdf() -> f64 {
    1.0 / (4.0 * PI)
}

fn estimate_integral(samples: usize, rng: &mut XorShift64) -> f64 {
    let pdf = sphere_pdf();
    let mut sum = 0.0;
    for _ in 0..samples {
        let (_, _, z) = sample_unit_vector(rng);
        let cosine_squared = z * z;
        sum += cosine_squared / pdf;
    }
    sum / samples as f64
}

pub fn generate_report() -> String {
    let mut output = String::new();
    output.push_str("MC Integration on the Sphere of Directions\n");
    output.push_str("========================================\n\n");
    output.push_str("Target integral: integral_{S^2} cos^2(theta) d_omega\n");
    output.push_str("Sampling PDF: p(direction) = 1/(4*pi)\n");
    output.push_str(&format!("Exact value: {:.12}\n\n", EXACT_INTEGRAL));

    let mut single_rng = XorShift64::new(0x3040_0001u64);
    let single_estimate = estimate_integral(1_000_000, &mut single_rng);
    output.push_str("Single run (N=1,000,000):\n");
    output.push_str("samples,estimate,abs_error\n");
    output.push_str(&format!(
        "1000000,{:.12},{:.12}\n\n",
        single_estimate,
        (single_estimate - EXACT_INTEGRAL).abs()
    ));

    output.push_str("Convergence checkpoints:\n");
    output.push_str("samples,estimate,abs_error\n");

    let checkpoints = [100usize, 1_000, 10_000, 100_000, 500_000];
    for (index, n) in checkpoints.iter().copied().enumerate() {
        let mut rng = XorShift64::new(0x3040_1000u64 + index as u64);
        let est = estimate_integral(n, &mut rng);
        output.push_str(&format!(
            "{},{:.12},{:.12}\n",
            n,
            est,
            (est - EXACT_INTEGRAL).abs()
        ));
    }

    output
}

Results in the Browser

abs_error is the absolute difference from the exact value 4π/3. Because each checkpoint uses an independent RNG, the error does not decrease monotonically, but it becomes smaller overall as it fluctuates.

Summary

  • I derived S2cos2θdω=4π/3 by integration in spherical coordinates.
  • I implemented uniform spherical sampling using z=12u and ϕ=2πv. Choosing z uniformly accounts for the sinθdθ area element.
  • I empirically confirmed that the Monte Carlo estimator I^N=1Nzi2/(1/4π) converges to 4π/3.

The next chapter connects the idea of a spherical PDF to the ray tracer's scattering model.