Sobel ink lines
A post pass that finds sharp changes in the image's brightness and inks them.
- 01GoalStylize
- 02TechniqueOutlines
- 03ExecutionSobel ink lines
Parameters
Copy for your platform
- GLSLCopy
- GodotCopy
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 UVPositionNormalScreen01 Scene color
lengthLengthlengthlengthlength In02 Sobel edge strength
smoothstepSmoothStepsmoothstepsmoothstepsmoothstep In03 Keep the strong edges
mixMixmixmixmix In04 Ink over the scene
How it works
The scene is rendered first, then this pass runs over every pixel of the screen and asks one question: how quickly does the image change here? Where it changes a lot, there is an edge, and the pass draws ink.
1. Scene color. The pass reads the finished scene from t_screen at the pixel. That color is what it will draw over.
2. Sobel edge strength. The pass reads the scene’s brightness at the eight pixels around this one, each turned into
one number: a tone map to squeeze the bright emissive shapes under 1, then the luminanceHow bright a color looks, from its linear red, green and blue weighted by the eye's sensitivity to each. Luminance How bright a color looks, from its linear red, green and blue weighted by the eye's sensitivity to each. Sobel operator A pair of 3×3 kernels that measure how fast an image changes across and up the screen. Combined, they give an edge strength per pixel, large where the image changes sharply. 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.Y = 0.2126 R + 0.7152 G + 0.0722 Bedge = length(vec2(gx, gy))out = Σ weight(i, j) · texel(x + i, y + j)
gx: -1 0 +1 gy: +1 +2 +1
-2 0 +2 0 0 0
-1 0 +1 -1 -2 -1
gx is the right column minus the left column, so it is large across a vertical edge; gy is the top row minus the
bottom row, large across a horizontal one. The middle taps count twice, which favours the pixel’s own row and column and
makes the result less noisy than a plain difference. The length of (gx, gy) is the edge strength in any direction,
and the step preview shows it as white lines on black.
3. Keep the strong edges. A narrow smoothstepA soft threshold. It is 0 below the first edge, 1 above the second, and follows an S-shaped curve with flat ends in between. Smoothstep A soft threshold. It is 0 below the first edge, 1 above the second, and follows an S-shaped curve with flat ends in between.t = clamp((x − e0) / (e1 − e0), 0, 1); s = t² (3 − 2t)
4. Ink over the scene. A mix toward the ink color where the edge passed draws the line.
Why brightness and not color
Comparing one number per pixel is cheaper than three, and the eye reads edges mostly from brightness anyway. The price is that two different colors of the same brightness get no line between them. Running the kernels on each channel and taking the largest catches those, at three times the math.
The width slider
The Line width spreads the eight taps apart by that many pixels. Wider spacing gives thicker lines, since an edge now shows up across 2 × width pixels. The centre pixel is never read, so a detail thinner than the spacing (a one-pixel wire, say) comes out as two parallel lines; real thick lines dilate the edge image in a second pass.
Good to know
- It inks what is on screen. Texture detail, specular highlights and the noise of a dithered image all have edges.
Raising the threshold drops them, along with the softer edges you wanted. Edge detection on depth and normals avoids
most of that, because those buffers only change at geometry. It needs the depth bufferAn image the size of the framebuffer that holds, for each pixel, how far away the nearest surface drawn so far is. The depth test uses it to hide what is behind.Rendering
Depth buffer
An image the size of the framebuffer that holds, for each pixel, how far away the nearest surface drawn so far is. The depth test uses it to hide what is behind.
Sources, so this card, which only needs the screen, leaves it for later. - Resolution-bound. One-pixel taps make one-pixel lines, which look thin on a high-density screen and shimmer when the camera moves. Scaling the width with the screen’s pixel density keeps them steady.
Make it in GLSL
A post pass. Render the scene into a texture first, then draw a full-screen triangle or quad with this fragment shader, the scene texture bound to t_screen and u_resolution set to the output size in pixels.
Render your scene into a framebuffer whose color attachment is a texture, ideally RGBA16F so values above 1 survive
(the shader tone-maps them for the edge test). Then bind the default framebuffer, bind that texture to t_screen with
linear filtering and clamp-to-edge wrapping, and draw one triangle that covers the screen.
The shader’s main() builds uv from the varying v_uv, so your full-screen vertex shader must pass 0..1 coordinates
there, origin at the bottom-left; v_position, v_normal and v_view must still be written, but this card never
reads them, so any value works as long as v_normal and v_view aren’t zero (main() normalizes them). Set
p_threshold, p_width and p_inkColor (decoded to linear). The pass encodes its output to sRGB itself.
To ink the depth and normal edges too, render those into textures in the scene pass and run the same eight taps on them; take the largest of the three edge values.
Make it in Godot
The shader is a spatial shader for a full-screen quad. Its vertex() places the quad over the whole screen wherever the node is, and t_screen reads the frame through hint_screen_texture. Godot's CompositorEffect can run the same math as a compute pass, with more setup.
Add a MeshInstance3D as a child of the Camera3D, give it a QuadMesh with Size set to 2 × 2, and in the QuadMesh’s
material slot create a new ShaderMaterial with this shader. The vertex function ignores the camera and writes the quad
straight into clip space, so it fills the screen at any distance, and depth_test_disabled keeps the scene from
hiding it.
Set Extra Cull Margin on the MeshInstance3D (Geometry section) to 16384 so it never gets culled. The three params appear under Shader Parameters.
In VisualShader, set the shader’s mode to Spatial and read the screen with a Texture2D node whose Source is Screen, eight times at offset UVs; the kernel is plain Add, Subtract and Multiply nodes, then a Length, a SmoothStep and a Mix. An Expression node holding the eight taps is far tidier than the node graph.
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// Outlines → Sobel ink lines, 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_threshold; // Threshold: 0.2 (0.02 to 1.0)uniform float p_width; // Line width (px): 1.0 (0.5 to 3.0)uniform vec3 p_inkColor; // Ink: vec3(0.08, 0.06, 0.1), sRGB; upload it decoded to linearuniform sampler2D t_screen; // the scene's color: linear HDR (RGBA16F), 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));}// Edges are found in brightness, one number per pixel, rather than in each color channel. The tone map first squeezes// the emissive shapes, far above 1.0, back under 1, or their edges would be the only ones strong enough to show.float brightness(vec2 uv) { vec3 c = texture(t_screen, uv).rgb; c = c / (1.0 + c); return dot(c, vec3(0.2126, 0.7152, 0.0722));}vec4 effect(Inputs i) { vec3 scene = texture(t_screen, i.uv).rgb; vec2 px = p_width / i.resolution; float tl = brightness(i.uv + px * vec2(-1.0, 1.0)); float t = brightness(i.uv + px * vec2(0.0, 1.0)); float tr = brightness(i.uv + px * vec2(1.0, 1.0)); float l = brightness(i.uv + px * vec2(-1.0, 0.0)); float r = brightness(i.uv + px * vec2(1.0, 0.0)); float bl = brightness(i.uv + px * vec2(-1.0, -1.0)); float b = brightness(i.uv + px * vec2(0.0, -1.0)); float br = brightness(i.uv + px * vec2(1.0, -1.0)); // The two Sobel kernels: right column minus left, top row minus bottom, the middle taps counted twice float gx = (tr + 2.0 * r + br) - (tl + 2.0 * l + bl); float gy = (tl + 2.0 * t + tr) - (bl + 2.0 * b + br); float edge = length(vec2(gx, gy)); float ink = smoothstep(p_threshold, p_threshold + 0.05, edge); vec3 color = mix(scene, p_inkColor, ink); 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);}// Outlines → Sobel ink lines, Godot 4. From Creative Center, MIT license.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 threshold : hint_range(0.02, 1.0, 0.01) = 0.2;uniform float width : hint_range(0.5, 3.0, 0.1) = 1.0;uniform vec3 inkColor : source_color = vec3(0.08, 0.06, 0.1);uniform sampler2D t_screen : hint_screen_texture, filter_linear, repeat_disable;struct 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);}// Edges are found in brightness, one number per pixel, rather than in each color channel. The tone map first squeezes// the emissive shapes, far above 1.0, back under 1, or their edges would be the only ones strong enough to show.float brightness(vec2 uv) { vec3 c = texture(t_screen, u_flipUv(uv)).rgb; c = c / (1.0 + c); return dot(c, vec3(0.2126, 0.7152, 0.0722));}vec4 effect(Inputs i) { vec3 scene = texture(t_screen, u_flipUv(i.uv)).rgb; vec2 px = width / i.resolution; float tl = brightness(i.uv + px * vec2(-1.0, 1.0)); float t = brightness(i.uv + px * vec2(0.0, 1.0)); float tr = brightness(i.uv + px * vec2(1.0, 1.0)); float l = brightness(i.uv + px * vec2(-1.0, 0.0)); float r = brightness(i.uv + px * vec2(1.0, 0.0)); float bl = brightness(i.uv + px * vec2(-1.0, -1.0)); float b = brightness(i.uv + px * vec2(0.0, -1.0)); float br = brightness(i.uv + px * vec2(1.0, -1.0)); // The two Sobel kernels: right column minus left, top row minus bottom, the middle taps counted twice float gx = (tr + 2.0 * r + br) - (tl + 2.0 * l + bl); float gy = (tl + 2.0 * t + tr) - (bl + 2.0 * b + br); float edge = length(vec2(gx, gy)); float ink = smoothstep(threshold, threshold + 0.05, edge); vec3 color = mix(scene, inkColor, ink); 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.