2D outline/inline

Adds an outer or inner stroke to a texture. Just attach the shader to a material and the material to a canvas item like a sprite. This shader is robust and configurable (color, width, pattern, placement, and the ability to add margins to the texture to make room for an outline). However, for textures with anti-aliased edges this shader might provide a better result.

 

For Godot 3, replace source_color at line 3 by hint_color.

 

For Godot 4, if you also want to use the modulate property, you can incorporate this solution for now and then replace texture(TEXTURE, uv) by texture(TEXTURE, uv) * modulate twice, color.rgb (case-sensitive) by color.rgb * modulate.rgb twice, and color.a (case-sensitive) by color.a * modulate.a twice.

 

Shader code
shader_type canvas_item;

uniform vec4 color : source_color = vec4(1.0);
uniform float width : hint_range(0, 10) = 1.0;
uniform int pattern : hint_range(0, 2) = 0; // diamond, circle, square
uniform bool inside = false;
uniform bool add_margins = true; // only useful when inside is false

void vertex() {
	if (add_margins) {
		VERTEX += (UV * 2.0 - 1.0) * width;
	}
}

bool hasContraryNeighbour(vec2 uv, vec2 texture_pixel_size, sampler2D texture) {
	for (float i = -ceil(width); i <= ceil(width); i++) {
		float x = abs(i) > width ? width * sign(i) : i;
		float offset;
		
		if (pattern == 0) {
			offset = width - abs(x);
		} else if (pattern == 1) {
			offset = floor(sqrt(pow(width + 0.5, 2) - x * x));
		} else if (pattern == 2) {
			offset = width;
		}
		
		for (float j = -ceil(offset); j <= ceil(offset); j++) {
			float y = abs(j) > offset ? offset * sign(j) : j;
			vec2 xy = uv + texture_pixel_size * vec2(x, y);
			
			if ((xy != clamp(xy, vec2(0.0), vec2(1.0)) || texture(texture, xy).a <= 0.0) == inside) {
				return true;
			}
		}
	}
	
	return false;
}

void fragment() {
	vec2 uv = UV;
	
	if (add_margins) {
		vec2 texture_pixel_size = vec2(1.0) / (vec2(1.0) / TEXTURE_PIXEL_SIZE + vec2(width * 2.0));
		
		uv = (uv - texture_pixel_size * width) * TEXTURE_PIXEL_SIZE / texture_pixel_size;
		
		if (uv != clamp(uv, vec2(0.0), vec2(1.0))) {
			COLOR.a = 0.0;
		} else {
			COLOR = texture(TEXTURE, uv);
		}
	} else {
		COLOR = texture(TEXTURE, uv);
	}
	
	if ((COLOR.a > 0.0) == inside && hasContraryNeighbour(uv, TEXTURE_PIXEL_SIZE, TEXTURE)) {
		COLOR.rgb = inside ? mix(COLOR.rgb, color.rgb, color.a) : color.rgb;
		COLOR.a += (1.0 - COLOR.a) * color.a;
	}
}
Live Preview
Tags
2d, border, configurable, inline, outline, stroke
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

More from Juulpower

Related shaders

Comments 29

  1. PudimLaranja

    That's exactly what i needed thank you so much :)

    1
  2. Gavin

    I know it's been awhile since you made this post, but might I ask if there's a way to make the shader enclose around the sprite to a greater extent, thanks

    1
  3. Alberto

    nice!. Just one question, Is it possible to make it compatible with a sprite sheet? I notice that if you enable add_margins, the sprite aren't show correctly. (At least in godot 4)

    1
      1. i made a little modification. I use this shader when the player is near to an item in the map, and then i apply the outline effect to indicate it's 'selected' to pick up. But, when the item is over the player, i modify the modulate of the item to see the player again. With this shader it's not possible, but i made it possible with this modification.

        Add a new shader parameter:

        uniform float global_alpha_color: hint_range(0.0, 1.0, 0.01) = 1.0; 
        

        Edit the "IF" in the fragment:

        	if ((COLOR.a > 0.0) == inside && hasContraryNeighbour(uv, TEXTURE_PIXEL_SIZE, TEXTURE)) {
        		COLOR.rgb = inside ? mix(COLOR.rgb, color.rgb, color.a) : color.rgb;
        		COLOR.a += (1.0 - COLOR.a) * (color.a * global_alpha_color);
        	}
        	else if (COLOR.a > 0.0)
        	{
        		COLOR.a = COLOR.a * global_alpha_color;
        	}
        
        2
        1. Thank you! I've just added a more general workaround to the description.

          0
  4. Great shader. I had to change hint_color to source_color in Godot 4.2 to make this work.

    3
  5. PikacKukac

    Great work! The issue is when I put an image that is smaller, for example rod, the rectangle at the middle is still visible. How would I remove this rectangle? Thanks!

    1
    1. I've added a workaround to the description for working with modulate in Godot 4 :) If you only replace texture(TEXTURE, uv) the outline itself will not be modulated.

      0
  6. yourFAN

    You are my God and savior! 
    I'm modifying the original sprite to be clear and using it as an outline to mark the other side of the obstacle. I wish you prosperity.

    2
  7. north

    It seems there is something wrong with lines 16 and 27?

    2
  8. Poop Fart

    Do you have any idea why this may not be working with a polygon 2d? If I use a black color, even with the lowest possible width above 0, the entire polygon becomes black. Thanks

    1
    1. I've had a look and to be honest this shader is very dependent on the presence of a texture. Without one you don't even have access to UV. I could get this version working with a texture, by setting the number_of_images uniform to the texture size divided by the polygon's bounding box size, but even then only for a rectangular polygon without texture scaling or rotation...

       

      Looking at it online, Polygon2D outlines can only be done properly by rendering it to a CanvasGroup or SubViewport, or by drawing a polyline or second polygon around/behind it manually.

      0
  9. When outlining with add_margins:
    Setting a texture_rect's "flip_H" to true will mess up the sprite, it doesn't correctly expand the width.
    Not sure why flipH is doing this, I don't know what flipH is actually doing behind the scenes

    1
    1. Actually the author may have found a solution in their sprite sheet shader:
      Replacing:

      VERTEX += (UV * 2.0 - 1.0) * width;
      

      With:

      VERTEX += (sign(VERTEX) * 2.0 - 1.0) * width;

      Seems to work.

      2
      1. Thanks for the tip! It works for me, not sure if this a best solution for everyone tho, since it scales up the sprite a little bit.

        0
  10. Fixed version for godot 4, now it works with modulate:
    (it's the same script with 3 new code strings, all new code has a comment //NEW CODE)

     

    shader_type canvas_item;

     

    varying flat vec4 modulate; // NEW CODE
    uniform vec4 color : source_color = vec4(1.0);
    uniform float width : hint_range(0, 10) = 1.0;
    uniform int pattern : hint_range(0, 2) = 0; // diamond, circle, square
    uniform bool inside = false;
    uniform bool add_margins = true; // only useful when inside is false

     

    void vertex() {
       modulate = COLOR; // NEW CODE
       if (add_margins) {
          VERTEX += (sign(VERTEX) * 2.0 - 1.0) * width;
       }
    }

     

    bool hasContraryNeighbour(vec2 uv, vec2 texture_pixel_size, sampler2D texture) {
       for (float i = -ceil(width); i <= ceil(width); i++) {
          float x = abs(i) > width ? width * sign(i) : i;
          float offset;

     

          if (pattern == 0) {
             offset = width - abs(x);
          } else if (pattern == 1) {
             offset = floor(sqrt(pow(width + 0.5, 2) - x * x));
          } else if (pattern == 2) {
             offset = width;
          }

     

          for (float j = -ceil(offset); j <= ceil(offset); j++) {
             float y = abs(j) > offset ? offset * sign(j) : j;
             vec2 xy = uv + texture_pixel_size * vec2(x, y);

     

             if ((xy != clamp(xy, vec2(0.0), vec2(1.0)) || texture(texture, xy).a <= 0.0) == inside) {
                return true;
             }
          }
       }

     

       return false;
    }

     

    void fragment() {
       vec2 uv = UV;

     

       if (add_margins) {
          vec2 texture_pixel_size = vec2(1.0) / (vec2(1.0) / TEXTURE_PIXEL_SIZE + vec2(width * 2.0));

     

          uv = (uv - texture_pixel_size * width) * TEXTURE_PIXEL_SIZE / texture_pixel_size;

     

          if (uv != clamp(uv, vec2(0.0), vec2(1.0))) {
             COLOR.a = 0.0;
          } else {
             COLOR = texture(TEXTURE, uv) * modulate; // NEW CODE
          }
       } else {
          COLOR = texture(TEXTURE, uv);
       }

     

       if ((COLOR.a > 0.0) == inside && hasContraryNeighbour(uv, TEXTURE_PIXEL_SIZE, TEXTURE)) {
          COLOR.rgb = inside ? mix(COLOR.rgb, color.rgb, color.a) : color.rgb;
          COLOR.a += (1.0 - COLOR.a) * color.a;
       }
    }

    0
    1. This worked for me but I got error:"Unknown character #8211: '–'

       

      I had to replace all of those with "-"

      1
  11. pawlogates

    doesnt work with polygon2d i think?

    0
  12. I did some modifications to add antialiasing if anybody is interested

     

    shader_type canvas_item;

     

    uniform vec4 outline_color : source_color = vec4(1.0);
    uniform float width : hint_range(0, 50) = 1.0;
    uniform int pattern : hint_range(0, 2) = 0; // diamond, circle, square
    uniform float min_alpha : hint_range(0, 1) = 0; // min alpha when considering pixel
    uniform float blur_level : hint_range(0, 5) = 1;
    uniform bool inside = false;
    uniform bool add_margins = true; // only useful when inside is false

     

    void vertex() {
    if (add_margins) {
    VERTEX += (UV * 2.0 - 1.0) * width;
    }
    }

     

    bool hasContraryNeighbour(vec2 uv, vec2 texture_pixel_size, sampler2D texture) {
    for (float i = -ceil(width); i <= ceil(width); i++) {
    float x = abs(i) > width ? width * sign(i) : i;
    float offset;

     

    if (pattern == 0) {
    offset = width - abs(x);
    } else if (pattern == 1) {
    offset = floor(sqrt(pow(width + 0.5, 2) - x * x));
    } else if (pattern == 2) {
    offset = width;
    }

     

    for (float j = -ceil(offset); j <= ceil(offset); j++) {
    float y = abs(j) > offset ? offset * sign(j) : j;
    vec2 xy = uv + texture_pixel_size * vec2(x, y);

     

    if ((xy != clamp(xy, vec2(0.0), vec2(1.0)) || texture(texture, xy).a < min_alpha) == inside) {
    return true;
    }
    }
    }

     

    return false;
    }

     

    vec4 col_lerp(vec4 a, vec4 b, float t) {
    return a + (t * (b - a));
    }

     

    float antialias(vec2 uv, vec2 texture_pixel_size, sampler2D in_texture) {
    float output = float(0);
    for (int j = 0; j <= 7; j++) {
    vec2 offset = vec2(sin(float(45) * float(j)) * (texture_pixel_size.x * blur_level), cos(float(45) * float(j)) * (texture_pixel_size.y * blur_level));
    if (hasContraryNeighbour(uv + offset, texture_pixel_size, in_texture)) {
    output += float(1);
    }
    }
    return output / float(8);
    }

     

    void fragment() {
    vec2 uv = UV;
    vec2 texture_pixel_size = vec2(1.0) / (vec2(1.0) / TEXTURE_PIXEL_SIZE + vec2(width * 2.0));
    if (add_margins) {
    uv = (uv - texture_pixel_size * width) * TEXTURE_PIXEL_SIZE / texture_pixel_size;

     

    if (uv != clamp(uv, vec2(0.0), vec2(1.0))) {
    COLOR.a = 0.0;
    } else {
    COLOR = texture(TEXTURE, uv);
    }
    } else {
    COLOR = texture(TEXTURE, uv);
    }

     

    if ((COLOR.a >= min_alpha) == inside && antialias(uv, TEXTURE_PIXEL_SIZE, TEXTURE) > float(0)) {
    COLOR.rgb = inside ? mix(COLOR.rgb, outline_color.rgb, outline_color.a) : outline_color.rgb;
    COLOR.a += (1.0 - COLOR.a) * outline_color.a;
    COLOR.a *= antialias(uv, TEXTURE_PIXEL_SIZE, TEXTURE);
    COLOR = col_lerp(COLOR, texture(TEXTURE, uv), texture(TEXTURE, uv).a);
    }
    }

    1