Three.js From Zero · Article s11-14
Hillaire 2020 Atmosphere — the missing Three.js library
Hillaire 2020 Atmosphere — the missing Three.js library is Article s11-14 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.
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.