- 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
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
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
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
smoothstep(0.0f, 0.01f, dist);, usesmoothstep(fwidth(dist), -fwidth(dist), dist);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í
É 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
forem nível de arquiteturaRelendo, 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
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
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
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 shadingMuito 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
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, ouvaryings, 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 programadaO 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
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
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
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
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
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?
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