Hemisphere Sampling¶
The way we choose the scattered ray direction in a diffuse material has a large impact on render quality. This page explains why the choice of sampling distribution matters and how RayON's cosine-weighted hemisphere sampling works.
The problem with uniform sampling¶
The simplest approach is to pick a random direction anywhere in the hemisphere above the surface. Every direction has equal probability:
However, Lambert's cosine law tells us that the contribution of a direction \(\omega\) is weighted by \(\cos\theta\) (the angle between \(\omega\) and the surface normal). Directions near the horizon contribute almost nothing, yet uniform sampling spends as much effort on them as on directions pointing straight up.
The result: high variance near shadow boundaries and at grazing angles, requiring many samples to converge.
Cosine-weighted hemisphere sampling¶
We choose directions with probability proportional to the cosine term:
Because the PDF matches the integrand's cosine factor, the Monte Carlo estimator becomes:
The cosine terms cancel — every sample contributes with equal weight. This is the most efficient unbiased sampler for Lambertian surfaces.
How to sample the cosine distribution¶
There is a compact geometric construction: pick a random point on a unit sphere and offset it along the surface normal. The resulting direction is automatically cosine-distributed.
// From cpu_renderers/renderer_cpu_single_thread.hpp
Vec3 random_unit_sphere() {
while (true) {
Vec3 p = Vec3::random(-1, 1);
if (p.length_squared() < 1.0)
return p.normalized();
}
}
Vec3 scatter_direction = rec.normal + random_unit_sphere();
// Guard against degenerate case (normal and random vector cancel)
if (scatter_direction.near_zero())
scatter_direction = rec.normal;
Alternatively, using spherical coordinates via Malley's method:
then transform to world space via an orthonormal basis aligned with the surface normal.
Orthonormal basis construction¶
To transform the sampled direction into world space, we build a local coordinate system aligned with the surface normal \(\hat{n}\):
// From data_structures/vec3.hpp
struct ONB {
Vec3 u, v, w; // w aligns with surface normal
void build_from_w(const Vec3& n) {
w = n.normalized();
Vec3 a = (fabs(w.x()) > 0.9) ? Vec3(0,1,0) : Vec3(1,0,0);
v = cross(w, a).normalized();
u = cross(w, v);
}
Vec3 local_to_world(const Vec3& a) const {
return a.x()*u + a.y()*v + a.z()*w;
}
};
This guarantees that a direction pointing "up" in local space (\(\hat{z}\)) maps to the surface normal in world space.
Visualising the distributions¶
The histograms below show how often samples land in each elevation band — confirming that cosine-weighted sampling concentrates samples near the normal (\(z \approx 1\)):
Impact on image quality¶
| Scene | Uniform SPP to match | Cosine SPP | Savings |
|---|---|---|---|
| Uniform diffuse walls | 512 | 128 | 4× |
| Shadow boundary close-up | 2048 | 256 | 8× |
| Indirect illumination | 1024 | 256 | 4× |
These numbers are approximate, scene-dependent, and assume the same convergence threshold. The effect is most pronounced in scenes with complex indirect lighting.
Multiple Importance Sampling (future)¶
A natural extension is Multiple Importance Sampling (MIS): combine the cosine-weighted BRDF sampler with a light-direction sampler, weighting each by its contribution using the balance heuristic:
This is listed in the ROADMAP and would further reduce noise in scenes with small, intense light sources (current weak point).