Taos Engine ▦ Taos: Building a Modern WebGPU Game Engine

Chapter 10: Atmosphere

The sky is the largest object in any outdoor scene. Taos supports multiple sky rendering techniques: HDR environment maps, a procedural atmospheric sky, a dynamic sky texture baked from that atmosphere on the GPU, and volumetric clouds.

10.1 HDR Environment Maps#

HDR cubemap unfolded into 6 faces, with RGBE byte layout and decode steps

The simplest sky is a fixed HDR cubemap — a 360° photograph of a real sky, stored in the Radiance HDR format (.hdr). The SkyTexturePass renders this cubemap as a fullscreen background:

// ── from src/shaders/sky.wgsl ──
let skyDir = normalize(camera.viewToWorld * screenRay);
let skyColor = textureSample(skyCubemap, skySampler, skyDir).rgb;

HDR maps preserve the full dynamic range of the sky, allowing the sun to be thousands of times brighter than the blue sky — essential for physically-based bloom and eye adaptation.

RGBE Decoding#

Radiance HDR files use RGBE encoding (one shared exponent for three color channels). Taos decodes this on the GPU using src/shaders/rgbe_decode.wgsl:

// ── from src/shaders/rgbe_decode.wgsl ──
fn rgbeToFloat(rgbe: vec4f) -> vec3f {
  let exponent = rgbe.a * 255.0 - 128.0;
  return rgbe.rgb * pow(2.0, exponent);
}

10.2 Atmospheric Sky#

Path length through the atmosphere shifts color from blue to red, and Rayleigh vs Mie phase functions in polar form

The AtmospherePass (src/renderer/render_graph/passes/atmosphere_pass.ts) renders a procedural sky using a simplified atmospheric scattering model. Rayleigh scattering (blue sky at zenith, red at sunset) and Mie scattering (sun halo) are computed per-pixel based on the view direction and sun position.

Single Scattering Approximation#

// ── from src/shaders/modules/atmosphere_model.wgsl ──
fn phaseR(mu: f32) -> f32 {   // Rayleigh
  return (3.0 / (16.0 * PI)) * (1.0 + mu * mu);
}

fn phaseM(mu: f32) -> f32 {   // Mie
  let g2 = G * G;             // G = 0.758 — forward-scattering haze
  return (3.0 / (8.0 * PI)) *
    ((1.0 - g2) * (1.0 + mu * mu)) /
    ((2.0 + g2) * pow(max(1.0 + g2 - 2.0 * G * mu, 1e-4), 1.5));
}

The atmosphere pass writes directly into the HDR target with a fullscreen draw. It supports a day/night cycle driven by the sun's elevation angle.

The scattering math itself lives in a shared, #import-able module — src/shaders/modules/atmosphere_model.wgsl. Its functions take the sun direction and ozone flag as explicit arguments rather than reading a uniform, so they carry no bind-group dependency. The screen-space pass is only one of the module's two consumers; the other is the dynamic sky bake (§10.5), which evaluates the very same model into a texture.

10.3 Oren-Nayar Diffuse Ground#

The base atmosphere model treats the ground as fully absorbing — a view ray that reaches the surface simply stops. In reality the lit ground reflects sunlight back up, brightening and slightly warming the lower atmosphere. The atmosphere model adds this as a ground-bounce term: the sunlit ground becomes a secondary, diffuse light source folded into the scattering integral.

Oren-Nayar BRDF#

The ground is rough, so its reflection is modeled with an Oren-Nayar BRDF rather than a pure Lambertian — Oren-Nayar keeps rough surfaces bright at grazing angles instead of darkening them:

// ── from src/shaders/modules/atmosphere_model.wgsl ──
fn orenNayar(cosI: f32, cosR: f32, cosPhiDiff: f32, roughness: f32) -> f32 {
  let sigma2 = roughness * roughness;
  let A = 1.0 - 0.5 * sigma2 / (sigma2 + 0.33);
  let B = 0.45 * sigma2 / (sigma2 + 0.09);
  let thetaI = acos(clamp(cosI, -1.0, 1.0));
  let thetaR = acos(clamp(cosR, -1.0, 1.0));
  let alpha  = max(thetaI, thetaR);
  let beta   = min(min(thetaI, thetaR), 1.2);  // clamp avoids the tan() blow-up
  return A + B * max(0.0, cosPhiDiff) * sin(alpha) * tan(beta);
}

roughness is the microfacet slope σ: at 0 the model collapses to Lambertian; larger values widen the reflection lobe. cosI is the cosine between the ground normal and the sun, cosR between the normal and the view ray, and cosPhiDiff their azimuthal difference — the max(0, cosPhiDiff) term is what brightens the ground when looking toward the sun.

Folding the Bounce into the Scattering Integral#

Before the view-ray march, scatter() computes the ground's outgoing radiance once — sunlight reaching the ground (attenuated by the atmosphere along the sun ray), shaped by the BRDF and the ground albedo:

// ── from src/shaders/modules/atmosphere_model.wgsl ──
let sunUp       = max(sunDir.y, 0.0);
let groundIrrad = SUN_INTENSITY * sunUp
                * transmittance(vec3<f32>(0.0, R_E, 0.0), sunDir, ozone);
let sunAz       = normalize(sunDir.xz + vec2<f32>(1e-6, 1e-6));
let viewAz      = normalize(rd.xz + vec2<f32>(1e-6, 1e-6));
let on          = orenNayar(sunUp, clamp(rd.y, 0.0, 1.0),
                            dot(sunAz, viewAz), GROUND_ROUGHNESS);
let groundRad   = GROUND_ALBEDO * groundIrrad * on * (1.0 / PI);

Each step of the march then accumulates a ground-bounce weight sumG, using only the camera→sample transmittance and an exp(-h / GROUND_BOUNCE_H) falloff so the bounce stays in the dense low haze where it is visible. After the loop the bounce is in-scattered with an isotropic phase — ground light arrives from a wide spread of downward directions, so it has no forward peak — and added to the direct single-scattering result:

// ── from src/shaders/modules/atmosphere_model.wgsl ──
let bounce = groundRad * (BETA_R + vec3<f32>(BETA_M)) * sumG
           * (GROUND_BOUNCE_STRENGTH / (4.0 * PI));
return direct + bounce;

GROUND_ALBEDO is a warm earthy tone, so the bounce tints the lower sky toward yellow-orange. The contribution scales with sunUp, so it is strongest when the sun is high and fades to nothing at night. GROUND_BOUNCE_STRENGTH is the overall gain — a single constant tuned by eye against the running sky.

10.4 Ozone Absorption (Chappuis Band)#

Ozone in the stratosphere absorbs visible light in the Chappuis band (500–700 nm, peak ~600 nm). While the effect is negligible at high sun angles, it becomes visible at twilight when sunlight travels through a long path in the ozone layer. The absorption gives the zenith sky a subtle purple-pink hue at sunset.

Unlike Rayleigh and Mie, ozone only absorbs light — it never scatters it. So ozone contributes to the atmosphere's extinction (it dims the transmittance term) but never appears as an in-scattered source term, and it needs no phase function.

Absorption Coefficient and Layer Profile#

The absorption is a wavelength-dependent extinction coefficient, ordered red/green/blue to match BETA_R. Absorption peaks in the orange-green band, so green is removed most strongly while blue passes almost untouched — it is this selective loss of green along the long twilight light path that produces the purple-pink cast:

// ── from src/shaders/modules/atmosphere_model.wgsl ──
const BETA_O: vec3<f32> = vec3<f32>(0.650e-6, 1.881e-6, 0.085e-6);

Ozone is concentrated in a stratospheric layer rather than thinning out with a scale height the way air molecules and haze do. Its density is modeled with a tent profile — peaking at 25 km and falling linearly to zero 15 km either side (so the layer spans roughly 10–40 km):

// ── from src/shaders/modules/atmosphere_model.wgsl ──
const OZONE_CENTER: f32 = 25000.0;      // altitude of peak concentration (m)
const OZONE_HALF_WIDTH: f32 = 15000.0;  // tent half-width → layer spans 10–40 km

fn ozoneDensity(h: f32) -> f32 {
  return max(0.0, 1.0 - abs(h - OZONE_CENTER) / OZONE_HALF_WIDTH);
}

Applying Ozone Extinction#

The ozone optical depth is accumulated alongside the Rayleigh and Mie integrals — opticalDepthToSky returns a vec3<f32> whose .z component carries the ozone term. The combined extinction folds ozone into the transmittance exactly like the other two:

// ── from src/shaders/modules/atmosphere_model.wgsl ──
let tau = BETA_R * (odR + odL.x)
        + BETA_M * 1.1 * (odM + odL.y)
        + BETA_O * (odO + odL.z);
let T   = exp(-tau);

Because ozone only enters through tau, it darkens both sumR and sumM without adding a source term of its own. This produces the characteristic purple-pink zenith glow at sunset that a Rayleigh+Mie-only model misses.

Optional Quality Setting#

Ozone adds a small per-sample cost: a tent evaluation at every step of both the view-ray march and the inner light-ray march. To keep the sky cheap on low-end hardware, it is exposed as a performance/quality toggle. A uniform flag (u.ozoneEnabled) gates the work — atmosphere.wgsl reads it and passes the result into the shared scatter(), where, because the flag is constant across the whole draw, the branch is coherent and genuinely costs nothing when ozone is off:

// ── from src/shaders/atmosphere.wgsl ──
let ozone = u.ozoneEnabled > 0.5;
var color = scatter(ro, rd, u.sunDir, ozone);

// ── from src/shaders/modules/atmosphere_model.wgsl — inside scatter()'s raymarch ──
if (ozone) { odO += ozoneDensity(h) * ds; }

On the host side, AtmospherePass.setOzoneEnabled(enabled) flips the flag, and the game wires it to the OZONE button in the control panel (crafty/config/effect_settings.ts). When disabled, the ozone optical depth stays zero and exp(-BETA_O * 0) leaves the transmittance unchanged — the sky falls back to the pure Rayleigh+Mie model.

10.5 The Dynamic Sky Texture#

The atmosphere pass (§10.2) paints the visible sky every frame, but the renderer also consumes the sky as an input. Image-based lighting reads it for ambient and specular reflection (§7.10), and the water surface samples it as the fallback for reflection rays that miss the scene. Loading a static .hdr panorama for that job (§10.1) freezes both at a single time of day — pick a noon sky and the world stays noon-lit through every sunset.

Taos drops the static asset entirely. Instead it bakes the sky on the GPU from the same scattering model the atmosphere pass uses, and rebakes it as the sun moves. DynamicSky (src/assets/dynamic_sky.ts) owns the panorama texture and the IBL cubes derived from it.

One Model, Two Consumers#

scatter(), transmittance(), the phase functions, the ozone tent, and the Oren-Nayar ground bounce all live in a #import-able module — src/shaders/modules/atmosphere_model.wgsl. Both atmosphere.wgsl (the screen-space dome) and sky_panorama.wgsl (the bake) pull it in, so the baked sky and the visible sky are guaranteed to agree.

The module functions take the toward-sun vector and the ozone flag as explicit arguments rather than reading a uniform. That keeps the module free of any bind-group dependency — each importing shader declares its own uniform layout and passes the values in:

// ── from src/shaders/modules/atmosphere_model.wgsl ──
fn scatter(ro: vec3<f32>, rd: vec3<f32>, sunDir: vec3<f32>, ozone: bool) -> vec3<f32> { ... }

Baking an Equirectangular Panorama#

sky_panorama.wgsl draws a fullscreen triangle into a 512×256 rgba16float texture. For each texel it maps the equirectangular UV back to a world direction — the exact inverse of the equirect_uv used by the IBL baker and the water shader — then evaluates scatter() toward that direction:

// ── from src/shaders/sky_panorama.wgsl ──
fn dir_from_equirect(uv: vec2<f32>) -> vec3<f32> {
  let az    = (uv.x - 0.5) * 2.0 * PI;  // atan2(-z, x)
  let polar = uv.y * PI;                // acos(y)
  let sinP  = sin(polar);
  return vec3<f32>(cos(az) * sinP, cos(polar), -sin(az) * sinP);
}

The hard sun disk that atmosphere.wgsl adds for the visible sky is deliberately left out of the bake. A single ~1000-intensity texel would blow the GGX-prefiltered IBL into a sparkling firefly and stamp a hard bright dot into water reflections. The directional light already supplies the scene's direct sun term, so the panorama only needs the scattered sky glow.

From Panorama to Lighting#

A bake records three stages into a single command buffer — fully GPU-side, with no CPU/GPU synchronization:

Stage Work
Panorama render Evaluate scatter() across the 512×256 equirectangular texture (mip 0).
Mip chain 2× box-downsample each level. The IBL prefilter does filtered importance sampling and needs coarser mips for wide GGX lobes.
IBL convolution Convolve the panorama into the diffuse irradiance cube and the GGX-prefiltered specular cube.

The IBL step reuses the compute shaders described in §7.10, but through a different driver. computeIblGpu() — the one-shot path — allocates fresh textures and awaits GPU completion on every call, which suits a sky baked once at load. For a sky that rebakes continuously, that per-call allocation and stall is wasteful, so src/assets/ibl.ts also exposes IblBaker:

// ── from src/assets/dynamic_sky.ts ──
bake(sunDir: SunDir, cameraHeight: number, ozoneEnabled: boolean): void {
  // ... write the SkyParams uniform ...
  const encoder = this._device.createCommandEncoder({ label: 'DynamicSkyBake' });
  // render panorama mip 0, then box-downsample the mip chain ...
  this._iblBaker.record(encoder);          // convolve irradiance + prefiltered cubes
  this._device.queue.submit([encoder.finish()]);
}

IblBaker allocates its cubes, uniform buffers, and bind groups exactly once, then re-records the 6 + 30 convolution dispatches into a caller-supplied encoder on demand — no per-bake allocation, no sync.

Rebaking as the Sun Moves#

The bake is cheap, but running it every frame would be pointless: ambient light and reflections only need to track the sun at a human-perceptible rate. The game loop gates the rebake on sun travel — once sunAngle has advanced past a small threshold since the last bake:

// ── from crafty/main.ts ──
if (Math.abs(sunAngle - lastSkyBakeAngle) > SKY_BAKE_ANGLE_THRESHOLD) {
  dynamicSky.bake(sun.direction, camPos.y, effects.ozone);
  lastSkyBakeAngle = sunAngle;
}

SKY_BAKE_ANGLE_THRESHOLD is ~0.05 rad of sunAngle — a few degrees of sun travel per rebake: coarse enough that the bake stays cheap, fine enough that the lighting shift reads as smooth. The day/night cycle that advances sunAngle is covered in §10.6.

Crucially, bake() overwrites the panorama and IBL textures in place. The deferred lighting pass and the water pass hold the same texture handles every frame, so a rebake costs no rewiring — the next frame simply samples the updated contents. Ambient lighting, specular reflections, and water reflections all drift with the sky through the whole day/night cycle.

10.6 Day/Night Cycle and Star Rendering#

The day/night cycle is driven from the game loop in crafty/main.ts. A single linear angle sunAngle controls the entire cycle:

// ── from crafty/main.ts ──
let sunAngle = welcome?.sunAngle ?? savedWorld?.sunAngle ?? Math.PI * 0.3;

Each frame the angle advances at a fixed rate, giving a full cycle of roughly 10.5 minutes:

// ── from crafty/main.ts ──
sunAngle += dt * 0.01;

Sun Position Skew#

The linear angle is skewed so the sun spends more time above the horizon than below, preventing unrealistically short days. A _dayFraction of 0.80 maps the first 80% of the linear cycle to the visible hemisphere (sun above horizon) and the remaining 20% to night:

// ── from crafty/main.ts ──
const _dayFraction = 0.80;
const _norm = ((sunAngle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
const _dayPortion = _dayFraction * 2 * Math.PI;
const _skewed = _norm < _dayPortion
  ? (_norm / _dayPortion) * Math.PI
  : Math.PI + ((_norm - _dayPortion) / (2 * Math.PI - _dayPortion)) * Math.PI;

_skewed is remapped from [0, 2π] to [0, π] during the day portion (sun rises, peaks, sets) and [π, 2π] during the night portion (sun below the horizon). This produces a smooth sinusoidal elevation profile throughout the day.

Sun Direction, Intensity, and Color#

The sun direction uses a fixed X component (the sun arcs across the sky rather than passing directly overhead) with Y and Z driven by the skewed angle:

// ── from crafty/main.ts ──
const sinA = Math.sin(_skewed);
const rawDirX = 0.25;
const rawDirY = -sinA;
const rawDirZ = Math.cos(_skewed);
const dLen = Math.sqrt(rawDirX * rawDirX + rawDirY * rawDirY + rawDirZ * rawDirZ);
sun.direction.set(rawDirX / dLen, rawDirY / dLen, rawDirZ / dLen);

The sun's elevation (sinA) directly controls intensity and color. Intensity ramps from 0 at the horizon to 6.0 at zenith. The color shifts from warm orange-red at sunrise/sunset to cool white at midday:

// ── from crafty/main.ts ──
const elev = sinA;
sun.intensity = Math.max(0, elev) * 6.0;
const t = Math.max(0, elev);
sun.color.set(1.0, 0.8 + 0.2 * t, 0.6 + 0.4 * t);

At sunrise (elev ≈ 0) the color is (1.0, 0.8, 0.6) — warm amber. At noon (elev = 1) it reaches (1.0, 1.0, 1.0) — pure white.

Sky and Cloud Driven by Day Fraction#

The elevation also feeds into the cloud ambient color and water reflection brightness:

// ── from crafty/main.ts ──
const dayT = Math.max(0, elev);
const cloudAmbient: [number, number, number] =
  [0.02 + 0.38 * dayT, 0.03 + 0.52 * dayT, 0.05 + 0.65 * dayT];
passes.waterPass!.updateTime(ctx, waterTime, Math.max(0.01, dayT));

Clouds are illuminated with a dim blueish ambient at night that brightens to warm white during the day. The water pass uses dayT as skyIntensity to control the brightness of sky reflections on the water surface.

Persistence#

The sunAngle is saved to SavedWorld.sunAngle each time the world is persisted (crafty/game/world_storage.ts), and restored on load. This means the time of day is preserved between sessions — when you re-enter a world, the sun is at the same position you left it.

Sun Disk and Flare Halo#

The atmosphere shader draws the sun as a hard bright disk wherever the view ray falls within SUN_COS_THRESH of the sun direction, attenuated by the atmospheric transmittance toward the sun. A hard disk on its own reads as a flat sticker, so a flare halo is layered around it — a soft glow that turns the sun into a hazy bright source and spreads warm glare into the surrounding sky.

The halo is the sum of two lobes: a tight inner lobe that hugs the disk and a broad, faint outer lobe that spreads across tens of degrees — the atmospheric glare you see looking near the sun:

// ── from src/shaders/modules/atmosphere_model.wgsl ──
fn sunFlare(mu: f32, flare_power: f32, flare_strength: f32) -> f32 {
  let m     = max(mu, 0.0);              // mu = dot(view, sunDir)
  let inner = pow(m, flare_power);       // tight core that hugs the disk
  let outer = pow(m, max(flare_power * 0.08, 2.0)) * 0.12;  // broad faint halo
  return (inner + outer) * flare_strength;
}

Both the disk and the halo are multiplied by transmittanceP(...) toward the sun, so the glare reddens with the sun at sunset. AtmospherePass.setSunFlare(power, strength) tunes it — a strength of 0 leaves just the hard disk.

Moon Rendering#

Originally the moon was a flat disk locked antipodal to the sun, so it was always geometrically full. It now carries its own direction, so it can sit anywhere in the sky and show real lit phases with earthshine on the shadowed limb.

moonDiskShade reconstructs the moon's surface normal at each point on its disk (treating the disk as the projection of a sphere), lights that normal by the sun with a soft terminator, and fills the dark side with a faint earthshine term — sunlight bounced off the planet:

// ── from src/shaders/modules/atmosphere_model.wgsl ──
fn moonDiskShade(rd, moonDir, sunDir, disk_cos, up_hint, earthshine) -> f32 {
  let cam_dir = -moonDir;                  // disk-center normal, toward the viewer
  // ... build a disk basis and recover the sub-disk point's sphere normal ...
  let ndl = dot(normal, sunDir);
  let lit = smoothstep(-0.08, 0.08, ndl);  // soft terminator
  return lit + earthshine * (1.0 - lit);   // earthshine fills the dark limb
}

The dome multiplies that brightness by the moon's transmittance, a cool tint, and a night_t factor that fades the moon in only after the sun sets — so it is invisible by day and fully visible at night:

// ── from src/shaders/atmosphere.wgsl ──
if (dot(rd, u.moonDir) > MOON_COS_THRESH) {
  let night_t = saturate((-sunElev - 0.05) * 10.0);
  let phase   = moonDiskShade(rd, u.moonDir, u.sunDir, MOON_COS_THRESH, localUp, u.earthshine);
  color += transmittanceP(ro, u.moonDir, ozone, atm)
         * vec3<f32>(0.85, 0.90, 1.0) * 15.0 * phase * night_t;
}

AtmosphereFeature({ moon: () => dir }) supplies the toward-moon direction; with no provider it defaults to the sun's antipode — a full moon, matching the legacy look, so existing scenes are unchanged. AtmospherePass.setEarthshine(amount) tunes the dark-side fill. The disk size is controlled by MOON_COS_THRESH = 0.9997 and the cool tint is (0.85, 0.90, 1.0).

The moon carries its own flare halo, separate from the sun's — the same two-lobe sunFlare glow but dim and cool-tinted, and weighted by the moon's illuminated fraction (0.5 · (1 − dot(moonDir, sunDir))), so a crescent glows far less than a full moon. Both the disk and its glow sit behind a frame-uniform night_t > 0 gate so the whole moon block folds out cheaply during the day. AtmospherePass.setMoonFlare(strength) tunes it (it reuses the sun's flare_power for tightness).

By default the phase follows the real sun↔moon geometry. For art direction the phase can also be set explicitly, decoupled from the sun: AtmospherePass.setMoonPhase(p) (with p ∈ [0,1]: 0.5 = full, 0/1 = new, 0.25/0.75 = the quarters; null restores the geometric phase). When enabled (flags bit 2), the dome lights the moon from a synthesized direction built by moonPhaseLightDir in the moon's own disk basis, so the terminator lands exactly where the slider asks. The atmosphere_test sample exposes this as its MOON PH slider.

Star Rendering#

Stars are implemented as a standalone post-process pass in StarsPass (src/renderer/render_graph/passes/stars_pass.ts) using a GPU-generated star field. Samples that want stars without the rest of the composite uber-shader (most of the engine samples, and any pipeline running on an HDR canvas without tonemap) wire StarsPass in directly; the game's CompositePass (src/renderer/render_graph/passes/composite_pass.ts) folds the same starfield code into its uber post-processor — one fullscreen pass that also does fog, the underwater effect, and tonemap — to avoid an extra HDR read/write on the hot game path. Both paths upload the sun direction to the star uniform buffer:

// ── from src/renderer/render_graph/passes/stars_pass.ts ──
d[20] = sunDir.x;  d[21] = sunDir.y;  d[22] = sunDir.z;
d[23] = this.blackLowerHemisphere ? 1 : 0;

Both the standalone shader (src/shaders/stars.wgsl) and the composite shader (src/shaders/composite.wgsl) pull the same starfield math from one shared module, src/shaders/modules/postfx_stars.wgsl — a single source of truth rather than a copy pasted into each (the atmosphere dome's own night sky imports it too). Stars are drawn only on sky pixels (depth >= 1.0) and faded by night_t computed from the sun's Y component:

// ── from src/shaders/composite.wgsl ──
if (depth >= 1.0) {
  let world_h = star_uni.invViewProj * vec4<f32>(in.uv * 2.0 - 1.0, 1.0, 1.0);
  let ray_dir = normalize(world_h.xyz / world_h.w - star_uni.cam_pos);
  if (ray_dir.y > -0.05) {
    let night_t     = saturate((-star_uni.sun_dir.y - 0.05) * 10.0);
    let above_horiz = saturate(ray_dir.y * 20.0);
    let star_fade   = night_t * above_horiz;
    if (star_fade > 0.001) {
      scene += postfx_sample_stars(ray_dir) * (star_fade * 2.0);
    }
  }
}

Three factors gate star visibility:

  • night_t — fades stars in as the sun drops below the horizon (same factor used for the moon)
  • above_horiz — prevents stars from rendering below the horizon even at night
  • depth >= 1.0 — restricts stars to sky pixels only (they are not reflected on geometry)

The postfx_sample_stars() function (in the shared module) generates a procedural star field using a pseudo-random hash of the view direction, producing thousands of stars of varying brightness without any texture. This keeps the star field crisp at any resolution.

Real-star catalog. Beyond the procedural field, StarsPass can draw the real night sky from the Yale Bright Star Catalog (BSC5, 9,096 stars). bakeStarCatalogTexture (src/assets/star_catalog.ts) splats each catalogued star — placed by right ascension / declination, tinted by its blackbody temperature, and weighted by apparent magnitude — into an equirectangular rgba16float texture once at startup (the catalog data itself is generated offline by scripts/build_star_catalog.mjs). The same shared module samples it: postfx_sample_star_catalog(tex, samp, dir) reads the baked equirect along the view ray, and the pass picks between the two paths each frame via a catalog_mode uniform — set only when catalogMode is on and a catalog has actually been baked, so it falls back to the procedural field until the asynchronous bake lands. StarsFeature({ catalog: { width: 4096 } }) wires this up; the planet_explorer and planet_atmosphere samples expose it as a Catalog stars toggle, and space_combat flips it with the B key.

Why stars run at the very end. The atmosphere pass might seem the natural home for stars — it already paints the sky and carries the very camera and sun uniforms a star field needs. Stars are deliberately added at the end of the frame instead (either as StarsPass after all post-processing, or inlined into CompositePass's uber-shader), running after depth of field, bloom, TAA, and auto-exposure. Drawing them there keeps each star a crisp, sub-pixel point of light at a fixed brightness. Were they written into the HDR target up front by the atmosphere pass, every downstream pass would work them over: depth of field would blur them, bloom would smear each into a soft blob, TAA would make them shimmer frame to frame, and auto-exposure would both meter against them and rescale them. Stars are the one part of the scene that must stay pin-sharp, so they are added past all of it.

10.7 Atmosphere LUTs (Transmittance + Multi-Scattering)#

The single-scattering model in §10.2 is evaluated per pixel — 16 view-ray steps, each step doing an 8-step inner integral toward the sun for optical depth, plus an ozone branch and a ground-bounce contribution. That's ~150 atmospheric-density samples per sky pixel, before the cloud raymarch piles its own integrals on top. It works on a modern GPU but it is wasteful: most of that work is recomputing the same optical_depth_to_sky numbers across pixels and across frames.

The standard fix, popularised by Sebastien Hillaire's 2020 paper A Scalable and Production Ready Sky and Atmosphere Rendering Technique, is to precompute the recurring sub-integrals into 2D look-up tables. The Taos engine ships two: a transmittance LUT and a multi-scattering LUT. Both are baked once at startup (and re-baked only when atmosphere parameters change), stored as persistent rgba16float textures, and sampled by every consumer that needs them.

10.7.1 Transmittance LUT#

A 256 × 64 texture keyed by (cos_zenith, normalized_altitude). Each texel stores exp(-extinction · optical_depth) — Rayleigh + Mie + ozone — from that altitude in that direction up to the top of the atmosphere. The bake shader is a fullscreen draw that reads the AtmosphereParams uniform and integrates the same opticalDepthToSkyP walk the analytical model does:

// ── from src/shaders/atmosphere_transmittance_lut.wgsl ──
@fragment
fn fs_main(in: VertOut) -> @location(0) vec4<f32> {
  let p = lut_uv_to_params(in.uv);
  let altitude   = p.x;
  let cos_zenith = p.y;
  let pos = vec3<f32>(0.0, u.r_e + altitude, 0.0);
  let sin_zenith = sqrt(max(1.0 - cos_zenith * cos_zenith, 0.0));
  let dir = vec3<f32>(sin_zenith, cos_zenith, 0.0);
  // ...
  let od = opticalDepthToSkyP(pos, dir, ozone, atm, true);
  let trans = exp(-(atm.beta_r * od.x + atm.beta_m * 1.1 * od.y + atm.beta_o * od.z));
  return vec4<f32>(trans, 1.0);
}

The U axis uses a sign(x) · x² warp (and the consumer reverses it with sign · sqrt) so the LUT spends most of its texels around the horizon — the angle range where transmittance changes fastest as a ray skims the limb. The V axis is linear in altitude.

AtmospherePass (when created with useMultiscatterLut: true) samples the LUT for the sun-disc and moon-disc attenuation, replacing the inline transmittanceP(...) calls.

10.7.2 Multi-scattering LUT#

A 32 × 32 texture keyed by (cos_sun_zenith, normalized_altitude). Each texel stores the integrated 2nd-order-and-higher scattered luminance per unit (sun_intensity × σ_scattering). The bake follows Hillaire 2020 §5 directly:

  1. For each LUT texel, shoot N = 16 uniformly-distributed view directions on the sphere (Fibonacci sampling — no clumping).
  2. For each direction, ray-march through the atmosphere with 16 steps. At each step, sample air density, look up sun-ray transmittance from the transmittance LUT, and accumulate the in-scatter using a uniform-phase source (1 / 4π).
  3. Accumulate two quantities: L₂ (the 2nd-order radiance) and f_ms (the same integral with the scattering coefficient itself set to 1 — the dimensionless fraction of light that ever scatters).
  4. The geometric series sums all higher orders: L_ms = L₂ · 1 / (1 − f_ms).
// ── from src/shaders/atmosphere_multiscatter_lut.wgsl ──
let inv_n = 1.0 / f32(N_DIRS);
L_2  *= inv_n;
f_ms *= inv_n;
let ms = L_2 / max(vec3<f32>(1.0) - f_ms, vec3<f32>(1e-6));

The bake reads the transmittance LUT as input (so it has to run after the transmittance bake — AtmosphereLutsPass schedules them in order). The result is sampled by AtmospherePass and added on top of the existing single-scatter integral:

// ── from src/shaders/atmosphere.wgsl ──
if (USE_MULTISCATTER_LUT) {
  let cam_alt = max(length(ro) - atm.r_e, 0.0);
  let cos_sun_at_cam = dot(localUp, u.sunDir);
  let ms = atm_sample_multiscatter(
    multiscatter_lut, atm_lut_samp, cam_alt, cos_sun_at_cam,
    atm.r_a - atm.r_e,
  );
  let dR = exp(-cam_alt / atm.h_r);
  let dM = exp(-cam_alt / atm.h_m);
  let sigma_s = atm.beta_r * dR + vec3<f32>(atm.beta_m) * dM;
  color += atm.sun_intensity * ms * sigma_s * sky_t;
}

The visible result: a ground-level sky is no longer crushed in the directions away from the sun (single-scatter alone leaves the zenith-opposite half of the sky too dark); indoor surfaces lit only by sky ambient stop reading as dead gray; and the noon horizon brightens without going washed-out (single-scatter overshoots the warm tint in that direction). The existing analytical integral remains the floor — the LUT contribution is strictly additive — so the system can never look worse than the un-LUT'd sky, only better.

10.7.3 Pipeline integration#

The LUTs are baked by AtmosphereLutsPass (src/renderer/render_graph/passes/atmosphere_luts_pass.ts) — a pass that owns two persistent textures ('atmosphere:transmittance_lut' and 'atmosphere:multiscatter_lut') and a dirty flag. On the frame the parameters first land (or whenever setParams() sees a change), it schedules both bake sub-passes; on idle frames it just returns the cached handles. AtmosphereFeature reads the handles from frame.extras published by AtmosphereLutsFeature:

// ── from samples/cloud_test.ts ──
engine.addFeature(new GeometryFeature());
engine.addFeature(new AtmosphereLutsFeature());                  // publishes LUTs to frame.extras
engine.addFeature(new AtmosphereFeature({
  planet: { radius: PLANET_RADIUS, atmosphereHeight: ATMOSPHERE_HEIGHT },
  horizonless: true,
  useMultiscatterLut: true,                                       // adds MS contribution to sky
}));
engine.addFeature(new AltitudeStarsFeature());
engine.addFeature(new CloudFeature({
  noises: cloudNoises, settings: stdCloudSettings, overlay: true,
}));
engine.addFeature(new TonemapFeature({ aces: true, exposure: 1.0 }));

AtmosphereFeature always declares a bind group for the LUTs in its pipeline layout (so the shape is stable). When the LUT flag is off, the host binds a 1×1 white dummy texture and the shader-level override constant tells the compiler to constant-fold the LUT sample away — there's no runtime cost on the no-LUT path.

Cost. Transmittance LUT bake: 256 × 64 fragments, each doing the same 8-step density walk the analytical model does — ~0.4 ms on a mid GPU at startup, zero on subsequent frames. Multi-scatter LUT bake: 32 × 32 fragments × 16 directions × 16 steps = 256 K texture taps, ~0.6 ms at startup. Per-frame consumer cost: one LUT sample replaces an 8-step integral (atmosphere sun-disc). Total memory: 256 × 64 × 8 bytes + 32 × 32 × 8 bytes ≈ 138 KB.

10.8 Cloud Rendering#

Volumetric cloud raymarch: a view ray steps through the volume, sampling density and accumulating transmittance

The CloudPass (src/renderer/render_graph/passes/cloud_pass.ts) renders volumetric clouds by raymarching a horizontal cloud slab bounded by two altitudes (cloudBase, cloudTop). The view ray is clipped to the slab and to scene geometry, then walked in CLOUD_MARCH_STEPS steps (a pipeline-overridable constant, default 48). At each step the shader samples cloud density, marches a short ray toward the sun for self-shadowing, and accumulates color and transmittance with Beer's law:

// ── from src/shaders/clouds.wgsl ──
for (var i = 0; i < CLOUD_MARCH_STEPS; i++) {
  let t = t_start + f32(i) * step_size;
  let p = camera.position + ray_dir * t;

  let dens = cd_sample_density(p, /* cloud params */, base_noise, detail_noise, noise_samp);
  if (dens < 0.001) { continue; }

  let shadow_t = light_march(p, sun_dir);       // transmittance toward the sun
  let opt      = dens * cloud.extinction * step_size;
  let t_step   = exp(-opt);                     // Beer's law for this step

  cloud_color += (sun_energy + amb_energy) * (1.0 - t_step) * total_trans;
  total_trans *= t_step;
}

A coarse march leaves visible banding — the slab is sliced into a few dozen flat shells, and their edges read as concentric rings or a stair-stepped gradient through the soft, low-opacity parts of the cloud. The fix is the same dither trick the god-ray march uses (§10.9): jitter the start of each pixel's march by a fraction of a step, so the shell boundaries land at different depths from pixel to pixel and the banding dissolves into noise.

// ── from src/shaders/clouds.wgsl ──
let jitter  = hash12(vec2<f32>(f32(coord.x), f32(coord.y)));  // white noise, [0,1)
let t_start = slab.x + jitter * step_size;

The choice of dither matters. A structured pattern such as interleaved gradient noise (IGN) produces a lower-variance result when it is averaged across frames by TAA — but CloudPass has no temporal resolve, so IGN's regular diagonal weave stays frozen on screen as a visible cross-hatch. A plain white-noise hash12 instead reads as fine, unstructured grain, which the eye accepts far more readily in a still frame. Pairing it with the bumped step count (48 vs. the old 24) shrinks the per-step density jump the dither has to hide, so the residual grain is fainter too. A caller that does run TAA can override CLOUD_MARCH_STEPS back to 24 — the per-frame jitter then averages away cleanly and the cheaper march suffices.

In overlay mode the pass outputs premultiplied (cloud_color, 1 - total_trans) and blends over the already-lit HDR scene, so clouds obscure the geometry behind them.

Lighting and Multiple Scattering#

Real clouds scatter light many times — a single-scattering model alone renders them as flat gray volumes because it ignores the light that bounces between droplets. Taos approximates that missing energy with two cheap tricks instead of explicit secondary marches.

A dual-lobe phase function. The directional sun_energy term is weighted by dual_phase, a blend of two Henyey-Greenstein lobes — a strong forward lobe (the bright silver lining seen looking toward the sun) plus a weak, slightly backward lobe standing in for light that has scattered around inside the cloud and lost most of its directionality:

// ── from src/shaders/clouds.wgsl ──
fn dual_phase(cos_theta: f32, g: f32) -> f32 {
  return 0.7 * hg_phase(cos_theta, g) + 0.3 * hg_phase(cos_theta, -0.25);
}

A height-varying ambient term. amb_energy adds isotropic skylight — light multiply-scattered to the point it no longer has a direction. It is scaled by the sample's height within the slab, so the brighter cloud tops receive more of it than the shaded undersides:

// ── from src/shaders/clouds.wgsl ──
let sun_energy = light.color * light.intensity * shadow_t * phase;
let amb_energy = cloud.ambientColor * mix(0.5, 1.0, height_frac);
cloud_color += (sun_energy + amb_energy) * (1.0 - t_step) * total_trans;

Together these give the characteristic bright, fluffy cumulus look that pure single scattering misses — the cloud interior glows instead of reading as a dark gray mass. ambientColor is supplied per-frame by the game and tinted by time of day (see §10.6).

Cloud Noise Texture Generation#

The cloud density textures are generated on the CPU at startup by src/assets/cloud_noise.ts. Two tileable 3D textures are created:

Texture Size Channels Content
baseNoise 64×64×64 R 4-octave Perlin FBM — cloud bulk shape
G/B/A Worley cellular noise at 2×, 4×, 8× frequency — erosion layers
detailNoise 32×32×32 R/G/B Worley noise at 4×, 8×, 16× frequency — fine edge detail

Tileable Perlin Noise#

The Perlin implementation wraps lattice coordinates via modular arithmetic so the noise tiles seamlessly at the texture boundaries. Gradient vectors are drawn from the classic 12-edge set stored as parallel Int8Arrays to avoid heap allocation on the hot path:

// ── from src/assets/cloud_noise.ts ──
const GRAD3_X = new Int8Array([ 1, -1,  1, -1,  1, -1,  1, -1,  0,  0,  0,  0]);
const GRAD3_Y = new Int8Array([ 1,  1, -1, -1,  0,  0,  0,  0,  1, -1,  1, -1]);
const GRAD3_Z = new Int8Array([ 0,  0,  0,  0,  1,  1, -1, -1,  1,  1, -1, -1]);

function gradDot(lx, ly, lz, period, seed, dx, dy, dz) {
  const wx = ((lx % period) + period) % period;
  const wy = ((ly % period) + period) % period;
  const wz = ((lz % period) + period) % period;
  const gi = Math.floor(hashS(wx, wy, wz, seed) * 12) % 12;
  return GRAD3_X[gi] * dx + GRAD3_Y[gi] * dy + GRAD3_Z[gi] * dz;
}

Trilinear interpolation uses a quintic smoothstep (6t⁵ - 15t⁴ + 10t³) to eliminate second-order discontinuities at lattice boundaries:

// ── from src/assets/cloud_noise.ts ──
function smoothstep5(t: number): number {
  return t * t * t * (t * (t * 6 - 15) + 10);
}

Fractal Brownian Motion#

Four octaves of Perlin noise are summed with halved amplitude and doubled frequency per octave, then remapped from the approximate range ±0.7 to [0, 1] for storage as unorm8:

// ── from src/assets/cloud_noise.ts ──
function perlinGradFbmTile(px, py, pz, octaves, baseFreq, seed) {
  let v = 0, a = 0.5, f = 1, tot = 0;
  for (let i = 0; i < octaves; i++) {
    v += perlinGradTile(px * f, py * f, pz * f, baseFreq * f, seed + i * 17) * a;
    tot += a;
    a *= 0.5;  f *= 2;
  }
  return Math.max(0, Math.min(1, v / tot * 0.85 + 0.5));
}

Tileable Worley Noise#

Worley (cellular) noise measures the distance to the nearest randomly-placed feature point in a 3D grid. Each cell contains one point at a hash-derived offset within the cell, and the search covers the 27-cell neighborhood. The modulo-wrapped cell coordinates ensure seam-free tiling:

// ── from src/assets/cloud_noise.ts ──
function worleyTile(px, py, pz, freq, seed) {
  const fx = px * freq, fy = py * freq, fz = pz * freq;
  const ix = Math.floor(fx), iy = Math.floor(fy), iz = Math.floor(fz);
  let minD2 = Infinity;
  for (let dz = -1; dz <= 1; dz++) {
    for (let dy = -1; dy <= 1; dy++) {
      for (let dx = -1; dx <= 1; dx++) {
        const cx = ix + dx, cy = iy + dy, cz = iz + dz;
        const wcx = ((cx % freq) + freq) % freq;
        const wcy = ((cy % freq) + freq) % freq;
        const wcz = ((cz % freq) + freq) % freq;
        const fpx = cx + hashS(wcx, wcy, wcz, seed);
        const fpy = cy + hashS(wcx, wcy, wcz, seed + 1);
        const fpz = cz + hashS(wcx, wcy, wcz, seed + 2);
        const d2 = (fx - fpx) ** 2 + (fy - fpy) ** 2 + (fz - fpz) ** 2;
        if (d2 < minD2) minD2 = d2;
      }
    }
  }
  return 1.0 - Math.min(Math.sqrt(minD2), 1.0);
}

Texture Upload#

The generated noise arrays are uploaded to the GPU as rgba8unorm 3D textures with a single queue.writeTexture() call — no staging buffer needed for a one-time upload:

// ── from src/assets/cloud_noise.ts ──
function make3dTexture(device, label, size, data) {
  const tex = device.createTexture({
    label, dimension: '3d',
    size: { width: size, height: size, depthOrArrayLayers: size },
    format: 'rgba8unorm',
    usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
  });
  device.queue.writeTexture(
    { texture: tex },
    data.buffer,
    { bytesPerRow: size * 4, rowsPerImage: size },
    { width: size, height: size, depthOrArrayLayers: size },
  );
  return tex;
}

The four-channel packing means a single texture sample fetches both the bulk density (R) and three erosion frequencies (GBA) simultaneously. cd_sample_pw() combines them — the Perlin shape in R eroded by the Worley channels — to carve realistic cloud shapes with wispy edges:

// ── from src/shaders/modules/cloud_density.wgsl ──
fn cd_sample_pw(base_noise: texture_3d<f32>, noise_samp: sampler, samp_uv: vec3<f32>) -> f32 {
  let s = textureSampleLevel(base_noise, noise_samp, samp_uv, 0.0);
  let w = s.g * 0.5 + s.b * 0.35 + s.a * 0.15;     // Worley erosion weight
  return cd_remap(s.r, 1.0 - w, 1.0, 0.0, 1.0);    // Perlin shape eroded by Worley
}

cd_sample_pw and the full cd_sample_density it feeds live in the shared module src/shaders/modules/cloud_density.wgsl, which both clouds.wgsl and the godray shader pull in with #import "cloud_density.wgsl". A single definition of "where the clouds are" drives both the cloud render and the godray shadowing — see §10.10.

This CPU-generation approach was chosen over GPU compute for simplicity — the noise is generated once at boot and needs no runtime modification. For a 64³ base texture and a 32³ detail texture, the total generation time is ~5 ms on a modern CPU.

10.9 Volumetric Fog#

Fog falloff: squared exponential distance curve and a height-based density gradient over a mountain silhouette

Fog is rendered as part of the final CompositePass. The fog density is computed from the fragment depth and mixed with the scene color:

// ── from src/shaders/composite.wgsl ──
let fogFactor = 1.0 - exp(-fogDensity * fogDensity * viewDepth * viewDepth);
outputColor = mix(sceneColor, fogColor, fogFactor);

Height-based fog varies the density with altitude, creating mist in valleys and clear air at higher elevations:

// ── from src/shaders/composite.wgsl ──
let heightFog = exp(-max(worldPos.y - seaLevel, 0.0) * fogHeightFalloff);
fogDensity *= heightFog;

Aerial Perspective and Analytic Height Fog#

The CompositePass fog above is a screen-space color tweak. The deferred lighting pass applies true aerial perspective to opaque geometry: it integrates the atmosphere between the camera and each surface and composites scene · transmittance + sky_inscatter, so distant geometry fades into the same sky color it sits against. The scattering parameters come from the light uniform's aerial* block — the same AtmosphereParams the sky dome uses — so the haze tracks the sky instead of drifting off hard-coded constants.

Layered on top is an analytic exponential height fog — ground/valley fog whose density falls off exponentially with altitude above fog_height. Because the profile is a simple exponential, the optical depth along the view ray has a closed form, so no marching is needed:

// ── from src/shaders/deferred_lighting.wgsl ──
fn atm_height_fog_t(ro: vec3<f32>, rd: vec3<f32>, dist: f32) -> f32 {
  let d0 = density * exp(-falloff * (ro.y - fog_height));
  let k  = falloff * rd.y;
  var integral: f32;
  if (abs(k) > 1e-6) { integral = (1.0 - exp(-k * dist)) / k; }  // ∫ exp(-k·t) dt
  else               { integral = dist; }                        // horizontal ray
  return exp(-clamp(d0 * integral, 0.0, 32.0));
}

Crucially, the fog in-scatters the same sky-scatter color the aerial perspective computes (warm toward the sun, cool away), tinted by a near-white fogColor, instead of a flat constant — so the ground fog matches the sky behind it rather than washing distant geometry to a uniform gray:

// ── from src/shaders/deferred_lighting.wgsl ──
let fog_tr        = atm_height_fog_t(camera.position, ray_dir, dist);
let fog_inscatter = fog_color * light.aerialFogColor;   // fog_color = sky scatter
return aerial * fog_tr + fog_inscatter * (1.0 - fog_tr);

Height fog defaults off (density 0) so existing scenes are visually unchanged; DeferredLightingPass.setAerialAtmosphere({ fogDensity, fogFalloff, fogHeight, fogColor }) enables and tunes it. The terrain sample's atmospheric_apply.wgsl carries the same analytic height fog layered over its distance fog. The atmosphere_test sample (samples/atmosphere_test.ts) exposes all of these — sun/moon angles, scattering strengths, flare, and height fog — as live sliders for tuning.

10.10 God Rays (Crepuscular Rays)#

The GodrayPass (src/renderer/render_graph/passes/godray_pass.ts) renders volumetric light shafts — the bright rays that appear where sunlight streams past occluders. Taos does not use the cheap screen-space radial blur. It marches a real 3D ray from the camera through the world and asks at each step "is this parcel of air lit by the sun?" Air the cascade shadow map reports as shadowed scatters no light toward the viewer; lit air does. The visible shafts are the shadow pattern carved into the haze.

God rays: marching the view ray and shadow-testing each step

The effect is five sub-passes — the first four run at low or half resolution to keep the march cheap:

  1. Cloud-shadow LUT (fs_main, godray_cloud_shadow_lut.wgsl) — bake a 2D top-down cloud transmittance texture (default 256×256, world-XZ-aligned, centered on the camera). One bilinear fetch from this replaces a per-step vertical column integral in the march.
  2. March (fs_march, godray_march.wgsl) — accumulate scattered light along each view ray.
  3. Blur H / Blur V (fs_blur, godray_composite.wgsl) — a depth-aware bilateral Gaussian that cleans up the noisy half-res result.
  4. Composite (fs_composite) — depth-aware upsample to full resolution, then additively blend the shaft color onto the HDR target.

Why a Cloud-Shadow LUT#

A naïve march integrates the cloud column straight up at every march step — 16 steps × 4 vertical sub-steps × 3 3D-noise fetches per density sample = 192 noise fetches per pixel, and the cloud-shadow term dominates the per-frame cost (the pass was running ~1.5 ms at 1080p). But the column is sampled at the march step's world-XZ, and that integral is purely a function of XZ — every march step at the same column re-computes the same value, and so do all the neighboring pixels marching through the same column.

The fix is to evaluate the integral once per (world XZ) cell at low resolution, store it in a texture, and let the march sample it with bilinear filtering. A 256×256 LUT covering a 1200-unit-square region around the camera (4.7 world units per texel — well below the cloud features' ~83-unit wavelength) replaces ~190 noise fetches per march pixel with one 2D texture fetch:

// ── from src/shaders/godray_cloud_shadow_lut.wgsl ──
let world_xz = lut_params.origin + in.uv * lut_params.extent;
var opt_depth = 0.0;
for (var i = 0u; i < num_steps; i++) {
  let y = cloud_density.cloudBase + (f32(i) + 0.5) * step_size;
  opt_depth += cd_sample_density(
    vec3<f32>(world_xz.x, y, world_xz.y),
    /* cloud params */, base_noise, detail_noise, noise_samp,
  ) * step_size;
}
let trans = exp(-opt_depth * cloud_density.extinction);

The LUT bake uses cd_sample_density — the same field clouds.wgsl raymarches — not the cheaper cd_coverage. The two functions differ in two ways that matter: cd_sample_density double-rotates XZ before sampling and subtracts the detail-noise erosion, while cd_coverage single-rotates and skips the detail layer. Using cd_coverage would place the cloud-shadow pattern at different world-XZ positions than the visible clouds (so shafts would get shadowed in clear sky and pass through visible clouds) and would over-estimate density everywhere (washing out the directional sun shafts). The shadow has to match the cloud render pixel-for-pixel; only the spatial sampling cadence changes.

The Ray March#

For each pixel the march reconstructs the world position behind it from the depth buffer and steps from the camera toward that point (sky pixels march to the far plane). A per-pixel dither offsets the start of the march, trading banding for noise that the blur removes:

// ── from src/shaders/godray_march.wgsl ──
let phase = henyey_greenstein(cos_theta, march_params.scattering);

var accum = 0.0;
for (var i = 0u; i < steps; i++) {
  let shad  = shadow_at(pos);            // cascade shadow map (PCF)
  let trans = cloud_shadow_lookup(pos);  // 1 bilinear fetch from the LUT
  accum += phase * shad * trans;
  pos   += ray_dir * step_len;
}
let fog = clamp(accum / f32(steps), 0.0, 1.0);

Three terms shape each sample:

  • phase — the Henyey-Greenstein phase function. Forward-scattering haze throws far more light toward the viewer near the sun, so shafts are brightest around the sun and fade off-axis.
  • shadow_at — samples the same cascade shadow map the deferred lighting uses. This is what carves the shaft shape: geometry that shadows the ground also shadows the air in front of it.
  • cloud_shadow_lookup — one bilinear fetch from the cloud-shadow LUT, so shafts dim under clouds and brighten in the gaps. Above the cloud slab the lookup short-circuits to transmittance 1.0 (the LUT stores a full-column integral, which would otherwise apply as a phantom shadow to march steps that have already exited the slab):
// ── from src/shaders/godray_march.wgsl ──
fn cloud_shadow_lookup(p: vec3<f32>) -> f32 {
  if (p.y >= lut_params.cloudTop) { return 1.0; }
  let lut_uv = (p.xz - lut_params.origin) / max(lut_params.extent, 0.001);
  return textureSampleLevel(cloud_shadow_lut, lut_samp, lut_uv, 0.0).r;
}

lut_params (origin, extent, cloudTop) is rewritten each frame from GodrayPass.updateLutParams(ctx) — origin tracks the camera so the LUT rect re-centers on every frame; clamp-to-edge sampling handles march samples that fall past the rect (rare unless the camera far plane exceeds lutExtent / 2, and the edge transmittance reads as "no cloud" in typical sun-direction-tracking layouts).

Texel-Snapping the LUT Origin#

Tracking the camera continuously is wrong — as the camera moves sub-texel amounts each frame, every LUT texel covers a slightly different world XZ region, and the bake re-evaluates each texel against that shifted world position. A fixed march sample point lands at a different sub-texel UV every frame, so the bilinear interpolation reads a different blend of four slightly different source values. The march sees a per-pixel jitter that the bilateral blur doesn't fully smooth out, and as the player walks the shafts visibly warble.

The fix is to snap the LUT origin to a texel-aligned world XZ:

// ── from src/renderer/render_graph/passes/godray_pass.ts ──
const texel = this.lutExtent / Math.max(1, this.lutResolution | 0);
data[0] = Math.floor((pos.x - half) / texel) * texel;
data[1] = Math.floor((pos.z - half) / texel) * texel;

The grid now jumps in discrete texel steps as the camera crosses a texel boundary instead of sliding continuously. Two consecutive frames behave like this:

  • Camera moves less than one texel. Math.floor((pos.x - half) / texel) returns the same integer, so the origin is identical and every texel's covered world XZ is identical. The bake produces an identical LUT. The march's lookup at any fixed world point is bit-exact stable.
  • Camera crosses a texel boundary. The origin jumps by exactly one texel. Texel i in the new frame now covers the world XZ that texel i+1 covered in the previous frame — so its baked value matches the previous frame's neighbor (modulo cloud wind, which is the intended per-frame change). The march's lookup at any stationary world point lands at the new lut_uv shifted by 1/N, sampling a window of source texels whose values were shifted by one to compensate. The interpolated result is unchanged.

In both cases stationary world geometry stays bit-stable in the godray output across camera motion — exactly the temporal property the continuous-tracking version lacked.

Shafts Shadowed by the Clouds You See#

cd_sample_density is the one source of truth for "where the clouds are" — clouds.wgsl raymarches it, and the godray LUT bake integrates it. Both shaders #import "cloud_density.wgsl", so a hole in the rendered clouds is a hole in the godrays: a shaft shines through a visible gap and is cut off under a visible cloud. The LUT is the cadence at which that field is sampled, not a separate model of it.

Blur and Composite#

The half-res fog texture is noisy from the dither and aliased along depth edges. fs_blur runs a 7-tap bilateral Gaussian (once horizontal, once vertical) that weights each tap by exp(-|Δdepth| · 1000), so the blur stops at silhouettes instead of smearing shafts across them.

fs_composite upsamples to full resolution — picking, for each pixel, the half-res neighbor closest in depth to avoid edge bleed — applies a pow(fog, fogCurve) contrast curve, fades the effect out as the sun crosses the horizon, and adds the result to the HDR buffer:

// ── from src/shaders/godray_composite.wgsl ──
fog = pow(max(fog, 0.0), params.fog_curve);
let horizon_fade = smoothstep(-0.05, 0.05, -light.direction.y);
let fog_color    = light.color * light.intensity * fog * horizon_fade;
return vec4<f32>(fog_color, 0.0);   // additive (one + one) blend

scattering, fogCurve, and maxSteps are tunable fields on the pass, uploaded by updateParams. lutResolution (default 256) and lutExtent (default 1200 world units) tune the cloud-shadow LUT — raise the resolution for sharper cloud-edge shadows, widen the extent to cover farther horizon rays. The godray pass runs before the cloud overlay, so a cloud both dims a shaft from above (via the LUT lookup) and then occludes it from the front when the overlay blends the cloud in.

Compositing with the Cloud Overlay#

The naive composite order above has a subtle artifact: the godray pass writes the full fog_color onto every sky pixel (depth = 1.0), and the cloud overlay that runs afterward blends premultiplied-alpha cloud onto the HDR with one + (one - src.alpha). The (one - src.alpha) factor occludes the underlying sky glow proportional to cloud opacity — which is correct for the sky behind the cloud, but it also means the bright shaft simply vanishes the instant it crosses a cloud's silhouette. Visually the rays appear "behind" the clouds even when the shaft should be lighting the cloud's front face.

The fix is a small piece of plumbing between the godray pass and the cloud passes:

  1. GodrayPass exports its half-res fog texture. A new fogTexture: ResourceHandle field on GodrayOutputs returns the post-blur, pre-composite fog texture (the single-channel sun-shaft accumulation). The pass still composites the fog into HDR as before — the texture is exported for downstream consumers, not as a replacement.
  2. GodrayFeature publishes the fog texture and the active fogCurve value into Frame.extras under GODRAY_FOG_TEXTURE_KEY / GODRAY_FOG_CURVE_KEY (src/renderer/features/godray_feature.ts). The fog-curve number is published during update() so cloud features can read it on the same frame; the texture handle is published during addPasses() so cloud features running after the godray feature can wire it into their pass deps.
  3. CloudFeature picks both up. It looks the keys up in frame.extras and forwards the texture into the cloud pass's addToGraph deps; it calls updateGodrayFogCurve(fogCurve) so the cloud shader's brightness shaping matches the sky composite's pow(fog, fog_curve).

The cloud shader (clouds.wgsl overlay branch) then re-evaluates the same horizon-faded, curve-shaped glow term and adds it to the cloud's premultiplied color, scaled by cloud opacity:

// ── from src/shaders/clouds.wgsl (overlay branch) ──
let opacity        = 1.0 - total_trans;
let fog_raw        = textureSampleLevel(fog_tex, fog_samp, in.uv, 0.0).r;
let fog            = pow(max(fog_raw, 0.0), max(light.godray_fog_curve, 1e-3));
let horizon_fade   = smoothstep(-0.05, 0.05, -light.direction.y);
let godray_glow    = light.color * light.intensity * fog * horizon_fade;
let glow_on_cloud  = godray_glow * opacity * step(1e-3, light.godray_fog_curve);
return vec4<f32>(cloud_color + glow_on_cloud, opacity);

The arithmetic is the conservation trick: the sky HDR already carries glow * 1.0 at every pixel, and the cloud overlay's (one - src.alpha) factor attenuates that by (1 - opacity), leaving glow * (1 - opacity). Adding glow * opacity back through the cloud's premultiplied color brings the per-pixel total back to exactly glow * 1.0 regardless of cloud thickness — the shaft brightness is identical across sky and cloud, no double-illumination, no missing glow on the cloud face.

The coupling is opt-in, gracefully degrades:

  • When godrays are off, the host binds a 1×1 zero-fill dummy fog texture and writes godray_fog_curve = 0 into the cloud-shader light uniforms. The step(1e-3, light.godray_fog_curve) gate zeroes the contribution, so the shader path is a no-op and there is no need to compile a separate variant.
  • When the godray feature is registered after a cloud feature, the cloud feature reads zero from extras on its first frame (the godray feature hasn't published yet) and the contribution stays off — which is the right behavior when the pipeline order doesn't match.
  • GodrayPass is unchanged for callers that don't read the new fogTexture field; the existing additive composite into HDR still runs.

10.11 Planetary Atmospheres and the Curved Horizon#

Everything so far assumes a flat world: the ground is the plane y = 0, "up" is world +Y, and the camera's altitude is just cameraPos.y. That is the right model for a walkable level, but it breaks down for a planet — fly high enough and the horizon should curve, the sky should thin to black, and eventually you should see the whole globe hang in space. Taos supports this with a planet mode in the atmosphere pass, plus a set of sample-side helpers that drive it from a real WGS84 globe (used by the Cesium geospatial samples — see Chapter 16's deep dives).

Cross-section of planet mode: the planet ground sphere (r_e) and atmosphere shell (r_a) about a planet center, a camera in space, and three view rays — one hitting the lit surface, one grazing the limb glow, one passing into the star field — with the radial local-up and the limb angle

10.11.1 Planet Mode#

AtmospherePass.setPlanet(...) switches the shader from the flat-world integral to a spherical one. The key difference is the ray origin: instead of parking the camera at (0, r_e + camH, 0) on the world-Y axis, planet mode treats the camera as a real position relative to the planet center, and "up" becomes the radial direction — so every point on the globe gets its own local horizon and day/night transition as the camera flies around it:

// ── from src/shaders/atmosphere.wgsl ──
var ro: vec3<f32>;
if (u.mode == 0u) {
  let camH = max(u.cameraPos.y, 1.0);
  ro = vec3<f32>(0.0, atm.r_e + camH, 0.0);   // flat world: altitude on +Y
} else {
  ro = u.cameraPos - u.planetCenter;          // planet: real position vs. center
}
let localUp = select(vec3<f32>(0.0, 1.0, 0.0), normalize(ro), u.mode == 1u);

The scattering coefficients, scale heights, ozone layer and ground-bounce falloff are all altitudes-above-a-surface quantities, so they have to scale with the planet's atmosphere thickness. planetAtmosphereParams stretches Earth's values proportionally to the requested shell thickness (relative to Earth's 60 km), so a thin-shelled small moon or a thick-shelled gas giant both scatter plausibly:

// ── from src/renderer/render_graph/passes/atmosphere_pass.ts ──
const thickness = opts.atmosphereHeight ?? radius * 0.16;
const s = thickness / 60000.0;                 // scale relative to Earth's shell
const scaleBeta = (e: number): number => e / s;   // thinner shell → denser scatter

10.11.2 The Floating-Origin Planet Center#

A real geospatial scene can't put the planet center at the world origin: Earth's radius is ~6.37 Mm, far past where f32 matrices stay precise, so those scenes render in a floating origin — a local tangent frame near the camera, with Earth's center millions of meters "below" (see Chapter 16). AtmospherePass.setPlanetCenter(world) tells the dome where the center sits in that frame; it is refreshed every frame because the origin re-anchors as the camera roams:

// ── from src/shaders/atmosphere.wgsl ──
ro = u.cameraPos - u.planetCenter;   // planet center is wherever the floating frame put it

(0,0,0) reproduces the legacy center-at-origin behavior, so non-geospatial planet samples (e.g. Terranaut) need not set it.

10.11.3 Coordinating the Curved Horizon#

Planet mode is only half the picture — for the horizon to read as curved, the aerial-perspective fog (§10.9) and the starfield (§10.6) have to be rebased onto the same globe, and they have to track the camera's true altitude. The Cesium samples wrap that bookkeeping in a small CurvedHorizonSky helper (samples/lib/geo_sky.ts). Toggling it on puts the dome and the aerial fog onto the globe; toggling it off restores the flat-world path tuned for the ground/city view:

// ── from samples/lib/geo_sky.ts ──
setEnabled(on: boolean): void {
  if (on) {
    this._atmosphere.pass!.setPlanet({ radius: WGS84_A, atmosphereHeight: ATMOSPHERE_SHELL_M });
    this._aerial.rE = WGS84_A;
    this._aerial.rA = WGS84_A + ATMOSPHERE_SHELL_M;
  } else {
    this._atmosphere.pass!.setEarth();   // flat-world scattering
  }
  this._atmosphere.pass!.setGroundSurface(on);   // §10.11.4
}

Each frame it does one subtle but essential correction. The atmosphere's ground is a true sphere of radius r_e, but the planet is an ellipsoid whose surface is ~21 km closer to the center at the poles. Feeding a fixed equatorial radius would read a mid-latitude camera as kilometers underground, and the in-scatter integral would crater to black. So the helper derives the local geocentric ground radius from the camera's geodetic altitude and overrides r_e/r_a with it:

// ── from samples/lib/geo_sky.ts (per-frame update) ──
const camRadius  = Math.hypot(dx, dy, dz);              // camera distance to center
const groundR    = camRadius - ecefToGeodetic(ec).height;  // local ground radius
const camAltitude = camRadius - groundR;
this._atmosphere.pass!.setOverrides({ rE: groundR, rA: groundR + ATMOSPHERE_SHELL_M });

The helper also feeds the starfield (which fades in with altitude through the shell, §10.6) and is written against a small CelestialBodyView abstraction, so the same code drives an Earth flyover or an airless body like the Moon (where the scattering shell collapses to a near-zero thickness, giving a black sky with stars from the ground up).

10.11.4 Aerial Fog That Knows Its Altitude#

Aerial perspective (§10.9) is a regional effect — it makes a distant ridge fade into the sky. At the global "blue-marble" level, though, integrating tens of thousands of kilometers of (mostly empty) atmosphere washes the entire planet into a flat haze. The curved-horizon helper fades it out with altitude, applied to a copy of the params so the user's fog sliders keep their authored values:

// ── from samples/lib/geo_sky.ts ──
const aerialFade = 1 - smoothstep01(AERIAL_FADE_LO_M, AERIAL_FADE_HI_M, camAltitude);
this._lighting.pass!.setAerialAtmosphere({
  ...this._aerial,
  maxOpacity: (this._aerial.maxOpacity ?? 1) * aerialFade,  // → 0 disables extinction + in-scatter
  fogDensity: (this._aerial.fogDensity ?? 0) * aerialFade,  // → 0 drops the height fog
});

Recall from §10.9 that geo_T = max(exp(-tau), 1 - aerialMaxOpacity), so driving maxOpacity to 0 floors the transmittance at 1 and the aerial composite geo·T + fog·(1 − T) collapses cleanly back to the unfogged geometry. By ~150 km the planet is crisp against space.

10.11.5 The Lit Planet Surface (Blue Marble)#

A geospatial scene only streams terrain tiles in a patch around the camera; from orbit the rest of the globe would be an empty void between the loaded patch and the atmosphere limb. To make the planet read as a sphere, the atmosphere shader can paint a stylized lit globe wherever a view ray strikes the ground sphere — opt-in via AtmospherePass.setGroundSurface(true) (flags bit 3), planet mode only, off by default so every other sample is unaffected.

Three-stage compositing: the atmosphere pass writes sky and the lit globe across the whole frame with no depth test, deferred lighting overwrites only the pixels that have streamed terrain, and the result shows the globe filling the gaps with tiles embedded; below, plots of the two-lobe limb-glow profile and the aerial-fog altitude fade

This works because of the pipeline order: the atmosphere pass runs after the geometry/G-buffer fill but before deferred lighting, and writes the HDR target with no depth test. So it fills the whole frame with sky + planet body, and the deferred lighting pass (which loads that HDR and overwrites only pixels that have real geometry) paints the streamed high-resolution terrain on top. The procedural globe shows exactly in the un-streamed gap and at the limb, and the real tiles take over seamlessly as you descend.

The surface itself is shaded as a sun-lit sphere with a soft day/night terminator and procedural continents (value-noise fBm over the surface direction — ocean / vegetation / arid / snow — not geographically accurate, but it reads as a planet), then composited under the in-scatter the spherical integral already produced for the camera→ground segment — the standard aerial-perspective surface·T + inscatter, which gives the blue limb haze for free:

// ── from src/shaders/atmosphere.wgsl ──
if (u.mode == 1u && render_ground && !horizonless) {
  let tg = raySphere(ro, rd, atm.r_e);
  if (tg.x > 0.0) {
    let p   = ro + rd * tg.x;                 // ground hit, planet-centered
    let n   = normalize(p);
    let alb = planetAlbedo(n);                // procedural ocean / land / snow
    let ndl = dot(n, u.sunDir);
    let sunGroundT = transmittanceP(n * atm.r_e, u.sunDir, ozone, atm);
    let direct  = alb * atm.sun_intensity * max(ndl, 0.0) * sunGroundT * (1.0 / PI);
    let surf    = direct + alb * vec3<f32>(0.015, 0.025, 0.05);   // + faint night-side fill
    color += surf * transmittanceP(p, -rd, ozone, atm);          // attenuate back to the camera
  }
}

A ground hit also gates off the sun disk, moon and stars for that pixel — they are behind the planet.

10.11.6 The Atmosphere Limb Glow#

The single-scatter integral already brightens the limb where a grazing ray passes through a long column of air, but from orbit it reads as a thin line. A dedicated limb-glow term widens it into the recognizable blue-marble airglow — a bright band hugging the surface fading through sky-blue into a soft halo that bleeds into space. It is parameterised by the view ray's angle off the planet-center direction (not a screen-space distance), which makes it altitude-correct and, crucially, naturally zero when looking away from the planet — so it never washes the star field:

// ── from src/shaders/atmosphere.wgsl ──
let ang     = acos(clamp(dot(rd, -ro / R), -1.0, 1.0));   // ray angle off center
let limb_a  = asin(clamp(atm.r_e / R, 0.0, 1.0));          // angle to the surface limb
let shell_a = asin(clamp(atm.r_a / R, 0.0, 1.0)) - limb_a; // shell's angular thickness
let s = (ang - limb_a) / max(shell_a, 1e-5);              // 0 at surface, 1 at shell top
let ring  = smoothstep(-0.2, 0.05, s);                     // suppress over the solid disk
let rim   = exp(-s * s * 3.5);                             // bright thin band at the limb
let broad = exp(-max(s, 0.0) * 0.5);                       // wide soft glow into space

The two lobes are tinted (white-blue rim → deep blue halo), weighted to the sunlit limb only, brightened toward the sun by a forward-Mie term, and faded in with altitude (smoothstep(30 km, 120 km)) so ground views are untouched.

10.11.7 Flying to Space#

One last piece lets you actually reach the blue-marble view: the camera's far plane has to grow with altitude. A city-scale 200 km far plane clips the ~6371 km planet, so the geospatial sample fits the far plane to the visible tangent-limb distance and lifts the near plane to keep depth precision once nothing is close:

// ── from samples/planet_explorer.ts ──
const limb = Math.sqrt(Math.max(camRadius * camRadius - WGS84_A * WGS84_A, 0));
camera.far  = Math.max(FAR, limb * 1.15 + 100_000);
camera.near = Math.min(Math.max(NEAR, camAlt * 0.0005), camera.far * 0.02);

Climb out, and the aerial fog melts away (§10.11.4), the lit globe fills the gaps the streamed tiles leave (§10.11.5), the limb glow rings the day side (§10.11.6), and the starfield fades up around a curved Earth.

10.12 Summary#

The atmosphere system combines several layered techniques:

  • HDR environment maps: RGBE decoding and equirectangular-to-cubemap conversion on GPU
  • Atmospheric scattering: Rayleigh and Mie single scattering, ozone absorption, and an Oren-Nayar ground bounce
  • Dynamic sky texture: the shared atmosphere model baked to an equirectangular HDR panorama on the GPU and convolved into IBL, rebaked as the sun moves
  • Day/night cycle: Sun position skew with color intensity ramping, a two-lobe sun-flare halo, and a phase-shaded moon with earthshine driven by its own (sun-independent) direction
  • Atmosphere LUTs: precomputed transmittance and multi-scattering tables for cheap evaluation of sun/moon attenuation and multi-scatter sky brightening
  • Volumetric clouds: raymarching a noise-defined density slab with a dual-lobe phase function and a height-varying ambient multi-scatter term
  • Volumetric fog: depth/height composite haze, plus deferred-pass aerial perspective and an analytic exponential height fog that both in-scatter the live sky-scatter color so distant geometry and ground fog match the sky behind them
  • God rays: a half-resolution 3D ray-march shadowed by the cascade maps and the shared cloud-density field. The cloud-shadow term is precomputed once per frame as a low-res 2D top-down LUT (one bilinear fetch per march step instead of a per-step vertical column integral), then bilateral-blurred and composited onto the HDR target. Exports its half-res fog texture so the cloud overlay can add the sun-tinted glow to its premultiplied output proportional to cloud opacity — rays appear on cloud surfaces instead of behind them
  • Planetary atmospheres: a spherical "planet mode" (camera as a real position relative to a floating-origin planet center, radial up), Earth-scaled scattering, a curved-horizon helper that rebases the dome + aerial fog onto the WGS84 ellipsoid and tracks the local ground radius, altitude-faded aerial perspective, a stylized sun-lit "blue marble" ground term that fills the gaps streamed terrain leaves, and an angular limb-glow halo — together giving a fly-to-space globe view

Further reading:

  • src/renderer/render_graph/passes/sky_texture_pass.ts — HDR cubemap sky
  • src/renderer/render_graph/passes/atmosphere_pass.ts — Procedural atmospheric sky
  • src/renderer/render_graph/passes/atmosphere_luts_pass.ts — Transmittance + multi-scattering LUT bake
  • src/renderer/render_graph/passes/cloud_pass.ts — Volumetric clouds
  • src/renderer/render_graph/passes/godray_pass.ts — Volumetric god rays
  • src/shaders/sky.wgsl — Sky shader
  • src/shaders/atmosphere.wgsl — Atmosphere scattering shader (screen-space sky dome)
  • src/shaders/modules/atmosphere_model.wgsl — Shared single-scattering model (screen sky + dynamic bake), sun-flare, moon-phase, and analytic height-fog helpers
  • samples/atmosphere_test.ts — Live atmosphere-settings sandbox (sun/moon, scattering, flare, height fog)
  • samples/lib/geo_sky.ts — Curved-horizon helper: rebases dome + aerial fog onto the globe, tracks the local ground radius, altitude-fades aerial perspective, drives the starfield (Earth/airless-body aware)
  • samples/planet_explorer.ts — Geospatial blue-marble sample: planet-mode sky, lit-globe ground, limb glow, and altitude-scaled far plane (fly to space)
  • src/shaders/sky_panorama.wgsl — Dynamic sky panorama bake shader
  • src/assets/dynamic_sky.ts — Dynamic sky: panorama render, mip chain, and IBL rebaking
  • src/shaders/clouds.wgsl — Cloud raymarching shader
  • src/shaders/godray_cloud_shadow_lut.wgsl — 2D top-down cloud transmittance LUT (per-frame bake)
  • src/shaders/godray_march.wgsl, godray_composite.wgsl — God-ray march, blur and composite
  • src/shaders/modules/cloud_density.wgsl — Cloud-density field shared by clouds and god rays