Screen-Space Outline
by GameDev.net · GLSL ES 3.00 (WebGL2) · 25 Aug 2026
Run the shader to adjust these controls.
What it demonstrates
An outline in screen space is an edge detector applied to the finished frame. This one is a Sobel operator over luminance: eight samples around each fragment, weighted so the result approximates how fast brightness is changing, with the magnitude of that change deciding where a line is drawn.
Shader inputs
void mainImage(out vec4 fragColor, in vec2 fragCoord)
Called once per pixel. Write the colour to fragColor.
-
iResolutionvec3 - Viewport size in pixels (z is the pixel aspect ratio).
-
iTimefloat - Seconds since the shader started.
-
iTimeDeltafloat - Seconds since the previous frame.
-
iFrameRatefloat - Frames per second, smoothed.
-
iFrameint - Frames rendered since the start.
-
iMousevec4 - Mouse position: xy while held, zw of the last click.
-
iDatevec4 - Year, month, day, and seconds within the day.
-
iChannel0sampler2D - Texture bound to channel 0.
-
iChannel1sampler2D - Texture bound to channel 1.
-
iChannel2sampler2D - Texture bound to channel 2.
-
iChannel3sampler2D - Texture bound to channel 3.
-
iChannelResolutionvec3[4] - Pixel size of each bound channel texture.
-
iChannelTimefloat[4] - Playback time of each channel, in seconds.
-
iSampleRatefloat - Audio sample rate, always 44100.
Common
// Shared by every pass, because the Common pass is prepended to each of them
// before compilation. The scene lives here rather than in a committed image: a
// post effect in a game runs on a frame the renderer just produced, and a
// rendered source also means the highlights are genuinely brighter than white,
// which is what a bright-pass needs in order to find anything at all.
//
// This file is identical across the post-processing seeds on purpose, so the
// effects are comparable: the only thing that differs between them is the
// processing, not the picture.
float hash11(float n) {
return fract(sin(n * 91.7) * 43758.5453);
}
vec3 renderScene(vec2 uv, float time) {
vec3 color = mix(vec3(0.04, 0.05, 0.10), vec3(0.30, 0.17, 0.26), uv.y + 0.5);
// Sun: small, hard edged, and far above the displayable range so it blooms
// hard. Deliberately no painted-on glow around it — every halo in the final
// image has to come from the effect, or the effect demonstrates nothing.
vec2 sunPos = vec2(0.44, 0.19);
float sunDist = length(uv - sunPos);
color += vec3(3.4, 2.5, 1.4) * (1.0 - smoothstep(0.052, 0.062, sunDist));
// Distant ridge, a flat silhouette to separate sky from city.
float ridge = -0.13 + 0.045 * sin(uv.x * 4.1 + 1.3) + 0.022 * sin(uv.x * 9.7);
if (uv.y < ridge) {
color = mix(color, vec3(0.09, 0.08, 0.15), 0.92);
}
// Near skyline with lit windows: hard edges and fine bright detail, which
// is exactly what outline, pixelate and bloom effects need to chew on.
float cell = 0.20;
float column = floor((uv.x + 2.0) / cell);
float height = -0.34 + 0.20 * hash11(column);
if (uv.y < height) {
color = vec3(0.045, 0.045, 0.075);
float row = floor((uv.y + 0.5) * 26.0);
vec2 local = fract(vec2((uv.x + 2.0) / cell * 3.0, (uv.y + 0.5) * 26.0));
float lit = hash11(column * 13.7 + row * 7.13);
float blink = 0.75 + 0.25 * sin(time * 1.7 + column);
if (local.x > 0.28 && local.x < 0.72 && local.y > 0.30 && local.y < 0.72
&& lit > 0.42) {
color = vec3(1.75, 1.35, 0.72) * blink;
}
}
// A single beacon so the frame is never completely static.
vec2 beacon = vec2(-0.62, -0.14);
float pulse = 0.5 + 0.5 * sin(time * 2.4);
color += vec3(2.2, 0.5, 0.4) * pulse
* (1.0 - smoothstep(0.006, 0.016, length(uv - beacon)));
return color;
}
vec2 sceneUv(vec2 fragCoord) {
return (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
}
Buffer A
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
fragColor = vec4(renderScene(sceneUv(fragCoord), iTime), 1.0);
}
Main Image4
uniform float uThickness; // @param 0.5..4.0 = 1.4 "Thickness"
uniform float uThreshold; // @param 0.02..1.20 = 0.18 "Edge threshold"
uniform vec3 uOutlineColor; // @param 0.0..1.0 = 1.0, 0.92, 0.75 color "Outline"
uniform float uDarken; // @param 0.0..1.0 = 0.55 "Darken scene"
float luma(vec3 color) {
// Tone mapped first: the scene runs far above one, and an unmapped
// luminance makes the sun's edge the only edge in the frame.
vec3 mapped = color / (color + 1.0);
return dot(mapped, vec3(0.2126, 0.7152, 0.0722));
}
float sampleLuma(vec2 uv) {
return luma(texture(iChannel0, uv).rgb);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec2 tap = uThickness / iResolution.xy;
float tl = sampleLuma(uv + vec2(-tap.x, tap.y));
float tc = sampleLuma(uv + vec2(0.0, tap.y));
float tr = sampleLuma(uv + vec2(tap.x, tap.y));
float ml = sampleLuma(uv + vec2(-tap.x, 0.0));
float mr = sampleLuma(uv + vec2(tap.x, 0.0));
float bl = sampleLuma(uv + vec2(-tap.x, -tap.y));
float bc = sampleLuma(uv + vec2(0.0, -tap.y));
float br = sampleLuma(uv + vec2(tap.x, -tap.y));
float gx = (tr + 2.0 * mr + br) - (tl + 2.0 * ml + bl);
float gy = (tl + 2.0 * tc + tr) - (bl + 2.0 * bc + br);
float edge = smoothstep(uThreshold, uThreshold * 2.0, length(vec2(gx, gy)));
vec3 scene = texture(iChannel0, uv).rgb;
scene = scene / (scene + 1.0);
vec3 color = mix(scene, scene * (1.0 - uDarken), edge);
color = mix(color, uOutlineColor, edge);
fragColor = vec4(color, 1.0);
}
Inputs for this pass
- iChannel0 Buffer A
- iChannel1 Empty
- iChannel2 Empty
- iChannel3 Empty
Learn from this shader
How it works
The two Sobel kernels measure the horizontal and vertical rate of change separately, weighting the direct neighbours twice as much as the diagonals. The length of those two numbers together is the edge strength regardless of which way the edge runs. Thickness is the sample spacing, so widening it finds broader edges rather than drawing a thicker line, which is the honest behaviour of this technique and the reason production outlines are usually drawn from a dilated mask instead. The threshold is applied as a smoothstep so the line has a soft shoulder instead of aliasing, and the scene is tone mapped before luminance is taken, because unmapped values above one would make the sun's rim overwhelmingly the strongest edge in the frame.
Try changing
Lower Edge threshold until every lit window is outlined and the frame turns into a wireframe, then raise it until only the silhouettes survive. Widen Thickness and watch lines detach from their edges. Set Darken scene to one for a comic look where interiors go black behind their own outlines.
Using it in a game
Luminance edges are the cheap version and they have a real weakness: they cannot tell a colour change from a shape change, so a texture edge outlines exactly like a silhouette. Production outlines sample depth and normals instead, which is the same operator run over different inputs — the geometry of this shader transfers directly, only the buffer being sampled changes.
Explore the techniques
Continue with curated explanations and progressively related examples.
Discussion