vidar.games

Physically based sky atmosphere

2026-11-11 · Discuss on X

A sunrise over seven ridge lines, built from four lookup tables. The far ridges sink into the sky because the sky and the distance haze come from the same scattering maths.

The problem

A gradient sky and linear distance fade look fine at noon and wrong at sunrise. The horizon colour is picked by hand, the fade colour never matches the sky behind it, and nothing changes when the sun moves. Real air reddens the low sun, turns blue overhead and adds haze that depends on where you look relative to the sun.

How it works

The air is two scattering layers and an ozone band. Rayleigh falls off with a scale height of 8 km, Mie with 1.2 km, ozone peaks at 25 km. The planet radius is 6,360 km and the atmosphere is 60 km thick.
Four tables hold the expensive parts:
Transmittance, 256×64: how much light survives a ray, by height and angle.
Multi-scattering, 32×32: light that bounced more than once, integrated over 64 fixed directions × 32 steps, by height and sun angle.
Sky-view, 256×128: the whole sky around the camera, 32 samples per ray.
Aerial perspective, 32×32×32 stored as 1024×32: in-scattered light and transmittance for 32 depth slices out to 32 km.
The sky reads the sky-view table plus a sun disk dimmed by transmittance. Geometry is multiplied by the slice transmittance and gets the slice light added.

What I hit

The aerial perspective table packs 32 slices side by side. Linear filtering bled each slice into its neighbour and drew bright stripes at the screen edges. Clamping the lookup inside the slice fixed it.
The source builds the slice rays for a 90° horizontal view. My camera is 36° vertical, so I scale the rays to its real field of view.
The multi-scattering values top out at 0.031, so the debug panel normalises that table to be visible.

My build

Ported the four table passes, the scattering terms and the sky and haze composite to GLSL in three.js, with the source defaults: sun intensity 31.4, Mie anisotropy 0.8, sun disk 1°. Two tables are built once. Sky-view and aerial perspective are rebuilt every frame as the sun rises.
The mountains are seven ridge strips from 260 m to 27 km, lit by the sun through the transmittance table plus sky-view light from above. That lighting is mine.
The baseline in the split uses the same scene with a hand-tuned gradient and a linear fade to 32 km.

Trade-offs

Haze stops growing past 32 km. The sky-view table spends 1.4° of elevation per row, so the horizon band is coarse. Depth slices map distance in 31 steps while slice centres sit half a slice further out, so near ridges pick up slightly more haze than their distance. Everything is single-planet, no clouds, no shadowing of the air by terrain.

When to use it

Open landscapes with a moving sun, flight or driving games, any scene with distant terrain. Two small per-frame passes replace hand-tuned sky colours and give haze that matches the sky.

New breakdowns go out on X first: @vidar_0208. If this one helped, you can buy me a coffee.

Source and credits

Technique source: RealTimeAtmosphere by AKGWSB, https://github.com/AKGWSB/RealTimeAtmosphere, MIT license, commit a44fa0a5f0bf5dadfc83990dc5bfb2514ed239cb. Ported to GLSL from its Scattering, Helper and Raymarching shader includes (Rayleigh, Mie and ozone terms, phase functions, sphere intersection, transmittance table mapping, multi-scattering integral, sky-view raymarch), the Transmittance, MultiScattering, SkyView and AerialPerspective table shaders, the Skybox shader with its sun disk, the AerialPerspective composite, the table sizes from AtmosphereRenderFeature and the parameter defaults from AtmosphereSettings and the shipped Atmosphere asset.

Render framework: three.js by mrdoob and contributors, https://github.com/mrdoob/three.js, MIT license, r170. Used for drawing and render targets. It is not the source of the atmosphere technique.

My own code: the procedural ridge layers, their lighting, the gradient-sky baseline, the table debug view, camera, labels and video. No external models or assets.