2 pontos por GN⁺ 2023-10-27 | 1 comentários | Compartilhar no WhatsApp
  • Partindo de uma animação simples de CSS border-radius na página inicial, implementa diretamente uma animação suave de blob com algumas linhas de fragment shader
  • Shader é um pequeno programa que, na GPU, transforma coordenadas de pixels em cores; graças à execução paralela, é rápido, mas tem limitações em compartilhamento de estado e abstrações de alto nível
  • O exemplo básico em GLSL mapeia coordenadas vUv para valores RGBA de gl_FragColor, apresentando a sintaxe de varying, uniform, tipos vetoriais e swizzling
  • Círculos e blobs são criados com distance(), step(), smoothstep() e Signed Distance Function (SDF), combinando vários SDFs com min() ou smooth minimum
  • Com u_time, o movimento das esferas é animado, e ao adicionar u_mouse ao array de centros, o shader evolui para um shader interativo em que o usuário controla parte da composição com o mouse

O que shaders fazem e suas limitações

  • Shader é um pequeno programa executado na GPU que, no mínimo, recebe coordenadas de pixels como entrada e produz uma cor como saída
  • Em videogames, é usado para efeitos visuais como iluminação, efeitos especiais e renderização com estilo cartoon, sendo uma base importante dos gráficos modernos
  • A chave da velocidade está na paralelização, já que ele roda ao mesmo tempo em vários pixels
  • Esta introdução foca em GLSL, a OpenGL Shading Language amigável para navegadores
  • Em troca de desempenho, shaders precisam ser escritos de forma pequena e de baixo nível
    • É difícil depender de abstrações de alto nível ou importar bibliotecas
    • Por causa da execução paralela, funciona de forma sem estado e sem memória, sem possibilidade de armazenar ou compartilhar dados entre pixels

Primeiro exemplo em GLSL: transformar coordenadas em cor

  • O shader converte coordenadas normalizadas em cores RGBA
    • As coordenadas geralmente são normalizadas entre 0 e 1
    • (0, 0) é o canto inferior esquerdo, e (1, 1) é o canto superior direito
    • Por convenção, essas coordenadas costumam ser chamadas de st ou uv
  • O exemplo mais simples é um gradiente em que o vermelho aumenta conforme a coordenada x cresce, e o verde aumenta conforme a coordenada y cresce
varying vec2 vUv;

void main() {
  vec2 st = vUv;
  gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
}
  • Um gradiente azul pode ser feito colocando a coordenada x no canal azul, como em gl_FragColor = vec4(0.0, 0.0, st.x, 1.0);
  • Elementos importantes da sintaxe:
    • varying: entrada cujo valor muda em cada pixel
    • uniform: entrada igual para todos os pixels
    • vec2, vec3, vec4, mat2, mat3: tipos de vetores e matrizes com tipagem explícita, como em C
    • swizzling: notação para extrair parte de um vetor, como vec4(1, 2, 3, 4).xy
    • gl_FragColor: saída que define a cor de cada pixel ao fim de main()

Criando círculos com distance(), step() e smoothstep()

  • Mesmo ao desenhar formas nítidas como círculos, em vez de usar uma função como drawCircle(), utiliza-se distância matemática
  • A distância entre o pixel atual e o centro do círculo pode ser calculada com distance(vec2 p1, vec2 p2)
  • Se a distância for mapeada diretamente para a cor, o resultado é um gradiente circular; um círculo sólido é obtido com step(float threshold, float value)
    • Se a distância ultrapassar o limiar, retorna 1
    • Caso contrário, retorna 0
  • step() faz uma transição abrupta e pode causar aliasing nas bordas do círculo
  • Bordas mais suaves podem ser criadas com smoothstep(float t_start, float t_end, float x)

Representando formas com Signed Distance Function

  • Signed Distance Function (SDF) representa, com distância com sinal, o quanto um ponto no espaço está afastado de uma forma
    • Dentro da forma, o valor é negativo
    • Fora da forma, o valor é positivo
    • Na borda, é 0
  • O SDF de um círculo pode ser feito subtraindo o raio da distância ao centro
float circleSDF(vec2 p, float r) {
    return length(p) - r;
}
  • Ao deslocar o ponto com base na posição do pixel uv, é possível calcular a distância até um círculo em qualquer posição no espaço UV
  • Se d < 0.0, o pixel está dentro do círculo e pode receber outra cor
  • Uma variedade maior de formas 2D com SDF pode ser vista na lista abrangente de Inigo Quilez

Combinando vários SDFs para criar blobs

  • SDFs facilitam a criação de novas formas com operações booleanas
  • A união de dois SDFs pode ser obtida usando min() entre as duas distâncias
    • Se estiver dentro de qualquer uma das duas formas, a menor distância será negativa
    • Se estiver fora de ambas, a menor distância será positiva
  • O motivo para usar 1. - smoothstep() é que step() e smoothstep() retornam 1 para distâncias maiores que o limiar, ou seja, fora da forma
  • Um min() simples cria uma descontinuidade aguda no ponto em que dois círculos se encontram
  • Para uma mistura suave, no estilo blob, usa-se smooth minimum
    • O parâmetro adicional k controla a intensidade da suavidade
    • O exemplo usa uma função polynomial smooth min
float smin(float a, float b, float k)
{
    float h = max( k-abs(a-b), 0.0 )/k;
    return min( a, b ) - h*h*k*(1.0/4.0);
}

Animando metaballs com o uniform de tempo

  • Em shaders, é possível passar valores arbitrários como uniforms, como se fossem sliders
  • A animação funciona enviando u_time, criado em JavaScript, como entrada para o shader, e usando esse valor no cálculo das coordenadas dos centros dos círculos
  • O shader é atualizado naturalmente 60 vezes por segundo com novos valores de u_time, gerando movimento suave
  • Os centros dos círculos podem oscilar com funções periódicas como sin e cos
  • Ao combinar vários círculos como metaballs, as coordenadas dos centros são armazenadas em um array e seus SDFs são acumulados em um loop
vec2 centers[4] = vec2[4](c1,c2,c3,c4);

float d = 99.;

for (int i = 0; i < 4; i++) {
  vec2 c = centers[i];
  float sdf = circleSDF(uv, c, .1*u_slider);
  d = smin(d, sdf, K);
}
  • O blob resultante funciona, mas ainda é monocromático, então depois são adicionados cor e interação

Interação final controlada pelo mouse

  • A etapa final recebe as coordenadas do mouse pelo uniform u_mouse, permitindo que o usuário controle diretamente uma das esferas dentro do blob
uniform vec2 u_mouse;
  • Ao adicionar as coordenadas do mouse ao array de centros das esferas, a interação entra com uma única linha
vec2 centers[5] = vec2[5](c1,c2,c3,c4,u_mouse);
  • O shader final inverte o eixo y do mouse para corresponder às coordenadas do canvas e combina os 4 centros em movimento com o centro controlado pelo mouse
  • As cores são compostas com várias chamadas a mix(colorA, colorB, percent)
    • Quando percent é usado como booleano, o comportamento se aproxima de um if/else
    • O valor de metaball, a distância aos centros, o brilho e o cálculo da membrana influenciam a cor final
  • Entendendo esse mesmo princípio, também fica mais fácil compreender o funcionamento interno de editores visuais de shader baseados em nós, como Blender’s shader nodes e Unity’s Shader Graph

Materiais adicionais

1 comentários

 
GN⁺ 2023-10-27
Comentários do Hacker News
  • Finalmente criei coragem para escrever na internet e me expor. Há tempos eu queria aprender shaders, e achei que seria bom documentar o processo de aprendizado e compartilhar com outras pessoas

    • Gosto muito do fato de você ter escrito no seu blog pessoal e transformado em um texto interativo. É diferente de alimentar o algoritmo do Google ou o copia-e-cola de IA com feeds horríveis do Notion/Medium/Dev.
      O blogging sincero está morrendo invisível à sombra dos algoritmos todo-poderosos, e a internet parece já estar no meio do Episódio IV de Star Wars. Meu único feedback é: obrigado, e espero que você escreva mais
    • Bem-vindo à internet, superMayo!
      Se quiser ver o que os mestres conseguem fazer com shaders, gostaria de recomendar Inigo Quilez e sua arte com shaders: https://www.youtube.com/watch?v=BFld4EBO2RE
      Adendo: não tinha percebido que você era o autor. O texto ficou muito bom, e eu estava procurando tutoriais que tratassem a arte de programar shaders de uma forma mais acessível e interativa
    • Se você estiver disposto a usar a extensão OES_standard_derivatives, que é bem suportada, ou WebGL 2, dá para melhorar o antialiasing. Em vez de escolher uma constante no chute, como em smoothstep(0.0f, 0.01f, dist);, use smoothstep(fwidth(dist), -fwidth(dist), dist);
    • Considerando que shaders são algo totalmente gráfico, este texto parece uma das melhores formas possíveis de introduzir shaders. O jeito como o código interativo está incorporado diretamente no texto, em vez de ser jogado para links de exercícios, funciona muito bem
  • O texto é bem bom, mas passa rápido demais pelo problema central dos shaders.
    Shaders são uma dor de cabeça que a maioria dos programas e aplicações não quer. 3D gosta de triângulos, a GPU se encaixa bem nessa abstração, e shaders são úteis para interpolar sobre esses triângulos
    Mas, em quase tudo que não é 3D, triângulos não são lá grande coisa. Renderização 2D quer caminhos, renderização de fontes quer caminhos ou pixmaps, GUIs combinam muito melhor com caminhos e pixmaps, compositores querem pixmaps, e decodificadores de vídeo querem pixmaps e renderização paralela
    O que o lado não 3D quer são pixmaps retangulares e uma forma de acessar esses pixmaps diretamente por computação, mas GPUs não gostam muito disso, e shaders também não se encaixam bem aí

    • Como alguém que já escreveu um renderizador de curvas 2D, eu precisava de computação paralela e alta largura de banda, e a GPU fornece isso. Especialmente nas interfaces novas que suportam scatter-write, embora eu não saiba bem quão difundidas elas estão nos navegadores
      É verdade que, em alto nível, essa não é a forma desejada, mas é uma base boa o bastante para implementar abstrações de nível mais alto. O hardware poderia dar suporte direto, mas as vantagens não são tão claras. Ninguém reclama que a CPU não dá suporte a loops for em nível de arquitetura
      Relendo, não entendo muito bem qual seria o problema com GPUs. Dá para ignorar completamente o pipeline de processamento de vértices e simplesmente desenhar um retângulo de tela cheia, ou usar shaders de computação, e a GPU lida muito bem com isso. O texto linkado também fala desse tipo de coisa
    • Vendo pelo lado oposto, a maioria dos programas não precisa necessariamente de uma GPU para obter o resultado desejado. Já em 3D, essa paralelização se torna praticamente indispensável assim que se passa de uma escala muito pequena
      Pelo menos era assim que se pensava nos anos 2000. Conforme o software ficou mais complexo, a GPU pode ter se tornado uma otimização desejável, mas o pipeline de GPU sempre foi rígido e fechado, e a mudança de programação single-core para multi-core muitas vezes exige algoritmos diferentes
      Programação de GPU de propósito geral livra você da dependência de triângulos, mas, para aproveitar esse paralelismo, ainda é preciso uma abordagem completamente diferente
    • Não entendo que parte de um pixel/fragment shader não mapeia bem para pixmaps
  • Legal! Recentemente eu estava entrando um pouco na toca do coelho dos SDFs. Fiquei feliz em ver o link para o site do iq; o material dele é realmente muito bom
    Não posso deixar de linkar o shader “happy bouncing” dele. Pessoalmente, acho impressionante: https://www.shadertoy.com/view/3lsSzf
    Também há um vídeo de 6 horas no YouTube mostrando o processo de criação. São cerca de 500 linhas de código bem densas

    • Inigo Quilez é um mago, e a comunidade de shaders deve muito a ele
  • Tentei várias vezes me interessar por esse assunto, mas nunca encontrava uma porta de entrada acessível; encontrei nesta introdução. É uma abordagem realmente divertida e brincalhona, e mal posso esperar para ler o próximo texto

  • Uma implicância pequena: ao mencionar cel shading, você escreveu cell. O termo vem do cel usado na animação desenhada à mão e dos tons quantizados desse tipo de shading

  • Muito bom. Como alguém que era artista e virou programador, de vez em quando sinto vontade de explorar programação gráfica
    Já escrevi alguns shaders bem básicos, mas, quando a matemática começa a entrar, na verdade bem cedo, eu bato numa parede. Fiz artes, não ciência da computação, então minhas habilidades matemáticas são praticamente inexistentes
    De todo modo, o texto está bem escrito e eu gostei

  • Excelente texto introdutório, e espero que continue. Textos assim começam com uma ótima introdução e, com frequência demais, acabam parando por aí

  • Nunca mexi com shaders, então talvez seja uma pergunta bem básica. Em um frame de um jogo, essencialmente é o shader que desenha tudo?
    Ou existem formas básicas como triângulos, quadrados e círculos, e o shader acrescenta coisas por cima, como sombras ou correção de bordas?
    Pelos exemplos, parece que dá para criar um shader que desenha qualquer objeto na cena e depois combinar outros shaders para obter sombras, iluminação e coisas do tipo. Na minha experiência muito limitada desenhando, eu desenhava formas, não desenhava com shaders. Eu sempre achei que shaders não desenhavam o objeto em si

    • Abaixo vai uma explicação simplificada do processo clássico de renderização direta; a chamada renderização diferida é um pouco diferente
      A GPU transforma formas vetoriais abstratas, como um triângulo definido por três vértices e dados associados a cada vértice, como normais, em um fluxo de fragmentos, um para cada pixel que a forma cobre no buffer de saída — ou mais, se houver multisampling. Essa parte é toda tratada pelo hardware
      Um fragmento consiste em coordenadas de pixel e dados fornecidos pelo usuário. Esses dados podem ser uniforms, que são constantes, ou varyings, que são os dados de vértice mencionados antes interpolados ao longo de toda a face do triângulo. Essa interpolação também é feita pelo hardware e não pode ser programada
      O fragment shader recebe um fragmento como entrada, calcula uma cor com base nesses dados e, depois de mais algumas etapas, a emite como a cor daquele pixel na tela ou em um buffer off-screen. Pode ser uma cor sólida ou um cálculo complexo de iluminação
      Na renderização por GPU, tudo isso acontece em paralelo em grande escala, com inúmeros fragmentos sendo processados ao mesmo tempo. Um shader é uma função pura e sem estado, então só consegue acessar suas entradas, e os efeitos que pode produzir se limitam a retornar algumas coisas, como cor e valor de profundidade
      Em resumo: o hardware da GPU calcula quais pixels precisam ser preenchidos para desenhar cada triângulo, e o fragment shader determina o valor de cor de cada um desses pixels
    • Shaders até fazem todo o desenho, mas isso é dividido em várias etapas. Não vou explicar todo o pipeline gráfico[1], mas boa parte — mais de 90% — do que as pessoas chamam de “shader” de forma genérica para efeitos de iluminação é a etapa de fragment/pixel shader do renderizador
      Antes de o fragment shader desenhar “sobre” a cena, outras etapas, como vértices e tesselação, desenham as formas básicas
      Mesmo sobre fragment shaders há muito mais coisa do que foi explicado. Um exemplo é a renderização diferida[2], que também vira um assunto grande quando você entra nele
      1: https://vulkan-tutorial.com/Drawing_a_triangle/Graphics_pipe...
      2: https://learnopengl.com/Advanced-Lighting/Deferred-Shading
    • Sim, a cor de todos os pixels no fim é decidida por um programa de shader, mas, como você deve imaginar, é mais complexo que isso
      Existe um pipeline gráfico que mistura etapas de hardware de função fixa com etapas programáveis. Em alto nível: 1) a GPU recebe da CPU um conjunto de triângulos 3D; 2) o vertex shader transforma e “achata” os vértices dos triângulos 3D em vértices de triângulos 2D com coordenadas de pixel; 3) a GPU rasteriza os triângulos 2D e decide exatamente quais pixels são cobertos por cada triângulo; 4) para cada pixel coberto, o pixel shader é executado para determinar sua cor; 5) a cor resultante do pixel é armazenada no frame buffer. Nesse momento, ela também pode ser combinada por blending com uma cor existente
      Esse pipeline se repete várias vezes, com diferentes malhas de triângulos e shaders, até que o frame inteiro seja desenhado
    • No geral, você entendeu certo. Pelo que sei, todo tipo de código que roda na GPU é chamado de shader. Por exemplo, operações especiais sobre dados também são chamadas de compute shaders, embora tenham natureza diferente
      Tarefas como cor, sombras, shading, efeitos de imagem e processamento geral de imagens são todas feitas como cálculos paralelos que combinam vários arrays de dados. Isso inclui vértices e seus atributos, texturas de origem, funções pré-calculadas, texturas de destino, buffers etc.
      Por exemplo, para obter luz e sombras, o shader precisa conseguir acessar, provavelmente por variáveis globais, coisas como a posição e a direção de um spotlight. Iluminação composta muitas vezes é obtida combinando vários passes de shader. Pode haver um passe básico de iluminação global e um passe para cada luz, com cada passe literalmente somando luz
      Agora, para não somar luz aos pixels em que a fonte de luz está bloqueada, ou seja, às sombras, a técnica mais comum é usar algo chamado Z-buffer. Na prática, é uma textura de ponto flutuante. Como queremos saber até onde cada luz da cena alcança, antes de aplicar toda a iluminação configuramos um único passe de shader que combina toda a geometria sólida da cena, usando a posição e a direção da luz como se fossem a transformação da câmera, e usamos um shader especial que só escreve no Z-buffer a distância até os objetos
      Depois, sempre que quisermos saber se a luz chega a um ponto no espaço, após um pouco de geometria amostramos esse Z-buffer e comparamos o valor armazenado naquela direção com a distância até o ponto. Pode ser cheio de bugs, e erros de precisão são comuns. Um motor decente já faz isso por você, mas permite ajustar o processo
      O resto são variações desse tema. Renderização diferida é renderizar dados, e não cores, em texturas intermediárias para processá-los depois e obter a cor. Efeitos de blur são tratados como uma convolução 2D de uma textura de renderização, por exemplo com um kernel gaussiano. Tessellation shaders têm a ver com gerar nova geometria a partir do vertex shader. Até desenhar texto é feito por meio de um atlas de fontes e pequenos retângulos
    • Dá para desenhar qualquer objeto com um fragment shader, isto é, um pixel shader. Isso porque é possível usar técnicas matemáticas específicas para desenhar formas, independentemente da tecnologia, como SDF ou trigonometria
      Por isso artistas talentosos empurram os limites dentro de fragment shaders e brigam com os compromissos de desempenho
      Fragment shaders são usados mais comumente para efeitos de filtro em tela cheia, como correção de cor
      Shaders também são usados para criar texturas e materiais de objetos básicos. Artistas de materiais muitas vezes geram texturas com matemática de shaders
      Muitos efeitos visuais são criados por meio do uso criativo de shaders
      Shaders rodam na GPU de uma forma paralela, em ondas. Um número muito grande de threads executa sobre os mesmos dados dentro de uma wave
      Em alguns casos, shaders são muito mais rápidos do que código com branches na CPU. Shaders também conseguem acessar alguns dados de renderização com mais facilidade

Por isso, é um ótimo espaço para criar efeitos especiais criativos. Em jogos, objetos com muitos detalhes de superfície são candidatos comuns para transferir esses detalhes para shaders. É o caso de superfícies oceânicas, malhas com tesselação etc.; e, como a GPU é poderosa e flexível, há muitos outros usos.

  • Se alguém estiver vendo a imagem como um ruído piscando, resolvi copiando a imagem no navegador e colando em outro lugar. Assim dá para ver a imagem corretamente
    É um link do Imgur. A primeira imagem é uma captura de tela do que aparece no navegador, e as demais são as imagens reais depois de coladas no Imgur
    https://imgur.com/a/F4203rz

    • Estranho. Qual navegador você está usando?
  • Meu entendimento sobre shaders é o seguinte.
    Ao desenhar uma linha na CPU, é uma função que percorre cada pixel entre o ponto A e o ponto B e desenha um pixel por vez, sequencialmente. Há exatamente tantas etapas quanto o número de pixels na linha, e ela é executada uma vez
    Ao desenhar uma linha na GPU, é uma função que verifica se aquele pixel está sobre a linha e, se estiver, o desenha. Ela é executada simultaneamente em todos os pixels da tela, incluindo até pixels bem distantes da linha
    Está certo?

    • Não exatamente. Primeiro, a GPU não consegue executar o pixel shader simultaneamente para todos os pixels. Uma tela comum tem cerca de 2 milhões de pixels, enquanto a GPU tem, no máximo, alguns milhares de threads de execução simultâneas; então, na prática, ela desenha em blocos de alguns milhares de pixels
      Segundo, a GPU também não precisa executar o pixel shader para a tela inteira. Usando triângulos, é possível executar o shader apenas para qualquer forma arbitrária desejada. Por isso, a forma eficiente de desenhar uma linha é enviar para a GPU dois triângulos que correspondam à geometria da linha desejada e executar o pixel shader apenas nos pixels sobrepostos por esses triângulos. É muito mais eficiente