Skip to main content
GDN8 PATIO

Comparing Various Blur Implementations and Relative Load

This note compares ways to build Blur in a Fragment Shader. Even with the same texture sample count the look changes with sample directions and weights. It covers one-directional convolution and kernels that sample the neighborhood in a single pass. The goal is not a quality ranking. It is a comparison for choosing an implementation from the blur shape you need and the load you can accept.

How the listed code is handled

Techniques such as Blur or Gaussian Convolution and the individual source that expresses them need to be kept apart. Explaining a common formula or algorithm is not the same as copying an external shader.

The code here is rewritten for comparison from the rules below. Third-party shaders are not reproduced.

  • The binomial kernel approximating a Gaussian normalizes the integer sequence from Pascal's triangle so the weights sum to 1
  • Samples on a circle are built from regular-polygon coordinates
  • Spiral coordinates are computed from the golden angle and a square-root radius

Implementations whose origin or license cannot be confirmed are not included. When bringing external library code into a project check that license separately. For example MIT License code that is copied or modified and distributed usually requires keeping the copyright and permission notices.

  • ※ The explanation here is a general arrangement for publishing an article. It is not a legal judgment for a specific project.
  • ※ Linking something as a reference is separate from being allowed to reproduce the code at that link.

How to read relative load

Relative load is an estimate that sets one pass with 5 texture samples as 1. It is not measured GPU time.

Implementation Texture samples / Pass Pass Relative load Sampling
BLUR_5_BINOMIAL 5 2 1 / Pass 1 direction
BLUR_9_BINOMIAL 9 2 1.8 / Pass 1 direction
BLUR_5_DIAGONAL 5 1 1 4 diagonals
BLUR_7_HEX 7 1 1.4 6 directions
BLUR_9_OCTAGON 9 1 1.8 8 directions
BLUR_9_SPIRAL 9 1 1.8 Spiral
BLUR_17_MULTI_RING 17 1 3.4 2 Rings

The pass counts for BLUR_5_BINOMIAL and BLUR_9_BINOMIAL assume use as a 2D blur. Split into horizontal and vertical passes and the total texture samples become 10 and 18 respectively.

  • ※ Render Target switches the original scene texture format and sampling cache are not included in relative load.
  • ※ At Device Pixel Ratio 2 the pixel count processed is 4× that of DPR 1.
  • ※ Judge the final load by measuring GPU time on the target device.

Shared assumptions

The following assumes WebGL 2 / GLSL ES 3.00. A texel is one pixel on the texture.

vec2 texel = 1.0 / textureSize;

For a one-directional kernel pass the move amount of one sample into stepUv.

vec2 stepX = vec2(texel.x, 0.0) * radius;
vec2 stepY = vec2(0.0, texel.y) * radius;

The eight directions used by 2D kernels are shared. Diagonal directions are normalized so their length is 1.

const float INV_SQRT_2 = 0.70710678118;

const vec2 OCTAGON[8] = vec2[8](
	vec2(1.0, 0.0),
	vec2(INV_SQRT_2, INV_SQRT_2),
	vec2(0.0, 1.0),
	vec2(-INV_SQRT_2, INV_SQRT_2),
	vec2(-1.0, 0.0),
	vec2(-INV_SQRT_2, -INV_SQRT_2),
	vec2(0.0, -1.0),
	vec2(INV_SQRT_2, -INV_SQRT_2)
);

Texture edges are affected by wrap mode. For a screen Render Target where you do not want color to repeat outside use ClampToEdgeWrapping.

BLUR_5_BINOMIAL

Relative load: 1 / Pass

A 5-tap kernel that divides Pascal's triangle weights 1, 4, 6, 4, 1 by their sum 16. The center weight is 6 / 16, one pixel out is 4 / 16, and two pixels out is 1 / 16.

vec3 blur5Binomial(
	sampler2D image,
	vec2 uv,
	vec2 stepUv
) {
	vec3 color = texture(image, uv).rgb * 0.375;
	color += texture(image, uv + stepUv).rgb * 0.25;
	color += texture(image, uv - stepUv).rgb * 0.25;
	color += texture(image, uv + stepUv * 2.0).rgb * 0.0625;
	color += texture(image, uv - stepUv * 2.0).rgb * 0.0625;
	return color;
}

One pass stretches only along stepUv. For a 2D Gaussian approximation store the horizontal result in a Render Target then apply a vertical pass to that texture.

BLUR_9_BINOMIAL

Relative load: 1.8 / Pass

At 9 taps divide 1, 8, 28, 56, 70, 56, 28, 8, 1 by their sum 256. Weights continue more smoothly farther out than with 5 taps.

vec3 blur9Binomial(
	sampler2D image,
	vec2 uv,
	vec2 stepUv
) {
	vec3 color = texture(image, uv).rgb * 0.2734375;
	color += texture(image, uv + stepUv).rgb * 0.21875;
	color += texture(image, uv - stepUv).rgb * 0.21875;
	color += texture(image, uv + stepUv * 2.0).rgb * 0.109375;
	color += texture(image, uv - stepUv * 2.0).rgb * 0.109375;
	color += texture(image, uv + stepUv * 3.0).rgb * 0.03125;
	color += texture(image, uv - stepUv * 3.0).rgb * 0.03125;
	color += texture(image, uv + stepUv * 4.0).rgb * 0.00390625;
	color += texture(image, uv - stepUv * 4.0).rgb * 0.00390625;
	return color;
}

The coefficients sum to 1.0. Uniform color input keeps brightness unchanged so missing kernel normalization is also easy to spot.

BLUR_5_DIAGONAL

Relative load: 1

Samples the center and four diagonals with equal weight. Five texture samples are enough to spread into 2D.

vec3 blur5Diagonal(
	sampler2D image,
	vec2 uv,
	vec2 texel,
	float radius
) {
	vec2 offset = texel * radius * INV_SQRT_2;
	vec3 color = texture(image, uv).rgb * 0.2;
	color += texture(image, uv + vec2(offset.x, offset.y)).rgb * 0.2;
	color += texture(image, uv + vec2(-offset.x, offset.y)).rgb * 0.2;
	color += texture(image, uv + vec2(-offset.x, -offset.y)).rgb * 0.2;
	color += texture(image, uv + vec2(offset.x, -offset.y)).rgb * 0.2;
	return color;
}

It spreads with few samples. At larger radii the diagonal shape becomes visible. Use a small radius or apply it several times to a downscaled texture.

BLUR_7_HEX

Relative load: 1.4

Samples the center and six directions at 60-degree intervals. With center weight 0.25 and 0.125 on each rim sample the total is 1.0.

vec3 blur7Hex(
	sampler2D image,
	vec2 uv,
	vec2 texel,
	float radius
) {
	const float SIN_60 = 0.86602540378;
	vec2 scale = texel * radius;
	vec3 color = texture(image, uv).rgb * 0.25;

	color += texture(image, uv + scale * vec2(1.0, 0.0)).rgb * 0.125;
	color += texture(image, uv + scale * vec2(0.5, SIN_60)).rgb * 0.125;
	color += texture(image, uv + scale * vec2(-0.5, SIN_60)).rgb * 0.125;
	color += texture(image, uv + scale * vec2(-1.0, 0.0)).rgb * 0.125;
	color += texture(image, uv + scale * vec2(-0.5, -SIN_60)).rgb * 0.125;
	color += texture(image, uv + scale * vec2(0.5, -SIN_60)).rgb * 0.125;

	return color;
}

The spread is closer to a circle than axis-aligned or four-diagonal sampling. At large radii a hexagonal outline remains and can be used as a bokeh shape.

BLUR_9_OCTAGON

Relative load: 1.8

Samples the center and an eight-direction rim. The loop is a fixed 8 iterations so the shader compiler can unroll it easily.

vec3 blur9Octagon(
	sampler2D image,
	vec2 uv,
	vec2 texel,
	float radius
) {
	vec2 scale = texel * radius;
	vec3 color = texture(image, uv).rgb * 0.2;

	for(int i = 0; i < 8; i++) {
		color += texture(image, uv + OCTAGON[i] * scale).rgb * 0.1;
	}

	return color;
}

Because every rim sample sits at the same radius a ring can appear around edges. When increasing the center weight reduce the rim side and keep the total at 1.0.

BLUR_9_SPIRAL

Relative load: 1.8

Samples are spread from the center outward instead of sitting on a single ring. Coordinates are precomputed from the formulas below and stored as constants.

angle(i) = i × π × (3 − √5)
radius(i) = √(i / 8)

No trigonometric functions are evaluated inside the Fragment Shader.

const vec2 SPIRAL_9[9] = vec2[9](
	vec2(0.0000000, 0.0000000),
	vec2(-0.2606993, 0.2388219),
	vec2(0.0437129, -0.4980855),
	vec2(0.3725912, 0.4859792),
	vec2(-0.6962976, -0.1231652),
	vec2(0.6670471, -0.4243208),
	vec2(-0.2248239, 0.8363338),
	vec2(-0.4311390, -0.8301320),
	vec2(0.9393213, 0.3430386)
);

vec3 blur9Spiral(
	sampler2D image,
	vec2 uv,
	vec2 texel,
	float radius
) {
	vec3 color = vec3(0.0);

	for(int i = 0; i < 9; i++) {
		color += texture(
			image,
			uv + SPIRAL_9[i] * texel * radius
		).rgb;
	}

	return color / 9.0;
}

Fixed-axis and single-ring shapes can be reduced. Covering a wide disk with only 9 points still shows sample bias at large radii.

BLUR_17_MULTI_RING

Relative load: 3.4

Uses the center plus two layers of eight-direction rings. Heavier weight sits on the inner ring and the outer ring extends the blur.

vec3 blur17MultiRing(
	sampler2D image,
	vec2 uv,
	vec2 texel,
	float radius
) {
	vec2 scale = texel * radius;
	vec3 color = texture(image, uv).rgb * 0.2;

	for(int i = 0; i < 8; i++) {
		color += texture(
			image,
			uv + OCTAGON[i] * scale * 0.45
		).rgb * 0.075;

		color += texture(
			image,
			uv + OCTAGON[i] * scale
		).rgb * 0.025;
	}

	return color;
}

Weights are 0.2 + 0.075 × 8 + 0.025 × 8 = 1.0. Before going up to 25 taps check whether 17 taps plus a lower-resolution Render Target already give the look you need.

How to choose

  • Small uniform blur: BLUR_5_BINOMIAL in 2 passes horizontal and vertical
  • Stretch one direction longer: BLUR_9_BINOMIAL
  • Spread into 2D with 5 taps: BLUR_5_DIAGONAL
  • Polygonal bokeh: BLUR_7_HEX or BLUR_9_OCTAGON
  • Reduce fixed-direction shapes: BLUR_9_SPIRAL
  • Spread inward and outward in one pass: BLUR_17_MULTI_RING

For large blur lower the Render Target resolution before growing the kernel. Halving width and height cuts processed pixels to one quarter. Several passes of a small kernel at low resolution can be lighter than one large kernel at full resolution.

References

  • ※ This article is an AI translation of the Japanese original.