☁️ Computer Graphics · WebGL
📅 March 2026 ⏱ ~10 min read 🟡 Intermediate

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:

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:

Beer–Lambert Law T(s) = exp(−∫₀ˢ σ_t(x) dx)

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.

Front-to-back compositing (each step) T_accumulated = T_accumulated × exp(−σ_t × density × step_size)
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:

Isotropic phase function p = 1 / (4π)

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.

Henyey–Greenstein phase function p(cosθ, g) = (1 − g²) / (4π · (1 + g² − 2g cosθ)^(3/2))

θ — 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:

Combined: absorption + emission L_out = L_in × T(s) + ∫₀ˢ σ_a(t) × L_e(t) × T(t) dt

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):

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
}
Tip: For convincing clouds, the density field should be built from layered 3D Worley noise (cellular) minus Perlin noise at higher frequencies. Use a 2D coverage map to mask cloud shapes, then erode with 3D detail noise. This matches the Andrew Schneider (Guerrilla Games) Nubis cloud model.

8. GPU Performance

Real production clouds: Cyberpunk 2077 uses ~128 primary steps and ~6 shadow steps at 1/16 resolution with checkerboard pattern, amortized over 16 frames via reprojection. The per-frame cost is equivalent to ~8 full steps at full res.