Render physically-accurate skies, sunsets, and planetary atmospheres through atmospheric scattering — Rayleigh, Mie, and Ozone models composed via raymarching in GLSL fragment shaders. Four progressive modes from flat backdrop to LUT-optimized planetary pipeline.
Modes
Mode
Output
Complexity
sky-dome
Single-pass fragment shader, flat sky backdrop
Entry point
atmosphere-post
Depth-aware post-processing effect with atmospheric fog
Intermediate
planet
Spherical atmosphere shell around a mesh, logarithmic depth
See references/scattering-coefficients.md for Mars and custom planet parameter sets.
2. Missing nested light march
Claude implements only the view-ray loop, producing a blue gradient but no sunsets. The critical missing piece: at each sample point along the primary ray, a secondary march toward the sun accumulates sunOD (optical depth between sample and sun). Without this, tau only contains view-direction extinction and the sky is uniformly blue regardless of sun angle.
// WRONG — view ray only, no sunset
vec3 tau = BETA_R * viewODR;
// RIGHT — view ray + sun path
vec3 sunOD = lightMarch(h, sunDirection.y);
vec3 tau = BETA_R * (viewODR + sunOD.x)
+ BETA_M_EXT * (viewODM + sunOD.y)
+ BETA_OZONE_ABS * (viewODO + sunOD.z);
The light march denominator uses a +0.15 offset to prevent infinite path length at exact horizon angles: float denom = max(sunY + 0.15, 0.04);
3. Incorrect logarithmic depth reconstruction
At planetary scale, a linear depth buffer causes z-fighting between atmosphere and surface. Enable logarithmicDepthBuffer: true in the R3F Canvas, then decode in the shader:
Claude defaults to depth * (far - near) + near, which is the linear formula and fails at planetary distances.
4. No LUT pipeline knowledge
Without guidance, Claude attempts the full nested raymarch (24 primary steps x 8 light march steps = 192 texture fetches per pixel) at screen resolution. The LUT approach precomputes transmittance into a 250x64 texture, then downstream LUTs sample it with a single texture2D call. See references/hillaire-lut-pipeline.md.
5. Wrong phase function normalization
The Mie phase function (Henyey-Greenstein) has a (2.0 + g*g) term in the denominator that Claude drops:
Claude's atmospheric models skip ozone entirely, losing the purple twilight shift and the correct sky-blue (as opposed to pure Rayleigh blue). Ozone peaks at ~25 km altitude with ~15 km width, absorbs but does not scatter:
Horizon mask: smoothstep(-0.12, 0.05, skyDir.y) to blend to space below horizon
Tonemap: ACESFilm(color)
Density functions, phase functions, and transmittance: see gotchas #1, #5, #6 above — those contain the correct implementations with wrong/right comparisons.
Atmosphere Post-Processing Mode
Depth-aware post-processing effect. Reconstructs world-space position from the depth buffer, marches through the scene volume, applies atmospheric fog that thickens with distance.
Nearby geometry gets dense sampling; distant sky rays distribute steps over a larger volume.
Ground-ray early termination: if rayDir.y < -1e-5, compute tGround = observerAltitude / max(-rayDir.y, 1e-4) and cap rayEnd to prevent marching below the surface.
R3F Integration
Three uniforms from the scene: depthBuffer, projectionMatrixInverse, viewMatrixInverse. Enable logarithmicDepthBuffer: true on the Canvas gl prop for planetary scale. FBOs for LUTs use dedicated WebGLRenderTarget instances rendered to off-screen scenes.
Planet Mode
Spherical atmosphere shell. Uses ray-sphere intersection to define atmosphere entry/exit, logarithmic depth buffer for planetary scale.
Ray-Sphere Intersection
vec2 raySphereIntersect(vec3 ro, vec3 rd, vec3 center, float radius) {
vec3 oc = ro - center;
float b = dot(oc, rd);
float c = dot(oc, oc) - radius * radius;
float disc = b * b - c;
if (disc < 0.0) return vec2(-1.0);
float sq = sqrt(disc);
return vec2(-b - sq, -b + sq);
}
Atmosphere Segment Clipping
Three cases: ray misses atmosphere, ray hits planet surface, scene object occludes planet.
Compare angular radii and separation of sun/moon from each sample point. Three cases: no overlap, moon >= sun angular size (total/annular), moon < sun (partial). Multiply transmittance by this value at each sample.
Each LUT is rendered to a dedicated FBO, passed as a uniform to downstream passes.
Transmittance LUT Parameterization
x-axis: mu = cos(zenith) from -1 (toward ground) to +1 (toward space)
y-axis: altitude from planetRadius to atmosphereRadius
Output: vec3(exp(-tau)) — surviving light fraction per channel
Composition
// Geometry pixels: blend original color with atmospheric haze
color = color * aerialPerspective.a + aerialPerspective.rgb;
// Background pixels: replace with sky color
color = sampleSkyViewLUT(rayDir, planetCenter);
color = ACESFilm(color);
color = pow(color, vec3(1.0 / 2.2));
See references/hillaire-lut-pipeline.md for full LUT shader implementations.
Scope Boundaries
This skill covers volumetric atmospheric light transport. It does NOT cover: