Shader de Contorno (Outline) no Godot 4 para Sprites 2D

Aprenda a criar um shader outline godot para sprites 2D no Godot 4, amostrando pixels vizinhos com TEXTURE_PIXEL_SIZE e usando uniforms de cor e espessura.
Contornar um sprite com uma linha de cor solida e um daqueles efeitos que parece simples ate voce tentar fazer na unha. A solucao mais limpa e usar um shader outline godot rodando direto na GPU, sem precisar duplicar o sprite nem desenhar varias copias deslocadas. Neste post vamos montar esse efeito do zero para sprites 2D no Godot 4, entendendo cada linha do codigo e o motivo de cada decisao.
Shader de Contorno (Outline) no Godot 4 para Sprites 2D
O contorno e util em varios contextos de jogo. Voce destaca um inimigo quando o jogador passa o mouse por cima, marca o item selecionado num menu, ou da feedback visual de que algo e interativo. A vantagem de fazer isso por shader e que o resultado acompanha qualquer animacao do sprite automaticamente, porque o calculo acontece por pixel a cada frame. Se voce ainda nao mexeu com shaders, vale dar uma passada antes no guia de shaders no Godot para iniciantes, que cobre a base do shader_type e dos uniforms.
Como o contorno funciona por tras
A ideia central e a seguinte. Um sprite com fundo transparente tem pixels com alpha 1 (o desenho) e pixels com alpha 0 (o vazio ao redor). Um pixel faz parte da borda quando ele proprio esta no vazio, mas pelo menos um vizinho dele esta dentro do desenho. Se a gente detecta esses pixels e pinta eles com uma cor escolhida, surge o contorno.
Para olhar os vizinhos a gente precisa amostrar a textura em coordenadas deslocadas. O Godot da uma variavel pronta para isso: TEXTURE_PIXEL_SIZE, que vale 1.0 / tamanho_da_textura em cada eixo. Multiplicando ela pela espessura desejada, chegamos exatamente no pixel ao lado, em cima, embaixo, e assim por diante.
Escrevendo o shader outline godot
Vamos comecar com a versao mais direta, que checa quatro vizinhos (cima, baixo, esquerda, direita). Crie um novo recurso de shader (extensao .gdshader) e cole o codigo abaixo.
shader_type canvas_item;
// Cor do contorno, com canal alpha
uniform vec4 outline_color : source_color = vec4(0.0, 0.0, 0.0, 1.0);
// Espessura em pixels da textura
uniform float outline_width : hint_range(0.0, 10.0) = 1.0;
void fragment() {
// Cor original do pixel atual
vec4 original = texture(TEXTURE, UV);
// Deslocamento de um "passo" baseado no tamanho do pixel
vec2 size = TEXTURE_PIXEL_SIZE * outline_width;
// Soma o alpha dos quatro vizinhos
float neighbor_alpha = 0.0;
neighbor_alpha += texture(TEXTURE, UV + vec2(size.x, 0.0)).a;
neighbor_alpha += texture(TEXTURE, UV + vec2(-size.x, 0.0)).a;
neighbor_alpha += texture(TEXTURE, UV + vec2(0.0, size.y)).a;
neighbor_alpha += texture(TEXTURE, UV + vec2(0.0, -size.y)).a;
// O pixel atual esta vazio, mas tem vizinho preenchido -> e borda
float is_outline = step(0.001, neighbor_alpha) * (1.0 - original.a);
// Mistura a cor original com a cor do contorno
COLOR = mix(original, outline_color, is_outline * outline_color.a);
}
Vale destriar as partes que costumam confundir quem esta comecando.
O : source_color no uniform da cor faz o editor mostrar um seletor de cor com gerenciamento correto de espaco de cor. Sem ele, a cor escolhida no inspetor sai diferente da renderizada.
O step(0.001, neighbor_alpha) retorna 1.0 quando ha qualquer alpha somado nos vizinhos e 0.0 quando nao ha. Usar 0.001 em vez de 0.0 evita problemas com sprites que tem bordas levemente translucidas por antialiasing.
A multiplicacao por (1.0 - original.a) garante que so pintamos contorno onde o pixel atual esta vazio. Sem isso, o contorno apareceria por cima do proprio desenho.
O problema da margem da textura
Tem uma pegadinha importante. Quando o desenho do sprite encosta na borda da imagem, o shader nao tem pixels vizinhos para amostrar do lado de fora, e o contorno fica cortado naquela aresta. O Godot por padrao tambem nao deixa o texture() ir alem de UV 0 a 1 num CanvasItem comum.
A correcao mais pratica e abrir a textura importada, ir na aba Import e ativar uma margem, ou simplesmente garantir alguns pixels transparentes de folga ao redor do desenho na propria arte. Para um sprite de 64x64 com personagem ocupando 60x60, deixe 2 pixels de respiro de cada lado. E uma daquelas coisas que voce so descobre quando o contorno some no canto e fica horas procurando bug no codigo onde nao tem.
Contorno em oito direcoes para bordas mais suaves
A versao de quatro vizinhos deixa cantos diagonais um pouco quadrados. Se voce quer um contorno mais redondo, amostre tambem as quatro diagonais. O custo extra e baixo para sprites 2D.
shader_type canvas_item;
uniform vec4 outline_color : source_color = vec4(0.0, 0.0, 0.0, 1.0);
uniform float outline_width : hint_range(0.0, 10.0) = 1.0;
void fragment() {
vec4 original = texture(TEXTURE, UV);
vec2 size = TEXTURE_PIXEL_SIZE * outline_width;
float a = 0.0;
a += texture(TEXTURE, UV + vec2(size.x, 0.0)).a;
a += texture(TEXTURE, UV + vec2(-size.x, 0.0)).a;
a += texture(TEXTURE, UV + vec2(0.0, size.y)).a;
a += texture(TEXTURE, UV + vec2(0.0, -size.y)).a;
a += texture(TEXTURE, UV + vec2(size.x, size.y)).a;
a += texture(TEXTURE, UV + vec2(-size.x, size.y)).a;
a += texture(TEXTURE, UV + vec2(size.x, -size.y)).a;
a += texture(TEXTURE, UV + vec2(-size.x, -size.y)).a;
float is_outline = step(0.001, a) * (1.0 - original.a);
COLOR = mix(original, outline_color, is_outline * outline_color.a);
}
A unica diferenca e que agora somamos oito amostras em vez de quatro. O resto da logica e identico. Na pratica, oito direcoes resolvem bem ate espessura 2 ou 3. Para contornos mais grossos, existem tecnicas com mais amostras, mas a maioria dos jogos 2D nao precisa disso.
Ligando o shader a hover e selecao via GDScript
O shader sozinho fica sempre ligado. O interessante e controlar ele pelo codigo, ativando o contorno quando o jogador passa o mouse ou seleciona algo. Para isso usamos um ShaderMaterial e mexemos nos uniforms por GDScript com set_shader_parameter.
Suponha um Sprite2D que tambem precisa detectar mouse. Coloque um Area2D com um CollisionShape2D como filho dele e conecte os sinais. O script abaixo liga o contorno no hover e desliga na saida.
extends Sprite2D
@export var cor_contorno: Color = Color(1.0, 0.85, 0.0, 1.0)
@export var espessura: float = 2.0
@onready var area: Area2D = $Area2D
var _material: ShaderMaterial
func _ready() -> void:
# Garante que o material seja um ShaderMaterial
_material = material as ShaderMaterial
if _material == null:
push_warning("Atribua um ShaderMaterial com o shader de contorno.")
return
# Comeca sem contorno (espessura zero)
_material.set_shader_parameter("outline_color", cor_contorno)
_material.set_shader_parameter("outline_width", 0.0)
area.mouse_entered.connect(_on_mouse_entered)
area.mouse_exited.connect(_on_mouse_exited)
func _on_mouse_entered() -> void:
_material.set_shader_parameter("outline_width", espessura)
func _on_mouse_exited() -> void:
_material.set_shader_parameter("outline_width", 0.0)
Repare que nao precisamos de um shader diferente para "ligado" e "desligado". Quando outline_width e zero, o deslocamento size tambem e zero, todas as amostras caem no proprio pixel, e o contorno simplesmente nao aparece. Mudar so esse uniform e mais barato que trocar o material inteiro.
Esse padrao de reagir a sinais do Area2D e o mesmo que aparece em interfaces arrastaveis. Se voce esta montando um sistema de itens, o efeito casa bem com o tutorial de drag and drop de inventario, onde dar feedback visual de qual slot esta ativo faz diferenca real na usabilidade.
Animando a espessura para um destaque mais vivo
Mudar a espessura de zero para o valor cheio de uma vez funciona, mas uma transicao curta fica mais agradavel. Da para usar um Tween criado na hora, sem precisar de AnimationPlayer.
func _on_mouse_entered() -> void:
_anima_contorno(espessura)
func _on_mouse_exited() -> void:
_anima_contorno(0.0)
func _anima_contorno(alvo: float) -> void:
var tween := create_tween()
tween.tween_method(
_set_largura,
_largura_atual(),
alvo,
0.12
)
func _set_largura(valor: float) -> void:
_material.set_shader_parameter("outline_width", valor)
func _largura_atual() -> float:
return _material.get_shader_parameter("outline_width")
O tween_method chama _set_largura varias vezes ao longo de 0.12 segundos, interpolando do valor atual ate o alvo. Como get_shader_parameter devolve o valor corrente, a animacao funciona certo mesmo se o mouse entrar e sair rapido, porque ela sempre parte de onde estava.
Cuidados de desempenho e pixel art
Tres pontos valem atencao na hora de usar isso em um jogo de verdade.
Primeiro, o filtro da textura. Em pixel art voce normalmente usa filtro Nearest. Como o shader le pixels vizinhos por UV, manter Nearest evita que o contorno fique borrado nas bordas. Configure isso no import da textura ou no texture_filter do CanvasItem.
Segundo, sprites em atlas ou folhas de sprite. Se varios frames moram na mesma textura, o TEXTURE_PIXEL_SIZE se refere a textura inteira, e amostrar vizinhos pode invadir o frame ao lado. Para animacao com AnimatedSprite2D, prefira frames em arquivos separados ou trate as margens com cuidado.
Terceiro, custo. Cada pixel faz quatro ou oito leituras extras de textura. Para alguns sprites na tela isso e irrelevante. Se voce pretende contornar centenas de objetos ao mesmo tempo, meca antes de assumir que vai pesar, mas em geral 2D aguenta tranquilo. O mesmo raciocinio de medir antes de otimizar vale para outros efeitos de tela, como o do shader de dissolucao.
Fechando o contorno
O contorno por shader resolve um problema comum de feedback visual com pouca complexidade: um arquivo .gdshader, dois uniforms e algumas linhas de GDScript para ligar e desligar conforme o estado do jogo. A peca central e entender que TEXTURE_PIXEL_SIZE te leva ate os pixels vizinhos, e que a borda nasce do contraste entre alpha vazio e alpha preenchido. Com isso no lugar, da para reaproveitar o mesmo material em inimigos, itens de inventario e botoes, mudando so a cor e a espessura. Comece pela versao de quatro vizinhos, suba para oito se precisar de cantos mais suaves, e lembre da margem na textura para o contorno nao sumir nas bordas.


