Three.js From Zero · Article s4-05

Volumetric Fog & God Rays

← threejs-from-zeroS4 · Article 05 Season 4
Article S4-05 · Three.js From Zero

Volumetric Fog & God Rays

Three.js's built-in Fog darkens pixels based on depth. That's flat fog — no shafts of light through the canopy, no bright patches where the sun breaks through. Real atmospheric fog is volumetric — light scatters inside it, and beams of sun become visible.

Volumetric fog is raymarched: for each pixel, step through the view ray accumulating light from the fog medium. Cheap approximation, huge visual payoff — the trick behind every dramatic forest shot in modern games.

loading…

Concept

A volumetric fog pass raymarches through each view ray. At every step, it asks:

  1. What's the fog density here? (from a function or 3D texture)
  2. How much light is reaching this point? (shadow-map lookup — did the ray from the sun get here?)
  3. How much is being scattered toward the camera? (phase function — Mie scattering)

Accumulate across the ray. The result: more fog = more atmospheric absorption. Near the sun direction = more in-scattering = visible shafts.

The ray march

float heightFog(vec3 p) {
  return exp(-max(0.0, p.y) * 0.5) * 0.5;
}
float sunShadow(vec3 p) {
  vec4 lightSpace = uLightMatrix * vec4(p, 1.0);
  vec2 uv = lightSpace.xy / lightSpace.w * 0.5 + 0.5;
  float d = texture2D(uShadowMap, uv).r;
  return lightSpace.z / lightSpace.w < d + 0.001 ? 1.0 : 0.0;
}

vec3 raymarchFog(vec3 rayOrigin, vec3 rayDir, float rayLen) {
  vec3 accumLight = vec3(0);
  float transmittance = 1.0;
  float step = rayLen / float(STEPS);

  for (int i = 0; i < STEPS; i++) {
    vec3 p = rayOrigin + rayDir * (step * (float(i) + random(uv)));

    float density = uDensity * heightFog(p);
    float lit = sunShadow(p);
    // Mie phase function — forward-scatter toward the camera-to-sun direction
    float cosTheta = dot(rayDir, uSunDir);
    float phase = 0.25 * (1.0 - g * g) / pow(1.0 + g * g - 2.0 * g * cosTheta, 1.5);

    vec3 inScatter = uSunColor * lit * phase * density * step;
    accumLight += inScatter * transmittance;
    transmittance *= exp(-density * step);
  }

  return accumLight;
}

Height fog

Density falls off exponentially with height. Thick at ground level, thin up high. Mountaintops above the fog layer look dramatic.

float density(vec3 p) {
  return uBaseDensity * exp(-max(0.0, p.y - uFogHeight) * uFalloff);
}

Mie phase function — the god-ray effect

Fog doesn't scatter uniformly. When your view ray is aligned with the sun, you get MASSIVE forward scattering — the bright shaft. When perpendicular, much less. The Henyey-Greenstein phase function models this:

float HG(float cosTheta, float g) {
  return (1.0 - g * g) / (4.0 * PI * pow(1.0 + g * g - 2.0 * g * cosTheta, 1.5));
}

g ∈ [-1, 1] — anisotropy. Near 1 = heavy forward scatter (strong god rays). Near 0 = isotropic (even glow). Near -1 = backward (rare). Typical atmospheric g ≈ 0.7.

Shadow-map integration

At each ray-march step, project into shadow-map light space. If the depth test says "this point isn't in shadow", the sun reaches it and it contributes to inscattering. Points in shadow don't scatter the sun's light.

Result: bright shafts where the ray passes through unshadowed air, dark gaps where a tree blocks the sun. That's the god-ray effect.

Dithering + blue-noise

48 steps isn't enough for smooth results — you get banding. Offset the starting position along the ray by a per-pixel random value. Combined with temporal accumulation (TAA from S5-03), you get clean fog at reasonable cost.

vec3 p = rayOrigin + rayDir * step * (i + bluenoise(uv));

Temporal accumulation

A 4-step raymarch per frame, reprojected across 8 frames, gives effectively 32-step quality at 1/4 the cost. Requires motion vectors (TAA pipeline).

Froxel (frustum-aligned voxel) fog

Modern AAA approach: discretize the view frustum into a 3D texture (e.g., 160×90×64 cells). One compute dispatch populates density + inscattering per cell. Full-screen pass integrates along the ray by stepping through the texture.

Much faster than per-pixel raymarching. DOOM Eternal uses this. Supported in TSL compute on WebGPU.

Three.js implementations

No first-party volumetric fog in core Three.js. Options:

  • postprocessing package's GodRaysEffect — cheap screen-space beam approximation, not true volumetric.
  • Community THREE-VolumetricFog — raymarched from shadow maps.
  • Roll your own ShaderPass using the pattern above.

Common first-time pitfalls

  • Banding stripes in fog. Add dither/bluenoise offset to ray start.
  • Shafts too intense. Phase function g too high, or density too high. Balance.
  • No shafts visible. Sun direction not aligned with view. Walk around until a tree is between you and the sun.
  • Fog disappears at camera edges. Ray length underestimate. Use scene depth buffer to clip ray to nearest geometry.

Exercises

  1. Froxel fog: build the 3D texture on the GPU. Compare cost + quality vs per-pixel.
  2. 3D noise density: multiply base density by 3D FBM noise. Cloud-like fog that moves in the wind.
  3. Colored point lights in fog: each scene light contributes inscattering. Flashlight beams become visible.

What's next

S4-06 — Subsurface scattering. The effect that makes skin look like skin.