Sometimes, we need to emphasize a game object dynamically:
In this tutorial, you will learn to:
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.
All you need for an outlined sprite is a Sprite with a shader:
outline2D.shader
. It will be easy to find it again and apply it to other sprites.
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;
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.
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
.
There you go, you have an outer stroke. Although, we’re not done yet. We have two artifacts to address.
The first issue you may face is that your outline gets clipped by the bounding box of your Sprite node.
A clipped outlineShaders 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:
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.
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 outlineAs 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 squareFor 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 outlineAnd 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.