Skip to content
Lens · BlurIntermediate

Separable Gaussian

A soft lens blur over the whole frame, done as a row pass and a column pass.

UVScreenTextureScreen / post
  1. 01GoalLens
  2. 02TechniqueBlur
  3. 03ExecutionSeparable Gaussian
Test scene

Parameters

3.0
1.00

Copy for your platform

  • GLSLAdapt
  • GodotAdapt

Node chain

Built step by step, in GLSL style, with its node names.

Built step by step, in Godot style, with its node names.

No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.

No Unity version of this card yet, so the nodes show the GLSL names.

No Blender version of this card yet, so the nodes show the GLSL names.

  1. textureTexture2Dtexturetexturetexture
    UV
    Screen
    Texture

    01 Read the scene

    Pass main

  2. texture * expExpressiontexture * exptexture * exptexture * exp

    02 Blur the rows

    Pass blurH

  3. texture * expExpressiontexture * exptexture * exptexture * exp

    03 Blur the columns

    Pass main

  4. mixMixmixmixmix

    04 Fade it in

    Pass main

How it works

The card blurs the test scene with a Gaussian kernel: every pixel becomes a weighted average of the pixels around it, and the weights follow a bell curve, heaviest at the center and fading smoothly with distance. Instead of reading the whole square around each pixel, it runs two passes. The first blurs along the rows into a render target; the second blurs that result along the columns.

Step by step

  1. Read the scene. sharp is the unblurred color of the frame at this pixel, kept for the last step. The scene reaches the shader as t_screen in linear light, so the average below mixes light, not encoded numbers.
  2. Blur the rows. The first pass, blurH, walks reach() texels to the left and to the right. Each tap’s weight is exp(-k² / 2σ²), the Gaussian at that distance; p_sigma is its width in pixels. The weighted colors are added up and divided by the sum of the weights, which keeps a flat area exactly as bright as it was. The result, across, lands in the pass’s own target, t_blurH: a frame smeared sideways only.
  3. Blur the columns. The last pass does the same along y, reading t_blurH instead of the scene. Every tap of this pass already holds a horizontal average, so blurred is the full 2D Gaussian.
  4. Fade it in. mix(sharp, blurred, p_amount) blends the blur over the original, so 0 shows the sharp frame and 1 the full blur.

Why two passes give the same picture

A 2D Gaussian factors into a horizontal one times a vertical one: G(x, y) = G(x) · G(y). Weighting a square of texels by G(x) · G(y) and adding them up gives the same sum as first adding each row with G(x) and then adding those row sums down the column with G(y). The second pass reads exactly those row sums. Box blurs factor the same way; most other shapes, like a round bokeh disc, don’t.

The taps stop at three sigmas (reach()), where the curve has dropped to about 1% of its peak. Cutting it there loses a sliver of the tails; dividing by the weights actually used puts that energy back, so the image doesn’t darken.

Production notes

At the default width (σ = 3) each pass takes 19 taps: 19 reads for the row pass and 20 for the column pass, counting sharp. A single-pass 19×19 kernel would read 361 texels per pixel. The math is about 11 operations per tap (the weight’s exp, the offset, the multiply-add), around 420 per pixel; the reads cost more than the math on most GPUs. The price of splitting is a second full-screen pass and a half-float target the size of the frame, which matters more for memory bandwidth than for arithmetic.

Doubling sigma doubles the taps of each pass. Past a radius of a dozen pixels or so, blur a smaller copy of the frame instead: half the resolution covers twice the radius with a quarter of the pixels, and bloom does exactly that. The weights here are recomputed per tap to keep the code readable; a shipped blur often bakes them into a constant array for a fixed sigma, and pairs neighboring taps into one bilinear read to halve the count.

Blur in linear light, as here. Averaging sRGB-encoded values darkens the edges between bright and dark areas and dims small highlights, which is the classic muddy blur.

Make it in GLSL

Two fragment shaders, one per pass. Render the scene into a half-float texture, draw pass "blurH" into a second texture of the same size, then draw pass "main" to the canvas with both bound.

Each pass is a full-screen draw: a triangle that covers the viewport, with a vertex shader that writes v_uv from 0 to 1 across it. The copied code holds both fragment shaders, each with its own main().

  1. Draw your scene into a framebuffer whose color attachment is an RGBA16F texture, in linear light. This is t_screen.
  2. Create a second RGBA16F texture the same size and attach it to another framebuffer. Draw pass blurH into it with t_screen bound and u_resolution set to its size. This is t_blurH.
  3. Bind the default framebuffer (the canvas) and draw pass main with t_screen and t_blurH bound. It writes sRGB, clamped, ready for the canvas.
  4. Set p_sigma (the blur’s width in pixels) and p_amount (0 sharp, 1 fully blurred) on both programs; both declare them.

To blur a texture instead of the screen, bind it as t_screen and skip step 1. For a wide blur, run both passes on a half-size copy of the frame and draw the result scaled up, as Neon bloom does: each tap then spans two screen pixels.

Make it in Godot

Two spatial shaders. Pass "blurH" runs inside a SubViewport that renders the same world from a camera following yours; pass "main" runs on a full-screen quad in front of your camera and reads that SubViewport's texture as t_blurH.

The copied code is two shaders, one per pass. Godot draws a post pass as a mesh in front of the camera whose vertex shader stretches it over the screen, and a pass can only read an earlier one through a ViewportTexture.

  1. Add a MeshInstance3D with a QuadMesh (size 2×2) as a child of your Camera3D. Give it a ShaderMaterial with pass main, set its Visual Layer to 3 only, and raise Extra Cull Margin to its maximum so it is never culled.
  2. Add a SubViewport to the scene. Set its Size to the window’s size, turn on Use HDR 2D, and set Update Mode to Always. Leave Own World 3D off, so it sees your scene.
  3. Inside it, add a Camera3D, and give your main camera a RemoteTransform3D child pointing at it, so it follows every move. Match its FOV to your camera’s. Set its Environment to a copy of your WorldEnvironment’s with Tonemap > Mode Linear, Exposure 1 and Glow off, so the row pass stays linear and only your camera tone-maps.
  4. Inside the SubViewport too, add a second 2×2 quad with pass blurH, on Visual Layer 2 only.
  5. Set the cameras’ Cull Mask: the SubViewport’s camera sees layers 1 and 2, your camera sees layers 1 and 3.
  6. On the main material, set t_blurH to a ViewportTexture of the SubViewport. Set sigma and amount on both materials.

The main pass writes ALBEDO unshaded, so your Environment’s tone mapper and Glow run once, after it, on the blurred frame. The SubViewport’s camera has its own linear Environment so the row pass isn’t tone-mapped twice.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

separable-gaussian.glslGLSL
#version 300 es// Blur → Separable Gaussian, pass "blurH" (1 of 2), web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_sigma; // Width (sigma, px): 3.0 (0.5 to 8.0)uniform float p_amount; // Amount: 1.0 (0.0 to 1.0)uniform sampler2D t_screen; // the scene's color: linear HDR (RGBA16F), linear filter, clamp to edgeuniform sampler2D t_blurH; // pass "blurH"'s output: linear HDR (RGBA16F), the output's size, linear filter, clamp to edgein vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// A 2D Gaussian equals a horizontal 1D Gaussian followed by a vertical one, so two passes of 2r + 1 taps replace one// pass of (2r + 1)² taps. Each pass keeps its own loop instead of sharing a helper that takes a sampler2D: Godot rejects// a sampler argument that receives the screen texture in one call and a plain texture in another.// The taps reach three sigmas out, where the bell curve has fallen to about 1% of its peakint reach() {	return int(ceil(3.0 * p_sigma));}float bell(int k) {	return exp(-0.5 * float(k * k) / (p_sigma * p_sigma));}vec4 blurH(Inputs i) {	vec2 texel = vec2(1.0 / i.resolution.x, 0.0);	vec3 sum = vec3(0.0);	float total = 0.0;	for (int k = -reach(); k <= reach(); k++) {		float w = bell(k);		sum += texture(t_screen, i.uv + texel * float(k)).rgb * w;		total += w;	}	// Dividing by the weights actually summed keeps a flat color exactly as bright, although the curve is cut off	vec3 across = sum / total;	return vec4(across, 1.0);}vec4 effect(Inputs i) {	vec3 sharp = texture(t_screen, i.uv).rgb;	vec2 texel = vec2(0.0, 1.0 / i.resolution.y);	vec3 sum = vec3(0.0);	float total = 0.0;	for (int k = -reach(); k <= reach(); k++) {		float w = bell(k);		sum += texture(t_blurH, i.uv + texel * float(k)).rgb * w;		total += w;	}	vec3 blurred = sum / total;	vec3 color = mix(sharp, blurred, p_amount);	return vec4(color, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	// Linear HDR into this pass's target, read by the passes after it	fragColor = blurH(i);}#version 300 es// Blur → Separable Gaussian, pass "main" (2 of 2), web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_sigma; // Width (sigma, px): 3.0 (0.5 to 8.0)uniform float p_amount; // Amount: 1.0 (0.0 to 1.0)uniform sampler2D t_screen; // the scene's color: linear HDR (RGBA16F), linear filter, clamp to edgeuniform sampler2D t_blurH; // pass "blurH"'s output: linear HDR (RGBA16F), the output's size, linear filter, clamp to edgein vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// A 2D Gaussian equals a horizontal 1D Gaussian followed by a vertical one, so two passes of 2r + 1 taps replace one// pass of (2r + 1)² taps. Each pass keeps its own loop instead of sharing a helper that takes a sampler2D: Godot rejects// a sampler argument that receives the screen texture in one call and a plain texture in another.// The taps reach three sigmas out, where the bell curve has fallen to about 1% of its peakint reach() {	return int(ceil(3.0 * p_sigma));}float bell(int k) {	return exp(-0.5 * float(k * k) / (p_sigma * p_sigma));}vec4 blurH(Inputs i) {	vec2 texel = vec2(1.0 / i.resolution.x, 0.0);	vec3 sum = vec3(0.0);	float total = 0.0;	for (int k = -reach(); k <= reach(); k++) {		float w = bell(k);		sum += texture(t_screen, i.uv + texel * float(k)).rgb * w;		total += w;	}	// Dividing by the weights actually summed keeps a flat color exactly as bright, although the curve is cut off	vec3 across = sum / total;	return vec4(across, 1.0);}vec4 effect(Inputs i) {	vec3 sharp = texture(t_screen, i.uv).rgb;	vec2 texel = vec2(0.0, 1.0 / i.resolution.y);	vec3 sum = vec3(0.0);	float total = 0.0;	for (int k = -reach(); k <= reach(); k++) {		float w = bell(k);		sum += texture(t_blurH, i.uv + texel * float(k)).rgb * w;		total += w;	}	vec3 blurred = sum / total;	vec3 color = mix(sharp, blurred, p_amount);	return vec4(color, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
separable-gaussian.gdshaderGodot shader
// Blur → Separable Gaussian, pass "blurH" (1 of 2), Godot 4. From Creative Center, MIT license.// Godot has no pass chain on one material: draw this pass in its own SubViewport (or a CompositorEffect) and// give its ViewportTexture to the passes that read t_blurH.shader_type spatial;// A full-screen quad over the finished frame: no lighting, no culling, never hidden by depthrender_mode unshaded, cull_disabled, depth_test_disabled;uniform float sigma : hint_range(0.5, 8.0, 0.1) = 3.0;uniform float amount : hint_range(0.0, 1.0, 0.01) = 1.0;uniform sampler2D t_screen : hint_screen_texture, filter_linear, repeat_disable;uniform sampler2D t_blurH : filter_linear, repeat_disable; // pass "blurH"'s output: its SubViewport's ViewportTexturestruct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// Godot's textures start at the top-left and the effect's uv at the bottom-left: each sample flips v backvec2 u_flipUv(vec2 uv) {	return vec2(uv.x, 1.0 - uv.y);}// A 2D Gaussian equals a horizontal 1D Gaussian followed by a vertical one, so two passes of 2r + 1 taps replace one// pass of (2r + 1)² taps. Each pass keeps its own loop instead of sharing a helper that takes a sampler2D: Godot rejects// a sampler argument that receives the screen texture in one call and a plain texture in another.// The taps reach three sigmas out, where the bell curve has fallen to about 1% of its peakint reach() {	return int(ceil(3.0 * sigma));}float bell(int k) {	return exp(-0.5 * float(k * k) / (sigma * sigma));}vec4 blurH(Inputs i) {	vec2 texel = vec2(1.0 / i.resolution.x, 0.0);	vec3 sum = vec3(0.0);	float total = 0.0;	for (int k = -reach(); k <= reach(); k++) {		float w = bell(k);		sum += texture(t_screen, u_flipUv(i.uv + texel * float(k))).rgb * w;		total += w;	}	// Dividing by the weights actually summed keeps a flat color exactly as bright, although the curve is cut off	vec3 across = sum / total;	return vec4(across, 1.0);}vec4 effect(Inputs i) {	vec3 sharp = texture(t_screen, u_flipUv(i.uv)).rgb;	vec2 texel = vec2(0.0, 1.0 / i.resolution.y);	vec3 sum = vec3(0.0);	float total = 0.0;	for (int k = -reach(); k <= reach(); k++) {		float w = bell(k);		sum += texture(t_blurH, u_flipUv(i.uv + texel * float(k))).rgb * w;		total += w;	}	vec3 blurred = sum / total;	vec3 color = mix(sharp, blurred, amount);	return vec4(color, 1.0);}void vertex() {	// Put the shader on a MeshInstance3D with a 2x2 QuadMesh in front of the camera; it covers the screen wherever it is	POSITION = vec4(VERTEX.xy, 1.0, 1.0);}void fragment() {	Inputs i;	// SCREEN_UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(SCREEN_UV.x, 1.0 - SCREEN_UV.y);	// A flat quad facing the viewer, the same as the site's 2D preview	i.position = vec3(i.uv * 2.0 - 1.0, 0.0);	i.normal = vec3(0.0, 0.0, 1.0);	i.view = vec3(0.0, 0.0, 1.0);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = VIEWPORT_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = blurH(i);	ALBEDO = frag_out.rgb;}// Blur → Separable Gaussian, pass "main" (2 of 2), Godot 4. From Creative Center, MIT license.// The last pass: it reads the earlier passes' ViewportTextures through their t_ uniforms.shader_type spatial;// A full-screen quad over the finished frame: no lighting, no culling, never hidden by depthrender_mode unshaded, cull_disabled, depth_test_disabled;uniform float sigma : hint_range(0.5, 8.0, 0.1) = 3.0;uniform float amount : hint_range(0.0, 1.0, 0.01) = 1.0;uniform sampler2D t_screen : hint_screen_texture, filter_linear, repeat_disable;uniform sampler2D t_blurH : filter_linear, repeat_disable; // pass "blurH"'s output: its SubViewport's ViewportTexturestruct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// Godot's textures start at the top-left and the effect's uv at the bottom-left: each sample flips v backvec2 u_flipUv(vec2 uv) {	return vec2(uv.x, 1.0 - uv.y);}// A 2D Gaussian equals a horizontal 1D Gaussian followed by a vertical one, so two passes of 2r + 1 taps replace one// pass of (2r + 1)² taps. Each pass keeps its own loop instead of sharing a helper that takes a sampler2D: Godot rejects// a sampler argument that receives the screen texture in one call and a plain texture in another.// The taps reach three sigmas out, where the bell curve has fallen to about 1% of its peakint reach() {	return int(ceil(3.0 * sigma));}float bell(int k) {	return exp(-0.5 * float(k * k) / (sigma * sigma));}vec4 blurH(Inputs i) {	vec2 texel = vec2(1.0 / i.resolution.x, 0.0);	vec3 sum = vec3(0.0);	float total = 0.0;	for (int k = -reach(); k <= reach(); k++) {		float w = bell(k);		sum += texture(t_screen, u_flipUv(i.uv + texel * float(k))).rgb * w;		total += w;	}	// Dividing by the weights actually summed keeps a flat color exactly as bright, although the curve is cut off	vec3 across = sum / total;	return vec4(across, 1.0);}vec4 effect(Inputs i) {	vec3 sharp = texture(t_screen, u_flipUv(i.uv)).rgb;	vec2 texel = vec2(0.0, 1.0 / i.resolution.y);	vec3 sum = vec3(0.0);	float total = 0.0;	for (int k = -reach(); k <= reach(); k++) {		float w = bell(k);		sum += texture(t_blurH, u_flipUv(i.uv + texel * float(k))).rgb * w;		total += w;	}	vec3 blurred = sum / total;	vec3 color = mix(sharp, blurred, amount);	return vec4(color, 1.0);}void vertex() {	// Put the shader on a MeshInstance3D with a 2x2 QuadMesh in front of the camera; it covers the screen wherever it is	POSITION = vec4(VERTEX.xy, 1.0, 1.0);}void fragment() {	Inputs i;	// SCREEN_UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(SCREEN_UV.x, 1.0 - SCREEN_UV.y);	// A flat quad facing the viewer, the same as the site's 2D preview	i.position = vec3(i.uv * 2.0 - 1.0, 0.0);	i.normal = vec3(0.0, 0.0, 1.0);	i.view = vec3(0.0, 0.0, 1.0);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = VIEWPORT_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	ALBEDO = frag_out.rgb;}

No Unreal Engine 5 code for this card yet.

No Unity code for this card yet.

No Blender code for this card yet.

Further reading