5 — Atmosphere & Clouds
▶ Live demo: run this tutorial in your browser — built from src/05_atmosphere_and_clouds.ts.
Tutorials 1–4 used a flat sky color. Here we replace it with a physically-based atmosphere (so the horizon curves and the sky reddens at sunset) and wrap volumetric clouds around the globe. This is the heaviest integration in the series; the production reference is samples/planet_explorer.ts and its sky helper samples/lib/geo_sky.ts.
The two pieces#
- Atmosphere — a sky dome + aerial-perspective haze, driven by
AtmosphereFeature. For a globe you tell it the planet radius and center, so the horizon dips correctly as you climb. - Clouds —
HQCloudFeature, a volumetric raymarched cloud deck that can be lit by the atmosphere and self-shadow.
Both ride on the same deferred preset and the same GeoScene from tutorial 1.
Step 1 — swap the flat sky for an atmosphere#
Two changes to the tutorial-1 setup. First, drop the flat sky color and let the preset use an atmosphere sky; second, turn on aerial perspective — it's opt-in (off by default), and an atmosphere sky is exactly what justifies it:
deferredPreset({
sky: { kind: 'atmosphere' }, // physical scattering instead of a flat color
ao: 'gtao',
lighting: { enableAerial: true }, // atmospheric haze — opt-in, pairs with the atmosphere sky
})(engine);
{ kind: 'atmosphere' } registers AtmosphereFeature for you. For a flat world
that's all you need. For a globe, you additionally want the sky dome to use
the real planet radius so the limb and the dipping horizon are correct — that's
where you reach past the preset and configure the feature directly.
Tune the aerial haze for your scale.
enableAerial: truestarts with planet-scale params (fogScale: 80,maxOpacity: 1) — great looking down at the whole globe, but at city/street scale they drown everything in haze. For a ground-level view, dial the distance haze down once the lighting pass exists:const lighting = engine.getFeature<DeferredLightingFeature>(DeferredLightingFeature.name); // ...on the first frame the pass is available: lighting!.pass!.setAerialAtmosphere({ betaR: [5.5e-6, 13.0e-6, 22.4e-6], betaM: 21.0e-6, rE: 6360000, rA: 6420000, hRayleigh: 8500, hMie: 1200, sunIntensity: 20, fogScale: 5, maxOpacity: 0.4, // a faint city-scale veil });
Driving the atmosphere on a planet#
The planet sample registers the atmosphere LUTs and feature explicitly so it can feed them the active body's radius and center each frame. The essential wiring:
import { AtmosphereFeature, AtmosphereLutsFeature } from 'taos/engine/index.js';
import { WGS84_A } from 'taos/geo/index.js';
// LUTs first (multiscatter / transmittance). Required before the cloud coupling.
engine.addFeature(new AtmosphereLutsFeature());
const atmosphere = new AtmosphereFeature({
directional: () => sun, // the sun that drives scattering
useSkyViewLut: true, // sky-view LUT acceleration
});
engine.addFeature(atmosphere);
// Tell the sky dome it's wrapping a planet (radius + a ~60 km shell).
atmosphere.pass!.setPlanet({ radius: WGS84_A, atmosphereHeight: 60000 });
atmosphere.pass!.setGroundSurface(true); // paint the globe where terrain hasn't streamed
Each frame, as the camera moves and (with reanchoring) the world origin shifts, update the planet center in world space and the ground radius under the camera so the horizon stays put:
engine.beforeFrame(() => {
const centerWorld = geo.frame.worldFromEcefPoint({ x: 0, y: 0, z: 0 }); // Earth center in world space
atmosphere.pass!.setPlanetCenter(centerWorld);
// setOverrides({ rE, rA }) refines the ground/atmosphere radii at the camera if needed.
});
geo_sky.ts packages all of this — planet vs.
flat fallback, aerial-perspective fade with altitude, airless-body handling — into
a reusable CurvedHorizonSky helper. Lift it wholesale rather than re-deriving
the radius math; this tutorial shows the shape so the sample reads clearly.
Sun direction conventions.
DirectionalLightuses the light-travel vector (pointing the way photons go). The atmosphere internally uses the toward-sun vector; the preset/feature wires the conversion. Just set the light normally.
Step 2 — add volumetric clouds#
Clouds need a set of 3D noise textures and an HQCloudFeature. Register the
feature after the atmosphere LUTs so it can read the transmittance LUT for
sunset tinting:
import { HQCloudFeature, DEFAULT_HQ_CLOUD_SETTINGS } from 'taos/engine/index.js';
import { createBakedCloudNoiseTextures } from './lib/cloud_noise_baked.js';
const noises = await createBakedCloudNoiseTextures(engine.ctx.device);
const clouds = new HQCloudFeature({
noises,
settings: {
...DEFAULT_HQ_CLOUD_SETTINGS,
localClouds: true, // a fly-through deck rather than an infinite sheet
earthRadius: WGS84_A, // curve the deck around the planet
bottomAltitude: 1500, // deck base, meters above the surface
altitudeRange: 2500, // deck thickness
coverage: 0.88, // how much sky is filled (0..1)
density: 0.6, // opacity of the filled parts
coverageVariation: 0.2, // weather-noise carving; the defaults (0.5/0.4) thin
densityVariation: 0.25, // a high-coverage deck into sparse clumps
},
useAtmosphere: true, // light clouds with the real sky ambient + aerial perspective
useTransmittanceLut: true, // tint the sun term through the atmosphere LUT (amber sunsets)
accuratePhase: true, // Mie phase → silver lining
beerShadowMap: true, // whole-column self-shadow
});
engine.addFeature(clouds);
Noise quality matters. HQ clouds want high-quality, single-tile 3D Perlin-Worley noise. The engine's built-in
createCloudNoiseTexturesis a small field that repeats ~4× per wrap — fine for godrays or a stylised deck, but on a planet-scale deck that recedes for kilometres the repetition reads as an obvious grid of identical puffs. The tutorial loads the standard baked 128³ shape + 32³ erosion textures instead (lib/cloud_noise_baked.ts, mirroring samples/hq_cloud). Supply your own baked Perlin-Worley volumes the same way for production.
The four boolean flags are the quality levers and are compile-time (set at
construction). useAtmosphere is the single biggest one — it lights the shaded
sides of clouds with the actual sky radiance instead of a constant.
Keeping the deck wrapped on the planet#
Like the atmosphere, the cloud deck must track the planet center and ground radius each frame, and fade out as you climb above it. The per-frame update from planet_explorer.ts:
engine.beforeFrame(() => {
const centerWorld = geo.frame.worldFromEcefPoint({ x: 0, y: 0, z: 0 });
clouds.settings.planetCenter = [centerWorld.x, centerWorld.y, centerWorld.z];
clouds.settings.earthRadius = WGS84_A;
// Fade the deck out once the camera flies well above it.
const camWorld = cameraGO.localToWorld().data;
const camAlt = /* camera height above the surface, meters */ 0;
const deckTop = clouds.settings.bottomAltitude + clouds.settings.altitudeRange;
const fade = 1 - Math.max(0, Math.min(1, (camAlt - deckTop - 3000) / 37000));
clouds.settings.density = 0.35 * fade;
clouds.enabled = fade > 0.01;
});
settings.* are live (read every frame); only the constructor booleans are
baked. planet_explorer also dims the cloud ambient with the sun's elevation so
the deck goes dark at night — see syncHqClouds there.
Feature order recap#
The registration order that makes the coupling work:
GeoFeature (addFeatureBefore ShadowFeature)
ShadowFeature, GeometryFeature, ... (stock, from the preset)
AtmosphereLutsFeature (before clouds)
AtmosphereFeature
HQCloudFeature (reads the LUT + sky)
geo.attach(engine, { cameraGO }) slots the GeoFeature into that first row
(and installs the floating-origin reanchor); the atmosphere and cloud features are
added explicitly, as shown earlier.
AtmosphereLutsFeature before HQCloudFeature is mandatory if you set
useTransmittanceLut: true — without the LUT producer, the cloud feature binds a
1×1 white dummy and the sunset tint is a no-op.
Next#
- Tutorial 6 — Clipping: cut away parts of the globe with geodetic planes and polygons.
- The full, battle-tested version of everything here (day/night sun, moonlight, reflection probe, SDSM shadows, altitude fades) is planet_explorer.ts.