Guia de iniciantes para programação de shaders gráficos
() translation by (you can also view the original English article)
Aprender a escrever shaders é aprender a aproveitar o poder da GPU, com seus milhares de núcleos todos funcionando em paralelo. É um tipo de programação que requer uma mentalidade diferente, mas libertar este potencial faz valer a pena as dificuldades iniciais.
Praticamente todas as simulações gráficas modernas que você vê é de alguma forma pelo código escrito para a GPU, os efeitos de iluminação realistas em jogos AAA, efeitos de pós-processamento 2D e simulações de fluidos.



O objetivo deste guia
Programar shaders as vezes é como magia negra e muitas vezes é incompreendido. Há muitos exemplos de código por aí que mostram como criar efeitos incríveis, mas oferecem pouca ou nenhuma explicação. Este guia pretende preencher essa lacuna. Vou me concentrar mais sobre os conceitos básicos da escrita e compreensão do código do shader, assim você pode facilmente ajustar, combinar ou escrever do zero!
Este é um guia geral, então o que você vai aprender aqui pode ser aplicado a qualquer coisa que execute shaders.
Então o que é um shader?
Um shader é simplesmente um programa que é executado no pipeline gráfico e diz ao computador como processar cada pixel. Estes programas são chamados de shaders, porque eles são muitas vezes usados para controlar a iluminação e efeitos de sombreamento, mas não há porque eles não possam lidar com outros efeitos especiais.
Shaders são escritos em uma linguagem especial. Não se preocupe, você não tem que aprender uma linguagem completamente nova; Nós usaremos GLSL (OpenGL Shading Language) que é uma linguagem de tipo C. (Há um monte de linguagens de shader para diferentes plataformas, mas como todas estão adaptadas para executar na GPU, são todas muito parecidas.)
Vamos começar!
Nós vamos usar o ShaderToy para este tutorial. Isso permite que você comece a programar direto no seu navegador, sem a necessidade de configurar nada! (Ele usa WebGL para renderizar, então você vai precisar de um navegador que suporte isso.) Criar uma conta é opcional, mas é útil para salvar seu código.
Nota: O ShaderToy está em beta no momento da escrita deste artigo. Alguns pequenos detalhes da interface de usuário ou sintaxe podem estar ligeiramente diferentes.
Ao clicar em New Shader, você deve ver algo parecido com isto:



A pequena seta preta na parte inferior é onde você clica para compilar o seu código.
O que está acontecendo?
Estou prestes a explicar como shaders funcionam em uma frase. Está pronto? Lá vai!
A única finalidade de um shader é retornar quatro números: r
, g
, b
e a
.
É tudo o que ele pode fazer. A função que você vê na sua frente é executada para cada pixel na tela. Ela retorna os valores de quatro cores, e isso torna-se a cor do pixel. Isto é o que se chama um Pixel Shader (também conhecido como Fragment Shader).
Com isso em mente, vamos tentar transformar nossa tela em um vermelho sólido. Os valores rgba (vermelho, verde, azul e "alfa", que define a transparência) vão de 0
a 1
, então tudo o que precisamos fazer é retornar r, g, b, a = 1,0,0,1
. O ShaderToy espera que a cor final do pixel seja armazenada em um fragColor
.
1 |
void mainImage( out vec4 fragColor, in vec2 fragCoord ) |
2 |
{
|
3 |
fragColor = vec4(1.0,0.0,0.0,1.0); |
4 |
}
|
Parabéns! Este é seu primeiro trabalho shader funcional!
Desafio: Você pode mudá-lo para a cor cinza sólido?
vec4
é apenas um tipo de dados, então poderíamos declarar nossa cor como uma variável, da seguinte forma:
1 |
void mainImage( out vec4 fragColor, in vec2 fragCoord ) |
2 |
{
|
3 |
vec4 solidRed = vec4(1.0,0.0,0.0,1.0); |
4 |
fragColor = solidRed; |
5 |
}
|
Isto não é muito interessante, eu sei. Nós temos o poder de executar este código centenas de milhares de pixels paralelamente e estamos mudando todos para a mesma cor.
Vamos tentar renderizar um gradiente na tela. Bem, não podemos fazer muito sem saber algumas coisas sobre o pixel que está sendo afetando, tais como sua localização na tela...
Entradas do shader
O shader de pixel passa algumas variáveis para você usar. O mais útil para nós é a fragCoord
, que detém do pixel as coordenadas x e y (e z, se estiver trabalhando em 3D). Vamos tentar transformar todos os pixels da metade esquerda da tela em preto e todos da metade direita em vermelho:
1 |
void mainImage( out vec4 fragColor, in vec2 fragCoord ) |
2 |
{
|
3 |
vec2 xy = fragCoord.xy; //We obtain our coordinates for the current pixel |
4 |
vec4 solidRed = vec4(0,0.0,0.0,1.0);//This is actually black right now |
5 |
if(xy.x > 300.0){//Arbitrary number, we don't know how big our screen is! |
6 |
solidRed.r = 1.0;//Set its red component to 1.0 |
7 |
}
|
8 |
fragColor = solidRed; |
9 |
}
|
Nota: Para qualquer vec4
, você pode acessar seus componentes através de obj.x
, obj.y
, obj.z
e obj.w
ou obj.r
, obj.g
, obj.b
, obj.a
. Eles são equivalentes; é apenas uma maneira conveniente de nomeá-los para tornar seu código mais legível, para que quando os outros virem obj.r
, entendem que obj
representa uma cor.
Você vê algum problema com o código acima? Tente clicar no botão go fullscreen no canto inferior direito da sua janela de visualização.
A proporção da parte vermelha da tela será diferente dependendo do tamanho da tela. Para garantir que exatamente a metade da tela está vermelha, nós precisamos saber quão grande é a nossa tela. O tamanho da tela não é uma variável como era a localização do pixel, porque normalmente é você, programador, que construiu o app, que definirá isso. Neste caso, Os desenvolvedores do ShaderToy definiram o tamanho da tela.
Se algo não está embutido em uma variável, você pode enviar essa informação da CPU (seu programa principal) para a GPU (seu shader). ShaderToy lida com isso para nós. Você pode ver todas as variáveis que são passadas na aba Shader Inputs.. Variáveis passadas desta forma da CPU para a GPU são chamadas de uniform em GLSL.



Vamos ajustar nosso código acima para obter corretamente o centro da tela. Nós precisamos usar a entrada iResolution
:
1 |
void mainImage( out vec4 fragColor, in vec2 fragCoord ) |
2 |
{
|
3 |
vec2 xy = fragCoord.xy; //We obtain our coordinates for the current pixel |
4 |
xy.x = xy.x / iResolution.x; //We divide the coordinates by the screen size |
5 |
xy.y = xy.y / iResolution.y; |
6 |
// Now x is 0 for the leftmost pixel, and 1 for the rightmost pixel
|
7 |
vec4 solidRed = vec4(0,0.0,0.0,1.0); //This is actually black right now |
8 |
if(xy.x > 0.5){ |
9 |
solidRed.r = 1.0; //Set its red component to 1.0 |
10 |
}
|
11 |
fragColor = solidRed; |
12 |
}
|
Se você tentar ampliar a janela de visualização desta vez, as cores devem estar perfeitamente divididas ao meio.
Da divisão ao gradiente
Transformar isso em um gradiente é muito fácil. Os valores de uma cor vão de 0
a 1
, e as nossas coordenadas agora vão de 0
a 1
também.
1 |
void mainImage( out vec4 fragColor, in vec2 fragCoord ) |
2 |
{
|
3 |
vec2 xy = fragCoord.xy; //We obtain our coordinates for the current pixel |
4 |
xy.x = xy.x / iResolution.x; //We divide the coordinates by the screen size |
5 |
xy.y = xy.y / iResolution.y; |
6 |
// Now x is 0 for the leftmost pixel, and 1 for the rightmost pixel
|
7 |
vec4 solidRed = vec4(0,0.0,0.0,1.0); //This is actually black right now |
8 |
solidRed.r = xy.x; //Set its red component to the normalized x value |
9 |
fragColor = solidRed; |
10 |
}
|
E voalá!
Desafio: você consegue transformar isso em um gradiente vertical? E diagonal? Que tal um gradiente com mais de uma cor?
Se você mexer um pouco com isso, você pode dizer que o canto superior esquerdo tem as coordenadas (0,1)
, e não (0,0)
. É importante ter isso em mente.
Desenhar imagens
Brincar com cores é divertido, mas se queremos fazer algo impressionante, nosso shader tem que ser capaz de receber uma imagem de entrada e alterá-la. Desta forma podemos fazer um shader que afeta a tela inteira do jogo (como um efeito subaquático ou correção de cor) ou afetar apenas certos objetos em determinadas maneiras baseado nas entradas (como um sistema de iluminação realista).
Se nós estivéssemos programando em uma plataforma normal, precisaríamos enviar nossa imagem (ou textura) para GPU como um uniform, da mesma forma que você teria mandado a resolução da tela. ShaderToy cuida disso para nós. Existem quatro canais de entrada na parte inferior:



Clique em iChannel0 e selecione alguma textura (imagem) que você gosta.
Feito isso, você tem uma imagem que está sendo passada para o seu shader. Mas há um problema: não há nenhuma função DrawImage()
. Lembre-se, a única coisa que um pixel shader pode fazer é alterar a cor de cada pixel.
Então se podemos retornar apenas cor, como desenharemos nossa textura na tela? Precisamos mapear o atual pixel que nosso shader tem, ao pixel correspondente na textura:



Podemos fazer isso usando a função texture2D(texture,coordinates)
, que recebe uma textura e coordenadas (x, y)
como entrada e retorna a cor da textura nessas coordenadas como um vec4
.
Você pode mapear as coordenadas para a tela como desejar. Você poderia desenhar a textura inteira em um quarto da tela (pulando pixels, efetivamente diminuindo) ou só desenhar uma parte da textura.
Para os nossos propósitos, só queremos ver a imagem, então nós igualaremos os pixels 1:1:
1 |
void mainImage( out vec4 fragColor, in vec2 fragCoord ) |
2 |
{
|
3 |
vec2 xy = fragCoord.xy / iResolution.xy;//Condensing this into one line |
4 |
xy.y = 1.0 - xy.y; |
5 |
vec4 texColor = texture2D(iChannel0,xy);//Get the pixel at xy from iChannel0 |
6 |
fragColor = texColor;//Set the screen pixel to that color |
7 |
}
|
Com isso, nós temos a nossa primeira imagem!



Agora que você está expondo corretamente os dados de uma textura, você pode manipulá-la como quiser! Você pode esticá-la e dimensioná-la, ou brincas com as suas cores.
Vamos tentar adicionar um gradiente, semelhante ao que fizemos acima:
1 |
texColor.b = xy.x; |



Parabéns, você fez seu primeiro efeito de pós-processamento!
Desafio: você consegue escrever um shader que transforma a imagem em preto e branco?
Note que mesmo que seja uma imagem estática, o que você vê está acontecendo em tempo real. Você pode ver isso por si mesmo, substituindo a imagem estática por um vídeo: clique sobre a entrada iChannel0 novamente e selecione um dos vídeos.
Adicionando algum movimento
Até agora todos os nossos efeitos foram estáticos. Podemos fazer coisas muito mais interessantes, mediante a utilização das entradas que o ShaderToy nos dá. iGlobalTime
é uma variável que aumenta constantemente; nós pode usá-la como uma semente para fazer os efeitos periódicos. Vamos tentar brincar com as cores um pouco:
1 |
void mainImage( out vec4 fragColor, in vec2 fragCoord ) |
2 |
{
|
3 |
vec2 xy = fragCoord.xy / iResolution.xy; // Condensing this into one line |
4 |
xy.y = 1.0-xy.y; // Flipping the y |
5 |
vec4 texColor = texture2D(iChannel0,xy); // Get the pixel at xy from iChannel0 |
6 |
texColor.r *= abs(sin(iGlobalTime)); |
7 |
texColor.g *= abs(cos(iGlobalTime)); |
8 |
texColor.b *= abs(sin(iGlobalTime) * cos(iGlobalTime)); |
9 |
fragColor = texColor; // Set the screen pixel to that color |
10 |
}
|
Existem funções seno e cosseno construídas em GLSL, bem como muitas outras funções úteis, como o comprimento de um vetor ou a distância entre dois vetores. As cores não devem para ser negativas, então para termos certeza de que podemos obter o valor absoluto, usando a função abs
.
Desafio: você pode fazer um shader que alterna entre preto e branco e colorido?
Uma nota sobre depuração de shaders
Embora você possa depurar seu código e imprimir os valores de tudo para ver o que está acontecendo, isso não é realmente possível ao escrever shaders. Você pode encontrar algumas ferramentas de depuração específicas para sua plataforma, mas em geral a sua melhor aposta é definir o valor que você está testando a alguma imagem para pré-visualizar.
Conclusão
Isto é apenas o básico para trabalhar com shaders, mas estar habituado com estes fundamentos permitirá que você faça muito mais. Navegue pelos efeitos em ShaderToy e veja se você consegue entender ou replicar alguns deles!
Uma coisa que não mencionei neste tutorial são os Vertex Shaders. Eles ainda são escritos na mesma linguagem, mas funcionam em cada vértice em vez de cada pixel, e eles retornam uma posição, bem como uma cor. Vertex Shader geralmente são responsáveis por projetar uma cena 3D na tela (algo que está embutido na maioria dos processos gráficos). Pixel Shaders são responsáveis por muitos dos efeitos avançados que vemos, por isso é que eles são o nosso foco.
Desafio final: você consegue escrever um shader que remove a tela verde em vídeos no ShaderToy e adiciona um outro vídeo como pano de fundo para o primeiro?
Isso é tudo para este guia! Eu gostaria muito de ver seus comentários e perguntas. Se houver algo específico você quer aprender mais, por favor deixe um comentário. Guias futuros podem incluir temas como os conceitos básicos de iluminação, sistemas, ou como fazer uma simulação de fluidos, ou como criar shaders para uma plataforma específica.