Introdução aos sombreadores

Introdução

Leia primeiro a introdução ao Three.js, já que ela é a base deste documento.

Quero falar sobre sombreadores. O WebGL é brilhante e, como eu disse antes, o Three.js (e outras bibliotecas) faz um trabalho fantástico de abstrair as dificuldades para você. Mas, às vezes, você vai querer alcançar um efeito específico ou se aprofundar um pouco mais em como esse conteúdo incrível apareceu na tela, e os sombreadores quase certamente farão parte dessa equação. Talvez você queira passar do básico do último tutorial para algo um pouco mais complicado.

Vou trabalhar com base no uso do Three.js, já que ele faz grande parte do trabalho para nós em termos de colocar o sombreador em funcionamento. Primeiro, você vai aprender o contexto dos sombreadores e, depois, as etapas avançadas neste documento.

1. Nossos dois sombreadores

O WebGL não oferece o uso do pipeline fixo, o que significa que ele não oferece nenhum meio de renderizar seu conteúdo de forma imediata. O que ele oferece é 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ê é responsável por renderizar os vértices e outros elementos na tela. Os sombreadores fazem parte desse pipeline, e há dois tipos deles:

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

Ambos, tenho certeza de que você concorda, não significam absolutamente nada por si só. O que você precisa saber sobre eles é que ambos são executados inteiramente na GPU da placa de vídeo. Isso significa que queremos transferir tudo o que pudermos para elas, deixando nossa CPU livre para realizar outras tarefas. Uma GPU moderna é altamente otimizada para as funções que os sombreadores exigem, então é ótimo poder usá-la.

2. Sombreadores de vértice

Pegue uma forma primitiva padrão, como uma esfera. Ele é composto por vértices, certo? Um sombreador de vértice recebe cada um desses vértices e pode mexer neles. O que o sombreador de vértice faz com cada um deles é de responsabilidade dele, mas ele 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 interessante, porque estamos falando de obter uma posição 3D (um vértice com x, y, z) em uma tela 2D ou projetada nela. Felizmente, se usarmos algo como Three.js, teremos uma maneira abreviada de definir o gl_Position sem que as coisas fiquem muito pesadas.

3. Sombreadores de fragmentos

Temos nosso objeto com os vértices, que foram projetados na tela 2D, mas e as cores que usamos? E quanto à texturização e iluminação? É exatamente para isso que serve o sombreador de fragmentos. Assim como o sombreador de vértice, o sombreador de fragmento também tem apenas uma tarefa obrigatória: 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 para desenhar cada pixel nesse 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 vão interpolar de vermelho para azul, passando por roxo.

4. Variáveis de sombreador

Há três declarações de variáveis que você pode fazer: Uniforms, Attributes e Varyings. Isso pode ser confuso, já que eles não correspondem a nenhum outro idioma comum. Veja como pensar neles:

  1. Os uniforms 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. Atributos são valores aplicados a vértices individuais. Os atributos estão disponíveis apenas para o sombreador de vértice. Por exemplo, cada vértice pode ter uma cor diferente. Os atributos têm uma relação de um para um com os vértices.

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

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

Não, confira um dos sombreadores mais simples que você pode criar.

5. Bonjourno World

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 color 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 é tão complicado, certo?

No sombreador de vértice, o Three.js envia alguns uniformes. Esses dois uniformes são matrizes 4D, chamadas de matriz de visualização de modelo e matriz de projeção. Você não precisa saber exatamente como eles funcionam, embora seja sempre melhor entender como as coisas funcionam, se possível. Em resumo, eles são a forma como a posição 3D do vértice é projetada para a posição 2D final na tela.

Eu os deixei de fora do snippet de exemplo porque o Three.js os adiciona ao topo do código do shader para que você não precise 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?

/**
* 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 vai compilar e executar os sombreadores anexados à malha que você atribuiu a esse material. Não tem nada mais fácil do que isso. Provavelmente sim, mas estamos falando de 3D em execução no seu navegador, então acho que você espera um certo nível de complexidade.

Podemos adicionar mais duas propriedades ao MeshShaderMaterial: uniformes e atributos. Ambos podem receber vetores, números inteiros ou flutuantes, mas, como mencionei antes, os uniformes são os mesmos para todo o frame ou para todos os vértices, então eles tendem a ser valores únicos. No entanto, os atributos são variáveis por vértice, então precisam ser uma matriz. Deve haver 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 dedicar um tempo para adicionar um loop de animação, atributos de vértice e um uniforme. Também vamos adicionar uma variável de variação 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 de lado e vai pulsar. É um pouco confuso, mas esperamos que isso ajude você a entender bem os três tipos de variáveis, como eles se relacionam entre si e a geometria subjacente.

8. Luz falsa

Agora, atualize a coloração para que o objeto não tenha uma cor uniforme. Poderíamos analisar como o Three.js lida com a iluminação, mas, como você deve imaginar, é mais complexo do que precisamos, então vamos simular. Confira os shaders fantásticos que fazem parte do Three.js e também aqueles do incrível projeto WebGL de Chris Milk e do Google, Rome. Vamos voltar aos nossos sombreadores. Vamos atualizar o shader de vértice para fornecer cada normal de vértice ao shader de fragmento. Fazemos isso com uma variável:

// 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 sombreador de fragmento, vamos configurar o mesmo nome de variável e usar o produto escalar da normal do vértice com um vetor que representa uma luz que brilha de cima e à direita da esfera. O resultado disso é 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 color
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

O produto escalar funciona porque, dados dois vetores, ele gera um número que indica o quanto eles são "semelhantes". Com vetores normalizados, se eles apontarem exatamente na mesma direção, você vai receber o valor 1. Se apontarem em direções opostas, o resultado será -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 (totalmente iluminado), enquanto um vértice na lateral terá um valor próximo de 0 e, na parte de trás, será -1. Fixamos o valor em 0 para qualquer valor negativo, mas quando você insere os números, acaba com a iluminação básica que estamos vendo.

A seguir Seria legal tentar mexer em algumas posições de vértices.

9. Atributos

Agora, quero que você anexe um número aleatório a cada vértice usando um atributo. Vamos usar esse número para empurrar o vértice para fora ao longo da normal. O resultado final será uma espécie de bola com espinhos estranha que vai mudar sempre que você atualizar a página. Ele ainda não será animado (isso vai acontecer em seguida), mas algumas atualizações de página vão mostrar que ele é aleatório.

Comece 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 ficou?

Não é muito diferente! Isso ocorre porque o atributo não foi configurado no MeshShaderMaterial. Portanto, o sombreador usa um valor zero. É como um marcador de posição por enquanto. 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 nova variável vec3 porque o atributo original, como todos os atributos, é somente leitura.

10. Como atualizar o MeshShaderMaterial

Vamos atualizar nosso MeshShaderMaterial com o atributo necessário para o deslocamento. Lembrete: os atributos são valores por vértice, então precisamos de um valor por vértice na 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);
}

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

11. Criar uma animação

Seria ótimo animar isso. Como fazemos isso? Precisamos fazer duas coisas:

  1. Um uniforme para animar quanto deslocamento deve 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 Vertex Shader:

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

Por enquanto, terminamos os sombreadores. Mas, no momento, parece que demos um passo para trás. Isso ocorre principalmente porque o valor da amplitude é 0 e, como multiplicamos isso pelo deslocamento, não vemos nenhuma mudança. Também não configuramos o loop de animação, então o 0 nunca muda.

No JavaScript, agora precisamos encapsular a chamada de renderização em uma função e usar requestAnimationFrame para chamá-la. Lá, 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);

Conclusão

Pronto. Agora, ele está se movendo de uma maneira estranha (e um pouco psicodélica) e pulsante.

Há muito mais que podemos abordar sobre shaders, e espero que esta introdução tenha sido útil. Agora você já consegue entender os sombreadores quando os vê e tem confiança para criar alguns sombreadores incríveis.