Introdução
Leia a introdução ao Three.js primeiro, porque ela é a base deste documento.
Quero discutir sobre shaders. O WebGL é brilhante e, como já disse antes, o Three.js (e outras bibliotecas) fazem um trabalho fantástico de abstração das dificuldades. No entanto, haverá momentos em que você vai querer alcançar um efeito específico ou se aprofundar um pouco mais em como essas coisas incríveis apareceram na tela, e os shaders 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 fato de que você está usando o Three.js, já que ele faz muito do trabalho para nós em termos de funcionamento do shader. Você vai aprender o contexto dos shaders primeiro, seguido de etapas avançadas mais adiante neste documento.
1. Nossos dois shaders
O WebGL não oferece o uso do pipeline fixo, que é uma maneira abreviada de dizer que ele não oferece nenhum meio de renderizar seu conteúdo. O que ele oferece é o pipeline programável, que é mais poderoso, mas também mais difícil de entender e usar. Em resumo, o pipeline programável significa que, como programador, você assume a responsabilidade de renderizar os vértices e assim por diante na tela. Os shaders fazem parte desse pipeline, e há dois tipos deles:
- Shaders de vértice
- Shaders de fragmento
Tenho certeza de que você concorda que ambos 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 descarregar tudo o que pudermos para eles, deixando nossa CPU para fazer outros trabalhos. Uma GPU moderna é altamente otimizada para as funções exigidas pelos shaders, então é ótimo poder usá-la.
2. Shaders de vértice
Considere uma forma primitiva padrão, como uma esfera. Ela é feita de vértices, certo? Um shader de vértice recebe cada um desses vértices por vez e pode mexer com eles. Cabe ao shader de vértice o que ele realmente faz com cada um deles, 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. Em si, esse é um processo bastante interessante, porque estamos falando de obter uma posição 3D (um vértice com x, y, z) em uma tela 2D. Felizmente para nós, se estivermos usando algo como o Three.js, temos uma maneira abreviada de definir o gl_Position sem que as coisas fiquem muito pesadas.
3. Shaders de fragmento
Temos nosso objeto com os vértices e os projetamos na tela 2D, mas e as cores que usamos? E a texturização e a iluminação? É exatamente para isso que o shader de fragmento serve. Assim como o shader de vértice, o shader de fragmento também tem apenas um trabalho obrigatório: ele precisa 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 o objetivo de 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, veremos os valores de cor interpolados do vermelho, passando pelo roxo, até o azul.
4. Variáveis de shader
Há três declarações de variáveis que você pode fazer: Uniforms, Attributes e Varyings. Elas podem ser confusas, já que não correspondem a nenhuma outra linguagem comum. Veja como você pode pensar nelas:
Uniforms são enviados aos shaders de vértice e de fragmento e contêm valores que permanecem os mesmos em todo o frame renderizado. Um bom exemplo disso pode ser a posição de uma luz.
Attributes são valores aplicados a vértices individuais. Os atributos estão disponíveis apenas para o shader de vértice. Isso pode ser algo como cada vértice ter uma cor distinta. Os atributos têm uma relação de um para um com os vértices.
Varyings são variáveis declaradas em o shader de vértice que queremos compartilhar com o shader de fragmento. Para fazer isso, declaramos uma variável variante do mesmo tipo e nome no shader de vértice e no shader 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 tarde, usamos todos os três tipos para que você possa ter uma ideia de como eles são realmente aplicados.
Não, dê uma olhada em um dos shaders mais simples que você pode criar.
5. Bonjourno World
Aqui está o Hello World dos shaders 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 shader 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);
}
Não é muito complicado, certo?
No shader de vértice, recebemos alguns uniformes do Three.js. Esses dois uniformes são matrizes 4D, chamadas de matriz de modelo-visualização e matriz de projeção. Não é necessário saber exatamente como elas funcionam, embora seja sempre melhor entender como as coisas fazem o que fazem, se possível. A versão curta é que elas são como a posição 3D do vértice é projetada na posição 2D final na tela.
Na verdade, eu os deixei de fora do snippet de exemplo porque o Three.js os adiciona à parte de cima do código do shader, então você não precisa se preocupar em fazer isso. Na verdade, ele adiciona muito mais do que isso, como dados de iluminação, 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. História real.
6. Como usar um MeshShaderMaterial
Tudo bem, temos um shader configurado, mas como o usamos 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 shaders anexados à malha a que você atribui esse material. Não fica muito mais fácil do que isso. Bem, provavelmente fica, mas estamos falando de 3D em execução no navegador, então acho que você espera um certo nível de complexidade.
Na verdade, podemos adicionar mais duas propriedades ao nosso MeshShaderMaterial: uniformes e atributos. Ambos podem usar 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. Os atributos, no entanto, são variáveis por vértice, então eles 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 passar um tempo adicionando um loop de animação, atributos de vértice e um uniforme. Também vamos adicionar uma variável variante para que o shader de vértice possa enviar alguns dados para o shader de fragmento. O resultado final é que nossa esfera rosa vai parecer iluminada de cima e de lado e vai pulsar. É meio estranho, mas esperamos que isso leve a uma boa compreensão dos três tipos de variáveis, bem como da relação entre elas e a geometria subjacente.
8. Luz falsa
Agora, atualize a cor para que não seja um objeto colorido simples. Poderíamos analisar como o Three.js processa a iluminação, mas, como tenho certeza de que você pode perceber, é mais complexo do que precisamos, então vamos fingir. Você deve analisar os shaders fantásticos que fazem parte do Three.js e também os do incrível projeto WebGL de Chris Milk e Google, Rome. Voltando aos nossos shaders. Vamos atualizar nosso shader de vértice para fornecer cada normal de vértice ao shader de fragmento. Fazemos isso com uma variante:
// 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 shader 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 brilhando de cima e à direita da esfera. O resultado líquido disso nos dá 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 motivo pelo qual o produto escalar funciona é que, dados dois vetores, ele sai com um número que informa o quão "semelhantes" os dois vetores são. Com vetores normalizados, se eles apontarem exatamente na mesma direção, você receberá um valor de 1. Se eles apontarem em 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 (totalmente iluminado), enquanto um vértice na lateral terá um valor próximo de 0 e a parte de trás será -1. Fixamos o valor em 0 para qualquer valor negativo, mas, ao inserir os números, você acaba com a iluminação básica que estamos vendo.
Qual é a próxima etapa? Seria bom tentar mexer com 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 usando um atributo. Vamos usar esse número para empurrar o vértice ao longo da normal. O resultado líquido será algum tipo de bola de pico estranha que vai mudar sempre que você atualizar a página. Ela ainda não será animada (isso acontece em seguida), mas algumas atualizações de página vão mostrar que ela é aleatória.
Comece 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 agora. Em um segundo, vamos adicionar o atributo ao MeshShaderMaterial no JavaScript, e o Three.js vai unir 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
Atualize nosso MeshShaderMaterial com o atributo necessário para alimentar nosso 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 estamos vendo uma esfera deformada, mas o legal é que todo o deslocamento está acontecendo na GPU.
11. Criar uma animação
Precisamos animar isso. Como fazemos isso? Há duas coisas que precisamos fazer:
- Um uniforme para animar quanto deslocamento deve ser aplicado em cada frame. Podemos usar seno ou cosseno para isso, já que eles variam de -1 a 1.
- Um loop de animação no JS
Vamos adicionar o uniforme ao MeshShaderMaterial e ao shader de vértice. Primeiro, o shader 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()
});
Nossos shaders estão prontos por enquanto. Mas agora parece que demos um passo para trás. Isso ocorre principalmente porque nosso valor de amplitude está em 0 e, como o multiplicamos pelo deslocamento, não vemos nenhuma mudança. Também não configuramos o loop de animação, então nunca vemos essa mudança de 0 para outra coisa.
No nosso JavaScript, agora precisamos incluir a chamada de renderização em uma função e usar requestAnimationFrame para chamá-la. Nela, 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 você pode ver que ele está animando de uma maneira pulsante estranha (e um pouco estranha).
Há muito mais que podemos abordar sobre shaders como um tópico, e espero que esta introdução tenha sido útil. Agora você poderá entender os shaders quando os vir e terá confiança para criar alguns shaders incríveis.