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.
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.
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.
// 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.
// 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);
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.
// 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;
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.
// 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.
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; }
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.
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); }
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.
// 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.
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; }
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.
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:
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
};