How do I use shaders to create border around a sprite?

Hi, I need to create a single pixel border around my 2d sprites. I don’t want to use sprites with the border drawn around them as there are several hundred, plus I’d like the game to be moddable.

I’ve read through lots of effects examples and managed to get some demos up and running but I’m still nowhere near being able to write my own that create a border. Is this possible with shaders?

I’ve got this so far based on a GameMaker tutorial, doesn’t seem to work though. Intention was to check neighbouring pixels to see if they have an alpha value of 1 and apply that to the current pixel if they do. :

float4 PixelShaderFunction(float4 pos : SV_POSITION, float4 color1 : COLOR0, float2 coords: TEXCOORD0) : COLOR0
{
float2 offsetX = { 1, 0 };
float2 offsetY = { 0, 1 };

float alpha = tex2D(s0, coords).a;
alpha = max(alpha, tex2D(s0, coords + offsetX).a);
alpha = max(alpha, tex2D(s0, coords - offsetX).a);
alpha = max(alpha, tex2D(s0, coords + offsetY).a);
alpha = max(alpha, tex2D(s0, coords - offsetY).a);

float4 color = tex2D(s0, coords);
color.a = alpha;
return color;

}

Any help with this would be much appreciated.

The 1 you specify as an offset basically is the whole width/height of the image because as far as the shader is concerned the image has the size {1, 1} (so {.5, .5} would be the exact center…

So you’d have to divide by texturesize in order to get the neighboring pixels…
(I pass the texturesize to the shader… maybe there’s another way to get it, but I don’t know)

This code here works for me and has a kernel-size of 5 instead of 3 like what you tried to do. That actually makes the outline broader…

sampler ColorMapSampler : register(s0);

float4 MaxColor;
float4 BoundaryColor;
float4 BoundarySecondColor;
float2 TextureSize;
float2 TexelSize; // should be (1f/textureSize.X, 1f/textureSize.Y)

float4 PixelShaderColorLimit(float4 pos : SV_POSITION, float4 color1 : COLOR0, float2 Tex : TEXCOORD0) : SV_TARGET0
{
	float4 sc = tex2D(ColorMapSampler, Tex);
	
	if(sc.a > MaxColor.a) {
		return MaxColor;
	}
	return sc;
}

float4 PixelShaderBoundaries(float4 pos : SV_POSITION, float4 color1 : COLOR0, float2 Tex : TEXCOORD0) : SV_TARGET0
{
	// Determine the floating point size of a texel for a screen with this specific width and height,
	// Since the TEXCOORD0 are in the form of (0f-1f, 0f-1f).
	
	// Is passed since this shader would use 66 arithmetic instructions otherwise in an older, unoptimized version (out of 64 available in shader model 2.0).
    //float2 TexelSize = float2(1.0f / TextureSize.x, 1.0f / TextureSize.y);
	
	// X = current pixel, F = first neighbours, O = second neighbours.
	//     O
	//   O F O
	// O F X F O
	//   O F O 
	//     O
	
	// We're reading the points into 3 vectors in order to use vector-operations (saves arithmetic operations).
	// The next line demonstrates how to set a result-vector to 1 if the point is not blank and zero, if it is.
	// 	r1 = (first > 0.0f) ? float4(1.0f, 1.0f, 1.0f, 1.0f) : float4(0.0f, 0.0f, 0.0f, 0.0f);
	// The next line sums up the calculated vector using a floating-point-vector-operation. The result is r1.x+r1.y+r1.z+r1.w
	// 	result = dot(r1, 1);
	// The next if just tests if at least one is blank and at least one is not blank.
	
	float4 Color = tex2D(ColorMapSampler, Tex);

	float4 first;
	first.x = tex2D(ColorMapSampler, Tex + float2(0.0f,-TexelSize.y)).a;
	first.y = tex2D(ColorMapSampler, Tex + float2(0.0f,TexelSize.y)).a;
	first.z = tex2D(ColorMapSampler, Tex + float2(-TexelSize.x,0.0f)).a;
	first.w = tex2D(ColorMapSampler, Tex + float2(TexelSize.x,0.0f)).a;
	
	float4 second1;
	second1.x = tex2D(ColorMapSampler, Tex + float2(0.0f,-2.0f * TexelSize.y)).a;
	second1.y = tex2D(ColorMapSampler, Tex + float2(TexelSize.x,-TexelSize.y)).a;
	second1.z = tex2D(ColorMapSampler, Tex + float2(2.0f * TexelSize.x,0.0f)).a;
	second1.w = tex2D(ColorMapSampler, Tex + float2(TexelSize.x,TexelSize.y)).a;	
	
	float4 second2;
	second2.x = tex2D(ColorMapSampler, Tex + float2(0.0f,2.0f * TexelSize.y)).a;
	second2.y = tex2D(ColorMapSampler, Tex + float2(-TexelSize.x,-TexelSize.y)).a;
	second2.z = tex2D(ColorMapSampler, Tex + float2(-2.0f * TexelSize.x,0.0f)).a;
	second2.w = tex2D(ColorMapSampler, Tex + float2(-TexelSize.x,-TexelSize.y)).a;
	
	float4 r1;
	float4 r2;
	float result;
	
	r1 = (first > 0.0f) ? float4(1.0f, 1.0f, 1.0f, 1.0f) : float4(0.0f, 0.0f, 0.0f, 0.0f);
	result = dot(r1, 1);
	if(result > 0.0f && result < 4.0f) {
		return BoundaryColor;
	}
	
	r1 = (second1 > 0.0f) ? float4(1.0f, 1.0f, 1.0f, 1.0f) : float4(0.0f, 0.0f, 0.0f, 0.0f);
	r2 = (second2 > 0.0f) ? float4(1.0f, 1.0f, 1.0f, 1.0f) : float4(0.0f, 0.0f, 0.0f, 0.0f);
	result = dot(r1, 1) + dot(r2, 1);
	if(result > 0.0f && result < 8.0f) {
		return BoundarySecondColor;
	}
	
    return Color;
}

technique ColorLimit
{
	pass P0
	{
		#if SM4
			PixelShader = compile ps_4_0_level_9_1 PixelShaderColorLimit();
		#elif SM3
			PixelShader = compile ps_3_0 PixelShaderColorLimit();
		#else
			PixelShader = compile ps_2_0 PixelShaderColorLimit();
		#endif;
	}
}

technique Boundaries
{
	pass P0
	{
		#if SM4
			PixelShader = compile ps_4_0_level_9_1 PixelShaderBoundaries();
		#elif SM3
			PixelShader = compile ps_3_0 PixelShaderBoundaries();
		#else
			PixelShader = compile ps_2_0 PixelShaderBoundaries();
		#endif;
	}
}

Edit: Now that I actually read my code again I wanna say: I really, wholeheartedly apologize for the rest of the code… It’s been years and I was young and I needed the money (and since this part worked, adhering to ancient coding traditions, I never touched it again) :slight_smile: :blush:

Brilliant! Set the texelSize with this:

Vector2 texelSize = new Vector2((float)(1 / (double)surge.Width), (float)(1 / (double)surge.Height));
effect.Parameters[“texelSize”].SetValue(texelSize);

Then it worked.

sampler s0;
float2 texelSize;

float4 PixelShaderFunction(float4 pos : SV_POSITION, float4 color1 : COLOR0, float2 coords: TEXCOORD0) : COLOR0
{
//float
float2 offsetX = float2(texelSize.x, 0);
float2 offsetY = { 0, texelSize.y };

float alpha = tex2D(s0, coords).a;
alpha = max(alpha, tex2D(s0, coords + offsetX).a);
alpha = max(alpha, tex2D(s0, coords - offsetX).a);
alpha = max(alpha, tex2D(s0, coords + offsetY).a);
alpha = max(alpha, tex2D(s0, coords - offsetY).a);

float4 color = tex2D(s0, coords);
if (alpha > color.a)
{
    color.rgb = 0;
}
color.a = alpha;
return color;

}

Thank you so much for your quick reply. After a whole day battling with this I can finally move forward! What did you mean by “kernel-size of 5 instead of 3”.

Haha… nice… thx.
No problem… I’m happy if someone has a problem I can help with :slight_smile:

By kernel-size I mean the size of the matrix you’re actually pulling over the image pixel by pixel determining the color of the center pixel… You’re essentially doing this by looking at neighboring pixels. You’re looking at one pixel above, below, left and right… I’m doing that in the form of a diamond like in the documentation of the method in my code above. Your ‘diamond’ has the width 3 including the center pixel, mine has the width 5 leading to a thicker outline.

I suspect I will be referring back to your version fairly soon then. Thanks again.

For simple sprites here is another,

It doesn’t change thickness or handle alpha threshold. It does detect edges of a texture as if they are zero alpha though. Text drawing with this is pretty messed up looking though.

#if OPENGL
    #define SV_POSITION POSITION
    #define VS_SHADERMODEL vs_3_0
    #define PS_SHADERMODEL ps_3_0
#else
    #define VS_SHADERMODEL vs_4_0_level_9_1
    #define PS_SHADERMODEL ps_4_0_level_9_1
#endif

float2 texelSize = float2(0.0f, 0.0f);
float4 outlineColor = float4(1.0f, 1.0f, 1.0f, 1.0f);

Texture2D SpriteTexture;

sampler2D SpriteTextureSampler = sampler_state
{
    Texture = <SpriteTexture>;
};

struct VertexShaderOutput
{
    float4 Position : SV_POSITION;
    float4 Color : COLOR0;
    float2 TextureCoordinates : TEXCOORD0;
};

float4 MainPS(VertexShaderOutput input) : COLOR
{
    float2 pos = float2(input.TextureCoordinates.x, input.TextureCoordinates.y);
    float4 col = tex2D(SpriteTextureSampler, pos) * input.Color;
    
    float center = ceil(col.a);

    pos = float2(input.TextureCoordinates.x - texelSize.x, input.TextureCoordinates.y);
    float left = ceil(tex2D(SpriteTextureSampler, pos).a) * ceil(pos.x);

    pos = float2(input.TextureCoordinates.x + texelSize.x, input.TextureCoordinates.y);
    float right = ceil(tex2D(SpriteTextureSampler, pos).a) * 1.0f - floor(pos.x);

    pos = float2(input.TextureCoordinates.x, input.TextureCoordinates.y - texelSize.y);
    float up = ceil(tex2D(SpriteTextureSampler, pos).a) * ceil(pos.y);

    pos = float2(input.TextureCoordinates.x, input.TextureCoordinates.y + texelSize.y);
    float down = ceil(tex2D(SpriteTextureSampler, pos).a) * 1.0f - floor(pos.y);

    float total = (left + right + up + down);
    if (center > 0.0f && total < 4.0f)
        col = outlineColor;
    return col;
}

technique OutlineEffect
{
    pass P0
    {
        PixelShader = compile PS_SHADERMODEL MainPS();
    }
};

This ones made to use with spritebatch so in game1.

//
Effect effect;

// in load
effect = Content.Load<Effect>("OutlineEffect");
effect.Parameters["texelSize"].SetValue(new Vector2(1f / (texture.Width-1f), 1f / (texture.Height-1f)));
effect.Parameters["outlineColor"].SetValue(new Vector4(1.0f, 1.0f, 1.0f, 1.0f));

// in draw  pass the effect to spritebatch
spriteBatch.Begin(SpriteSortMode.Immediate, null, null, null, null, effect, null);
spriteBatch.Draw(texture, new Vector2(0,0), Color.White);
spriteBatch.End();
1 Like

Old thread but I found a way to change the border width on willmotil’s implementation. You can add another texelSize (in my example I referred to it as “innerTexel”)

float borderThickness = 4.0f;    // 4 pixels wide border
float pixelWidth = 1.0f / borderThickness ;
effect.Parameters["texelSize"].SetValue(new Vector2(1f / (texture.Width-1f), 1f / (texture.Height-1f)));
effect.Parameters["innerTexel"].SetValue(new Vector2(1f / (pixelWidth * texture.Width), 1f / (pixelWidth * texture.Height)));

And then in your Effect file, repeat what you did for texelSize under a new total variable and do:

if (center > 0.0f && (total1 < 4.0f || total2 < 4.0f))
    col = _borderColor;