Volumetric Rendering
Surface rendering draws triangles. Volumetric rendering steps a ray through a 3D field of density, accumulating absorbed and scattered light at every sample. The result: photorealistic clouds, fog, fire, and god rays — and it all runs in a fragment shader.
1. Participating Media
A participating medium is any material that affects light within its volume — not just at a surface. Clouds, fog, smoke, murky water, the atmosphere, skin subsurface scattering — all are participating media. Three optical phenomena occur:
- Absorption — light is converted to heat. Beer's law.
- Out-scattering — light is redirected away from the ray.
- In-scattering — light from other directions is redirected into our ray.
- Emission — the medium itself emits light (blackbody radiation, fire).
The extinction coefficient σ_t = σ_a + σ_s combines absorption (σ_a) and scattering (σ_s). The single-scatter albedo ω = σ_s / σ_t describes how much of the extinction is scattering vs absorption.
2. Beer–Lambert Absorption
Light intensity decays exponentially as it travels through an absorbing medium:
T — transmittance (fraction of light that survives)
s — distance travelled
σ_t — extinction coefficient at position x
For a homogeneous medium (constant σ_t), this simplifies to
T = exp(−σ_t × s). Double the density or distance
→ the same exponential attenuation.
3. Ray Marching Through a Volume
We can't analytically integrate the full rendering equation for complex media, so we discretize with ray marching: step along the view ray in N equal-sized steps, sample density at each point, and accumulate colour and transparency.
color += T_accumulated × in_scatter_color × step_size
We march front-to-back and stop early when transmittance drops below a threshold (e.g. 0.01) — the ray can't see any further. This is crucial for performance.
4. Scattering and Phase Functions
When a photon scatters, which direction does it go? The phase function p(ω_i, ω_o) describes the angular probability distribution. Two common choices:
Isotropic Scattering
Equal probability in every direction. Simple, used for fog and dense clouds:
Henyey–Greenstein
The standard model for clouds and atmosphere. Controlled by asymmetry parameter g ∈ (−1, 1). Positive g → forward scattering (like clouds haloing the sun). g = 0 → isotropic.
θ — angle between incident and scattered ray
g = 0.8 is typical for water clouds
For dual-lobe clouds (bright forward corona + broad back scatter),
combine two HG terms: p = lerp(HG(cosθ, g₁), HG(cosθ, g₂), blend).
5. Emission (Fire and Lava)
Emissive volumes — fire, plasma, lava — add a term to the radiance integral independent of incoming light:
L_e — emitted radiance (often from a temperature→colour LUT)
In practice: sample a 3D noise texture for density, remap density to temperature, look up a fire colour gradient, and add this to the accumulated colour. The Beer-Lambert transmittance still applies on top.
6. Light Cone Marching (Shadows)
For volumetric shadows within clouds, at each primary sample point we fire a secondary shadow ray toward the light source and integrate density along it to compute the transmittance from that point to the sun. This is expensive — 6–16 shadow samples per primary sample.
Optimisations used in game engines (Horizon Zero Dawn, Red Dead Redemption 2):
- Reprojection: Render at quarter resolution, reproject from the previous frame for free temporal accumulation.
- Blue-noise sample offsets: Randomise the starting point of each march. TAA smooths jitter.
- LOD noise textures: Use cheap 2D cloud coverage maps to skip marching in empty sky regions.
- Precomputed multiple scattering: Analytically approximate multiple-scattering contributions to avoid expensive path tracing.
7. GLSL Implementation
A minimal volumetric fog fragment shader (WebGL 2 / GLSL ES 3.0):
// Uniform volume density, single directional light
uniform sampler3D u_density; // 3D density texture
uniform vec3 u_lightDir;
uniform float u_sigma_t; // extinction coefficient
uniform int u_steps; // primary ray steps (e.g. 64)
// Front-to-back ray march
vec4 raymarch(vec3 ro, vec3 rd, float tMin, float tMax) {
float stepSize = (tMax - tMin) / float(u_steps);
float transmit = 1.0;
vec3 color = vec3(0.0);
for (int i = 0; i < u_steps; ++i) {
float t = tMin + (float(i) + 0.5) * stepSize;
vec3 pos = ro + rd * t;
float density = texture(u_density, pos * 0.5 + 0.5).r;
if (density < 0.001) continue;
// Beer-Lambert transmittance for this step
float extinction = u_sigma_t * density * stepSize;
float stepTransmit = exp(-extinction);
// Shadow ray toward light (simplified: single sample)
float shadowDensity = texture(u_density,
(pos + u_lightDir * 0.3) * 0.5 + 0.5).r;
float shadow = exp(-u_sigma_t * shadowDensity * 0.3);
vec3 scatter = vec3(0.82, 0.90, 1.0) * shadow; // sky tint
color += transmit * (1.0 - stepTransmit) * scatter;
transmit *= stepTransmit;
if (transmit < 0.01) break; // early exit
}
return vec4(color, 1.0 - transmit); // premultiplied alpha
}
8. GPU Performance
- Resolution: Render at ¼ resolution (half width × half height), upsample with bilateral/depth-aware filter. 4× cheaper.
- Temporal reprojection: Accumulate 4–8 frames using motion vectors. Only 1/4 of samples per frame need to be fresh.
- Empty space skipping: Build a min-max 3D mipmap of the density field. Skip steps entirely when the local max density is near zero.
- Compute shaders: WebGPU compute shaders can ray march the entire screen in one pass, skipping rasterisation overhead.
- Ray budget: 32–64 primary steps + 6–16 shadow steps per pixel at ¼ res is the sweet spot for real-time (60 fps on a mid-tier GPU).