Pular para o conteúdo
Fundamento · AmostragemIntermediário

Kernels e convolução

Somas ponderadas de texels vizinhos que desfocam, aumentam a nitidez ou encontram bordas, e por que um desfoque gaussiano roda em duas passadas.

Como funciona

Uma única leitura de textura conta a um pixel sobre um ponto só. Muitos efeitos precisam da vizinhança: um desfoque tira a média dela, um filtro de nitidez exagera a diferença em relação a ela, um contorno procura uma mudança brusca nela. Todos são a mesma operação, a convolução. Para cada pixel de saída, leia os texels em volta, multiplique cada um por um peso e some os resultados. A grade de pesos é o kernel.

Um desfoque de caixa 3×3

O kernel mais simples dá o mesmo peso, 1/9, aos nove texels em volta do pixel:

// no escopo do arquivo:
uniform sampler2D uImage;
// dentro de main():
vec2 texel = 1.0 / vec2(textureSize(uImage, 0));
vec3 sum = vec3(0.0);
for (int y = -1; y <= 1; y++) {
    for (int x = -1; x <= 1; x++) {
        sum += texture(uImage, uv + vec2(x, y) * texel).rgb;
    }
}
vec3 blurred = sum / 9.0;

Duas regras sobre os pesos dizem o que um kernel faz antes de você rodá-lo. Quando somam 1, uma área de cor lisa sai igual, então o kernel mantém o brilho: desfoques e filtros de nitidez. Quando somam 0, uma área lisa sai preta e só as mudanças sobrevivem: detectores de borda.

Perto das bordas da imagem, os kernels leem além do limite. O modo de repetição decide o que encontram ali, e clamp to edge é a escolha comum; com repeat, a borda oposta vaza para dentro.

Nitidez

Um kernel de nitidez dá ao centro um peso acima de 1 e subtrai os vizinhos, então afasta cada pixel do seu entorno. Com os quatro vizinhos diretos:

vec3 center = texture(uImage, uv).rgb;
vec3 around = texture(uImage, uv + vec2(texel.x, 0.0)).rgb
            + texture(uImage, uv - vec2(texel.x, 0.0)).rgb
            + texture(uImage, uv + vec2(0.0, texel.y)).rgb
            + texture(uImage, uv - vec2(0.0, texel.y)).rgb;
vec3 sharp = center * 5.0 - around; // pesos 5 e quatro vezes -1: somam 1

O resultado pode ficar abaixo de 0 e acima de 1, o que aparece como halos escuros e claros ao longo das bordas. Misture com o original para controlar a força.

Bordas com Sobel

O operador de Sobel é um par de kernels 3×3 que estimam a rapidez com que o brilho muda na horizontal e na vertical. Cada um pesa a linha ou a coluna do meio o dobro dos cantos, e cada um soma 0:

float luma(vec2 p) { return dot(texture(uImage, p).rgb, vec3(0.2126, 0.7152, 0.0722)); }

// dentro de main():
float gx = 0.0;
float gy = 0.0;
for (int y = -1; y <= 1; y++) {
    for (int x = -1; x <= 1; x++) {
        float v = luma(uv + vec2(x, y) * texel);
        gx += float(x) * (y == 0 ? 2.0 : 1.0) * v; // coluna da direita menos a da esquerda
        gy += float(y) * (x == 0 ? 2.0 : 1.0) * v; // linha de cima menos a de baixo
    }
}
float edge = length(vec2(gx, gy)); // 0 nas áreas lisas, grande nas bordas

Rodar sobre a luminância em vez de cada canal encontra as bordas que o olho vê e custa um conjunto só de leituras. A direção de vec2(gx, gy) é a direção da mudança mais forte, perpendicular à borda. Rode sobre a profundidade ou as normais em vez da cor e ele encontra as silhuetas e os vincos que os efeitos de contorno desenham.

Desfoque gaussiano e passadas separáveis

Um desfoque de caixa tem uma área quadrada, de borda dura, que transforma detalhes claros pequenos em quadrados visíveis. Um desfoque gaussiano pesa os texels por uma curva de sino da distância, então o centro conta mais e os pesos somem suavemente. As linhas do triângulo de Pascal são uma aproximação inteira rápida: 1 2 1 (dividido por 4), 1 4 6 4 1 (dividido por 16).

O gaussiano tem uma propriedade que torna desfoques grandes acessíveis: ele é separável. O kernel 2D é igual a um kernel 1D horizontal seguido de um vertical. Um desfoque 9×9 lido direto custa 81 leituras por pixel; como uma passada horizontal para uma textura intermediária e depois uma passada vertical sobre ela, custa 9 + 9 = 18. Desfoques de caixa também são separáveis, e o Sobel também, já que cada kernel dele é uma suavização 1 2 1 vezes uma diferença −1 0 1. O preço é uma segunda passada e um lugar para guardar o resultado entre elas, um render target.

A filtragem bilinear corta as leituras de novo. Uma leitura posta entre dois texels, no ponto ponderado pelos pesos do kernel, devolve os dois misturados na proporção certa: para pesos w1 e w2 nos deslocamentos o1 e o2, amostre em (o1 * w1 + o2 * w2) / (w1 + w2) e multiplique por w1 + w2. Um gaussiano 1D de 9 amostras vira 5 leituras.

Custo e cor

Cada amostra é uma leitura de textura, e as leituras costumam ser o que limita a velocidade de um kernel, então conte: um kernel 3×3 são 9 por pixel, um de 13×13 na tela inteira são 169 por pixel. Raios grandes vêm de passadas separáveis, de texturas reduzidas (um desfoque numa imagem com metade do tamanho cobre o dobro do raio com um quarto dos pixels) ou das duas coisas.

Tire a média da luz, não dos números codificados: desfoque em cor linear, senão os detalhes claros perdem energia e aparecem halos escuros em volta deles.

Leitura complementar