GLSL God Rays: Radial Blur Post-Processing

This article explains how to implement screen-space volumetric light shafts, commonly known as god rays or crepuscular rays, using a radial blur post-processing shader in GLSL. It covers the core concept of directional radial sampling from a screen-space light source, the preparation of the occlusion buffer, the complete GLSL fragment shader implementation, and the key parameters used to fine-tune the visual quality and performance of the effect.

The Core Concept

Volumetric light shafts occur in nature when sunlight is scattered by atmospheric particles and partially blocked by obstacles like clouds or trees. Simulating full 3D participating media is computationally expensive for real-time rendering.

The radial blur approach, popularized by Kenny Mitchell in GPU Gems 3, approximates this effect entirely in 2D post-processing. The algorithm operates on the assumption that light shafts stream outward radially from the light source's position on the screen. By repeatedly sampling along a vector pointing from the current pixel toward the screen-space light position, accumulating the sampled values, and applying exponential falloff, the shader creates the optical illusion of volumetric light shafts piercing through silhouettes.

Prerequisites: The Occlusion Map

Before applying the radial blur pass, you must render a simplified version of the scene known as the occlusion texture (or light-mask buffer):

  1. Light Emitters / Sky: Render the light source (e.g., the sun or an emissive bulb) and unobstructed sky as bright, unshaded colors.
  2. Occluding Geometry: Render all foreground objects and scene geometry as completely black (vec3(0.0)).

This high-contrast buffer isolates the light that directly reaches the camera from the light that is blocked.

The Algorithm Breakdown

  1. Transform Light to Screen Space: Project the world-space light position into clip space, then divide by the \(w\) component to obtain Normalized Device Coordinates (NDC). Remap these coordinates to UV space range \([0.0, 1.0]\).
  2. Calculate Step Vector: For every pixel, compute a directional vector heading toward the screen-space light position: \[\vec{D} = \frac{\text{UV} - \text{LightUV}}{\text{SampleCount}} \times \text{Density}\]
  3. Iterative Sampling: Step backward along \(\vec{D}\) across multiple iterations.
  4. Decay and Weighting: Multiply each sample by a decaying attenuation factor (\(\text{Decay}^i\)) and an initial weight, then add it to an accumulation buffer.
  5. Exposure Adjustment: Scale the final accumulated color by an exposure constant.

GLSL Fragment Shader Implementation

Below is a complete GLSL fragment shader demonstrating the radial blur pass:

#version 330 core

in vec2 v_TexCoord;
out vec4 FragColor;

uniform sampler2D u_OcclusionTexture;
uniform vec2 u_LightScreenPos; // Light position in UV coordinates [0.0, 1.0]

// Adjustable algorithm parameters
const int NUM_SAMPLES = 64;
uniform float u_Density = 0.84;
uniform float u_Weight = 5.65;
uniform float u_Decay = 0.97;
uniform float u_Exposure = 0.24;

void main()
{
    // Direction vector pointing from the light source toward the current pixel
    vec2 deltaTexCoord = (v_TexCoord - u_LightScreenPos);
    
    // Scale the step vector by sample count and user-defined density
    deltaTexCoord *= 1.0 / float(NUM_SAMPLES) * u_Density;
    
    vec2 currentTexCoord = v_TexCoord;
    float illuminationDecay = 1.0;
    vec4 accumulatedColor = vec4(0.0);
    
    // March along the ray toward the light source
    for (int i = 0; i < NUM_SAMPLES; ++i)
    {
        currentTexCoord -= deltaTexCoord;
        
        // Sample the occlusion buffer
        vec4 sampleColor = texture(u_OcclusionTexture, currentTexCoord);
        
        // Apply decay and weight attenuation
        sampleColor *= illuminationDecay * u_Weight;
        
        // Accumulate result
        accumulatedColor += sampleColor;
        
        // Update decay factor for the next sample
        illuminationDecay *= u_Decay;
    }
    
    // Apply final exposure tone
    FragColor = accumulatedColor * u_Exposure;
}

Parameter Definitions

  • NUM_SAMPLES: The number of texture lookups along the ray. Higher values (64–128) yield smooth rays, while lower values (16–32) improve frame rates but can cause visible banding.
  • u_Density: Controls the separation distance between individual samples along the ray. Higher values stretch the reach of the rays across the screen.
  • u_Weight: Controls the brightness contribution of each individual texture tap.
  • u_Decay: A value between \(0.0\) and \(1.0\) that dictates how quickly the light fades as it travels further away from its origin.
  • u_Exposure: A global multiplier that tunes the overall intensity of the final output.

Final Compositing

The output of the radial blur shader represents only the light shafts. To integrate the effect into the final frame:

  1. Render the main scene normally to an HDR framebuffer.
  2. Render the radial blur pass to an offscreen buffer (frequently at half or quarter resolution to reduce texture fill-rate overhead).
  3. Combine both textures in a final composite pass using additive blending:
vec4 sceneColor = texture(u_MainSceneTexture, v_TexCoord);
vec4 godRayColor = texture(u_GodRayTexture, v_TexCoord);
FragColor = sceneColor + godRayColor;

When the light source is behind the camera, the screen-space position falls outside the visible viewport; you must clamp, fade out, or disable the effect to avoid inverted visual artifacts.