Three.js From Zero · Article s11-14
S11-14 Hillaire 2020 Atmosphere
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
| Year | Paper | What it added |
|---|---|---|
| 1999 | Preetham et al. | Clear-sky analytic gradient (Three's built-in Sky) |
| 2008 | Bruneton & Neyret | Precomputed atmospheric scattering — 4 LUTs (transmittance, irradiance, single-scattering Rayleigh + Mie packed 4D), runtime is texture fetches |
| 2014 | Wronski (AC4) | Volumetric fog froxel grid; unifies fog + god rays + light shafts |
| 2020 | Hillaire (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:
- If sun moved or atmosphere parameters changed, regenerate transmittance + multi-scatter (cheap).
- Generate sky-view LUT for the current camera (~fast — one fragment per texel, raymarched short distances).
- Generate aerial-perspective volume (one compute dispatch).
- Sky pass: fragment shader samples sky-view LUT.
- 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:
- Compute the world-space view direction from the screen-space UV.
- Find the ray's intersection with the planet ellipsoid (skip below-horizon work).
- Raymarch ~24 steps from camera to atmosphere top.
- 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).
- Apply phase functions (Rayleigh symmetric cos θ; Mie Henyey-Greenstein with g).
- Add a multi-scatter ambient lift — the bit that makes shadowed sides of the sky brighter than single-scatter would predict.
- 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
| Effect | Three's Sky | This article | Full Hillaire |
|---|---|---|---|
| Daylight blue | Yes | Yes | Yes |
| Sunset red | No | Yes | Yes |
| Twilight gradient | Crude | Yes (sun below horizon → coloured haze) | Yes (multi-scatter LUT) |
| Mie sun halo | Approximate | Yes (HG phase, tunable g) | Yes |
| Multi-scatter brightening | No | Approximated | LUT-driven |
| Aerial perspective on geometry | No | No (out of scope) | Yes |
| Time-of-day animatable | Yes | Yes | Yes |
| 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:
- 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.
- 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.
- Sky-view LUT pass. 192×108 RGBA16F, generated per frame from current camera. Fragment raymarches the sky in the camera's local frame.
- Aerial-perspective volume. 32×32×32 RGBA16F, indexed by NDC.xy and linearised depth. Fragment raymarches frustum slices.
- Sky pass. Sample the sky-view LUT in your sky shader. ~3 lines of fragment code.
- 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.SRGBColorSpaceand 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.