Dual Depth Peeling in GLSL Explained

Dual depth peeling is an order-independent transparency technique that cuts the required rendering passes in half compared to standard depth peeling by simultaneously extracting both the front-most and back-most geometry layers in a single pass. By maintaining two depth bounds and utilizing Multiple Render Targets (MRT) alongside specialized blending equations, a GLSL fragment shader can discard fragments that have already been processed, isolate the next closest and furthest surfaces, and accumulate their colors into separate buffers until the entire volume is resolved.

The Limitation of Standard Depth Peeling

Standard depth peeling renders geometry iteratively from front to back (or back to front). Each pass uses the depth buffer of the previous pass to peel away exactly one layer of fragments. If a scene has a maximum depth complexity of \(N\) layers, it requires \(N\) geometry passes. Dual depth peeling solves this inefficiency by peeling inward from both directions simultaneously, requiring only \(\lceil N / 2 \rceil\) passes.

Buffer Setup and Render Targets

Dual depth peeling relies on OpenGL’s Multiple Render Targets (MRT) and the ability to output to different color attachments with distinct blend operations. A typical implementation uses:

  1. Depth/Min-Max Texture: A two-channel 32-bit floating-point texture (GL_RG32F). The red channel stores the negative nearest depth (to use GL_MAX blending as a proxy for minimum depth, or standard min-max blending where supported), and the green channel stores the maximum depth.
  2. Front Color Accumulator: Accumulates colors peeled from front to back using an "under" blending operator (\(C_{dst} = C_{src} \cdot (1 - \alpha_{dst}) + C_{dst}\)).
  3. Back Color Accumulator: Accumulates colors peeled from back to front using standard "over" blending (\(C_{dst} = C_{src} + C_{dst} \cdot (1 - \alpha_{src})\)).

The Initialization Pass

Before peeling begins, an initialization pass determines the initial scene boundaries:

  1. Depth testing is disabled, and blending is set to write the minimum and maximum depths of the entire scene into the depth target.
  2. The fragment shader outputs the fragment’s depth \(z\) to both channels (often storing \(-z\) in the first channel and \(z\) in the second channel if relying on standard GL_MAX blending).
  3. The resulting texture contains the global nearest depth (\(z_{min}\)) and the global farthest depth (\(z_{max}\)).

The Peeling Pass in GLSL

During each peeling iteration, the geometry is rendered with depth writes disabled. The fragment shader samples the previous pass’s depth texture to retrieve the active front boundary (\(z_{front}\)) and back boundary (\(z_{back}\)).

The GLSL fragment logic operates as follows:

#version 330 core

uniform sampler2D u_PrevDepthTex;
in vec4 v_Color;

layout (location = 0) out vec2 o_Depth;
layout (location = 1) out vec4 o_FrontColor;
layout (location = 2) out vec4 o_BackColor;

void main()
{
    float z = gl_FragCoord.z;
    vec2 bounds = texelFetch(u_PrevDepthTex, ivec2(gl_FragCoord.xy), 0).rg;
    float minDepth = -bounds.r;
    float maxDepth = bounds.g;

    // Discard fragments outside the current unpeeled layer range
    if (z <= minDepth || z >= maxDepth) {
        discard;
    }

    // Initialize depth outputs to default extremes
    o_Depth = vec2(-1.0, 0.0);
    o_FrontColor = vec4(0.0);
    o_BackColor = vec4(0.0);

    // Identify if the fragment belongs to the new front or back layer
    if (z > minDepth && z < maxDepth) {
        // Output depths for the next layer's min-max search
        o_Depth = vec2(-z, z);
    }

    if (z == minDepth) {
        o_FrontColor = vec4(v_Color.rgb * v_Color.a, v_Color.a);
    } else if (z == maxDepth) {
        o_BackColor = v_Color;
    }
}

Depth Isolation and Color Blending

Because GLSL cannot write simultaneously to the color buffers and resolve the next minimum/maximum depths in a single step without race conditions, the peeling step is typically split into two sub-passes per layer:

  1. Geometry Extraction Pass: Reads the previous depth limits, discards fragments outside the interval \((z_{front}, z_{back})\), and uses glBlendEquationSeparate set to GL_MAX on the depth target to capture the closest new depth and farthest new depth for remaining fragments.
  2. Color Accumulation Pass: Renders the geometry again using the newly found depth bounds. Fragments matching the new \(z_{front}\) are written to the front accumulator using under-blending, while fragments matching the new \(z_{back}\) are blended into the back accumulator using over-blending.

Final Composition

Once the loop completes—either after reaching a set number of iterations or when an occlusion query indicates zero fragments were processed—a full-screen compositing shader blends the front accumulation buffer over the back accumulation buffer, producing the correctly ordered transparent image.