2D outline universal
Yet another outline shader…
Why ? Because I did not find any outline shader :
– working with atlas sprite as well as sprite
– clean outline
– not outline width limitation
– clipping other regions of the atlas sprite (no bleeding from other sub sprites)
An atlas-aware 2D outline shader for TextureRect and Sprite2D. It expands the node boundaries beyond the original node boundaries and draws an uniform outline without clipping or sampling neighboring atlas sprites.
Supports custom outline width and color.
Limitations:
If you use it on an atlas sprite you need to add control with the following script to feed the shader with the atlas region and size.
You need to:
– Assign the target (the node with the outline) to this node.
– Adjust the OuterOutlineShader value to your projects
public partial class NAtlasOutline : Node
{
private static readonly Shader OuterOutlineShader = ResourceLoader.Load<Shader>(
"res://shaders/atlas_outer_outline.gdshader");
private static readonly StringName OutlineWidthParameter = "outline_width";
private static readonly StringName OutlineTintParameter = "outline_tint";
private static readonly StringName AtlasRegionPositionParameter = "atlas_region_pos";
private static readonly StringName AtlasRegionSizeParameter = "atlas_region_size";
private static readonly StringName DrawSizeParameter = "draw_size";
private CanvasItem? _target;
private float _outlineWidth = 4f;
private Color _outlineTint = Colors.White;
private ShaderMaterial? _material;
private Texture2D? _lastTexture;
private Rect2 _lastRegion;
private Vector2 _lastDrawSize;
[Export]
public CanvasItem? Target
{
get => _target;
set
{
_target = value;
ResetTrackedState();
ApplyOutlineParameters();
}
}
[Export(PropertyHint.Range, "0,32,0.5")]
public float OutlineWidth
{
get => _outlineWidth;
set
{
_outlineWidth = Mathf.Max(0f, value);
ApplyOutlineParameters();
}
}
[Export]
public Color OutlineTint
{
get => _outlineTint;
set
{
_outlineTint = value;
ApplyOutlineParameters();
}
}
public override void _Ready()
{
ApplyOutlineParameters();
}
public override void _Process(double delta)
{
var target = ResolveTarget();
var texture = TextureFor(target);
var region = RegionFor(texture);
var drawSize = DrawSizeFor(target, texture);
if (!ReferenceEquals(texture, _lastTexture) ||
region != _lastRegion ||
drawSize != _lastDrawSize ||
target?.Material != _material)
{
ApplyOutlineParameters();
}
}
public void ApplyOutlineParameters()
{
if (!IsInsideTree())
{
return;
}
var target = ResolveTarget();
var texture = TextureFor(target);
if (target == null || texture == null)
{
return;
}
var region = RegionFor(texture);
var drawSize = DrawSizeFor(target, texture);
var material = LocalMaterialFor(target);
material.SetShaderParameter(OutlineWidthParameter, _outlineWidth);
material.SetShaderParameter(OutlineTintParameter, _outlineTint);
material.SetShaderParameter(AtlasRegionPositionParameter, region.Position);
material.SetShaderParameter(AtlasRegionSizeParameter, region.Size);
material.SetShaderParameter(DrawSizeParameter, drawSize);
_lastTexture = texture;
_lastRegion = region;
_lastDrawSize = drawSize;
}
private CanvasItem? ResolveTarget()
{
return _target != null && GodotObject.IsInstanceValid(_target)
? _target
: GetParentOrNull<CanvasItem>();
}
private ShaderMaterial LocalMaterialFor(CanvasItem target)
{
if (_material != null && GodotObject.IsInstanceValid(_material) && target.Material == _material)
{
return _material;
}
if (target.Material is ShaderMaterial existing && existing.Shader == OuterOutlineShader)
{
_material = (ShaderMaterial)existing.Duplicate();
}
else
{
_material = new ShaderMaterial
{
Shader = OuterOutlineShader
};
}
_material.ResourceLocalToScene = true;
target.Material = _material;
return _material;
}
private static Texture2D? TextureFor(CanvasItem? target)
{
return target switch
{
Sprite2D sprite => sprite.Texture,
TextureRect textureRect => textureRect.Texture,
_ => null
};
}
private static Rect2 RegionFor(Texture2D? texture)
{
if (texture is AtlasTexture atlasTexture)
{
return atlasTexture.Region;
}
return texture == null
? new Rect2()
: new Rect2(Vector2.Zero, new Vector2(texture.GetWidth(), texture.GetHeight()));
}
private static Vector2 DrawSizeFor(CanvasItem? target, Texture2D? texture)
{
var drawSize = target switch
{
TextureRect textureRect => textureRect.Size,
Sprite2D { RegionEnabled: true } sprite => sprite.RegionRect.Size,
Sprite2D sprite when texture != null => new Vector2(
texture.GetWidth() / float.Max(1f, sprite.Hframes),
texture.GetHeight() / float.Max(1f, sprite.Vframes)),
_ => Vector2.One
};
return new Vector2(float.Max(1f, drawSize.X), float.Max(1f, drawSize.Y));
}
private void ResetTrackedState()
{
_material = null;
_lastTexture = null;
_lastRegion = new Rect2();
_lastDrawSize = Vector2.Zero;
}
}
Shader code
shader_type canvas_item;
render_mode unshaded;
uniform float outline_width : hint_range(0.0, 32.0) = 4.0;
uniform vec4 outline_tint : source_color = vec4(1.0, 1.0, 1.0, 1.0);
// AtlasTexture region in atlas pixels. These values are explicit because
// REGION_RECT is unavailable in vertex().
uniform vec2 atlas_region_pos = vec2(0.0);
uniform vec2 atlas_region_size = vec2(1.0);
uniform vec2 draw_size = vec2(1.0);
varying vec2 expanded_local_uv;
varying vec4 item_modulate;
vec2 resolved_region_size(vec2 atlas_size) {
return atlas_region_size.x > 1.0 && atlas_region_size.y > 1.0
? atlas_region_size
: atlas_size;
}
void vertex() {
vec2 atlas_size = vec2(1.0) / TEXTURE_PIXEL_SIZE;
vec2 region_size = resolved_region_size(atlas_size);
vec2 region_uv_pos = atlas_region_pos / atlas_size;
vec2 region_uv_size = region_size / atlas_size;
// Atlas sprites supply region UVs rather than quad-local UVs.
expanded_local_uv = (UV - region_uv_pos) / region_uv_size;
item_modulate = COLOR;
VERTEX += (expanded_local_uv * 2.0 - 1.0) * outline_width;
}
void fragment() {
vec2 atlas_size = vec2(1.0) / TEXTURE_PIXEL_SIZE;
vec2 region_size = resolved_region_size(atlas_size);
vec2 resolved_draw_size = max(draw_size, vec2(1.0));
vec2 outline_size = vec2(outline_width);
vec2 expanded_draw_size = resolved_draw_size + outline_size * 2.0;
// Outline geometry and sampling use node-local draw pixels. Atlas pixels
// are only used at texture lookup, so differently sized sprites retain
// the same visual stroke width.
vec2 draw_pixel = expanded_local_uv * expanded_draw_size - outline_size;
bool inside_source =
draw_pixel.x >= 0.0 &&
draw_pixel.y >= 0.0 &&
draw_pixel.x < resolved_draw_size.x &&
draw_pixel.y < resolved_draw_size.y;
vec4 sampled_rgba = vec4(0.0);
if (inside_source) {
vec2 region_pixel = draw_pixel / resolved_draw_size * region_size;
vec2 safe_pixel = clamp(region_pixel, vec2(0.5), region_size - vec2(0.5));
sampled_rgba = texture(TEXTURE, (atlas_region_pos + safe_pixel) / atlas_size);
}
float outline_alpha = 0.0;
if (outline_width > 0.0) {
const int direction_count = 16;
const int step_count = 4;
for (int direction_index = 0; direction_index < direction_count; direction_index++) {
float angle = TAU * float(direction_index) / float(direction_count);
vec2 direction = vec2(cos(angle), sin(angle));
for (int step_index = 1; step_index <= step_count; step_index++) {
float step_ratio = float(step_index) / float(step_count);
vec2 sample_draw_pixel = draw_pixel + direction * outline_size * step_ratio;
bool sample_inside =
sample_draw_pixel.x >= 0.0 &&
sample_draw_pixel.y >= 0.0 &&
sample_draw_pixel.x < resolved_draw_size.x &&
sample_draw_pixel.y < resolved_draw_size.y;
if (sample_inside) {
vec2 sample_pixel = sample_draw_pixel / resolved_draw_size * region_size;
vec2 safe_sample_pixel = clamp(sample_pixel, vec2(0.5), region_size - vec2(0.5));
outline_alpha = max(
outline_alpha,
texture(TEXTURE, (atlas_region_pos + safe_sample_pixel) / atlas_size).a);
}
}
}
}
outline_alpha *= outline_tint.a * (1.0 - sampled_rgba.a);
vec4 result_rgba = vec4(outline_tint.rgb, outline_alpha);
result_rgba.rgb = mix(result_rgba.rgb, sampled_rgba.rgb, sampled_rgba.a);
result_rgba.a = sampled_rgba.a + outline_alpha;
COLOR = result_rgba * item_modulate;
}
