Skip to content
Stylize · OutlinesIntermediate

Sobel ink lines

A post pass that finds sharp changes in the image's brightness and inks them.

UVPositionNormalScreenScreen / post
  1. 01GoalStylize
  2. 02TechniqueOutlines
  3. 03ExecutionSobel ink lines
Test scene

Parameters

0.20
1.0
#140f1a

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.

  1. textureTexture2Dtexturetexturetexture
    UV
    Position
    Normal
    Screen

    01 Scene color

  2. lengthLengthlengthlengthlength

    02 Sobel edge strength

  3. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    03 Keep the strong edges

  4. mixMixmixmixmix

    04 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 luminance weights. The Sobel operator combines them with two 3×3 kernels:

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 smoothstep above the Threshold keeps the strong edges and drops the faint ones: the soft shading across the sphere changes slowly and stays under it, while the silhouettes and the checker squares change at once and pass.

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 buffer, 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

sobel-ink-lines.fragGLSL
#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);}
sobel-ink-lines.gdshaderGodot shader
// 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.

Further reading