Sometimes, we need to emphasize a game object dynamically:

  • In a point-and-click adventure, you can highlight interactive objects when the player hovers them with the mouse cursor.
  • When a character walks behind a wall, you can use an outline as an overlay to indicate where it is.
  • You may also want to have an anime-like art style in your game, with skeleton-based animation. In this case, the outline needs to be drawn in real-time.
Sprite outline

In this tutorial, you will learn to:

  • Create a 2D outline shader with a controllable width and color. You can animate these properties for dynamic outlines.
  • Create two variations of the shader using different mask calculations: an inner stroke, and a both inner and outer stroke.

In short

To outline a 2D sprite, you need to draw a copy of the sprite, offset by a number of pixels equal to the side and colored how you want it. You repeat this process left, right, up, down, etc, and combine them into one. The original sprite is used as a mask to not draw lines where we don’t want them.

Setting up the scene

All you need for an outlined sprite is a Sprite with a shader:

  1. Add a Sprite node to your scene.
  2. Assign a texture to it.
  3. Assign a new Shader Material under the sprite’s Material property.
  4. Assign a new Shader to the Shader property.
  5. Right-click on the new shader and select Save to save it to disk. I named it outline2D.shader. It will be easy to find it again and apply it to other sprites.
The example scene

Adding a shader

Coding the outline shader

Sprites use the rendering pipelines for canvas items, so we need to set our shader type accordingly:

shader_type canvas_item;

We want to be able to control our outline’s color and thickness. To do so, we are going to expose two uniforms.

uniform vec4 line_color : hint_color = vec4(1.0); // White color
uniform float line_thickness : hint_range(0.0, 10.0) = 1.0;

You can change the default values or the hint_range to your liking and your needs. Note that the outline’s line_thickness is a value in pixels here. Scaling the sprite scales its pixels on the screen, so it will also scale the stroke accordingly.

From there on, we will work in the fragment function. Write all the code below inside of it:

void fragment() {
    // Write your code here

In our fragment function, we first need to convert our line_thickness in pixels into a size in the UV coordinates. For that, we can use the Godot shader language’s built-in TEXTURE_PIXEL_SIZE, which tells us the size of the current fragment in UV coordinates.

vec2 size = TEXTURE_PIXEL_SIZE * line_thickness;

Creating the outline

Here is where we create the outline.

We are going to sample the texture multiple times, each time offsetting it by our desired line thickness in different directions.

As we process each pixel drawn on the screen separately, we have to take the UV coordinates of the current fragment and add a 2d vector to it to sample the texture at an offset:

texture(TEXTURE, UV + vec2(x_offset, y_offset))

We also have to work backward. To offset the texture to the right, we have to offset the UV coordinates towards the left, and vice-versa. This is because we are not moving the texture: we are sampling at an offset from the current fragment’s coordinates:

vec4 offset_towards_right = texture(TEXTURE, UV + vec2(-size.x, 0));

The more you repeat that step, the more accurate your outline will look at the cost of performances. But to start with, we are going to have four directions: left, right, up, and down.

float left = texture(TEXTURE, UV + vec2(-size.x, 0)).a;
float right = texture(TEXTURE, UV + vec2(size.x, 0)).a;
float up = texture(TEXTURE, UV + vec2(0, size.y)).a;
float down = texture(TEXTURE, UV + vec2(0, -size.y)).a;

We then combine the offsets by adding them and limit their value to 1.0 using the min function. If you let alpha values go past 1.0, you will end up with visual artifacts.

float sum = left + right + up + down;
float outline = min(sum, 1.0);

This function takes the lowest of the two arguments you give to it. Here, it will limit the value of sum to 1.0, that is, the maximum value we should use for the alpha channel.

Note: when it comes to writing shaders, the idiomatic way of doing it is to avoid if and else statements. Instead, to control your values, you are going to use a range of built-in functions like min, max, clamp, step, and smoothstep.

Finally, we need to sample our input texture to get its color, and to mix it with our outline:

vec4 color = texture(TEXTURE, UV);
COLOR = mix(color, line_color, outline - color.a);

The mix function linearly interpolates between two colors, using the third parameter. Here, we only want the line_color to show outside of the character’s sprite. To do so, we subtract the alpha of our input texture, making the outline transparent where the sprite is fully opaque: outline - color.a.

Result so far

There you go, you have an outer stroke. Although, we’re not done yet. We have two artifacts to address.

Refining and troubleshooting

The first issue you may face is that your outline gets clipped by the bounding box of your Sprite node.

A clipped outline

Shaders operate within a node’s bounds, which you can increase by enabling the region_enabled property on your sprite and by changing the region_rect property manually:

Region settings

There is an interactive editor in the bottom panel to do the same, TextureRegion. Click and drag on the box’s handles to resize the sprite bounding box. Note that the region_enabled property must be true for it to apply.

TextureRegion editor

Note that increasing a sprite’s region also increases the number of pixels the graphics card has to process every frame. The graphics card and the shader process every one of them, even if they are transparent. This is why, by default, the bounding box fits the texture’s dimensions.

With that, your outline should look as expected: