Skip to content
Stylize · Sprite outlineBeginner

Pixel outline

A one-pixel border around a pixel-art sprite, found from its own alpha.

UVTexture2D / sprite
  1. 01GoalStylize
  2. 02TechniqueSprite outline
  3. 03ExecutionPixel outline
Sample sprite

Parameters

#ffcc33

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. textureTexture2DParametertexturetexturetexture
    UV
    Texture

    01 The sprite

  2. maxMaxmaxmaxmax

    02 Any neighbour solid?

  3. *Multiply***

    03 Only outside the sprite

  4. mixMixmixmixmix

    04 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 texel to the left, right, below and above, and keeps the largest with 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 mix swaps those pixels to the line color, at full alpha, and leaves the rest of the sprite untouched. The card’s alpha blending 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) the same problem has a twin: the neighbour of a frame’s edge pixel is the first pixel of the next frame. If the frames touch, solid pixels from the next frame leak an outline into this one. Pad every frame, or pack the sheet with spacing between frames.

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

pixel-outline.fragGLSL
#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);}
pixel-outline.gdshaderGodot shader
// 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.

Further reading