Velocity Buffer Tile Max Dilation in GLSL
This article details the TileMax and NeighborMax dilation algorithms introduced by Morgan McGuire et al. for computing maximum screen-space motion blur velocity tiles in GLSL. Screen-space motion blur requires gathering samples along a pixel's motion vector; however, fast-moving background or foreground elements can cast blurs across neighboring areas where the local velocity is zero. To resolve this without sampling over an impractically large radius per pixel, modern rendering pipelines downsample the full-resolution velocity buffer into coarse tiles and dilate these tiles over their adjacent neighbors to track the maximum potential motion vector affecting any given screen region.
The TileMax and NeighborMax Pipeline
Computing maximum tile dilation typically involves a two-pass post-processing approach:
- TileMax Pass: The full-resolution velocity buffer is divided into tiles of size \(K \times K\) pixels (typically \(K = 16\) or \(K = 20\), matching the target maximum blur radius). A shader downsamples each tile into a single velocity vector representing the maximum magnitude velocity within that tile.
- NeighborMax Pass (Dilation): The TileMax buffer is filtered so that each tile gathers the maximum velocity among itself and its 8 neighboring tiles (\(3 \times 3\) tile neighborhood). This dilates fast-moving silhouettes into neighboring tiles, providing the reconstruction pass with a conservative upper bound on the maximum velocity that could smear across any pixel.
Step 1: GLSL TileMax Pass
The TileMax shader reads all velocity vectors within a \(K \times K\) region and writes out the vector with the highest squared magnitude. It can be implemented efficiently as a compute shader using shared memory parallel reduction, or as a fragment shader using texture downsampling.
Below is a GLSL compute shader example computing a \(16 \times 16\) TileMax:
#version 450
layout(local_size_x = 16, local_size_y = 16) in;
layout(binding = 0) uniform sampler2D uVelocityTexture;
layout(binding = 1, rg16f) uniform writeonly image2D uTileMaxImage;
shared vec2 sharedVelocities[256];
void main() {
ivec2 pixelCoord = ivec2(gl_GlobalInvocationID.xy);
uint localIndex = gl_LocalInvocationIndex;
// Fetch velocity from full-resolution texture
vec2 vel = texelFetch(uVelocityTexture, pixelCoord, 0).xy;
sharedVelocities[localIndex] = vel;
barrier();
// Parallel reduction to find maximum velocity magnitude
for (uint stride = 128; stride > 0; stride >>= 1) {
if (localIndex < stride) {
vec2 v1 = sharedVelocities[localIndex];
vec2 v2 = sharedVelocities[localIndex + stride];
if (dot(v2, v2) > dot(v1, v1)) {
sharedVelocities[localIndex] = v2;
}
}
barrier();
}
// Write the maximum velocity of this tile to the TileMax texture
if (localIndex == 0) {
ivec2 tileCoord = ivec2(gl_WorkGroupID.xy);
imageStore(uTileMaxImage, tileCoord, vec4(sharedVelocities[0], 0.0, 0.0));
}
}Step 2: GLSL NeighborMax (Dilation) Pass
After obtaining the TileMax texture, the NeighborMax pass dilates the maximum velocity across adjacent tiles. For each tile, the shader checks a \(3 \times 3\) neighborhood of tiles and preserves the vector with the largest magnitude.
Below is a GLSL fragment shader implementation of the NeighborMax pass:
#version 450
in vec2 vTexCoord;
out vec2 outNeighborMax;
layout(binding = 0) uniform sampler2D uTileMaxTexture;
void main() {
ivec2 tileCoord = ivec2(gl_FragCoord.xy);
vec2 maxVelocity = vec2(0.0);
float maxMagSq = -1.0;
// Search 3x3 neighborhood of tiles
for (int y = -1; y <= 1; ++y) {
for (int x = -1; x <= 1; ++x) {
ivec2 neighborCoord = tileCoord + ivec2(x, y);
vec2 vel = texelFetch(uTileMaxTexture, neighborCoord, 0).xy;
float magSq = dot(vel, vel);
if (magSq > maxMagSq) {
maxMagSq = magSq;
maxVelocity = vel;
}
}
}
outNeighborMax = maxVelocity;
}Integration into the Motion Blur Reconstruction Pass
During final motion blur reconstruction, a pixel samples the dilated NeighborMax buffer using its coarse tile coordinates.
- If the NeighborMax velocity magnitude is below a minimum threshold (e.g., less than 0.5 pixels), the pixel is completely stationary and the blur step can be skipped entirely.
- If the NeighborMax vector is significant, it dictates the sampling distance and direction required to gather motion samples, correctly handling both foreground objects blurring across a stationary background and blurred boundaries spilling between tiles.