Screen-Space Atmospheric Light Scattering

Rendering Volumetric God Rays Without Ray Marching

volumetricatmosphereglslpost-processinglighting

Volumetric lighting creates that ethereal quality where light seems to fill the air itself - think fog-pierced headlights or sunbeams through cathedral windows. Traditional approaches ray-march through 3D volumes, but for real-time applications with multiple dynamic lights, we need something faster. This technique renders atmospheric scattering entirely in screen-space, achieving rich volumetric effects at a fraction of the cost.

Screen-Space vs Ray-Marched Volumetric Light

The Core Concept

Instead of marching rays through a 3D volume, we project each light source to screen space and compute a radial falloff function. The key insight is that atmospheric scattering from a point light approximates a radial gradient centered on the light's screen position - we just need to make it physically plausible with proper falloff, perspective correction, and noise modulation.

Radial Falloff from Screen-Space Light Position

World-to-Screen Projection

First, we need to transform world-space light positions to screen coordinates. This happens on the CPU and is passed as uniforms. We also compute visibility - lights behind the camera should contribute nothing, and lights near screen edges should fade smoothly to avoid harsh cutoffs.

Light Screen Position and Visibility Setup glsl
// Screen-space light positions (camera-transformed, in pixels)
uniform vec2 uLight0Screen;
uniform vec2 uLight1Screen;
uniform vec2 uLight2Screen;

// Light visibility (0.0 = behind camera, 1.0 = visible)
uniform float uLight0Visible;
uniform float uLight1Visible;
uniform float uLight2Visible;

// World positions for perspective correction
uniform vec3 uLight0WorldPos;
uniform vec3 uLight1WorldPos;
uniform vec3 uLight2WorldPos;

// Camera state
uniform vec3 uCameraPos;
uniform float uCameraRotX;  // Pitch
uniform float uCameraRotY;  // Yaw

Edge Fade for Off-Screen Lights

Lights near or beyond screen edges need smooth fade-out to prevent jarring transitions when rotating the camera. We compute separate X and Y fade factors that smoothly reduce intensity as the light moves off-screen.

Smooth Edge Fade Implementation glsl
// Convert screen pixels to normalized UV
vec2 lightUV = lightScreenPos / uResolution;
lightUV.y = 1.0 - lightUV.y;  // Flip Y for GL convention

// Edge fade - smoothly reduce intensity off-screen
// The 1.5 factor allows glow to extend slightly beyond edges
float edgeFadeX = 1.0 - smoothstep(0.0, 1.5, abs(lightUV.x - 0.5) - 0.5);
float edgeFadeY = 1.0 - smoothstep(0.0, 1.5, abs(lightUV.y - 0.5) - 0.5);
float edgeFade = edgeFadeX * edgeFadeY;

// Early out for lights too far off-screen
if (edgeFade < 0.01) return vec3(0.0);
Edge Fade Zones

Perspective-Correct Distance

A naive screen-space distance calculation would make lights appear to grow as you move away from them - the opposite of reality. We correct for perspective by scaling the screen distance based on the camera-to-light depth, normalized against a reference viewing distance.

Perspective Correction for Light Falloff glsl
// Aspect-corrected distance in screen space
vec2 delta = uv - lightUV;
delta.x *= uResolution.x / uResolution.y;  // Correct for aspect ratio
float screenDist = length(delta);

// Compute camera-to-light distance for perspective correction
float lightDepth = length(lightWorldPos - uCameraPos);

// Normalize against reference distance(typical viewing distance)
float referenceDepth = 1.0;
float perspectiveScale = max(lightDepth, 0.1) / referenceDepth;

// Scale screen distance by depth ratio
// Farther away: perspectiveScale > 1.0, shrinks apparent radius
// Closer: perspectiveScale < 1.0, grows apparent radius
float dist = screenDist * perspectiveScale;
Perspective Correction Effect

Physically-Based Falloff

Light intensity follows an inverse-power law: I = 1 / (1 + d^n). Using n=2 gives classic inverse-square falloff. The '1 +' in the denominator prevents division by zero at the light center and controls the core brightness.

Inverse-Power Falloff glsl
// Parameters
uniform float uVolumetricIntensity;  // Overall brightness (default 1.0)
uniform float uVolumetricFalloff;    // Falloff exponent (default 2.0)
uniform float uVolumetricScale;      // Distance scale (default 3.0)

// Base light falloff (inverse-power law)
// I = 1 / (1 + (d * scale)^falloff)
float light = 1.0 / (1.0 + pow(dist * uVolumetricScale, uVolumetricFalloff));

// Apply intensity multiplier
light *= uVolumetricIntensity * lightIntensity;

Animated Volumetric Noise

Real atmospheric scattering isn't uniform - dust particles, moisture, and air currents create variation. We sample 3D noise using a skybox ray direction, giving us view-dependent patterns that feel volumetric rather than screen-aligned. Time-based animation adds subtle motion.

Skybox Direction for 3D Noise Sampling glsl
vec3 getSkyboxDirection(vec2 screenPos) {
    float fov = 0.8;
    vec3 rayDir = normalize(vec3(screenPos.x * fov, screenPos.y * fov, 1.0));

    // Apply camera rotation
    float cosRotX = cos(uCameraRotX);
    float sinRotX = sin(uCameraRotX);
    float cosRotY = cos(uCameraRotY);
    float sinRotY = sin(uCameraRotY);

    // Rotate around X axis (pitch)
    float ry = rayDir.y * cosRotX - rayDir.z * sinRotX;
    float rz = rayDir.y * sinRotX + rayDir.z * cosRotX;
    rayDir.y = ry;
    rayDir.z = rz;

    // Rotate around Y axis (yaw)
    float rx = rayDir.x * cosRotY + rayDir.z * sinRotY;
    rz = -rayDir.x * sinRotY + rayDir.z * cosRotY;
    rayDir.x = rx;
    rayDir.z = rz;

    return rayDir;
}
3D Noise Sampling via Skybox Direction

FBM Noise with Controllable Octaves

We use Fractional Brownian Motion (FBM) to layer multiple noise frequencies. A controllable octave parameter lets us balance detail versus performance - fewer octaves for mobile, more for desktop.

FBM with Runtime Octave Control glsl
uniform float uVolumetricNoiseOctaves;  // 0-1, blends octaves

float volumetricFBM(vec3 p) {
    float totalAmp = 0.5;
    float v = volumetricNoise3D(p) * 0.5;  // Base octave

    // Second octave (enabled when octaves > 0)
    float oct2 = clamp(uVolumetricNoiseOctaves * 2.0, 0.0, 1.0);
    v += volumetricNoise3D(p * 2.0) * 0.25 * oct2;
    totalAmp += 0.25 * oct2;

    // Third octave (enabled when octaves > 0.5)
    float oct3 = clamp((uVolumetricNoiseOctaves - 0.5) * 2.0, 0.0, 1.0);
    v += volumetricNoise3D(p * 4.0) * 0.125 * oct3;
    totalAmp += 0.125 * oct3;

    return v / totalAmp;  // Normalize
}

float sampleNoiseAt(vec3 worldPos, float lightIndex) {
    // Offset by light index to decorrelate patterns
    // Animate with time for subtle motion
    vec3 pos = worldPos * uVolumetricNoiseScale 
             + vec3(0.0, 0.0, lightIndex * 50.0 + uTime * 0.02);
    return volumetricFBM(pos);
}
FBM Octave Blending

Wavelength-Dependent Scattering

Real atmospheric scattering is wavelength-dependent - blue light scatters more than red (Rayleigh scattering). We expose per-channel scatter rates that let artists tune the color shift from warm centers to cool edges.

Per-Channel Scatter Rates glsl
// Scatter rates per channel (Rayleigh-inspired)
uniform float uVolumetricScatterR;  // Red (default 0.3, less scatter)
uniform float uVolumetricScatterG;  // Green (default 0.5)
uniform float uVolumetricScatterB;  // Blue (default 1.0, more scatter)
uniform float uVolumetricSaturation; // Color boost (default 1.8)

// Saturate light color for vivid appearance
float luma = dot(lightColor, vec3(0.299, 0.587, 0.114));
vec3 saturatedColor = mix(vec3(luma), lightColor, uVolumetricSaturation);

Combining Multiple Lights

Each light contributes independently, with its own screen position, color, intensity, and visibility. We sum contributions additively - physically correct for light transport.

Multi-Light Accumulation glsl
vec3 volumetricLightContribution(vec2 uv, vec3 skyDir) {
    vec3 result = vec3(0.0);
    
    // Each light adds independently
    result += volumetricLight(uv, uLight0Screen, uLightColor0, 0.0, 
                              skyDir, uLight0Intensity, uLight0Visible, 
                              uLight0WorldPos);
    
    result += volumetricLight(uv, uLight1Screen, uLightColor1, 1.0, 
                              skyDir, uLight1Intensity, uLight1Visible, 
                              uLight1WorldPos);
    
    result += volumetricLight(uv, uLight2Screen, uLightColor2, 2.0, 
                              skyDir, uLight2Intensity, uLight2Visible, 
                              uLight2WorldPos);
    
    return result;
}
Additive Multi-Light Blending

Final Compositing

The final pass applies vignette for subtle edge darkening, soft tone mapping to prevent blowout, and ensures we never go darker than the background color.

Final Compositing Pass glsl
void main() {
    vec2 uv = vUV;
    vec2 screenPos = (uv - 0.5) * 2.0;
    screenPos.x *= uResolution.x / uResolution.y;  // Aspect correction

    // Get view direction for noise sampling
    vec3 skyDir = getSkyboxDirection(screenPos);

    // Accumulate volumetric contribution
    vec3 finalColor = volumetricLightContribution(uv, skyDir);

    // Vignette (subtle edge darkening)
    float vignette = 1.0 - length(uv - 0.5) * uVignetteStrength;
    finalColor *= vignette;

    // Soft Reinhard tone mapping
    finalColor = finalColor / (finalColor + vec3(0.6));

    // Never go darker than background
    vec3 bgColor = vec3(0.039, 0.059, 0.078);  // #0a0f14
    finalColor = max(finalColor, bgColor);

    gl_FragColor = vec4(finalColor, 1.0);
}

Performance Considerations

  • Single full-screen pass: O(pixels) complexity, independent of light count for small N
  • Early-out for invisible lights: Skip entire contribution when behind camera or off-screen
  • Distance fade: Avoid computing noise for pixels far from any light
  • Octave scaling: Reduce noise octaves on mobile (0.3) vs desktop (0.5-0.7)
  • Resolution scaling: Can render at 1/2 or 1/4 resolution and upsample with bilinear

Tunable Parameters

The shader exposes many parameters for artistic control. Here are recommended starting values and their effects:

Recommended Parameter Values javascript
volumetricParams = {
    intensity: 1.0,        // Overall brightness
    falloff: 2.0,          // Inverse-square (physical)
    scale: 3.0,            // Controls spread radius
    saturation: 1.8,       // Color vividness
    noiseScale: 4.0,       // Noise frequency
    noiseStrength: 0.12,   // Noise displacement amount
    noiseOctaves: 0.5,     // Detail level (0-1)
    scatterR: 0.3,         // Red scatter (low)
    scatterG: 0.5,         // Green scatter (medium)
    scatterB: 1.0,         // Blue scatter (high)
    vignetteStrength: 0.3  // Edge darkening
};

Want to see more?

Check out my interactive portfolio with live shader demos