Skip to main content
GameDev.net gamedev.net

Screen-Space Outline

by GameDev.net · GLSL ES 3.00 (WebGL2) · 25 Aug 2026

Use in your engine

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.

Source Revision 1

Author notes are linked to specific lines.

Shader inputs

void mainImage(out vec4 fragColor, in vec2 fragCoord)

Called once per pixel. Write the colour to fragColor.

iResolution vec3
Viewport size in pixels (z is the pixel aspect ratio).
iTime float
Seconds since the shader started.
iTimeDelta float
Seconds since the previous frame.
iFrameRate float
Frames per second, smoothed.
iFrame int
Frames rendered since the start.
iMouse vec4
Mouse position: xy while held, zw of the last click.
iDate vec4
Year, month, day, and seconds within the day.
iChannel0 sampler2D
Texture bound to channel 0.
iChannel1 sampler2D
Texture bound to channel 1.
iChannel2 sampler2D
Texture bound to channel 2.
iChannel3 sampler2D
Texture bound to channel 3.
iChannelResolution vec3[4]
Pixel size of each bound channel texture.
iChannelTime float[4]
Playback time of each channel, in seconds.
iSampleRate float
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
1 uniform float uThickness; // @param 0.5..4.0 = 1.4 "Thickness"
2 uniform float uThreshold; // @param 0.02..1.20 = 0.18 "Edge threshold"
3 uniform vec3 uOutlineColor; // @param 0.0..1.0 = 1.0, 0.92, 0.75 color "Outline"
4 uniform float uDarken; // @param 0.0..1.0 = 0.55 "Darken scene"
5
6 float luma(vec3 color) {
7 // Tone mapped first: the scene runs far above one, and an unmapped
8 // luminance makes the sun's edge the only edge in the frame.
9 vec3 mapped = color / (color + 1.0);
10 return dot(mapped, vec3(0.2126, 0.7152, 0.0722));
11 }
12
13 float sampleLuma(vec2 uv) {
14 return luma(texture(iChannel0, uv).rgb);
15 }
16
17 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
18 vec2 uv = fragCoord / iResolution.xy;
19 vec2 tap = uThickness / iResolution.xy;
20
21 float tl = sampleLuma(uv + vec2(-tap.x, tap.y));
22 float tc = sampleLuma(uv + vec2(0.0, tap.y));
23 float tr = sampleLuma(uv + vec2(tap.x, tap.y));
24 float ml = sampleLuma(uv + vec2(-tap.x, 0.0));
25 float mr = sampleLuma(uv + vec2(tap.x, 0.0));
26 float bl = sampleLuma(uv + vec2(-tap.x, -tap.y));
27 float bc = sampleLuma(uv + vec2(0.0, -tap.y));
28 float br = sampleLuma(uv + vec2(tap.x, -tap.y));
29
30 float gx = (tr + 2.0 * mr + br) - (tl + 2.0 * ml + bl);
31 float gy = (tl + 2.0 * tc + tr) - (bl + 2.0 * bc + br);
32 float edge = smoothstep(uThreshold, uThreshold * 2.0, length(vec2(gx, gy)));
33
34 vec3 scene = texture(iChannel0, uv).rgb;
35 scene = scene / (scene + 1.0);
36 vec3 color = mix(scene, scene * (1.0 - uDarken), edge);
37 color = mix(color, uOutlineColor, edge);
38 fragColor = vec4(color, 1.0);
39 }
40

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.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...