Contorno de pixel
Uma borda de um pixel em volta de um sprite de pixel art, encontrada a partir do próprio alfa dele.
- 01ObjetivoEstilizar
- 02TécnicaContorno de sprite
- 03ExecuçãoContorno de pixel
Parâmetros
Copie para a sua plataforma
- GLSLCopiar
- GodotCopiar
Cadeia de nós
Montada passo a passo, no estilo do GLSL, com os nomes de nós dele.
Montada passo a passo, no estilo do Godot, com os nomes de nós dele.
Este card ainda não tem versão para Unreal Engine 5, então os nós mostram os nomes do GLSL.
Este card ainda não tem versão para Unity, então os nós mostram os nomes do GLSL.
Este card ainda não tem versão para Blender, então os nós mostram os nomes do GLSL.
textureTexture2DParametertexturetexturetexture UVTextura01 O sprite
maxMaxmaxmaxmax Entrada02 Algum vizinho sólido?
*Multiply*** Entrada03 Só fora do sprite
mixMixmixmixmix Entrada04 Cor da linha ali
Como funciona
O sprite é uma pequena gosma com uma folha na cabeça e pixels transparentes em volta. O shader desenha uma linha nesses pixels transparentes, mas só nos que encostam na gosma, então a linha abraça o formato dela com um pixel do sprite de largura.
1. O sprite. O shader lê o sprite no próprio pixel. Onde está a gosma, own.a vale 1; em volta, 0.
2. Algum vizinho sólido? Ele lê o alfa um texelUm elemento de uma textura, a versão do pixel numa textura. Uma amostra mistura texels próximos ou devolve o mais próximo. Texel Um elemento de uma textura, a versão do pixel numa textura. Uma amostra mistura texels próximos ou devolve o mais próximo.texel size = 1 / textureSize(sampler, 0)max. O deslocamento de um texel é 1.0 / textureSize(t_sprite, 0), então o shader funciona
com qualquer tamanho de sprite sem precisar de parâmetro. A prévia do passo mostra uma gosma um pixel mais gorda em toda
direção: tudo o que é sólido ou encosta em algo sólido.
3. Só fora do sprite. Multiplicar por 1.0 - own.a tira o próprio sprite disso, e sobra o anel de pixels
transparentes que fazem divisa com ele.
4. A cor da linha ali. Um Blending Combinar a cor que o shader de fragmento devolve com a cor que já está no framebuffer, em vez de substituí-la. Transparência, brilho aditivo e multiplicação vêm daí.mix troca esses pixels pela cor da linha, com alfa cheio, e deixa o resto do sprite
como está. O blendingCombinar a cor que o shader de fragmento devolve com a cor que já está no framebuffer, em vez de substituí-la. Transparência, brilho aditivo e multiplicação vêm daí.result = src × srcFactor + dst × dstFactor
Por que quatro vizinhos
Testar só os vizinhos da esquerda, da direita, de cima e de baixo deixa de fora o pixel na diagonal de cada canto externo, então a linha corta os cantos de leve, como pixel artists costumam desenhar, e custa cinco leituras. Somar as quatro diagonais preenche esses cantos, numa linha mais pesada e quadrada, por nove leituras. As duas têm um pixel de espessura; um contorno de pixel mais grosso precisa de vizinhos mais distantes, e de mais leituras de novo.
A armadilha do espaço em volta
A linha vai para fora da arte, em pixels que o sprite já precisa ter. Um sprite recortado rente à arte não tem espaço, e o contorno é cortado na borda do quad: o shader só roda nos pixels do quad. Deixe pelo menos um pixel transparente em volta da arte (esta gosma tem pelo menos dois), mais para uma linha mais grossa.
Numa sprite sheet (abre em uma nova aba)Uma única imagem com vários sprites ou quadros de animação lado a lado. Cada sprite lê só o próprio retângulo, mas a filtragem e as leituras de vizinhos perto da borda podem pegar pixels do quadro ao lado. Sprite sheet Uma única imagem com vários sprites ou quadros de animação lado a lado. Cada sprite lê só o próprio retângulo, mas a filtragem e as leituras de vizinhos perto da borda podem pegar pixels do quadro ao lado.
Filtragem
A textura é lida com filtragem nearest, então cada leitura devolve o valor exato de um texel e o contorno fica nítido. Com filtragem linear, o alfa na borda da arte se mistura com os pixels transparentes em volta, e a linha borra numa faixa suave e meio transparente.
Monte no GLSL
O botão de copiar entrega um shader de fragmento para o quad de um sprite. Ligue o sprite em t_sprite com filtragem NEAREST e CLAMP_TO_EDGE, enviado como SRGB8_ALPHA8 para ser lido em linear, e desenhe com blending de alfa. A saída tem alfa direto (não pré-multiplicado).
Desenhe um quad com a proporção do sprite, com o shader de vértice passando coordenadas de 0 a 1 em v_uv, origem
embaixo à esquerda. As outras varyings ainda precisam ser escritas; qualquer valor serve, desde que v_normal e
v_view não sejam zero.
Envie o sprite como SRGB8_ALPHA8 para ele ser lido como cor linear, com filtros NEAREST e CLAMP_TO_EDGE, e ligue-o
em t_sprite. O shader acha o tamanho do texel com textureSize, então não precisa de uniform de tamanho. Preencha
p_lineColor decodificada para linear, ligue o blending e desenhe. A saída é codificada em sRGB no main().
Para uma sprite sheet, passe o retângulo do quadro para o shader e some-o a v_uv (ou calcule a UV de cada quadro no
shader de vértice); os deslocamentos dos vizinhos continuam sendo um texel da folha inteira, então os quadros precisam
de um espaço transparente entre eles.
Monte no Godot
Um shader canvas_item para um Sprite2D. Ele lê o sprite pelo próprio uniform t_sprite, que você preenche com a mesma textura do Sprite2D, porque o TEXTURE do Godot não pode ser lido dentro de uma função. Ele supõe o HDR 2D desligado (o padrão) e faz a própria decodificação e codificação sRGB.
Selecione o Sprite2D, crie um novo ShaderMaterial no espaço Material (seção CanvasItem) e cole o shader num novo Shader.
Em Shader Parameters, arraste a mesma imagem que o Sprite2D mostra para t_sprite e escolha a cor da linha.
O próprio shader põe filtragem nearest em t_sprite, então o contorno fica nítido qualquer que seja o filtro de
textura padrão do projeto. Deixe pelo menos um pixel transparente em volta da arte na imagem, ou a linha é cortada na
borda do sprite. Para ligar o contorno só num sprite selecionado, acrescente um uniform bool e multiplique outline
por ele, ou troque o material por script.
No VisualShader, use o modo CanvasItem: um Texture2DParameter alimentando cinco nós Texture2D (o pixel e os quatro vizinhos, deslocados pelo tamanho do texel vindo de um nó Input em texture_pixel_size), nós Max para os vizinhos, um OneMinus e um Multiply para o anel de fora e um Mix ligado em Color e Alpha.
Ainda não há tutorial de Unreal Engine 5 para este card.
Ainda não há tutorial de Unity para este card.
Ainda não há tutorial de Blender para este card.
Código
#version 300 es// Sprite outline → Pixel outline, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform vec3 p_lineColor; // Line color: vec3(1.0, 0.8, 0.2), sRGB; upload it decoded to linearuniform sampler2D t_sprite; // slime.png: sRGB color, uploaded as SRGB8_ALPHA8 so it samples linear; nearest filter, clamp to edgein vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// The four direct neighbours only, one texel away: the line is one sprite pixel thick and skips the pixel diagonal to a// corner, which softens corners the way pixel artists often do. Adding the four diagonals fills those corners in.vec4 effect(Inputs i) { vec2 texel = 1.0 / vec2(textureSize(t_sprite, 0)); vec4 own = texture(t_sprite, i.uv); float left = texture(t_sprite, i.uv - vec2(texel.x, 0.0)).a; float right = texture(t_sprite, i.uv + vec2(texel.x, 0.0)).a; float down = texture(t_sprite, i.uv - vec2(0.0, texel.y)).a; float up = texture(t_sprite, i.uv + vec2(0.0, texel.y)).a; float neighbors = max(max(left, right), max(down, up)); float outline = neighbors * (1.0 - own.a); vec4 result = mix(own, vec4(p_lineColor, 1.0), outline); return result;}void main() { Inputs i; i.uv = v_uv; i.position = v_position; // Interpolation shortens the vectors between vertices i.normal = normalize(v_normal); i.view = normalize(v_view); i.time = u_time; i.fragCoord = gl_FragCoord.xy; i.resolution = u_resolution; vec4 frag_out = effect(i); // The output is sRGB: clamped and encoded once, here, with no tone mapping fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), frag_out.a);}// Sprite outline → Pixel outline, Godot 4. From Creative Center, MIT license.shader_type canvas_item;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded, blend_mix;uniform vec3 lineColor : source_color = vec3(1.0, 0.8, 0.2);uniform sampler2D t_sprite : source_color, filter_nearest, repeat_disable; // the sprite: the Sprite2D's own texture again (TEXTURE can't be read inside a function)struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};// Godot's 2D renderer works in sRGB (HDR 2D off, the default), so the frame decodes color params and sRGB// textures to linear itself and encodes the result. With HDR 2D on, make these return c unchanged.vec3 u_toLinear(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec4 u_toLinear4(vec4 c) { return vec4(u_toLinear(c.rgb), c.a);}vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// Godot's textures start at the top-left and the effect's uv at the bottom-left: each sample flips v backvec2 u_flipUv(vec2 uv) { return vec2(uv.x, 1.0 - uv.y);}// The four direct neighbours only, one texel away: the line is one sprite pixel thick and skips the pixel diagonal to a// corner, which softens corners the way pixel artists often do. Adding the four diagonals fills those corners in.vec4 effect(Inputs i) { vec2 texel = 1.0 / vec2(textureSize(t_sprite, 0)); vec4 own = u_toLinear4(texture(t_sprite, u_flipUv(i.uv))); float left = u_toLinear4(texture(t_sprite, u_flipUv(i.uv - vec2(texel.x, 0.0)))).a; float right = u_toLinear4(texture(t_sprite, u_flipUv(i.uv + vec2(texel.x, 0.0)))).a; float down = u_toLinear4(texture(t_sprite, u_flipUv(i.uv - vec2(0.0, texel.y)))).a; float up = u_toLinear4(texture(t_sprite, u_flipUv(i.uv + vec2(0.0, texel.y)))).a; float neighbors = max(max(left, right), max(down, up)); float outline = neighbors * (1.0 - own.a); vec4 result = mix(own, vec4(u_toLinear(lineColor), 1.0), outline); return result;}void fragment() { Inputs i; // UV starts at the top-left; the effect expects GLSL's bottom-left i.uv = vec2(UV.x, 1.0 - UV.y); // A flat quad facing the viewer, the same as the site's 2D preview i.position = vec3(i.uv * 2.0 - 1.0, 0.0); i.normal = vec3(0.0, 0.0, 1.0); i.view = vec3(0.0, 0.0, 1.0); i.time = TIME; // FRAGCOORD's origin is the top-left too i.resolution = 1.0 / SCREEN_PIXEL_SIZE; i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y); vec4 frag_out = effect(i); COLOR = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), frag_out.a);}Ainda não há código de Unreal Engine 5 para este card.
Ainda não há código de Unity para este card.
Ainda não há código de Blender para este card.
Leitura complementar
- texture (abre em uma nova aba)Referência
- textureSize (abre em uma nova aba)Referência
- CanvasItem shaders (abre em uma nova aba)Documentação
- Texture atlas (abre em uma nova aba)Referência