Pixel outline
A one-pixel border around a pixel-art sprite, found from its own alpha.
- 01GoalStylize
- 02TechniqueSprite outline
- 03ExecutionPixel outline
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.
textureTexture2DParametertexturetexturetexture UVTexture01 The sprite
maxMaxmaxmaxmax In02 Any neighbour solid?
*Multiply*** In03 Only outside the sprite
mixMixmixmixmix In04 Line color there
How it works
The sprite is a little slime with a leaf on its head and transparent pixels around it. The shader draws a line in those transparent pixels, but only in the ones that touch the slime, so the line hugs its shape one sprite pixel wide.
1. The sprite. The shader reads the sprite at its own pixel. Where the slime is, own.a is 1; around it, 0.
2. Any neighbour solid? It reads the alpha one texelOne element of a texture, the texture's version of a pixel. A sample blends nearby texels or returns the nearest one. Texel One element of a texture, the texture's version of a pixel. A sample blends nearby texels or returns the nearest one.texel size = 1 / textureSize(sampler, 0)max. The offset of one texel is 1.0 / textureSize(t_sprite, 0), so the shader works for
any sprite size without a parameter. The step preview shows a slime one pixel fatter in every direction: everything
that is solid or touches something solid.
3. Only outside the sprite. Multiplying by 1.0 - own.a removes the sprite itself from that, leaving the ring of
transparent pixels that border it.
4. Line color there. A Blending Combining the color a fragment shader outputs with the color already in the framebuffer, instead of replacing it. Transparency, additive glow and multiply all come from it.mix swaps those pixels to the line color, at full alpha, and leaves the rest of the sprite
untouched. The card’s alpha blendingCombining the color a fragment shader outputs with the color already in the framebuffer, instead of replacing it. Transparency, additive glow and multiply all come from it.result = src × srcFactor + dst × dstFactor then puts the outlined sprite over the backdrop.
Why four neighbours
Testing only the left, right, up and down neighbours leaves out the pixel diagonal to each outer corner, so the line cuts corners slightly, the way pixel artists often draw it, and costs five reads. Adding the four diagonals fills those corners in, for a heavier, squarer line and nine reads. Both are one pixel thick; a thicker pixel outline needs neighbours further out, more reads again.
The padding pitfall
The line goes outside the art, into pixels the sprite must already have. A sprite cropped tight to its art has no room, and the outline is cut off at the quad’s edge: the shader only runs on the quad’s pixels. Leave at least one transparent pixel around the art (this slime has at least two), more for a thicker line.
In a sprite sheet (opens in a new tab)One image that holds many sprites or animation frames side by side. Each sprite reads only its own rectangle, but filtering and neighbour reads near its border can pick up the next frame's pixels. Sprite sheet One image that holds many sprites or animation frames side by side. Each sprite reads only its own rectangle, but filtering and neighbour reads near its border can pick up the next frame's pixels.
Filtering
The texture is sampled with nearest filtering, so every read returns one texel’s exact value and the outline stays crisp. With linear filtering, the alpha at the art’s border blends with the transparent pixels around it, and the line smears into a soft half-transparent band.
Make it in GLSL
The copy button gives a fragment shader for a sprite quad. Bind the sprite to t_sprite with NEAREST filtering and CLAMP_TO_EDGE wrapping, uploaded as SRGB8_ALPHA8 so it samples linear, and draw it with alpha blending. Its output is straight (not premultiplied) alpha.
Draw a quad with the sprite’s aspect ratio, its vertex shader passing 0..1 coordinates in v_uv with the origin at the
bottom-left. The other varyings must still be written; any value works as long as v_normal and v_view aren’t zero.
Upload the sprite as SRGB8_ALPHA8 so it samples as linear color, with NEAREST filters and CLAMP_TO_EDGE wrapping,
and bind it to t_sprite. The shader finds the texel size with textureSize, so it needs no size uniform. Set
p_lineColor decoded to linear, enable blending, and draw. The output is encoded to sRGB in main().
For a sprite sheet, pass the frame’s rectangle to the shader and add it to v_uv (or compute the UV per frame in the
vertex shader); the neighbour offsets stay one texel of the whole sheet, so the frames need a transparent gap between
them.
Make it in Godot
A canvas_item shader for a Sprite2D. It reads the sprite through its own t_sprite uniform, which you set to the same texture as the Sprite2D, because Godot's TEXTURE can't be read inside a function. It assumes HDR 2D is off (the default) and does its own sRGB decode and encode.
Select the Sprite2D, create a new ShaderMaterial in its Material slot (CanvasItem section) and paste the shader into a
new Shader. Under Shader Parameters, drag the same image that the Sprite2D shows into t_sprite and pick the line
color.
The shader sets nearest filtering on t_sprite itself, so the outline stays crisp whatever the project’s default
texture filter is. Leave at least one transparent pixel around the art in the image, or the line is cut off at the
sprite’s edge. To switch the outline on only for a selected sprite, add a uniform bool and multiply outline by it,
or swap the material from a script.
In VisualShader, use the CanvasItem mode: a Texture2DParameter feeding five Texture2D nodes (the pixel and its four neighbours, offset by the texel size from an Input node set to texture_pixel_size), Max nodes for the neighbours, a OneMinus and Multiply for the outside ring, and a Mix into Color and Alpha.
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// Sprite outline → Pixel outline, 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 vec3 p_lineColor; // Line color: vec3(1.0, 0.8, 0.2), sRGB; upload it decoded to linearuniform sampler2D t_sprite; // slime.png: sRGB color, uploaded as SRGB8_ALPHA8 so it samples linear; nearest 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));}// The four direct neighbours only, one texel away: the line is one sprite pixel thick and skips the pixel diagonal to a// corner, which softens corners the way pixel artists often do. Adding the four diagonals fills those corners in.vec4 effect(Inputs i) { vec2 texel = 1.0 / vec2(textureSize(t_sprite, 0)); vec4 own = texture(t_sprite, i.uv); float left = texture(t_sprite, i.uv - vec2(texel.x, 0.0)).a; float right = texture(t_sprite, i.uv + vec2(texel.x, 0.0)).a; float down = texture(t_sprite, i.uv - vec2(0.0, texel.y)).a; float up = texture(t_sprite, i.uv + vec2(0.0, texel.y)).a; float neighbors = max(max(left, right), max(down, up)); float outline = neighbors * (1.0 - own.a); vec4 result = mix(own, vec4(p_lineColor, 1.0), outline); return result;}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 fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), frag_out.a);}// Sprite outline → Pixel outline, Godot 4. From Creative Center, MIT license.shader_type canvas_item;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded, blend_mix;uniform vec3 lineColor : source_color = vec3(1.0, 0.8, 0.2);uniform sampler2D t_sprite : source_color, filter_nearest, repeat_disable; // the sprite: the Sprite2D's own texture again (TEXTURE can't be read inside a function)struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};// Godot's 2D renderer works in sRGB (HDR 2D off, the default), so the frame decodes color params and sRGB// textures to linear itself and encodes the result. With HDR 2D on, make these return c unchanged.vec3 u_toLinear(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec4 u_toLinear4(vec4 c) { return vec4(u_toLinear(c.rgb), c.a);}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));}// 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);}// The four direct neighbours only, one texel away: the line is one sprite pixel thick and skips the pixel diagonal to a// corner, which softens corners the way pixel artists often do. Adding the four diagonals fills those corners in.vec4 effect(Inputs i) { vec2 texel = 1.0 / vec2(textureSize(t_sprite, 0)); vec4 own = u_toLinear4(texture(t_sprite, u_flipUv(i.uv))); float left = u_toLinear4(texture(t_sprite, u_flipUv(i.uv - vec2(texel.x, 0.0)))).a; float right = u_toLinear4(texture(t_sprite, u_flipUv(i.uv + vec2(texel.x, 0.0)))).a; float down = u_toLinear4(texture(t_sprite, u_flipUv(i.uv - vec2(0.0, texel.y)))).a; float up = u_toLinear4(texture(t_sprite, u_flipUv(i.uv + vec2(0.0, texel.y)))).a; float neighbors = max(max(left, right), max(down, up)); float outline = neighbors * (1.0 - own.a); vec4 result = mix(own, vec4(u_toLinear(lineColor), 1.0), outline); return result;}void fragment() { Inputs i; // UV starts at the top-left; the effect expects GLSL's bottom-left i.uv = vec2(UV.x, 1.0 - 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 = 1.0 / SCREEN_PIXEL_SIZE; i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y); vec4 frag_out = effect(i); COLOR = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), frag_out.a);}No Unreal Engine 5 code for this card yet.
No Unity code for this card yet.
No Blender code for this card yet.