Separable Gaussian
A soft lens blur over the whole frame, done as a row pass and a column pass.
- 01GoalLens
- 02TechniqueBlur
- 03ExecutionSeparable Gaussian
Parameters
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.
textureTexture2Dtexturetexturetexture UVScreenTexture01 Read the scene
Pass main
texture * expExpressiontexture * exptexture * exptexture * exp In02 Blur the rows
Pass blurH
texture * expExpressiontexture * exptexture * exptexture * exp In03 Blur the columns
Pass main
mixMixmixmixmix In04 Fade it in
Pass main
How it works
The card blurs the test scene with a Gaussian kernelA small grid of weights. Each output pixel is the weighted sum of its neighbors, which blurs, sharpens or finds edges depending on the weights. Kernel A small grid of weights. Each output pixel is the weighted sum of its neighbors, which blurs, sharpens or finds edges depending on the weights. Render target A texture a draw writes into instead of the screen, so a later pass can read the result. Blur, bloom and every multi-pass effect use them.out = Σ weight(i, j) · texel(x + i, y + j)
Step by step
- Read the scene.
sharpis the unblurred color of the frame at this pixel, kept for the last step. The scene reaches the shader ast_screenin linear light, so the average below mixes light, not encoded numbers. - Blur the rows. The first pass,
blurH, walksreach()texelsOne element of a texture, the texture's version of a pixel. A sample blends nearby texels or returns the nearest one.FundamentalTexel
One element of a texture, the texture's version of a pixel. A sample blends nearby texels or returns the nearest one.
texel size = 1 / textureSize(sampler, 0)Sourcesto the left and to the right. Each tap’s weight is- texture (opens in a new tab)Reference
- texelFetch (opens in a new tab)Reference
exp(-k² / 2σ²), the Gaussian at that distance;p_sigmais 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. - Blur the columns. The last pass does the same along y, reading
t_blurHinstead of the scene. Every tap of this pass already holds a horizontal average, soblurredis the full 2D Gaussian. - 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().
- Draw your scene into a framebuffer whose color attachment is an
RGBA16Ftexture, in linear light. This ist_screen. - Create a second
RGBA16Ftexture the same size and attach it to another framebuffer. Draw passblurHinto it witht_screenbound andu_resolutionset to its size. This ist_blurH. - Bind the default framebuffer (the canvas) and draw pass
mainwitht_screenandt_blurHbound. It writes sRGB, clamped, ready for the canvas. - Set
p_sigma(the blur’s width in pixels) andp_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.
- Add a
MeshInstance3Dwith aQuadMesh(size 2×2) as a child of yourCamera3D. Give it aShaderMaterialwith passmain, set its Visual Layer to 3 only, and raise Extra Cull Margin to its maximum so it is never culled. - Add a
SubViewportto 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. - Inside it, add a
Camera3D, and give your main camera aRemoteTransform3Dchild 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. - Inside the
SubViewporttoo, add a second 2×2 quad with passblurH, on Visual Layer 2 only. - Set the cameras’ Cull Mask: the SubViewport’s camera sees layers 1 and 2, your camera sees layers 1 and 3.
- On the
mainmaterial, sett_blurHto aViewportTextureof theSubViewport. Setsigmaandamounton 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
#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);}// 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.