This is the second of three ray tracing notes, covering how rays produce shadows and global illumination.
Shadow rays
Shadows are ray tracing's most immediately rewarding application: cast a ray from the shading point towards the light, and if something is in the way, the point is in shadow.
MSLbool isOccluded(float3 origin, float3 normal, float3 lightPos,
instance_acceleration_structure accel)
{
float3 toLight = lightPos - origin;
float dist = length(toLight);
ray r;
r.origin = origin + normal * 0.001; // offset along the normal against self-hits
r.direction = toLight / dist;
r.min_distance = 0.001;
r.max_distance = dist - 0.001; // do not hit the light itself
intersector<instancing> i;
i.accept_any_intersection(true); // only ask whether, not which
return i.intersect(r, accel).type != intersection_type::none;
}
accept_any_intersection(true) is the key optimization. A shadow ray does not need the nearest hit, only whether any hit exists, so traversal can return at the first one instead of continuing to look for something closer. This routinely makes shadow rays twice as fast as ordinary ones.
Compared with a shadow map, traced shadows have no resolution limit, no bias to tune, no cascade seams and no Peter Panning. They are simply correct.
Soft shadows
Real shadows have a penumbra because lights have area. The technique is to sample the light's surface rather than aim at a point:
MSLfloat3 sampleSphereLight(Light light, float2 xi) {
float3 dir = uniformSampleSphere(xi);
return light.position + dir * light.radius;
}
// one ray per pixel, denoised by temporal accumulation
float shadow = isOccluded(p, n, sampleSphereLight(light, rand2(gid, frame)), accel) ? 0.0 : 1.0;
The penumbra's width follows naturally from the light's size and the distance between occluder and receiver β no parameters at all. This is ray tracing's most immediately visible advantage over PCF: PCF softens by a constant radius, whereas a real penumbra is sharp where the occluder meets the ground and widens with distance from it.
A single sample is very noisy, and temporal accumulation (TAA, or a dedicated shadow denoiser) recovers the rest.
Next event estimation
Naive path tracing samples only the BRDF and hopes a ray happens to hit a light. For small lights that probability is tiny, and the image stays speckled for a long time.
Next event estimation (NEE, also called direct light sampling) casts an explicit shadow ray towards a light at every path vertex:
MSLfloat3 directLighting(float3 p, float3 n, float3 wo, Material m,
constant Light *lights, uint lightCount,
instance_acceleration_structure accel, float2 xi)
{
// pick a light uniformly; divide by the selection probability afterwards
uint li = min(uint(xi.x * lightCount), lightCount - 1);
float pdfL = 1.0 / float(lightCount);
float3 lp = sampleLightSurface(lights[li], xi);
float3 wi = normalize(lp - p);
float dist = length(lp - p);
if (dot(n, wi) <= 0.0) return 0.0;
if (isOccluded(p, n, lp, accel)) return 0.0;
float3 brdf = evaluateBRDF(m, n, wi, wo);
float G = dot(n, wi) / (dist * dist);
return brdf * lights[li].color * G / pdfL;
}
With NEE, direct lighting is clean even at one bounce, and the noise is confined to the indirect term. This is the step that turns path tracing from theoretically correct into practically usable.
NEE and BRDF sampling should be combined with MIS: NEE is good for small lights, BRDF sampling for large lights and specular reflections, and a weighted combination covers both.
The path tracing loop
A full path tracer assembles these into a loop rather than recursion, since there is no stack to recurse on:
MSLfloat3 pathTrace(ray r, instance_acceleration_structure accel,
constant Scene &scene, thread uint &seed)
{
float3 radiance = 0.0;
float3 throughput = 1.0; // accumulated BRDF / pdf
for (uint bounce = 0; bounce < MAX_BOUNCES; bounce++) {
auto hit = trace(r, accel);
if (hit.type == intersection_type::none) {
radiance += throughput * sampleEnvironment(r.direction);
break;
}
SurfaceData s = unpackSurface(hit, scene);
// direct light (NEE)
radiance += throughput * directLighting(s.position, s.normal, -r.direction,
s.material, scene.lights, scene.lightCount,
accel, rand2(seed));
// sample the next direction
float3 wi; float pdf;
float3 brdf = sampleBRDF(s.material, s.normal, -r.direction, rand2(seed), wi, pdf);
if (pdf <= 0.0) break;
throughput *= brdf * abs(dot(s.normal, wi)) / pdf;
// Russian roulette
if (bounce > 2) {
float q = max(throughput.x, max(throughput.y, throughput.z));
if (rand(seed) > q) break;
throughput /= q; // compensate, keeping the estimator unbiased
}
r.origin = s.position + s.normal * 0.001;
r.direction = wi;
}
return radiance;
}
Russian roulette deserves explanation. Simply truncating paths at a fixed depth introduces bias β the energy of the discarded long paths is lost and the image comes out too dark. Russian roulette terminates a path with probability 1-q but divides a surviving path's contribution by q to compensate. The expectation is unchanged, so the estimator remains unbiased, while the average path length drops sharply.
Using the maximum component of throughput as q is the standard choice: paths that already contribute little are the ones most likely to be killed.
Hybrid rendering
Pure path tracing does not fit a real-time budget. The practical arrangement today is hybrid: rasterization handles primary visibility, and ray tracing handles the things it is good at.
Plain TextG-Buffer (raster) ββ¬β> traced shadows ββββ
ββ> traced reflections βΌβ> denoise β> composite β> post
ββ> traced AO / GI βββββ
The reasoning is concrete: primary visibility from rasterization is essentially noise-free with predictable cost, while shadows, reflections and AO are exactly where screen-space techniques fail most visibly.
Casting rays from the G-Buffer is far cheaper than casting from the camera, because the first intersection has already been performed by the rasterizer. And the normals and roughness in the G-Buffer can decide how many rays each pixel deserves: smooth reflections need few, rough ones can fall back to IBL.
Denoising
A 1 spp traced image is mostly noise. What makes it usable is the denoiser, which matters as much as the tracing itself.
Two directions:
Temporal accumulation. Reproject the previous frame's result through motion vectors and blend it with new samples. This effectively multiplies the sample count by tens. The difficulty is deciding whether history is still valid β when occlusion or lighting changes, history must be discarded, or you get ghosting.
Spatial filtering. Supplement with neighbouring samples, but the filter must be edge-aware: only blend with neighbours of similar normal and depth, or lighting from different surfaces smears together. Γ-trous wavelet filtering achieves a wide kernel at O(n) cost by filtering repeatedly with increasing stride.
Apple ships MPSSVGF (spatiotemporal variance-guided filtering) as a ready-made implementation that combines both and uses a variance estimate to set filter strength β noisier regions are filtered harder.
The next note covers ray tracing performance.