Edge detection · Intermediate
Sobel edge detection
Real-time outlines from a G-buffer — run a Sobel filter over depth and normals, and edit the post-process live.
Edge detection is the backbone of a lot of stylised rendering — outlines, toon shading, "blueprint" looks, selection highlights. The naïve version runs a filter over the final image's brightness, which works but smears: it fires on textures and lighting as much as on real shape. The game-ready version runs the same filter over the geometry buffers instead — depth and normals — so you get clean lines that follow the actual silhouette and creases of the mesh, and nothing else.
This page is a small deferred-style playground. A fixed scene pass raymarches an SDF and writes three buffers; the shader below is the post-process that reads them. Use the buffers switch under the canvas to see the raw channels the filter reads — or mask to isolate the edge it produces — and drag to orbit the camera, with the + / − buttons (or pinch) to zoom.
The buffers you get
The scene is rendered once into a compact G-buffer, then handed to your post-process pass:
// available in the post-process (uv is 0..1, iTexel = 1.0 / resolution):
vec3 sampleAlbedo(vec2 uv); // the lit "beauty" render
vec3 sampleNormal(vec2 uv); // surface normal, decoded to -1..1
float sampleDepth (vec2 uv); // linear depth, 0 (near) .. 1 (far)
float sampleMask (vec2 uv); // 1.0 on the object, 0.0 on the background
You only write the read-and-process step — the same split a real engine uses, where the lighting pass fills the G-buffer and post-process shaders just consume it.
Sobel, live
The Sobel operator estimates the gradient of a field with two 3×3 kernels (one horizontal, one vertical); the length of that gradient is your edge strength. Here it runs over depth (which catches silhouettes) and over normals (which catch creases where depth barely changes). Edit the thresholds and watch it recompile:
Things to try
- See the mask on its own: replace the last three lines with
O = vec4(vec3(edge), 1.0);. - Depth only vs. normals only: drop the
normalEdgeterm (set its weight to0.0) and flip to the depth buffer — you'll see silhouettes stay but interior creases vanish. Then do the opposite. That contrast is the lesson. - Line weight: widen the taps by scaling
iTexel(e.g.* 2.0) for chunkier outlines. - Coloured lines: change the ink colour, or
mixtoward a hue instead of near-black.
Why depth and normals
Depth catches silhouette edges — where the surface drops away to something much further back — because those are large jumps in the depth buffer. But two faces of the same box meeting at a corner are at almost the same depth, so a depth-only filter slides right over them. Normals change sharply there even when depth doesn't, so adding a normal-difference term recovers those interior creases. Real outline shaders (from Guilty Gear to countless toon pipelines) combine both for exactly this reason.
The same kernel in HLSL, for reference — the maths is identical, only the sampling API differs:
float SobelDepth(Texture2D depthTex, SamplerState s, float2 uv, float2 texel)
{
float d[9];
[unroll] for (int i = 0; i < 9; i++) {
int2 o = int2(i % 3 - 1, i / 3 - 1);
d[i] = depthTex.Sample(s, uv + o * texel).r;
}
float gx = (d[2] + 2 * d[5] + d[8]) - (d[0] + 2 * d[3] + d[6]);
float gy = (d[6] + 2 * d[7] + d[8]) - (d[0] + 2 * d[1] + d[2]);
return length(float2(gx, gy));
}
Next in this family: the Laplacian (a single-kernel edge detector), and using edges as a mask to drive a toon ramp rather than just inking lines.