Dual Kawase Blur in GLSL: Fast Wide Blur Radii

Dual Kawase blur achieves exceptionally wide blur radii at high frame rates by combining a multi-pass resolution pyramid with strategically offset downsampling and upsampling kernels. Instead of using a single high-sample pass at native resolution, the algorithm progressively downscales an image through specialized sampling patterns and then reconstructs it using a smooth upsampling kernel. This guide explains how these paired kernels operate mathematically and structurally in GLSL to deliver large-radius blurs with minimal texture fetches.

The Core Concept: Resolution Scaling Meets Kernel Offsets

Traditional Gaussian blurs scale linearly or quadratically in cost as the blur radius increases. To blur across 50 or 100 pixels, a standard filter requires dozens of texture taps per pixel, even when separated into horizontal and vertical passes.

Dual Kawase solves this by decoupling the apparent blur radius from the sampling count. It relies on two principles:

  1. Resolution Halving (Exponential Footprint Expansion): Every time the render target is halved in resolution, a single-pixel offset in the downsampled space covers twice the distance of the previous pass in full-resolution screen space. Running 4 downsample iterations yields a \(16\times\) footprint expansion without increasing sample counts.
  2. Hardware Bilinear Exploitation: By sampling at half-texel offsets between texel centers, a single texture fetch samples a \(2\times2\) texel footprint using GPU hardware interpolation, quadrupling the effective sample coverage per fetch.

The Downsampling Kernel

The downsampling pass halves the render target dimensions (e.g., \(1920\times1080\) to \(960\times540\)) while applying a box-and-diagonal sampling pattern.

Rather than taking a simple box average, the Dual Kawase downsample kernel samples the center and four diagonal points offset into neighboring texels. Because these coordinates lie on texel boundaries, the GPU's bilinear filtering blends adjacent pixels automatically.

GLSL Downsample Implementation

#version 330 core
out vec4 FragColor;
in vec2 vTexCoord;

uniform sampler2D uTexture;
uniform vec2 uTexelSize; // Texel size of the source (higher-res) texture
uniform float uOffset;    // Blur spread multiplier, typically 1.0

void main() {
    vec2 halfPixel = uTexelSize * 0.5;
    vec2 d = uTexelSize * uOffset;

    // Center sample (weighted higher)
    vec4 sum = texture(uTexture, vTexCoord) * 4.0;

    // Four diagonal bilinear samples
    sum += texture(uTexture, vTexCoord + vec2(-d.x, -d.y));
    sum += texture(uTexture, vTexCoord + vec2( d.x, -d.y));
    sum += texture(uTexture, vTexCoord + vec2(-d.x,  d.y));
    sum += texture(uTexture, vTexCoord + vec2( d.x,  d.y));

    FragColor = sum * 0.125; // Normalize: 4.0 + 1.0 * 4 = 8.0 -> 1/8 = 0.125
}

This 5-tap kernel effectively gathers data from a large pixel footprint on the higher-resolution input buffer while storing the averaged result into a halved buffer.

The Upsampling Kernel

If an image is simply downscaled and bilinearly magnified back to screen resolution, severe blocky, diamond-shaped artifacts appear. The upsampling pass eliminates these artifacts by filtering the image during each magnification step.

The upsampling pass uses an 8-tap kernel arranged in a tent-like configuration: four axial samples (top, bottom, left, right) and four diagonal samples (corners). The axial samples receive double the weight of the diagonal samples to create a circularly symmetric, Gaussian-like reconstruction.

GLSL Upsample Implementation

#version 330 core
out vec4 FragColor;
in vec2 vTexCoord;

uniform sampler2D uTexture;
uniform vec2 uTexelSize; // Texel size of the source (lower-res) texture
uniform float uOffset;    // Blur spread multiplier, typically 1.0

void main() {
    vec2 d = uTexelSize * uOffset;

    vec4 sum = vec4(0.0);

    // Four axial samples with weight 2.0
    sum += texture(uTexture, vTexCoord + vec2(-d.x * 2.0, 0.0)) * 2.0;
    sum += texture(uTexture, vTexCoord + vec2( d.x * 2.0, 0.0)) * 2.0;
    sum += texture(uTexture, vTexCoord + vec2(0.0, -d.y * 2.0)) * 2.0;
    sum += texture(uTexture, vTexCoord + vec2(0.0,  d.y * 2.0)) * 2.0;

    // Four corner samples with weight 1.0
    sum += texture(uTexture, vTexCoord + vec2(-d.x, -d.y));
    sum += texture(uTexture, vTexCoord + vec2( d.x, -d.y));
    sum += texture(uTexture, vTexCoord + vec2(-d.x,  d.y));
    sum += texture(uTexture, vTexCoord + vec2( d.x,  d.y));

    FragColor = sum / 12.0; // Normalize: 4*2.0 + 4*1.0 = 12.0
}

How the Radius Scales Across Iterations

Achieving wide blur radii depends on chaining multiple downsample and upsample passes into a pyramid structure:

  1. Downsample Phase: The base image is rendered sequentially into smaller Framebuffer Objects (FBOs): Full \(\to\) Half \(\to\) Quarter \(\to\) Eighth. At each stage, the downsampling shader executes.
  2. Upsample Phase: The smallest FBO is rendered back up through the chain: Eighth \(\to\) Quarter \(\to\) Half \(\to\) Full. At each stage, the upsampling shader executes.

Because an offset of 1 texel at the 1/8th resolution stage covers an area equivalent to 8 pixels on the original surface, small kernel offsets compound exponentially. An iteration depth of 4 to 5 passes yields an effective blur radius spanning hundreds of pixels, while requiring only 5 texture fetches on downsampling steps and 8 on upsampling steps.