Introdução aos sombreadores

Introdução

Já fiz uma introdução ao Three.js. Se você não leu, talvez seja do seu interesse, já que é a base que criarei durante este artigo.

Quero falar sobre sombreadores. O WebGL é brilhante, e, como já mencionei antes, o Three.js (e outras bibliotecas) faz um trabalho fantástico de abstrair as dificuldades para você. No entanto, em alguns casos, você vai querer ter um efeito específico ou se aprofundar um pouco mais na forma como essas coisas incríveis apareciam na tela, e os sombreadores provavelmente fazem parte dessa equação. Além disso, se você for como eu, vai da parte básica do último tutorial para algo um pouco mais complicado. Vamos trabalhar partindo do princípio de que você está usando o three.js, já que ele faz muito do trabalho para nós em termos de ativação do sombreador. No início, explicarei o contexto dos sombreadores e que, na última parte deste tutorial, entraremos em um território um pouco mais avançado. O motivo disso é que os sombreadores são incomuns à primeira vista e precisam explicar.

1. Nossos sombreadores

O WebGL não oferece o uso do pipeline fixo, que é uma forma abreviada de dizer que não há meios de renderizar o que é pronto para uso. O que ele oferece, no entanto, é o pipeline programável, que é mais eficiente, mas também mais difícil de entender e usar. Em resumo, o pipeline programável significa que, como programador, você assume a responsabilidade por gerar os vértices e assim por diante na tela. Os sombreadores fazem parte desse pipeline e há dois tipos deles:

  1. Sombreadores de vértice
  2. Sombreadores de fragmento

Tenho certeza que vocês vão concordar, ambas não significando absolutamente nada. O que você precisa saber sobre eles é que ambos são executados inteiramente na GPU da placa gráfica. Isso significa que queremos transferir tudo o que pudermos para eles, deixando a CPU para fazer outro trabalho. Uma GPU moderna é altamente otimizada para as funções exigidas pelos sombreadores, por isso é ótimo poder usá-la.

2. Sombreadores de vértice

Pegue uma forma primitiva padrão, como uma esfera. Ela é composta de vértices, certo? Um sombreador de vértice recebe cada um desses vértices por vez e pode mexer neles. Cabe ao sombreador de vértice o que ele realmente faz com cada um, mas tem uma responsabilidade: em algum momento, ele precisa definir algo chamado gl_Position, um vetor flutuante 4D, que é a posição final do vértice na tela. Esse é um processo bastante interessante por si só, porque na verdade estamos falando de conseguir uma posição 3D (um vértice com x, y, z) ou projetada em uma tela 2D. Felizmente, se estivermos usando algo como o three.js, teremos uma forma abreviada de definir o gl_Position sem que fique muito pesado.

3. Sombreadores de fragmentos

Temos nosso objeto com os vértices dele e os projetamos na tela 2D. Mas e as cores que usamos? E quanto a textura e iluminação? O sombreador de fragmento serve exatamente para isso. Assim como o sombreador de vértice, o sombreador de fragmento também tem apenas uma tarefa essencial: definir ou descartar a variável gl_FragColor, outro vetor flutuante 4D, que é a cor final do nosso fragmento. Mas o que é um fragmento? Pense em três vértices que formam um triângulo. Cada pixel dentro desse triângulo precisa ser desenhado. Um fragmento são os dados fornecidos por esses três vértices com a finalidade de desenhar cada pixel nesse triângulo. Por isso, os fragmentos recebem valores interpolados dos vértices constituintes deles. Se um vértice tiver a cor vermelha e o vizinho for azul, os valores de cor serão interpolados de vermelho para roxo até azul.

4. Variáveis de sombreador

Quando se trata de variáveis, você pode fazer três declarações: Uniformes, Atributos e Variações. Quando ouvi esses três pela primeira vez, fiquei muito confuso, porque eles não correspondem a nada com o qual já trabalhei. Mas veja como você pode pensar neles:

  1. Os uniformes são enviados para ambos os sombreadores de vértice e de fragmento e contêm valores que permanecem os mesmos em todo o frame que está sendo renderizado. Um bom exemplo disso pode ser a posição de uma luz.

  2. Atributos são valores aplicados a vértices individuais. Os atributos estão disponíveis apenas para o sombreador de vértice. Pode ser algo como cada vértice com uma cor distinta. Os atributos têm uma relação de um para um com vértices.

  3. Variações são variáveis declaradas no sombreador de vértice que queremos compartilhar com o sombreador de fragmento. Para fazer isso, garantimos que declaremos uma variável variável do mesmo tipo e nome no sombreador de vértice e no sombreador de fragmento. Um uso clássico disso seria o normal de um vértice, já que pode ser usado nos cálculos de iluminação.

Mais tarde, usaremos os três tipos para você ter uma ideia de como eles são aplicados.

Agora que falamos sobre sombreadores de vértice e de fragmento, e os tipos de variáveis com que eles lidam, vale a pena analisar os sombreadores mais simples que podemos criar.

5. Mundo de Bonjourno

Aqui está o Hello World dos sombreadores de vértice:

/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}   

Confira o mesmo valor para o sombreador de fragmento:

/**
* Set the colour to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}

Mas não é muito complicado, certo?

No sombreador de vértice, recebemos alguns uniformes do Three.js. Esses dois uniformes são matrizes 4D, chamadas de matriz de visualização e de projeção. Você não precisa saber exatamente como elas funcionam, mas é sempre melhor entender como as coisas funcionam, se possível. A versão abreviada é que eles representam como a posição 3D do vértice é realmente projetada para a posição 2D final na tela.

Na verdade, os excluí do snippet acima porque o three.js os adiciona à parte superior do código do sombreador para que você não precise se preocupar com isso. A verdade é que, na verdade, isso acrescenta muito mais do que isso, como dados de luz, cores de vértice e normais de vértices. Se você estivesse fazendo isso sem o Three.js, teria que criar e definir todos esses uniformes e atributos por conta própria. Verdade.

6. Como usar um MeshShaderMaterial

Temos um sombreador configurado, mas como podemos usá-lo com o Three.js? É muito fácil. É mais ou menos assim:

/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader:   $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});

Depois, o Three.js vai compilar e executar os sombreadores anexados à malha a que você fornece esse material. Não é muito mais fácil do que isso. Provavelmente funciona, mas estamos falando sobre a execução em 3D no navegador, então acho que você espera um certo nível de complexidade.

Na verdade, podemos adicionar mais duas propriedades ao MeshShaderMaterial: uniformes e atributos. Ambos podem assumir vetores, números inteiros ou flutuantes, mas, como mencionei antes, os uniformes são os mesmos para todo o frame, ou seja, para todos os vértices, então eles tendem a ser valores únicos. Os atributos, no entanto, são variáveis por vértice, portanto, espera-se que sejam uma matriz. É preciso que haja uma relação de um para um entre o número de valores na matriz de atributos e o número de vértices na malha.

7. Próximas etapas

Agora, vamos adicionar um loop de animação, atributos de vértice e um uniforme. Também vamos adicionar uma variável variável para que o sombreador de vértice possa enviar alguns dados ao sombreador de fragmento. O resultado final é que nossa esfera rosa vai parecer iluminada de cima para baixo e vai piscar. É meio impreciso, mas espero que leve você a uma boa compreensão dos três tipos de variáveis, bem como como eles se relacionam entre si e com a geometria subjacente.

8. Uma luz falsa

Vamos atualizar a coloração para que não seja um objeto de cor lisa. Podemos dar uma olhada em como o Three.js lida com a iluminação, mas, como eu tenho certeza de que você sabe que é mais complexo do que precisamos agora, então vamos fingir isso. É importante dar uma olhada nos sombreadores fantásticos que fazem parte do Three.js, e também os do incrível projeto WebGL de Chris Milk e do Google, Roma. Vamos voltar para os sombreadores. Vamos atualizar o Vertex Shader para fornecer cada vértice normal ao sombreador de fragmentos. Fazemos isso com uma variação:

// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;

void main() {

// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}

No Fragment Shader, vamos configurar o mesmo nome de variável e, em seguida, usar o produto escalar do vértice normal com um vetor que representa uma luz brilhante de cima para a direita da esfera. O resultado final oferece um efeito semelhante a uma luz direcional em um pacote 3D.

// same name and type as VS
varying vec3 vNormal;

void main() {

// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
    
// ensure it's normalized
light = normalize(light);

// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));

// feed into our frag colour
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

Assim, o produto escalar funciona porque, com dois vetores, ele exibe um número que informa o quanto eles são "semelhantes". Com vetores normalizados, se eles apontarem exatamente na mesma direção, você terá um valor de 1. Se elas apontarem para direções opostas, você receberá um -1. O que fazemos é pegar esse número e aplicá-lo à nossa iluminação. Assim, um vértice no canto superior direito terá um valor próximo ou igual a 1, ou seja, totalmente iluminado, enquanto um vértice na lateral teria um valor próximo a 0 e arredondar o verso seria -1. Fixamos o valor como 0 para qualquer item negativo, mas quando você conecta os números, fica com a iluminação básica que estamos vendo.

Qual é a próxima etapa? Bem, seria bom tentar mexer em algumas posições de vértice.

9. Atributos

O que eu gostaria que fizéssemos agora é anexar um número aleatório a cada vértice por meio de um atributo. Vamos usar esse número para empurrar o vértice para fora ao longo do normal. O resultado final será algum tipo de bola de pico estranha que muda toda vez que você atualiza a página. Ela ainda não será animada (isso acontecerá em seguida), mas algumas atualizações de página mostrarão que ela é aleatória.

Vamos começar adicionando o atributo ao sombreador de vértice:

attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

Como é?

Na verdade, não é muito diferente! Isso ocorre porque o atributo não foi configurado no MeshShaderMaterial. Portanto, o sombreador usa um valor zero. É como um espaço reservado agora. Em seguida, vamos adicionar o atributo ao MeshShaderMaterial no JavaScript e o Three.js vai unir os dois automaticamente.

Também é importante notar que eu tive que atribuir a posição atualizada a uma variável vec3 nova porque o atributo original, como todos os atributos, é somente leitura.

10. Como atualizar o MeshShaderMaterial

Vamos direto para a atualização do MeshShaderMaterial com o atributo necessário para alimentar nosso deslocamento. Lembrete: os atributos são valores de vértice, portanto, precisamos de um valor por vértice em nossa esfera. Assim:

var attributes = {
displacement: {
    type: 'f', // a float
    value: [] // an empty array
}
};

// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}

O legal é que todo o deslocamento acontece na GPU.

11. Animando o olho

Devemos fazer uma animação. Como fazemos isso? Precisamos implementar duas coisas:

  1. Um uniforme para animar o deslocamento que precisa ser aplicado em cada frame. Podemos usar seno ou cosseno para isso, já que eles vão de -1 a 1
  2. Um loop de animação no JavaScript.

Vamos adicionar o uniforme ao MeshShaderMaterial e ao Vertex Shader. Primeiro, o sombreador de vértice:

uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement *
                        amplitude);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

Em seguida, vamos atualizar o MeshShaderMaterial:

// add a uniform for the amplitude
var uniforms = {
amplitude: {
    type: 'f', // a float
    value: 0
}
};

// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms:       uniforms,
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

Por enquanto, nossos sombreadores estão concluídos. Mas à direita, pareceríamos ter dado um passo para trás. Isso acontece principalmente porque o valor da amplitude é 0 e, como multiplicamos isso pelo deslocamento, não vemos mudanças. Também não configuramos o loop de animação, então nunca vemos esse 0 mudar para mais nada.

Em nosso JavaScript, agora precisamos envolver a chamada de renderização em uma função e usar requestAnimationFrame para chamá-la. Nele, também precisamos atualizar o valor do uniforme.

var frame = 0;
function update() {

// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;

renderer.render(scene, camera);

// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);

12. Conclusão

Pronto. Agora você vê que ele está animado de uma forma pulsante estranha (e um pouco estranha).

Há muito mais que podemos abordar sobre sombreadores, mas espero que essa introdução tenha sido útil. Agora você vai conseguir entender os sombreadores ao vê-los, além de ter a confiança para criar seus próprios sombreadores.