Three.js From Zero · Article s11-14

S11-14 Hillaire 2020 Atmosphere

Season 11 · Article 14

Hillaire 2020 Atmosphere — the missing Three.js library

Sébastien Hillaire's 2020 paper, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique," is the modern AAA standard for sky shaders. Unreal 5 ships it. Frostbite ships it. Six years later there is no drop-in Three.js port. This article builds as much of one as fits in one tutorial — and explains the rest.

1. Three.js's built-in Sky and why it's not enough

Three.js ships a Sky addon. Under the hood it is the 1999 Preetham analytical model — a clear-sky daylight gradient parameterised by sun direction, turbidity, and a few colour terms. It looks fine for noon-on-a-clear-day. Past that:

  • No twilight. Sunset reds are absent.
  • No multi-scattering. Real skies are brighter in shadowed directions because light bounces inside the atmosphere; Preetham models only single scattering.
  • No aerial perspective. Distant objects don't fade into the atmospheric haze.
  • No altitude. View from a plane window? Indistinguishable from view at sea level.

For a configurator with a window, a flight sim, an open-world game, an architectural visualisation that shows a building under different times of day — Preetham is the demo, not the answer. The answer in 2026 is Hillaire.

2. The lineage — Bruneton → Hillaire

YearPaperWhat it added
1999Preetham et al.Clear-sky analytic gradient (Three's built-in Sky)
2008Bruneton & NeyretPrecomputed atmospheric scattering — 4 LUTs (transmittance, irradiance, single-scattering Rayleigh + Mie packed 4D), runtime is texture fetches
2014Wronski (AC4)Volumetric fog froxel grid; unifies fog + god rays + light shafts
2020Hillaire (Frostbite)Drop the 4D LUT. Replace with transmittance 2D + multi-scatter 2D + sky-view LUT (camera-relative) + aerial-perspective frustum 3D. Mobile-friendly, dynamic per-frame parameters.

Why Hillaire won: Bruneton's 4D LUT is huge in VRAM and slow to update if you want time-of-day to actually change. Hillaire's per-camera sky-view LUT plus aerial-perspective volume gives you everything Bruneton did at a fraction of the precompute cost, and crucially it animates well — sun moves, clouds drift, sun-sets all happen in real time.

3. Rayleigh + Mie in 90 seconds

Two scattering mechanisms model the atmosphere:

  • Rayleigh. Air molecules. Wavelength-dependent — scatters blue strongly, red weakly. Hence: blue sky during the day; red sun at low angles when blue has scattered out of the line of sight.
  • Mie. Aerosols (dust, water droplets). Larger particles, scatters all wavelengths roughly equally, strongly forward-biased. Hence: the bright halo around the sun.

Each has a scattering coefficient (β_R, β_M), a height falloff (atmosphere thins exponentially), and a phase function (Rayleigh's is symmetric in cos θ; Mie's is the Henyey-Greenstein with anisotropy g, typically 0.76).

The integral along a view ray:

L(view) = ∫₀^∞  T(0→s) · (β_R(s)·P_R(θ) + β_M(s)·P_M(θ)) · L_sun · ds

Where T is transmittance (how much light survives along the ray) and θ is the angle to the sun. Doing this by raymarch every fragment is too expensive. So we precompute.

4. Hillaire's contribution — the four LUTs

Hillaire's pipeline produces and consumes four lookup textures:

  • Transmittance LUT (2D, ~256×64). Indexed by view-zenith angle and altitude. Returns how much light survives a ray to space.
  • Multi-scatter LUT (2D, ~32×32). Approximates light that has bounced 2+ times in the atmosphere. This is the lift over Bruneton — multi-scatter LUT is cheap and makes shadowed parts of the sky believably brighter.
  • Sky-view LUT (2D, ~192×108). Camera-relative panorama, indexed by view azimuth and zenith. Updated per frame from the camera position.
  • Aerial-perspective volume (3D, ~32×32×32). View-frustum-aligned, indexed by NDC.xy and depth. Stores in-scattered light + transmittance per froxel. Used to fade distant geometry.

Render path each frame:

  1. If sun moved or atmosphere parameters changed, regenerate transmittance + multi-scatter (cheap).
  2. Generate sky-view LUT for the current camera (~fast — one fragment per texel, raymarched short distances).
  3. Generate aerial-perspective volume (one compute dispatch).
  4. Sky pass: fragment shader samples sky-view LUT.
  5. Geometry pass: each opaque material looks up its froxel and modulates its colour with aerial perspective.

5. The version this article ships

A full Hillaire port — multi-scatter LUT, sky-view LUT, frustum-aligned aerial perspective volume, dynamic atmosphere parameters — runs into thousands of lines and several render targets. Not one tutorial. So this article ships a simplified analytic Hillaire-style sky: two LUTs in spirit (transmittance and a single-scatter integral), Rayleigh + Mie phase functions, multi-scatter approximation, and a sun direction slider. It is good enough for product viewers, slow time-of-day cycles, and architectural visualisations. The full path is sketched in §10.

6. Live demo — sun direction, Rayleigh, Mie

Drag the sun slider through the day. Pull Rayleigh down for a Mars-thin atmosphere; pull Mie up for haze. ACES tone mapping is on; turn it off to see why HDR matters for sky.

7. The shader, walked through

The demo above is a fullscreen sky pass plus a small foreground horizon. Sky pass per fragment:

  1. Compute the world-space view direction from the screen-space UV.
  2. Find the ray's intersection with the planet ellipsoid (skip below-horizon work).
  3. Raymarch ~24 steps from camera to atmosphere top.
  4. At each step: compute density (Rayleigh exp, Mie exp), accumulate transmittance, compute optical depth to sun (a second short march per step — the costly bit).
  5. Apply phase functions (Rayleigh symmetric cos θ; Mie Henyey-Greenstein with g).
  6. Add a multi-scatter ambient lift — the bit that makes shadowed sides of the sky brighter than single-scatter would predict.
  7. Tone map (ACES); output.

Concretely:

// Phase functions
float phaseRayleigh(float cosTheta) {
  return (3.0 / (16.0 * PI)) * (1.0 + cosTheta * cosTheta);
}
float phaseMie(float cosTheta, float g) {
  float g2 = g * g;
  return (3.0 / (8.0 * PI)) *
         ((1.0 - g2) * (1.0 + cosTheta*cosTheta)) /
         ((2.0 + g2) * pow(1.0 + g2 - 2.0*g*cosTheta, 1.5));
}

// Density (height falloff)
vec2 densities(float h) {
  return vec2(
    exp(-h / 8000.0),       // Rayleigh: 8 km scale height
    exp(-h / 1200.0)        // Mie:      1.2 km scale height
  );
}

The full shader is in this page's source — read it for the detail.

8. Tone mapping — why ACES matters here

Atmosphere shaders produce HDR. Real sky luminance ranges over 5 orders of magnitude across a day. If you skip tone mapping, sunsets clip, mid-day washes out, and the noon disc burns the screen.

ACES Filmic (the toggle in the demo) compresses the high end softly. renderer.toneMapping = THREE.ACESFilmicToneMapping + renderer.toneMappingExposure is the one-line answer. Hillaire 2020 explicitly assumes you have HDR + tone mapping; without them the LUTs are pointless because every value above 1 truncates to white.

9. Why this beats Three's Sky

EffectThree's SkyThis articleFull Hillaire
Daylight blueYesYesYes
Sunset redNoYesYes
Twilight gradientCrudeYes (sun below horizon → coloured haze)Yes (multi-scatter LUT)
Mie sun haloApproximateYes (HG phase, tunable g)Yes
Multi-scatter brighteningNoApproximatedLUT-driven
Aerial perspective on geometryNoNo (out of scope)Yes
Time-of-day animatableYesYesYes
Performance~0.05 ms~0.6 ms (raymarched)~0.4 ms (LUT-sampled)

The demo here trades performance for simplicity — raymarching every pixel beats precomputing LUTs in lines-of-code, at a small ms cost. For a configurator hero scene that's fine. For a flight sim, do the full LUT path.

10. The full Hillaire path — what to build next

If you want to extend this into a production stack:

  1. Transmittance LUT pass. Render a 256×64 RGBA16F target once at startup. Each texel: integrate optical depth from a planet-surface point at altitude h to space, in direction with view-zenith angle θ. Pure geometry; no time dependence.
  2. Multi-scatter LUT pass. Render a 32×32 target. Each texel: estimate isotropic multi-scattering at altitude h with sun-zenith θ. Iterative — bounce light a few times. Tiny target, runs in microseconds.
  3. Sky-view LUT pass. 192×108 RGBA16F, generated per frame from current camera. Fragment raymarches the sky in the camera's local frame.
  4. Aerial-perspective volume. 32×32×32 RGBA16F, indexed by NDC.xy and linearised depth. Fragment raymarches frustum slices.
  5. Sky pass. Sample the sky-view LUT in your sky shader. ~3 lines of fragment code.
  6. Geometry pass. Every opaque material samples the AP volume by its NDC + depth, multiplies in-scattering, attenuates by transmittance. ~5 lines per material.

For TSL, all of the above maps to a chain of Fn(...) graphs and render-target passes. With WebGPU compute, the LUT generation is a single dispatch each. The @takram/three-atmosphere package and the trist.am writeup are the closest existing references.

11. Pitfalls and tuning

  • Linear workflow. Atmosphere math assumes linear-light input. renderer.outputColorSpace = THREE.SRGBColorSpace and your textures sRGB-tagged.
  • Sun disc. The math only gives you the diffuse sky. The bright disc itself is a separate billboard or a hard threshold in the shader (step(cos(0.5°), cos θ_sun)).
  • Below-horizon clipping. If your camera sees below the horizon plane (mountains, terrain), you need the planet-shadow term — Hillaire's multi-scatter LUT covers this; an analytic version needs a manual ground term.
  • Ozone layer. Hillaire 2020 includes an ozone absorption layer at ~25 km that bites blue out of the sunset spectrum, deepening the red. Worth adding for realism.
  • Mobile thermals. Even raymarched at 24 steps the cost adds up on phones. Bake to a cubemap when the sun is stationary.

12. Takeaways

  • Three's built-in Sky is Preetham — fine for "noon clear day," wrong for everything else.
  • Hillaire 2020 is the modern AAA standard. Four LUTs replace Bruneton's 4D table; sun moves freely; mobile-friendly.
  • This article ships a simplified raymarch — Rayleigh + Mie + HG phase + multi-scatter approximation + ACES tone mapping. About 60 lines of fragment shader.
  • For production, add transmittance/multi-scatter/sky-view/AP LUTs and apply aerial perspective to geometry.
  • HDR + tone mapping is non-negotiable. Without it the math truncates to white.
  • Paper: sebh.github.io/publications/egsr2020.pdf. Reference port study: trist.am 2024 writeup.