Files
skills/shader-dev/techniques/atmospheric-scattering.md
shihao 6487becf60 Initial commit: add all skills files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:52:49 +08:00

18 KiB
Raw Permalink Blame History

Atmospheric & Subsurface Scattering

Use Cases

  • Sky rendering (sunrise/sunset/noon/night)
  • Aerial perspective
  • Sun halo (Mie scattering haze)
  • Planetary atmosphere rim glow
  • Translucent material SSS (candles, skin, jelly)
  • Volumetric light (God rays)

Core Principles

Three physical mechanisms:

Rayleigh scattering — molecular-scale particles, β_R(λ) ∝ 1/λ⁴, shorter wavelengths scatter more strongly (blue sky / red sunset). Sea-level values: vec3(5.5e-6, 13.0e-6, 22.4e-6) m⁻¹. Phase function: P_R(θ) = 3/(16π) × (1 + cos²θ), symmetric forward-backward.

Mie scattering — aerosol particles, wavelength-independent, strong forward scattering (sun halo). Sea-level value: vec3(21e-6) m⁻¹. Phase function: Henyey-Greenstein, g ≈ 0.76~0.88.

Beer-Lambert attenuationT(A→B) = exp(-∫ σ_e(s) ds), exponential decay of light through a medium.

Algorithm flow: ray march along the view ray; at each sample point: compute density → compute optical depth toward the sun → Beer-Lambert attenuation → phase function weighting → accumulate.

Implementation Steps

Step 1: Ray-Sphere Intersection

// Returns (t_near, t_far); no intersection when t_near > t_far
vec2 raySphereIntersect(vec3 p, vec3 dir, float r) {
    float b = dot(p, dir);
    float c = dot(p, p) - r * r;
    float d = b * b - c;
    if (d < 0.0) return vec2(1e5, -1e5);
    d = sqrt(d);
    return vec2(-b - d, -b + d);
}

Step 2: Atmospheric Physical Constants

#define PLANET_RADIUS 6371e3
#define ATMOS_RADIUS  6471e3
#define PLANET_CENTER vec3(0.0)

#define BETA_RAY vec3(5.5e-6, 13.0e-6, 22.4e-6)  // Rayleigh scattering coefficients
#define BETA_MIE vec3(21e-6)                        // Mie scattering coefficients
#define BETA_OZONE vec3(2.04e-5, 4.97e-5, 1.95e-6) // Ozone absorption

#define MIE_G 0.76          // Anisotropy parameter 0.76~0.88
#define MIE_EXTINCTION 1.1  // Extinction/scattering ratio

#define H_RAY 8000.0        // Rayleigh scale height
#define H_MIE 1200.0        // Mie scale height
#define H_OZONE 30e3        // Ozone peak altitude
#define OZONE_FALLOFF 4e3   // Ozone decay width

#define PRIMARY_STEPS 32    // Primary ray steps 8(mobile)~64(high quality)
#define LIGHT_STEPS 8       // Light direction steps 4~16

Step 3: Phase Functions

float phaseRayleigh(float cosTheta) {
    return 3.0 / (16.0 * 3.14159265) * (1.0 + cosTheta * cosTheta);
}

// Henyey-Greenstein phase function
float phaseMie(float cosTheta, float g) {
    float gg = g * g;
    float num = (1.0 - gg) * (1.0 + cosTheta * cosTheta);
    float denom = (2.0 + gg) * pow(1.0 + gg - 2.0 * g * cosTheta, 1.5);
    return 3.0 / (8.0 * 3.14159265) * num / denom;
}

Step 4: Atmospheric Density Sampling

// Returns vec3(rayleigh, mie, ozone) density
vec3 atmosphereDensity(vec3 pos, float planetRadius) {
    float height = length(pos) - planetRadius;
    float densityRay = exp(-height / H_RAY);
    float densityMie = exp(-height / H_MIE);
    float denom = (H_OZONE - height) / OZONE_FALLOFF;
    float densityOzone = (1.0 / (denom * denom + 1.0)) * densityRay;
    return vec3(densityRay, densityMie, densityOzone);
}

Step 5: Light Direction Optical Depth

vec3 lightOpticalDepth(vec3 pos, vec3 sunDir) {
    float atmoDist = raySphereIntersect(pos - PLANET_CENTER, sunDir, ATMOS_RADIUS).y;
    float stepSize = atmoDist / float(LIGHT_STEPS);
    float rayPos = stepSize * 0.5;
    vec3 optDepth = vec3(0.0);
    for (int i = 0; i < LIGHT_STEPS; i++) {
        vec3 samplePos = pos + sunDir * rayPos;
        float height = length(samplePos - PLANET_CENTER) - PLANET_RADIUS;
        if (height < 0.0) return vec3(1e10); // Occluded by planet
        optDepth += atmosphereDensity(samplePos, PLANET_RADIUS) * stepSize;
        rayPos += stepSize;
    }
    return optDepth;
}

Step 6: Primary Scattering Integration

vec3 calculateScattering(
    vec3 rayOrigin, vec3 rayDir, float maxDist,
    vec3 sunDir, vec3 sunIntensity
) {
    vec2 atmoHit = raySphereIntersect(rayOrigin - PLANET_CENTER, rayDir, ATMOS_RADIUS);
    if (atmoHit.x > atmoHit.y) return vec3(0.0);

    vec2 planetHit = raySphereIntersect(rayOrigin - PLANET_CENTER, rayDir, PLANET_RADIUS);

    float tStart = max(atmoHit.x, 0.0);
    float tEnd = atmoHit.y;
    if (planetHit.x > 0.0) tEnd = min(tEnd, planetHit.x);
    tEnd = min(tEnd, maxDist);

    float stepSize = (tEnd - tStart) / float(PRIMARY_STEPS);
    float cosTheta = dot(rayDir, sunDir);
    float phaseR = phaseRayleigh(cosTheta);
    float phaseM = phaseMie(cosTheta, MIE_G);

    vec3 totalRay = vec3(0.0), totalMie = vec3(0.0), optDepthI = vec3(0.0);
    float rayPos = tStart + stepSize * 0.5;

    for (int i = 0; i < PRIMARY_STEPS; i++) {
        vec3 samplePos = rayOrigin + rayDir * rayPos;
        vec3 density = atmosphereDensity(samplePos, PLANET_RADIUS) * stepSize;
        optDepthI += density;

        vec3 optDepthL = lightOpticalDepth(samplePos, sunDir);
        vec3 tau = BETA_RAY * (optDepthI.x + optDepthL.x)
                 + BETA_MIE * 1.1 * (optDepthI.y + optDepthL.y)
                 + BETA_OZONE * (optDepthI.z + optDepthL.z);
        vec3 attenuation = exp(-tau);

        totalRay += density.x * attenuation;
        totalMie += density.y * attenuation;
        rayPos += stepSize;
    }

    return sunIntensity * (totalRay * BETA_RAY * phaseR + totalMie * BETA_MIE * phaseM);
}

Step 7: Tone Mapping

vec3 tonemapExposure(vec3 color) { return 1.0 - exp(-color); }

vec3 tonemapReinhard(vec3 color) {
    float l = dot(color, vec3(0.2126, 0.7152, 0.0722));
    vec3 tc = color / (color + 1.0);
    return mix(color / (l + 1.0), tc, tc);
}

vec3 gammaCorrect(vec3 color) { return pow(color, vec3(1.0 / 2.2)); }

Complete Code Template

Fully runnable Rayleigh + Mie atmospheric scattering for ShaderToy:

#define PI 3.14159265359

#define PLANET_RADIUS 6371e3
#define ATMOS_RADIUS  6471e3
#define PLANET_CENTER vec3(0.0)

#define BETA_RAY vec3(5.5e-6, 13.0e-6, 22.4e-6)
#define BETA_MIE vec3(21e-6)
#define BETA_OZONE vec3(2.04e-5, 4.97e-5, 1.95e-6)

#define MIE_G 0.76
#define MIE_EXTINCTION 1.1

#define H_RAY 8e3
#define H_MIE 1.2e3
#define H_OZONE 30e3
#define OZONE_FALLOFF 4e3

#define PRIMARY_STEPS 32
#define LIGHT_STEPS 8

#define SUN_INTENSITY vec3(40.0)

vec2 raySphereIntersect(vec3 p, vec3 dir, float r) {
    float b = dot(p, dir);
    float c = dot(p, p) - r * r;
    float d = b * b - c;
    if (d < 0.0) return vec2(1e5, -1e5);
    d = sqrt(d);
    return vec2(-b - d, -b + d);
}

float phaseRayleigh(float cosTheta) {
    return 3.0 / (16.0 * PI) * (1.0 + cosTheta * cosTheta);
}

float phaseMie(float cosTheta, float g) {
    float gg = g * g;
    float num = (1.0 - gg) * (1.0 + cosTheta * cosTheta);
    float denom = (2.0 + gg) * pow(1.0 + gg - 2.0 * g * cosTheta, 1.5);
    return 3.0 / (8.0 * PI) * num / denom;
}

vec3 atmosphereDensity(vec3 pos) {
    float height = length(pos - PLANET_CENTER) - PLANET_RADIUS;
    float dRay = exp(-height / H_RAY);
    float dMie = exp(-height / H_MIE);
    float dOzone = (1.0 / (pow((H_OZONE - height) / OZONE_FALLOFF, 2.0) + 1.0)) * dRay;
    return vec3(dRay, dMie, dOzone);
}

vec3 calculateScattering(
    vec3 start, vec3 dir, float maxDist,
    vec3 sceneColor, vec3 sunDir, vec3 sunIntensity
) {
    start -= PLANET_CENTER;

    float a = dot(dir, dir);
    float b = 2.0 * dot(dir, start);
    float c = dot(start, start) - ATMOS_RADIUS * ATMOS_RADIUS;
    float d = b * b - 4.0 * a * c;
    if (d < 0.0) return sceneColor;

    vec2 rayLen = vec2(
        max((-b - sqrt(d)) / (2.0 * a), 0.0),
        min((-b + sqrt(d)) / (2.0 * a), maxDist)
    );
    if (rayLen.x > rayLen.y) return sceneColor;

    bool allowMie = maxDist > rayLen.y;
    rayLen.y = min(rayLen.y, maxDist);
    rayLen.x = max(rayLen.x, 0.0);

    float stepSize = (rayLen.y - rayLen.x) / float(PRIMARY_STEPS);
    float rayPos = rayLen.x + stepSize * 0.5;

    vec3 totalRay = vec3(0.0);
    vec3 totalMie = vec3(0.0);
    vec3 optI = vec3(0.0);

    float mu = dot(dir, sunDir);
    float phaseR = phaseRayleigh(mu);
    float phaseM = allowMie ? phaseMie(mu, MIE_G) : 0.0;

    for (int i = 0; i < PRIMARY_STEPS; i++) {
        vec3 pos = start + dir * rayPos;
        float height = length(pos) - PLANET_RADIUS;

        vec3 density = vec3(exp(-height / H_RAY), exp(-height / H_MIE), 0.0);
        float dOzone = (H_OZONE - height) / OZONE_FALLOFF;
        density.z = (1.0 / (dOzone * dOzone + 1.0)) * density.x;
        density *= stepSize;
        optI += density;

        float la = dot(sunDir, sunDir);
        float lb = 2.0 * dot(sunDir, pos);
        float lc = dot(pos, pos) - ATMOS_RADIUS * ATMOS_RADIUS;
        float ld = lb * lb - 4.0 * la * lc;
        float lightStepSize = (-lb + sqrt(ld)) / (2.0 * la * float(LIGHT_STEPS));
        float lightPos = lightStepSize * 0.5;
        vec3 optL = vec3(0.0);

        for (int j = 0; j < LIGHT_STEPS; j++) {
            vec3 posL = pos + sunDir * lightPos;
            float heightL = length(posL) - PLANET_RADIUS;
            vec3 densityL = vec3(exp(-heightL / H_RAY), exp(-heightL / H_MIE), 0.0);
            float dOzoneL = (H_OZONE - heightL) / OZONE_FALLOFF;
            densityL.z = (1.0 / (dOzoneL * dOzoneL + 1.0)) * densityL.x;
            densityL *= lightStepSize;
            optL += densityL;
            lightPos += lightStepSize;
        }

        vec3 attn = exp(
            -BETA_RAY * (optI.x + optL.x)
            - BETA_MIE * MIE_EXTINCTION * (optI.y + optL.y)
            - BETA_OZONE * (optI.z + optL.z)
        );

        totalRay += density.x * attn;
        totalMie += density.y * attn;

        rayPos += stepSize;
    }

    vec3 opacity = exp(-(BETA_MIE * optI.y + BETA_RAY * optI.x + BETA_OZONE * optI.z));

    return (
        phaseR * BETA_RAY * totalRay +
        phaseM * BETA_MIE * totalMie
    ) * sunIntensity + sceneColor * opacity;
}

vec3 getCameraVector(vec3 resolution, vec2 coord) {
    vec2 uv = coord.xy / resolution.xy - vec2(0.5);
    uv.x *= resolution.x / resolution.y;
    return normalize(vec3(uv.x, uv.y, -1.0));
}

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec3 rayDir = getCameraVector(iResolution, fragCoord);
    vec3 cameraPos = vec3(0.0, PLANET_RADIUS + 100.0, 0.0);
    vec3 sunDir = normalize(vec3(0.0, cos(-iTime / 8.0), sin(-iTime / 8.0)));

    vec4 scene = vec4(0.0, 0.0, 0.0, 1e12);
    vec3 sunDisk = vec3(dot(rayDir, sunDir) > 0.9998 ? 3.0 : 0.0);
    scene.xyz = sunDisk;

    vec2 groundHit = raySphereIntersect(cameraPos - PLANET_CENTER, rayDir, PLANET_RADIUS);
    if (groundHit.x > 0.0) {
        scene.w = groundHit.x;
        vec3 hitPos = cameraPos + rayDir * groundHit.x - PLANET_CENTER;
        vec3 normal = normalize(hitPos);
        float shadow = max(0.0, dot(normal, sunDir));
        scene.xyz = vec3(0.1, 0.15, 0.08) * shadow;
    }

    vec3 col = calculateScattering(
        cameraPos, rayDir, scene.w,
        scene.xyz, sunDir, SUN_INTENSITY
    );

    col = 1.0 - exp(-col);
    col = pow(col, vec3(1.0 / 2.2));

    fragColor = vec4(col, 1.0);
}

Advanced Fog Models

Three progressive fog techniques, from simple to physically motivated. These can be used standalone or combined with the full atmospheric scattering above.

Level 1: Basic Exponential Fog

vec3 applyFog(vec3 col, float t) {
    float fogAmount = 1.0 - exp(-t * density);
    vec3 fogColor = vec3(0.5, 0.6, 0.7);
    return mix(col, fogColor, fogAmount);
}

Level 2: Sun-Aware Fog (Scattering Tint)

Fog color shifts warm when looking toward the sun — creates a very natural light dispersion effect:

vec3 applyFogSun(vec3 col, float t, vec3 rd, vec3 sunDir) {
    float fogAmount = 1.0 - exp(-t * density);
    float sunAmount = max(dot(rd, sunDir), 0.0);
    vec3 fogColor = mix(
        vec3(0.5, 0.6, 0.7),          // base fog (blue-grey)
        vec3(1.0, 0.9, 0.7),          // sun-facing fog (warm gold)
        pow(sunAmount, 8.0)
    );
    return mix(col, fogColor, fogAmount);
}

Level 3: Height-Based Fog (Analytical Integration)

Density decreases exponentially with altitude: d(y) = a * exp(-b * y). The formula is an exact analytical integral along the ray, not an approximation — fog pools in valleys and clears at altitude:

vec3 applyFogHeight(vec3 col, float t, vec3 ro, vec3 rd) {
    float a = 0.5;    // density multiplier
    float b = 0.3;    // density falloff with height
    float fogAmount = (a / b) * exp(-ro.y * b) * (1.0 - exp(-t * rd.y * b)) / rd.y;
    fogAmount = clamp(fogAmount, 0.0, 1.0);
    vec3 fogColor = vec3(0.5, 0.6, 0.7);
    return mix(col, fogColor, fogAmount);
}

Level 4: Extinction + Inscattering Separation

Independent RGB coefficients for absorption and scattering — allows chromatic fog effects where different wavelengths scatter differently:

vec3 applyFogPhysical(vec3 col, float t, vec3 fogCol) {
    vec3 be = vec3(0.02, 0.025, 0.03);   // extinction coefficients (RGB)
    vec3 bi = vec3(0.015, 0.02, 0.025);  // inscattering coefficients (RGB)
    vec3 extinction = exp(-t * be);
    vec3 inscatter = (1.0 - exp(-t * bi));
    return col * extinction + fogCol * inscatter;
}

Common Variants

Variant 1: Non-Physical Analytic Approximation (No Ray March)

Extremely low-cost analytic sky, suitable for mobile / backgrounds.

#define zenithDensity(x) 0.7 / pow(max(x - 0.1, 0.0035), 0.75)

vec3 getSkyAbsorption(vec3 skyColor, float zenith) {
    return exp2(skyColor * -zenith) * 2.0;
}

float getMie(vec2 p, vec2 lp) {
    float disk = clamp(1.0 - pow(distance(p, lp), 0.1), 0.0, 1.0);
    return disk * disk * (3.0 - 2.0 * disk) * 2.0 * 3.14159;
}

vec3 getAtmosphericScattering(vec2 screenPos, vec2 lightPos) {
    vec3 skyColor = vec3(0.39, 0.57, 1.0);
    float zenith = zenithDensity(screenPos.y);
    float rayleighMult = 1.0 + pow(1.0 - clamp(distance(screenPos, lightPos), 0.0, 1.0), 2.0) * 1.57;
    vec3 absorption = getSkyAbsorption(skyColor, zenith);
    vec3 sunAbsorption = getSkyAbsorption(skyColor, zenithDensity(lightPos.y + 0.1));
    vec3 sky = skyColor * zenith * rayleighMult;
    vec3 mie = getMie(screenPos, lightPos) * sunAbsorption;
    float sunDist = clamp(length(max(lightPos.y + 0.1, 0.0)), 0.0, 1.0);
    vec3 totalSky = mix(sky * absorption, sky / (sky + 0.5), sunDist);
    totalSky += mie;
    totalSky *= sunAbsorption * 0.5 + 0.5 * length(sunAbsorption);
    return totalSky;
}

Variant 2: Ozone Absorption Layer

Already integrated in the complete template. Set BETA_OZONE to a non-zero value to enable, producing a deeper blue zenith and purple tones at sunset.

Variant 3: Subsurface Scattering (SSS)

For translucent materials (candles/skin/jelly), using SDF-estimated thickness to control light transmission.

float subsurface(vec3 p, vec3 viewDir, vec3 normal) {
    vec3 scatterDir = refract(viewDir, normal, 1.0 / 1.5); // IOR 1.3~2.0
    vec3 samplePos = p;
    float accumThickness = 0.0;
    float MAX_SCATTER = 2.5;
    for (float i = 0.1; i < MAX_SCATTER; i += 0.2) {
        samplePos += scatterDir * i;
        accumThickness += map(samplePos); // SDF function
    }
    float thickness = max(0.0, -accumThickness);
    float SCATTER_STRENGTH = 16.0;
    return SCATTER_STRENGTH * pow(MAX_SCATTER * 0.5, 3.0) / thickness;
}
// Usage: float ss = max(0.0, subsurface(hitPos, viewDir, normal));
// vec3 sssColor = albedo * smoothstep(0.0, 2.0, pow(ss, 0.6));
// finalColor = mix(lambertian, sssColor, 0.7) + specular;

Variant 4: LUT Precomputation Pipeline (Production Grade)

Precompute Transmittance/Multiple Scattering/Sky-View into LUTs, only table lookups at runtime.

// Transmittance LUT query (Hillaire 2020)
vec3 getValFromTLUT(sampler2D tex, vec2 bufferRes, vec3 pos, vec3 sunDir) {
    float height = length(pos);
    vec3 up = pos / height;
    float sunCosZenithAngle = dot(sunDir, up);
    vec2 uv = vec2(
        256.0 * clamp(0.5 + 0.5 * sunCosZenithAngle, 0.0, 1.0),
        64.0 * max(0.0, min(1.0, (height - groundRadiusMM) / (atmosphereRadiusMM - groundRadiusMM)))
    );
    uv /= bufferRes;
    return texture(tex, uv).rgb;
}

Variant 5: Analytic Fast Atmosphere (with Aerial Perspective)

Analytic exponential approximation replacing ray march, with distance attenuation support.

void getRayleighMie(float opticalDepth, float densityR, float densityM, out vec3 R, out vec3 M) {
    vec3 C_RAYLEIGH = vec3(5.802, 13.558, 33.100) * 1e-6;
    vec3 C_MIE = vec3(3.996e-6);
    R = (1.0 - exp(-opticalDepth * densityR * C_RAYLEIGH / 2.5)) * 2.5;
    M = (1.0 - exp(-opticalDepth * densityM * C_MIE / 0.5)) * 0.5;
}

vec3 getLightTransmittance(vec3 lightDir) {
    vec3 C_RAYLEIGH = vec3(5.802, 13.558, 33.100) * 1e-6;
    vec3 C_MIE = vec3(3.996e-6);
    vec3 C_OZONE = vec3(0.650, 1.881, 0.085) * 1e-6;
    float extinction = exp(-clamp(lightDir.y + 0.05, 0.0, 1.0) * 40.0)
                     + exp(-clamp(lightDir.y + 0.5, 0.0, 1.0) * 5.0) * 0.4
                     + pow(clamp(1.0 - lightDir.y, 0.0, 1.0), 2.0) * 0.02
                     + 0.002;
    return exp(-(C_RAYLEIGH + C_MIE + C_OZONE) * extinction * 1e6);
}

Performance & Composition

Performance Tips

  • Nested ray march (O(N*M)): reduce step counts (mobile: PRIMARY=12, LIGHT=4), use analytic approximation instead of light march, precompute Transmittance LUT
  • Dense exp()/pow(): Schlick approximation replacing HG phase function — k = 1.55*g - 0.55*g³; phase = (1-k²) / (4π*(1+k*cosθ)²)
  • Full-screen per-pixel: Sky-View LUT (200x200) table lookup, half-resolution rendering + bilinear upsampling
  • Banding dithering: non-uniform step offset of 0.3, temporal blue noise dithering

Composition Tips

  • + Volumetric clouds: atmospheric transmittance determines sun color reaching the cloud layer, set maxDist to cloud distance
  • + SDF scene: SDF hit distance → maxDist, scene color → sceneColor, automatic aerial perspective
  • + God Rays: add occlusion to scattering integration (shadow map or additional ray march)
  • + Terrain: finalColor = terrainColor * transmittance + inscattering
  • + PBR/SSS: diffuse = mix(lambert, sss, 0.7); final = ambient + albedo*diffuse + specular + fresnel*env

Further Reading

For full step-by-step tutorials, mathematical derivations, and advanced usage, see reference