How Screen Space Reflections Trace Rays in GLSL

Screen-space reflections (SSR) simulate specular surface reflections in real-time graphics by marching rays directly across the screen's existing depth and color buffers rather than traversing complex scene geometry. This article explains the underlying mechanics of tracing rays through a depth buffer inside a GLSL fragment shader, covering ray generation in view space, projection to screen space, depth comparisons, intersection refinement techniques, and artifact mitigation.

1. Ray Initialization in View Space

The SSR process begins in the fragment shader by establishing the origin and direction of the reflection ray in view (camera) space.

  1. Reconstruct View Position: The origin of the ray (\(\mathbf{P}\)) is the view-space position of the current fragment. This can be sampled directly from a G-buffer position texture or reconstructed from the camera's depth buffer using the fragment's screen coordinates and the inverse projection matrix:

    vec4 clipSpacePos = vec4(vTexCoord * 2.0 - 1.0, depthSample * 2.0 - 1.0, 1.0);
    vec4 viewSpacePos = invProjectionMatrix * clipSpacePos;
    vec3 rayOrigin = viewSpacePos.xyz / viewSpacePos.w;
  2. Calculate Reflection Vector: The view vector (\(\mathbf{V}\)) points from the camera (origin in view space, \((0,0,0)\)) to the fragment position (\(\mathbf{P}\)). Given the surface normal (\(\mathbf{N}\)) in view space, the reflection direction (\(\mathbf{R}\)) is computed using the GLSL built-in reflect function:

    vec3 viewDir = normalize(rayOrigin);
    vec3 rayDir = normalize(reflect(viewDir, normalize(viewNormal)));

2. Ray Marching and Projection

Rays can be traversed either by stepping in view space and projecting each step onto the screen, or by stepping directly in 2D screen space. The most common and robust method for general implementations steps linearly along the ray in view space and projects each step to texture space.

At each step \(i\), with a step size \(\Delta t\):

\[\mathbf{P}_i = \text{rayOrigin} + (\text{rayDir} \cdot \Delta t \cdot i)\]

To sample the depth buffer, \(\mathbf{P}_i\) must be transformed into normalized device coordinates (NDC) and then into \([0, 1]\) texture coordinates:

vec4 projCoord = projectionMatrix * vec4(currentRayPos, 1.0);
projCoord.xy /= projCoord.w; // Perspective division to [-1, 1]
vec2 sampleUV = projCoord.xy * 0.5 + 0.5; // Map to [0, 1]

3. Depth Comparison and Intersection Logic

Because the depth buffer represents a 2.5D heightfield of the visible scene rather than full 3D geometry, an intersection test must evaluate whether the ray has passed behind a visible surface.

  1. Depth Fetching: The shader samples the depth buffer at sampleUV. This raw depth is transformed into linear view-space depth (\(Z_{\text{buffer}}\)) using near and far clipping planes.
  2. Ray Depth Evaluation: The ray's actual view-space depth (\(Z_{\text{ray}} = \mathbf{P}_{i}.z\)) is compared against \(Z_{\text{buffer}}\).
  3. Thickness Thresholding: A simple test of \(Z_{\text{ray}} \le Z_{\text{buffer}}\) (assuming negative \(Z\) in view space) is insufficient because the depth buffer has no back-faces; objects appear infinitely thick. To avoid false intersections with objects behind foreground geometry, a surface thickness tolerance (\(\tau\)) is enforced:

\[Z_{\text{buffer}} \ge Z_{\text{ray}} \ge Z_{\text{buffer}} - \tau\]

In GLSL:

float rawDepth = texture(depthTexture, sampleUV).r;
float sceneDepth = linearizeDepth(rawDepth);
float rayDepth = -currentRayPos.z; // Treat depth as positive distance

if (rayDepth >= sceneDepth && (rayDepth - sceneDepth) < thickness) {
    // Hit detected
}

Linear ray marching requires large step sizes for performance, which leads to banding artifacts and imprecise contact points. When a hit is detected during linear marching, a binary search (bisection) is performed between the previous step (a known miss) and the current step (the detected hit) to resolve the exact surface boundary:

vec3 startPos = currentRayPos - rayDir * stepSize;
vec3 endPos = currentRayPos;

for (int j = 0; j < BINARY_SEARCH_STEPS; j++) {
    vec3 midPos = (startPos + endPos) * 0.5;
    vec4 proj = projectionMatrix * vec4(midPos, 1.0);
    vec2 uv = (proj.xy / proj.w) * 0.5 + 0.5;
    
    float sceneDepth = linearizeDepth(texture(depthTexture, uv).r);
    float rayDepth = -midPos.z;

    if (rayDepth >= sceneDepth) {
        endPos = midPos; // Ray is inside or behind surface
    } else {
        startPos = midPos; // Ray is in front of surface
    }
}

After refinement, the final uv coordinates represent the hit location.

5. GLSL Ray Marching Implementation

The following complete fragment shader function combines view-space ray marching, depth comparisons, and screen-edge validation:

vec3 traceScreenSpaceRay(vec3 rayOrigin, vec3 rayDir, int maxSteps, float stepSize, float thickness) {
    vec3 currentRayPos = rayOrigin;
    
    for (int i = 0; i < maxSteps; i++) {
        currentRayPos += rayDir * stepSize;
        
        vec4 proj = projectionMatrix * vec4(currentRayPos, 1.0);
        vec3 hitNDC = proj.xyz / proj.w;
        vec2 sampleUV = hitNDC.xy * 0.5 + 0.5;
        
        // Ray stepped outside the screen boundaries
        if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) {
            break;
        }
        
        float rawDepth = texture(depthTexture, sampleUV).r;
        float sceneDepth = linearizeDepth(rawDepth);
        float rayDepth = -currentRayPos.z;
        
        // Intersection check with thickness threshold
        if (rayDepth > sceneDepth && (rayDepth - sceneDepth) < thickness) {
            // Binary search refinement
            vec3 minRay = currentRayPos - rayDir * stepSize;
            vec3 maxRay = currentRayPos;
            
            for (int j = 0; j < 8; j++) {
                vec3 midRay = (minRay + maxRay) * 0.5;
                vec4 midProj = projectionMatrix * vec4(midRay, 1.0);
                vec2 midUV = (midProj.xy / midProj.w) * 0.5 + 0.5;
                
                float d = linearizeDepth(texture(depthTexture, midUV).r);
                if (-midRay.z > d) {
                    maxRay = midRay;
                } else {
                    minRay = midRay;
                }
            }
            
            vec4 finalProj = projectionMatrix * vec4(maxRay, 1.0);
            return vec3((finalProj.xy / finalProj.w) * 0.5 + 0.5, 1.0); // xy = UV, z = hit flag
        }
    }
    
    return vec3(0.0); // No intersection found
}

6. Handling Boundary Conditions and Edge Fading

Because SSR relies strictly on screen-space data, rays marching off-screen or reflecting geometry facing away from the camera will cause visual anomalies. To resolve these:

  • Screen Edge Fade: Calculate a falloff weight based on the hit UV’s proximity to screen edges \((0.0\text{ and }1.0)\). Multiply the sampled reflection color by this weight to transition smoothly to a fallback environment map.
  • Ray Direction Fade: Smoothly fade out reflections as the reflection vector points back toward the camera (\(\mathbf{R} \cdot \mathbf{V} \approx 1\)), where data is obscured or absent in the depth buffer.
  • Max Distance Fade: Gradually diminish reflection intensity as the ray length approaches the maximum step limit to prevent abrupt popping artifacts.