Pular para o conteúdo
Estilizar · ContornosIntermediário

Linhas de tinta com Sobel

Um passe de pós-processamento que encontra mudanças bruscas no brilho da imagem e as cobre de tinta.

UVPosiçãoNormalTelaTela / pós
  1. 01ObjetivoEstilizar
  2. 02TécnicaContornos
  3. 03ExecuçãoLinhas de tinta com Sobel
Cena de teste

Parâmetros

0.20
1.0
#140f1a

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.

  1. textureTexture2Dtexturetexturetexture
    UV
    Posição
    Normal
    Tela

    01 Cor da cena

  2. lengthLengthlengthlengthlength

    02 Força da borda (Sobel)

  3. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    03 Manter as bordas fortes

  4. mixMixmixmixmix

    04 Tinta sobre a cena

Como funciona

A cena é renderizada primeiro, e depois este passe roda sobre cada pixel da tela e faz uma pergunta: quão rápido a imagem muda aqui? Onde ela muda muito, há uma borda, e o passe põe tinta.

1. Cor da cena. O passe lê a cena pronta de t_screen no pixel. É sobre essa cor que ele vai desenhar.

2. Força da borda (Sobel). O passe lê o brilho da cena nos oito pixels em volta deste, cada um virando um número: um tone mapping para trazer as formas emissivas muito claras para baixo de 1 e depois os pesos de luminância. O operador de Sobel os combina com dois kernels 3×3:

gx:  -1  0  +1        gy:  +1  +2  +1
     -2  0  +2              0   0   0
     -1  0  +1             -1  -2  -1

gx é a coluna da direita menos a da esquerda, então fica grande atravessando uma borda vertical; gy é a linha de cima menos a de baixo, grande atravessando uma horizontal. As leituras do meio contam em dobro, o que favorece a linha e a coluna do próprio pixel e deixa o resultado menos ruidoso que uma diferença simples. O comprimento de (gx, gy) é a força da borda em qualquer direção, e a prévia do passo a mostra como linhas brancas sobre preto.

3. Manter as bordas fortes. Um smoothstep estreito acima do Limiar mantém as bordas fortes e descarta as fracas: o sombreado suave da esfera muda devagar e fica abaixo dele, enquanto as silhuetas e as casas do xadrez mudam de uma vez e passam.

4. Tinta sobre a cena. Um mix em direção à cor da tinta onde a borda passou desenha a linha.

Por que brilho e não cor

Comparar um número por pixel é mais barato que três, e o olho lê bordas principalmente pelo brilho mesmo. O preço é que duas cores diferentes com o mesmo brilho não ganham linha entre elas. Rodar os kernels em cada canal e ficar com o maior pega esses casos, pelo triplo da conta.

O controle de espessura

A Espessura da linha afasta as oito leituras por esse número de pixels. Um espaçamento maior dá linhas mais grossas, porque a borda passa a aparecer em 2 × a espessura em pixels. O pixel do centro nunca é lido, então um detalhe mais fino que o espaçamento (um fio de um pixel, digamos) vira duas linhas paralelas; linhas grossas de verdade dilatam a imagem das bordas num segundo passe.

Bom saber

  • Ele põe tinta no que está na tela. Detalhes de textura, brilhos especulares e o ruído de uma imagem com dithering têm bordas. Subir o limiar os descarta, junto com as bordas mais suaves que você queria. Detectar bordas na profundidade e nas normais evita a maior parte disso, porque esses buffers só mudam onde há geometria. Isso exige o depth buffer, então este card, que só precisa da tela, deixa isso para depois.
  • Preso à resolução. Leituras de um pixel fazem linhas de um pixel, que ficam finas numa tela de alta densidade e tremem quando a câmera se mexe. Multiplicar a espessura pela densidade de pixels da tela as mantém estáveis.

Monte no GLSL

Um passe de pós-processamento. Renderize a cena numa textura primeiro e depois desenhe um triângulo ou quad de tela inteira com este shader de fragmento, a textura da cena ligada em t_screen e u_resolution com o tamanho da saída em pixels.

Renderize a cena num framebuffer cujo anexo de cor seja uma textura, de preferência RGBA16F para que valores acima de 1 sobrevivam (o shader faz tone mapping deles para o teste de borda). Depois ligue o framebuffer padrão, ligue essa textura em t_screen com filtragem linear e clamp-to-edge e desenhe um triângulo que cubra a tela.

O main() do shader monta uv a partir da varying v_uv, então o seu shader de vértice de tela inteira precisa passar coordenadas de 0 a 1 ali, com a origem embaixo à esquerda; v_position, v_normal e v_view ainda precisam ser escritas, mas este card nunca as lê, então qualquer valor serve, desde que v_normal e v_view não sejam zero (main() as normaliza). Preencha p_threshold, p_width e p_inkColor (decodificada para linear). O passe codifica a própria saída em sRGB.

Para pôr tinta também nas bordas de profundidade e de normal, renderize essas duas em texturas no passe da cena e rode as mesmas oito leituras sobre elas; fique com o maior dos três valores de borda.

Monte no Godot

O shader é um shader spatial para um quad de tela inteira. O vertex() dele põe o quad sobre a tela toda onde quer que o nó esteja, e t_screen lê o quadro por hint_screen_texture. O CompositorEffect do Godot consegue rodar a mesma conta como um passe de compute, com mais configuração.

Acrescente um MeshInstance3D como filho da Camera3D, dê a ele um QuadMesh com Size em 2 × 2 e, no espaço de material do QuadMesh, crie um novo ShaderMaterial com este shader. A função de vértice ignora a câmera e escreve o quad direto no clip space, então ele cobre a tela a qualquer distância, e o depth_test_disabled impede que a cena o esconda.

Ponha o Extra Cull Margin do MeshInstance3D (seção Geometry) em 16384 para ele nunca ser descartado. Os três parâmetros aparecem em Shader Parameters.

No VisualShader, ponha o modo do shader em Spatial e leia a tela com um nó Texture2D com Source em Screen, oito vezes em UVs deslocadas; o kernel é feito de nós Add, Subtract e Multiply comuns, depois um Length, um SmoothStep e um Mix. Um nó Expression com as oito leituras fica bem mais organizado que o grafo de nós.

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

sobel-ink-lines.fragGLSL
#version 300 es// Outlines → Sobel ink lines, 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 float p_threshold; // Threshold: 0.2 (0.02 to 1.0)uniform float p_width; // Line width (px): 1.0 (0.5 to 3.0)uniform vec3 p_inkColor; // Ink: vec3(0.08, 0.06, 0.1), sRGB; upload it decoded to linearuniform sampler2D t_screen; // the scene's color: linear HDR (RGBA16F), linear 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));}// Edges are found in brightness, one number per pixel, rather than in each color channel. The tone map first squeezes// the emissive shapes, far above 1.0, back under 1, or their edges would be the only ones strong enough to show.float brightness(vec2 uv) {	vec3 c = texture(t_screen, uv).rgb;	c = c / (1.0 + c);	return dot(c, vec3(0.2126, 0.7152, 0.0722));}vec4 effect(Inputs i) {	vec3 scene = texture(t_screen, i.uv).rgb;	vec2 px = p_width / i.resolution;	float tl = brightness(i.uv + px * vec2(-1.0, 1.0));	float t = brightness(i.uv + px * vec2(0.0, 1.0));	float tr = brightness(i.uv + px * vec2(1.0, 1.0));	float l = brightness(i.uv + px * vec2(-1.0, 0.0));	float r = brightness(i.uv + px * vec2(1.0, 0.0));	float bl = brightness(i.uv + px * vec2(-1.0, -1.0));	float b = brightness(i.uv + px * vec2(0.0, -1.0));	float br = brightness(i.uv + px * vec2(1.0, -1.0));	// The two Sobel kernels: right column minus left, top row minus bottom, the middle taps counted twice	float gx = (tr + 2.0 * r + br) - (tl + 2.0 * l + bl);	float gy = (tl + 2.0 * t + tr) - (bl + 2.0 * b + br);	float edge = length(vec2(gx, gy));	float ink = smoothstep(p_threshold, p_threshold + 0.05, edge);	vec3 color = mix(scene, p_inkColor, ink);	return vec4(color, 1.0);}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; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
sobel-ink-lines.gdshaderShader do Godot
// Outlines → Sobel ink lines, Godot 4. From Creative Center, MIT license.shader_type spatial;// A full-screen quad over the finished frame: no lighting, no culling, never hidden by depthrender_mode unshaded, cull_disabled, depth_test_disabled;uniform float threshold : hint_range(0.02, 1.0, 0.01) = 0.2;uniform float width : hint_range(0.5, 3.0, 0.1) = 1.0;uniform vec3 inkColor : source_color = vec3(0.08, 0.06, 0.1);uniform sampler2D t_screen : hint_screen_texture, filter_linear, repeat_disable;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// 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);}// Edges are found in brightness, one number per pixel, rather than in each color channel. The tone map first squeezes// the emissive shapes, far above 1.0, back under 1, or their edges would be the only ones strong enough to show.float brightness(vec2 uv) {	vec3 c = texture(t_screen, u_flipUv(uv)).rgb;	c = c / (1.0 + c);	return dot(c, vec3(0.2126, 0.7152, 0.0722));}vec4 effect(Inputs i) {	vec3 scene = texture(t_screen, u_flipUv(i.uv)).rgb;	vec2 px = width / i.resolution;	float tl = brightness(i.uv + px * vec2(-1.0, 1.0));	float t = brightness(i.uv + px * vec2(0.0, 1.0));	float tr = brightness(i.uv + px * vec2(1.0, 1.0));	float l = brightness(i.uv + px * vec2(-1.0, 0.0));	float r = brightness(i.uv + px * vec2(1.0, 0.0));	float bl = brightness(i.uv + px * vec2(-1.0, -1.0));	float b = brightness(i.uv + px * vec2(0.0, -1.0));	float br = brightness(i.uv + px * vec2(1.0, -1.0));	// The two Sobel kernels: right column minus left, top row minus bottom, the middle taps counted twice	float gx = (tr + 2.0 * r + br) - (tl + 2.0 * l + bl);	float gy = (tl + 2.0 * t + tr) - (bl + 2.0 * b + br);	float edge = length(vec2(gx, gy));	float ink = smoothstep(threshold, threshold + 0.05, edge);	vec3 color = mix(scene, inkColor, ink);	return vec4(color, 1.0);}void vertex() {	// Put the shader on a MeshInstance3D with a 2x2 QuadMesh in front of the camera; it covers the screen wherever it is	POSITION = vec4(VERTEX.xy, 1.0, 1.0);}void fragment() {	Inputs i;	// SCREEN_UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(SCREEN_UV.x, 1.0 - SCREEN_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 = VIEWPORT_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	ALBEDO = frag_out.rgb;}

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