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.
- 01ObjetivoEstilizar
- 02TécnicaContornos
- 03ExecuçãoLinhas de tinta com Sobel
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.
textureTexture2Dtexturetexturetexture UVPosiçãoNormalTela01 Cor da cena
lengthLengthlengthlengthlength Entrada02 Força da borda (Sobel)
smoothstepSmoothStepsmoothstepsmoothstepsmoothstep Entrada03 Manter as bordas fortes
mixMixmixmixmix Entrada04 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ânciaO quanto uma cor parece clara, a partir do vermelho, verde e azul lineares, pesados pela sensibilidade do olho a cada um. Luminância O quanto uma cor parece clara, a partir do vermelho, verde e azul lineares, pesados pela sensibilidade do olho a cada um. Operador de Sobel Um par de kernels 3×3 que medem o quanto a imagem muda na horizontal e na vertical. Combinados, dão uma força de borda por pixel, grande onde a imagem muda de repente. Kernel Uma pequena grade de pesos. Cada pixel de saída é a soma ponderada dos vizinhos, o que desfoca, aumenta a nitidez ou encontra bordas, conforme os pesos.Y = 0.2126 R + 0.7152 G + 0.0722 Bedge = length(vec2(gx, gy))out = Σ weight(i, j) · texel(x + i, y + j)
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 smoothstepUm limiar suave. Vale 0 abaixo da primeira borda, 1 acima da segunda e segue uma curva em S com as pontas planas entre elas. Smoothstep Um limiar suave. Vale 0 abaixo da primeira borda, 1 acima da segunda e segue uma curva em S com as pontas planas entre elas.t = clamp((x − e0) / (e1 − e0), 0, 1); s = t² (3 − 2t)
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 bufferUma imagem do tamanho do framebuffer que guarda, para cada pixel, a distância da superfície mais próxima desenhada até agora. O teste de profundidade a usa para esconder o que fica atrás.Renderização
Buffer de profundidade
Uma imagem do tamanho do framebuffer que guarda, para cada pixel, a distância da superfície mais próxima desenhada até agora. O teste de profundidade a usa para esconder o que fica atrás.
Fontes, 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
#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);}// 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.