Free shaders · CC0 · Godot 4.7

Sprite Outline Shader

A crisp pixel outline for any 2D sprite, drawn outside or inside the art, that stays clean on sprite sheets and atlases.

Drop it into your project

  1. Copy sprite_outline.gdshader, sprite_outline.gdshader.uid and sprite_outline_material.tres into your project, for example into res://shaders/. Keep the .uid file next to the shader. The material finds the shader by that ID, so it works in any folder you choose.
  2. Select your Sprite2D or AnimatedSprite2D. In the Inspector, open CanvasItem > Material and drag sprite_outline_material.tres into the Material slot. If you only copied the shader, create a New ShaderMaterial there and drag the .gdshader into its Shader slot instead.
  3. For pixel art, set CanvasItem > Texture > Filter to Nearest on the node, or set it once for the whole project in Project Settings > Rendering > Textures > Canvas Textures > Default Texture Filter.
  4. Check that the art has transparent space around it. An outside outline is drawn on the empty pixels next to the art, so it needs at least width empty pixels on every side inside the image or atlas frame. If you cannot add space, turn on inside.
  5. Open Shader Parameters on the material and set the width, colour and pattern. Every sprite that uses the same .tres shares these settings. To give one sprite its own, click the arrow next to its material and choose Make Unique.

Parameters

NameTypeDefaultWhat it does
widthint (1 to 8)1Thickness in texels, the pixels of the sprite's own texture. On a sprite scaled up 4 times, width 1 shows as a 4 pixel line on screen, so the outline always matches the art's pixel grid.
colorvec4 (source_color)Color(1, 1, 1, 1)Outline colour. Lower the alpha for a see-through outline, or set it to 0 to hide the outline without removing the material. The node's modulate is applied on top, so fading the sprite fades its outline too.
insideboolfalseOff draws the outline on the empty pixels around the art, so the sprite looks bigger. On recolours the art's own edge pixels instead, so the silhouette never grows and no padding is needed.
patternint (enum)0 (Diamond)Which neighbours count as touching. Diamond leaves the corners out, Circle rounds them, Square keeps them. At width 1, Circle and Square are the same and Diamond is the classic pixel-art outline with no diagonal pixels.

How it works

The shader looks at every pixel of the sprite and at the pixels around it in the texture. If an empty pixel touches the art, it paints that pixel with the outline colour. The rest of the work is choosing which neighbours to check, and never reading pixels that belong to a different sprite.

Stepping one texel at a time

A texel is one pixel of a texture. In a canvas_item shader, Godot gives you the size of one texel in UV units as TEXTURE_PIXEL_SIZE. For the 32 by 40 pixel knight in the demo it is vec2(1.0 / 32.0, 1.0 / 40.0). Adding a whole multiple of it to UV moves exactly that many texels, whatever size the sprite is on screen. That is why width counts texels: the outline lines up with the art's pixels at any scale.

Before it samples anything, the shader snaps UV to the centre of the texel it is in. A sprite scaled 10 times covers each texel with 100 screen pixels, and snapping makes all of them take the same samples and reach the same answer. The outline edge then falls exactly on the texel grid. A texel counts as solid when its alpha is at least 0.5, set by ALPHA_CUT near the top of the file.

Diamond, circle and square

The shader visits every offset from -width to width on both axes and asks whether that neighbour is solid. pattern decides which offsets take part. Diamond keeps offsets where abs(x) + abs(y) <= width, which leaves the corners notched. Circle keeps those where x * x + y * y <= width * width + width, which for whole numbers is the same as a distance under width + 0.5 and gives rounded corners. Square keeps every offset, so corners come out square. The differences grow with width: watch the gem in the preview change shape as the pattern changes at width 3.

gdshader
bool in_pattern(int x, int y, int w) {
	if (pattern == 0) {
		return abs(x) + abs(y) <= w;          // Diamond
	}
	if (pattern == 1) {
		return x * x + y * y <= w * w + w;    // Circle
	}
	return true;                             // Square
}

Outside and inside

In the default mode the search runs on empty texels: if any neighbour in the pattern is solid, the empty texel becomes outline. The outline grows outward, so the sprite looks width texels bigger on every side, and it needs that much empty room in the image. With inside on, the search runs on solid texels instead: if any neighbour is empty, that texel of the art is recoloured. The silhouette stays the same size and no padding is needed, but the outline now covers your own edge pixels. Any part that is 2 × width texels thick or thinner is recoloured all the way through. The sword of the demo knight is three texels wide, so it keeps its light core only at width 1.

Why atlases bleed, and how this shader stops it

When a sprite comes from a sprite sheet, through an AtlasTexture, a Sprite2D region or the frames of an AnimatedSprite2D, TEXTURE is the whole sheet. TEXTURE_PIXEL_SIZE is the size of one texel of the sheet, and UV only covers the current frame's rectangle. A simple outline shader that steps a few texels past the frame's edge reads the next frame's pixels and draws stray bars of outline along that edge.

Godot tells the shader where the frame is through REGION_RECT: its position and size in UV, as (x, y, width, height). This shader treats every sample outside that rectangle as empty, so a neighbouring frame is never read. It also takes the absolute size, because flip_h and flip_v make the stored size negative while the rectangle stays where it is. The demo shows it. gem_sheet.png has a row of solid stone wall tiles directly under the four gem frames, touching their bottom edge, the way tiles end up on a packed game atlas. The outline on the gem is 3 texels wide and the gem has 8 empty texels below it, so the bottom 3 rows of every frame are within reach of the stone. A copy of the shader without the region test draws a solid white bar across the bottom of every gem frame. This one draws nothing there. Flipped sprites were checked the same way.

Fading with the sprite

In vertex(), COLOR holds the vertex colour multiplied by the node's modulate and self_modulate. The shader passes it to fragment() in a varying. In outside mode the outline colour is multiplied by it. In inside mode the outline only replaces the colour of the edge texels, and their alpha already carries the fade. Either way, fade a sprite to half transparency and its outline fades with it, keeping its colour, instead of hanging in the air.

sprite_outline.gdshader
// Sprite Outline
// A pixel-exact outline for 2D sprites, drawn outside or inside the art, safe on atlases.
// Godot 4.7 · CC0 1.0 · godotlearning.com/shaders/sprite-outline
shader_type canvas_item;

// Outline thickness, counted in texels of the sprite's texture, not in screen pixels.
uniform int width : hint_range(1, 8) = 1;
// Outline colour. Lower its alpha for a see-through outline.
uniform vec4 color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
// Off: the outline grows outward into transparent pixels.
// On: it recolours the sprite's own edge pixels instead.
uniform bool inside = false;
// Which neighbours count as touching:
// Diamond skips corners, Circle rounds them, Square keeps them.
uniform int pattern : hint_enum("Diamond", "Circle", "Square") = 0;

// A texel whose alpha is at least this much counts as part of the sprite.
const float ALPHA_CUT = 0.5;

// The node's modulate and self_modulate, without the texture.
// Used to tint and fade the outline along with the sprite.
varying vec4 modulate;

void vertex() {
	modulate = COLOR;
}

// True when uv lies inside this sprite's own rectangle of the texture (lo..hi).
// Samples outside it count as empty, so a neighbouring atlas frame is never read.
bool in_region(vec2 uv, vec2 lo, vec2 hi) {
	return all(greaterThanEqual(uv, lo)) && all(lessThanEqual(uv, hi));
}

// True when the offset (x, y) is inside the neighbourhood picked by `pattern`.
bool in_pattern(int x, int y, int w) {
	if (pattern == 0) {
		return abs(x) + abs(y) <= w;
	}
	if (pattern == 1) {
		return x * x + y * y <= w * w + w;
	}
	return true;
}

void fragment() {
	vec2 texel = TEXTURE_PIXEL_SIZE;
	// The part of the texture this sprite draws, in UV: a Sprite2D region, an
	// AtlasTexture frame or the whole texture. flip_h and flip_v make the size
	// negative while the rectangle stays where it is, hence abs().
	vec2 lo = max(REGION_RECT.xy, vec2(0.0));
	vec2 hi = min(REGION_RECT.xy + abs(REGION_RECT.zw), vec2(1.0));
	// Snap to the centre of the current texel, so every sample lands on a texel centre
	// however much the sprite is scaled.
	vec2 centre = (floor(UV / texel) + 0.5) * texel;
	// textureLod at level 0 on purpose: texture() picks a mip level from screen-space
	// derivatives, and those are not reliable inside a loop that can stop early.
	bool own = in_region(centre, lo, hi) && textureLod(TEXTURE, centre, 0.0).a >= ALPHA_CUT;

	// Outside mode looks for a solid neighbour around an empty texel.
	// Inside mode looks for an empty neighbour around a solid texel.
	if (own == inside) {
		bool edge = false;
		for (int y = -width; y <= width && !edge; y++) {
			for (int x = -width; x <= width; x++) {
				if ((x == 0 && y == 0) || !in_pattern(x, y, width)) {
					continue;
				}
				vec2 sample_uv = centre + vec2(float(x), float(y)) * texel;
				bool solid = in_region(sample_uv, lo, hi)
						&& textureLod(TEXTURE, sample_uv, 0.0).a >= ALPHA_CUT;
				if (solid != own) {
					edge = true;
					break;
				}
			}
		}
		if (edge) {
			if (inside) {
				// COLOR.a already holds the texel alpha times modulate.a, so only
				// the colour's own alpha decides the blend. Fading stays a fade.
				COLOR.rgb = mix(COLOR.rgb, color.rgb * modulate.rgb, color.a);
			} else {
				COLOR = color * modulate;
			}
		}
	}
}

Pitfalls and renderer notes

  • The outline is cut off flat on one side. The art touches the edge of its image or atlas frame, and a sprite only draws inside that rectangle. Add transparent padding of at least width pixels around the art, or switch on inside.
  • Changing one sprite's outline changes others. They share one material resource. Use Make Unique on that sprite's material, tick Resource > Local to Scene on the material when it lives in a scene you instance many times, or call material = material.duplicate() in _ready().
  • The material shows no shader after you copy it into another folder. The .tres finds the shader by its UID, which lives in sprite_outline.gdshader.uid. Copy that file along with the shader, or drag the shader into the material's Shader slot again.
  • Two sprites show different outline thickness at the same width. Width counts texels, so a sprite at scale 4 draws a 4 pixel line where a sprite at scale 2 draws 2. Use the same scale for sprites that sit side by side, or give each its own material and width.
  • Soft edges or a faint shadow in the art get outlined, or get ignored. A texel counts as solid at alpha 0.5 and above. Change ALPHA_CUT in the shader to move that line.
  • Wide outlines cost more. The search runs once per screen pixel the sprite covers, not once per texel. In outside mode, every transparent pixel of the sprite that is not next to the art checks (2 × width + 1)² − 1 neighbours, which is 288 samples at width 8 with the square pattern. A sprite scaled up 4 times covers 16 screen pixels with each texel, so it costs 16 times as much as at scale 1. The loop stops at the first hit, so pixels next to the art are cheap. If you outline hundreds of sprites on a phone, keep the width low and profile.

The demo scene was run under Forward+, Mobile and Compatibility with no errors or warnings. The shader uses only canvas_item built-ins, a loop and textureLod(), and a test scene with the same shader gave pixel-identical screenshots on all three. Compatibility is the renderer web exports use.

Pairs with

Shader

Pixelate Shader

Turn the whole screen, 2D and 3D, into chunky pixels with one ColorRect, with optional colour steps and dithering for a retro palette.

  • Godot 4.7
  • CC0
See the shader
Tool

Platformer Starter

A complete 2D platformer template for Godot 4.7: a player with coyote time, jump buffering and variable jump height, a TileMapLayer level, a camera that stops at the level's edges, coins, checkpoints, death and a HUD. 42 tests prove each part.

  • Godot 4.7
  • CC0
See the tool

Questions

Why is my sprite outline cut off at the edge of the sprite?

A Sprite2D only draws inside the rectangle of its texture or region, and an outside outline is painted on the sprite's own empty pixels. If the art touches the border of the image, there is nowhere to draw. Add transparent padding of at least the outline width around the art in your image editor, leave the same gap around every frame on a sprite sheet, or turn on inside so the outline is drawn on the art itself.

Does this outline shader work on AnimatedSprite2D?

Yes. Put the same material in the AnimatedSprite2D's Material slot. Frames cut from a sprite sheet are AtlasTextures, and the shader reads REGION_RECT to stay inside the current frame, so the frame next to it never leaks into the outline. The spinning gem in the demo is an AnimatedSprite2D playing four frames from one sheet, with solid stone tiles touching the bottom edge of every frame. A shader without the region test draws a white bar under the gem; this one does not.

How do I show the outline only when the mouse is over the sprite?

Give the sprite an Area2D child with a CollisionShape2D that covers it. Connect the area's mouse_entered signal to set the colour with material.set_shader_parameter("color", Color.WHITE), and mouse_exited to set Color(1, 1, 1, 0), which hides it. Give every hoverable sprite its own material, or they will all light up together.