Pixel Outline for Tilemaps
Dynamic tilemap outline shader
Intended to create an internal outline around 2D tilemaps.
I have only tested it with pixel art (16 x 16)
WHY IS THIS NECESSARY?
> With a normal outline shader you will either get NO OUTLINE (if the tile size and tileset atlas texture region are the same) or a full outline for every tile REGARDLESS of the other tiles around it.
> Tilemaps render each tile as its own texture.
> You cannot access area outside your tile to check if it’s empty or not.
> You cannot access data about any of the other tiles in the tilemap.
HOW THE SHADER WORKS
The shader works by first getting the world coordinate, then finding relative coordinates for which tile is currently being drawn (and in which chunk)
It then takes in data (uniform) from outside for which parts to outline (top of block, right of block, bottom of block, and left of block). Finally, the shader draws the outline based on this data.
The data is an array of unsigned integers with the number of slots you want for your entire tilemap.
0 corresponds to no outline, 1 to the top, 2 right, 3 bottom, 4 left. 5, 6, 7, and 8 have two outlines at a right angle. 9, 10, 11, 12 have three outlines. 13 and 14 are two outlines across each other. 15 is a full outline.
You don’t have to use this exact enumeration. Any integer or float-based indexing system can work when you implement the logic.
IN ORDER TO COMPLETE
Logic for which sides to show has to be calculated in a different script (probably attached to the tilemaplayer itself) then passed into the shader’s ‘sides’ uniform.
Notes for creating this script:
>find solid tiles and create an array representing your tilemap
>make a function that finds the outline enumeration by calculating it from its neighboring tiles (whether solid or air)
>convert to PackedInt32Array and pass into the shader
>if using chunks, consider checking the edges of the 4 neighboring chunks for solid blocks
>VERY IMPORTANT if using this shader for multiple things, DUPLICATE the shader material (with material = material.duplicate() or similar) in the _ready or _init functions. Otherwise, the changes you pass in will affect everything using the shader (resource must be made unique)
Room for improvement:
> outline thickness, dynamic colors being passed in (i.e. different blocks get a different colored outline).
> a version with an outline ‘outside’ the block rather than internal to the block.
> addition shader code: a standard non-tilemap outline being added (for blocks that arent the full tile size such as a slab)
Shader code
shader_type canvas_item;
render_mode world_vertex_coords;
uniform uint sides[256];
uniform vec4 outline_color:source_color;
uniform float tile_size = 16;
uniform float chunk_size = 16;
varying vec2 world_coord;
void vertex() {
world_coord = VERTEX;
}
void fragment() {
vec2 block_coord = floor(world_coord / tile_size);
vec2 relative_world_coord = floor(world_coord - (tile_size * block_coord));
vec2 chunk_coord = floor(block_coord / chunk_size);
vec2 relative_block_coord = floor(block_coord - (chunk_size * chunk_coord));
uint side_index = uint(relative_block_coord.x + relative_block_coord.y * tile_size);
uint side_data = sides[side_index];
if (side_data == uint(1) || side_data == uint(5) || side_data == uint(9) || side_data == uint(15) || side_data == uint(8) || side_data == uint(11) || side_data == uint(12) || side_data == uint(13)) {
if (uint(0) == uint(relative_world_coord.y)) { //Outlining the top of the shape
COLOR = outline_color;
}
}
if (side_data == uint(2) || side_data == uint(6) || side_data == uint(10) || side_data == uint(15) || side_data == uint(5) || side_data == uint(9) || side_data == uint(12) || side_data == uint(14)) {
if (uint(tile_size - 1.0) == uint(relative_world_coord.x)) { //Outlining the right of the shape
COLOR = outline_color;
}
}
if (side_data == uint(3) || side_data == uint(7) || side_data == uint(11) || side_data == uint(15) || side_data == uint(6) || side_data == uint(9) || side_data == uint(10) || side_data == uint(13)) {
if (uint(tile_size - 1.0) == uint(relative_world_coord.y)) { //Outlining the bottom of the shape
COLOR = outline_color;
}
}
if (side_data == uint(4) || side_data == uint(8) || side_data == uint(12) || side_data == uint(15) || side_data == uint(7) || side_data == uint(10) || side_data == uint(11) || side_data == uint(14)) {
if (uint(0) == uint(relative_world_coord.x)) { //Outlining the left of the shape
COLOR = outline_color;
}
}
}


