GLSL Kawase Blur Ping-Pong Texture Mechanism

The Kawase blur filter is a highly efficient real-time post-processing technique that approximates a Gaussian blur by executing multiple rendering passes with expanding sampling offsets. In OpenGL Shading Language (GLSL), this multi-pass process relies on a "ping-pong" texture mechanism, where two alternating framebuffers read from and write to one another to overcome GPU read-write hazards. This article breaks down the operational architecture of the Kawase blur, how the ping-pong rendering loop is structured in GLSL, and why this design delivers exceptional graphical performance.

The Core Concept of Kawase Blur

Introduced by Masaki Kawase in 2003, the Kawase blur approximates large-radius blurs by applying multiple small-radius filter passes instead of a single massive convolution kernel.

A standard 2D Gaussian blur requires \(N \times N\) texture fetches per pixel, which quickly becomes computationally prohibitive. While a separable Gaussian blur reduces this to \(2N\) fetches across two passes, the Kawase blur samples only four diagonal corners per pass:

// Standard 4-tap diagonal sampling in GLSL
vec4 sum = vec4(0.0);
sum += texture(u_Texture, v_TexCoord + vec2(-offset.x, -offset.y));
sum += texture(u_Texture, v_TexCoord + vec2( offset.x, -offset.y));
sum += texture(u_Texture, v_TexCoord + vec2(-offset.x,  offset.y));
sum += texture(u_Texture, v_TexCoord + vec2( offset.x,  offset.y));
fragColor = sum * 0.25;

To create a wide, smooth blur, the filter runs sequentially over several iterations (typically 4 to 6 passes). With each iteration, the distance (kernel offset) between the samples expands.

The Problem: GPU Read-Write Hazards

Modern GPUs cannot safely read from and write to the same texture attachment within the same render pass. Attempting to bind a texture as both an active sampler input and an active Framebuffer Object (FBO) render target causes pipeline stalls, texture feedback loops, and undefined visual artifacts.

Because each iteration of the Kawase blur requires the entire rendered output of the previous iteration, the application must store intermediate results across separate render steps.

The Ping-Pong Texture Mechanism

The ping-pong mechanism resolves the read-write conflict without allocating a unique texture for every pass. It uses two Framebuffer Objects and two associated color textures (Texture A and Texture B) of identical dimensions:

  1. Pass 0 (Initialization): The scene or source image is bound as the input texture. The render target is set to FBO A (Texture A). The Kawase fragment shader executes with an offset of 0.5 texels.
  2. Pass 1: FBO B (Texture B) becomes the render target. Texture A is bound as the input sampler. The shader executes with an increased offset (e.g., 1.5 texels).
  3. Pass 2: FBO A becomes the render target again. Texture B is bound as the input sampler. The shader executes with an offset of 2.5 texels.
  4. Subsequent Passes: The targets alternate (ping-pong) until the final iteration is reached.

The host program manages this sequence via a simple toggle loop:

bool horizontal = true;
bool first_iteration = true;
int iterations = 5;

for (int i = 0; i < iterations; i++) {
    glBindFramebuffer(GL_FRAMEBUFFER, horizontal ? fboA : fboB);
    
    // Bind the previous pass output as the current input
    GLuint currentTexture;
    if (first_iteration) {
        currentTexture = sceneTexture;
        first_iteration = false;
    } else {
        currentTexture = horizontal ? textureB : textureA;
    }
    
    glBindTexture(GL_TEXTURE_2D, currentTexture);
    glUniform1f(offsetUniformLocation, i + 0.5f);
    
    renderQuad();
    
    horizontal = !horizontal;
}

Fragment Shader Implementation in GLSL

The GLSL fragment shader receives the current texture along with a uniform specifying the expansion offset. Rather than changing the shader code per pass, only the offset value changes on the CPU side:

#version 330 core

in vec2 v_TexCoord;
out vec4 FragColor;

uniform sampler2D u_InputTexture;
uniform vec2 u_TexelSize; // vec2(1.0 / width, 1.0 / height)
uniform float u_Offset;

void main()
{
    vec2 step = u_TexelSize * (u_Offset + 0.5);

    vec4 color = vec4(0.0);
    color += texture(u_InputTexture, v_TexCoord + vec2(-step.x, -step.y));
    color += texture(u_InputTexture, v_TexCoord + vec2( step.x, -step.y));
    color += texture(u_InputTexture, v_TexCoord + vec2(-step.x,  step.y));
    color += texture(u_InputTexture, v_TexCoord + vec2( step.x,  step.y));

    FragColor = color * 0.25;
}

Using bilinear filtering on the textures (GL_LINEAR) further smooths the result, effectively sampling a \(2\times2\) region at each of the four tap points.

Why This Mechanism Is Efficient

  • Fixed Memory Footprint: Regardless of how many passes are performed (e.g., 4, 8, or 16), the GPU memory usage never exceeds two textures.
  • Low Sample Count: Five passes of a 4-tap Kawase blur require only 20 texture fetches per output pixel, yet produce a dispersion radius comparable to a standard 2D Gaussian blur requiring hundreds of fetches.
  • Cache Coherency: Because each pass samples a tightly clustered quad relative to the expanding offset, GPU texture caches remain effective compared to wide single-pass kernels.