Introdução aos sombreadores

Introdução

Já dei uma introdução ao Three.js. Se você não tiver lido, talvez seja interessante, já que é a base que vou criar durante este artigo.

O que eu quero é discutir shaders. O WebGL é brilhante, e como eu disse antes, o Three.js (e outras bibliotecas) fazem um trabalho fantástico de abstração das dificuldades para você. Mas haverá momentos em que você vai querer alcançar um efeito específico ou entender melhor como essas coisas incríveis apareceram na tela. E é quase certo que os shaders vão fazer parte dessa equação. Além disso, se você é como eu, talvez queira ir das coisas básicas do último tutorial para algo um pouco mais complicado. Trabalharei considerando que você está usando o Three.js, já que ele faz muito do trabalho burro para nós, em termos de uso do shader. Vou explicar o contexto dos shaders no início, e a parte final deste tutorial é onde entraremos em um território um pouco mais avançado. O motivo é que os shaders são incomuns à primeira vista e exigem um pouco de explicação.

1. Nossos dois sombreadores

O WebGL não oferece o uso do pipeline fixo, que é uma maneira abreviada de dizer que ele não oferece nenhum meio de renderização do conteúdo pronto para uso. O que oferece, no entanto, é o pipeline programável, que é mais poderoso, mas também mais difícil de entender e usar. Resumindo, o pipeline programável significa que, como programador, você é responsável por renderizar os vértices e assim por diante na tela. Os shaders fazem parte desse pipeline e são de dois tipos:

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

Tenho certeza de que você concorda que os dois não significam absolutamente nada sozinhos. O que você precisa saber é que ambos são executados inteiramente na GPU da placa de vídeo. Isso significa que queremos descarregar tudo o que pudermos para eles, deixando nossa CPU para fazer outro trabalho. Uma GPU moderna é altamente otimizada para as funções que os shaders exigem, então é ótimo poder usá-la.

2. Vertex Shaders

Pegue uma forma primitiva padrão, como uma esfera. Isso é feito de vértices. Um shader de vértice recebe cada um desses vértices por vez e pode alterá-los. Cabe ao sombreador de vértice definir o que ele faz com cada um, mas ele tem uma responsabilidade: em algum momento, ele precisa definir algo chamado gl_Position, um vetor de ponto flutuante 4D, que é a posição final do vértice na tela. Por si só, esse é um processo bastante interessante, porque estamos falando sobre como conseguir uma posição 3D (um vértice com x, y, z) em ou projetada em uma tela 2D. Felizmente, se estivermos usando algo como o Three.js, teremos uma maneira abreviada de definir o gl_Position sem que as coisas fiquem muito pesadas.

3. Shaders de fragmento

Temos o objeto com os vértices e os projetamos na tela 2D, mas e as cores que usamos? E quanto a texturização e iluminação? É exatamente para isso que o sombreador de fragmento serve. Muito parecido com o sombreador de vértice, o sombreador de fragmentos também tem apenas uma tarefa obrigatória: ele precisa definir ou descartar a variável gl_FragColor, outro vetor de ponto flutuante 4D, que é a cor final do 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 exibido. Um fragmento é o conjunto de dados fornecido por esses três vértices para desenhar cada pixel no triângulo. Por isso, os fragmentos recebem valores interpolados dos vértices constituintes. Se um vértice for colorido de vermelho e o vizinho for azul, os valores de cor serão interpolados do vermelho, passando pelo roxo, até o azul.

4. Variáveis de sombreador

Quando se trata de variáveis, é possível fazer três declarações: Uniforms, Attributes e Varyings. Quando ouvi falar sobre esses três, fiquei muito confuso, porque eles não combinam com nada com que já trabalhei. Mas aqui está como você pode pensar nelas:

  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 é a posição de uma luz.

  2. Os atributos são valores aplicados a vértices individuais. Os atributos são somente disponíveis para o sombreador de vértice. Isso pode ser algo como cada vértice com uma cor distinta. Os atributos têm um relacionamento de um para um com vértices.

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

Mais tarde, vamos usar os três tipos para que você possa sentir como eles são aplicados na prática.

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

5. O mundo Bonjourno

Este é 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);
}   

E aqui está o mesmo 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);
}

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 modelo-vista e matriz de projeção. Você não precisa saber exatamente como elas funcionam, mas é sempre melhor entender como as coisas funcionam. A versão curta é que eles são como a posição 3D do vértice é projetada para a posição 2D final na tela.

Na verdade, deixei-as de fora do snippet acima porque o Three.js as adiciona ao topo do código do shader. Assim, você não precisa se preocupar com isso. Na verdade, ele adiciona muito mais do que isso, como dados de luz, cores de vértice e normais de vértice. Se você estivesse fazendo isso sem o Three.js, teria que criar e definir todos esses uniformes e atributos por conta própria. História real.

6. Como usar um MeshShaderMaterial

Ok, temos um sombreador configurado, mas como usá-lo com o Three.js? Na verdade, é 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()
});

A partir daí, o Three.js compila e executa os sombreadores anexados à malha à qual você fornece esse material. Não é muito mais fácil. Provavelmente sim, mas estamos falando de 3D no seu navegador. Então, imagino que você espera uma certa complexidade.

Podemos adicionar mais duas propriedades ao MeshShaderMaterial: uniformes e atributos. Eles podem receber vetores, números inteiros ou flutuantes, mas, como mencionei antes, os uniformes são iguais para todo o frame, ou seja, para todos os vértices, portanto, tendem a ser valores únicos. No entanto, os atributos são variáveis por vértice, então eles precisam ser uma matriz. Deve haver uma relação 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 passar um tempo adicionando um loop de animação, atributos de vértice e um uniforme. Também adicionaremos 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 que era rosa vai parecer iluminada de cima e para o lado e vai pulsar. É meio confuso, mas esperamos que ele ajude você a entender melhor os três tipos de variáveis, além de como eles se relacionam entre si e com a geometria subjacente.

8. Uma luz falsa

Vamos atualizar a coloração para que ela não seja um objeto plano. Poderíamos analisar como o Three.js lida com a iluminação, mas, como tenho certeza de que você pode apreciar que é mais complexo do que precisamos no momento, vamos simular. Você precisa conferir os shaders fantásticos que fazem parte do Three.js e também os do incrível projeto WebGL recente de Chris Milk e Google, Rome. Vamos voltar aos sombreadores. Vamos atualizar o Vertex Shader para fornecer cada vértice normal ao Fragment Shader. 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);
}

e no Fragment Shader vamos configurar o mesmo nome de variável e usar o produto escalar da direção normal do vértice com um vetor que representa uma luz que brilha de cima e à direita da esfera. O resultado final gera 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);

}

O produto escalar funciona porque, dado dois vetores, ele traz um número que indica quão semelhantes são os dois. Com vetores normalizados, se eles apontarem exatamente na mesma direção, você vai receber um valor de 1. Se eles apontarem em direções opostas, você recebe um -1. O que fazemos é pegar esse número e aplicá-lo à 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 no lado terá um valor próximo de 0 e o da parte de trás será -1. Limitamos o valor a 0 para qualquer valor negativo, mas quando você insere os números, você acaba com a iluminação básica que estamos vendo.

A seguir 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 um atributo. Vamos usar esse número para empurrar o vértice ao longo da normalidade. O resultado líquido será uma bola de espinhos estranha que vai mudar sempre que você atualizar a página. Ela ainda não será animada, mas algumas atualizações da página vão mostrar que ela é aleatória.

Vamos começar adicionando o atributo ao shader 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 ficou?

Não é muito diferente. Isso ocorre porque o atributo não foi configurado no MeshShaderMaterial, então o shader usa um valor zero. É como um marcador de posição no momento. Em um segundo, vamos adicionar o atributo ao MeshShaderMaterial no JavaScript, e o Three.js vai vincular os dois automaticamente.

Também é importante observar que 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 acionar o deslocamento. Lembrete: os atributos são valores por vértice, então precisamos de um valor por vértice na 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);
}

Agora estamos vendo uma esfera distorcida, mas o legal é que todo o deslocamento está acontecendo na GPU.

11. Animando o Otário

Devemos animar isso. Como fazemos isso? Há duas coisas que precisamos fazer:

  1. Um uniforme para animar quanto deslocamento 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 JS

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, atualizamos 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()
});

Os sombreadores estão prontos por enquanto. Mas parece que demos um passo para trás. Isso ocorre principalmente porque nosso valor de amplitude está em 0 e, como multiplicamos isso com o deslocamento, não há mudança. Também não configuramos o ciclo de animação para que o 0 nunca mude para outra coisa.

No JavaScript, agora precisamos agrupar a chamada de renderização em uma função e usar o requestAnimationFrame para fazer a chamada. 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, ele está animado de uma maneira estranha (e um pouco psicodélica).

Há muito mais sobre o assunto dos shaders, mas espero que esta introdução tenha sido útil. Agora você vai entender os shaders quando os encontrar e também vai ter confiança para criar shaders incríveis!