Outlines for 2D sprites

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;

Offsetting a texture

To create an outline for our sprites, we sample their 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.

We use function calls that look like this (this code listing and the following are only examples, you don’t need to copy the code into your shader):

// The offset variables below are values in the UV space, so within the [0, 1] range.
texture(TEXTURE, UV + vec2(x_offset, y_offset))

Now, the offset works in a counter-intuitive way: 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 a fragment at an offset from the current fragment’s coordinates.

For example, to get the color of a fragment size UV units to the right, we need to subtract the size to the UV coordinates, like so:

// We sample the color -size.x UV units to the right of the current fragment.
vec4 offset_towards_right = texture(TEXTURE, UV + vec2(-size.x, 0));

The more you repeat that sampling step, the more accurate your outline will look at the cost of performances.

Creating the outline

Let’s start creating an outline with four samples. You’ll want to copy the code below into your fragment() function.

We’ll calculate a black-and-white mask to use for the outline. We sample our TEXTURE four times in four different directions.

// For each side, we want a single value, so we extract the alpha component of 
// the sampled color.
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;
// This value represents the black-and-white mask we'll use to outline the sprite.
float outline = min(sum, 1.0);

The min() 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 coding 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);
// We apply the outline color by calculating a mask surrounding the sprite.
COLOR = mix(color, line_color, outline - color.a);

The mix function linearly interpolates between two colors, using the third parameter.

Here, we want the line_color only 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:

Fixed outline

Producing a smoother outline

As we are only working with horizontal and vertical texture offsets, our outline will not work well for some shapes, such as this square:

Broken outline on a square

For sharp corners that line themselves up very well with those cartesian coordinates, we’re skipping an entire set of pixels in the diagonals! As a result, we need to sample our sprite four more times to cover everywhere.

float upper_left = texture(TEXTURE, UV + vec2(-size.x, size.y)).a;
float upper_right = texture(TEXTURE, UV + vec2(size.x, size.y)).a;
float bottom_left = texture(TEXTURE, UV + vec2(-size.x, -size.y)).a;
float bottom_right = texture(TEXTURE, UV + vec2(size.x, -size.y)).a;

The outline should now be the sum of all eight offsets:

float sum = left + right + up + down + upper_left + upper_right + bottom_left + bottom_right;
float outline = min(sum, 1.0);

Here is the result:

Complete outline

And the final code. Notice how instead of creating variables, we sum each offset directly on every line:

shader_type canvas_item;

uniform vec4 line_color : hint_color = vec4(1);
uniform float line_thickness : hint_range(0, 10) = 1.0;

void fragment() {
    vec2 size = TEXTURE_PIXEL_SIZE * line_thickness;

    float outline = texture(TEXTURE, UV + vec2(-size.x, 0)).a;
    outline += texture(TEXTURE, UV + vec2(0, size.y)).a;
    outline += texture(TEXTURE, UV + vec2(size.x, 0)).a;
    outline += texture(TEXTURE, UV + vec2(0, -size.y)).a;
    outline += texture(TEXTURE, UV + vec2(-size.x, size.y)).a;
    outline += texture(TEXTURE, UV + vec2(size.x, size.y)).a;
    outline += texture(TEXTURE, UV + vec2(-size.x, -size.y)).a;
    outline += texture(TEXTURE, UV + vec2(size.x, -size.y)).a;
    outline = min(outline, 1.0);

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

This leads to a cleaner and more readable shader, but the result is the same.

Community Discussion