Introducción a los sombreadores

Introducción

Anteriormente te di una introducción a Three.js. Si aún no leíste, es posible que desees hacerlo, ya que es la base sobre la que trabajaré durante este artículo.

Quiero hablar sobre los sombreadores. WebGL es brillante y, como dije antes, Three.js (y otras bibliotecas) hacen un trabajo fantástico al abstraerte de las dificultades. Pero habrá ocasiones en las que quieras lograr un efecto específico o es posible que quieras profundizar un poco más en cómo aparecía ese contenido increíble en tu pantalla, y es muy probable que los sombreadores formen parte de esa ecuación. Si eres como yo, quizás quieras pasar de lo básico del último instructivo a algo un poco más complicado. Voy a trabajar sobre la base de que usas Three.js, ya que hace gran parte del trabajo por nosotros en términos de poner en marcha el sombreador. También diré por adelantado que, al principio, explicaré el contexto para los sombreadores, y que, en la última parte de este instructivo, abordaremos temas un poco más avanzados. Esto se debe a que los sombreadores son inusuales a primera vista, y solo debes explicarlo un poco.

1. Nuestros dos sombreadores

WebGL no ofrece el uso de la canalización fija, que es una forma abreviada de decir que no te brinda ningún medio para renderizar los elementos desde el primer momento. Sin embargo, lo que ofrece es la canalización programable, que es más potente, pero también más difícil de entender y usar. En resumen, la canalización programable significa que, como programador, asumes la responsabilidad de renderizar los vértices y así sucesivamente en la pantalla. Los sombreadores forman parte de esta canalización y hay dos tipos de ellos:

  1. Sombreadores de Vertex
  2. Sombreadores de fragmentos

Estoy de acuerdo, y estas dos cosas no significan absolutamente nada por sí solas. Lo que debes saber es que ambos se ejecutan por completo en la GPU de tu tarjeta gráfica. Esto significa que queremos transferirle todo lo que podamos y dejar que la CPU haga otro trabajo. Una GPU moderna está muy optimizada para las funciones que requieren los sombreadores, por lo que es genial poder usarla.

2. Sombreadores de Vertex

Toma una forma primitiva estándar, como una esfera. Se compone de vértices, ¿verdad? Un sombreador de vértices recibe cada uno de estos vértices, a su vez, y puede jugar con ellos. Depende del sombreador de vértices lo que realmente hace con cada uno, pero tiene una responsabilidad: en algún momento, debe configurar algo llamado gl_Position, un vector de número de punto flotante 4D, que es la posición final del vértice en la pantalla. En sí mismo, es un proceso bastante interesante, porque estamos hablando de colocar una posición 3D (un vértice con x,y,z) en una pantalla 2D o proyectar una en ella. Afortunadamente, si usamos algo como Three.js, tendremos una forma abreviada de configurar gl_Position sin demasiado peso.

3. Sombreadores de fragmentos

Tenemos el objeto con sus vértices y los proyectamos en la pantalla 2D, pero ¿qué sucede con los colores que usamos? ¿Qué pasa con la textura y la iluminación? Para eso está el sombreador de fragmentos. Al igual que el sombreador de vértices, el sombreador de fragmentos solo tiene una tarea obligatoria: debe configurar o descartar la variable gl_FragColor, otro vector de número de punto flotante 4D, que es el color final de nuestro fragmento. Pero ¿qué es un fragmento? Piensa en tres vértices que forman un triángulo. Cada píxel dentro de ese triángulo debe dibujarse. Un fragmento son los datos que proporcionan esos tres vértices con el objetivo de dibujar cada píxel del triángulo. Debido a esto, los fragmentos reciben valores interpolados de los vértices constituyentes. Si un vértice es de color rojo y el vecino es azul, veríamos que los valores de color se interpolan del rojo, del púrpura al azul.

4. Variables del sombreador

Cuando se habla de variables, puedes realizar tres declaraciones: Uniformes, Atributos y Varyings. Cuando escuché hablar de ellos, estaba muy confundido, ya que no coincidían con nada con los que había trabajado. Pero aquí te mostramos cómo se hace:

  1. Los uniformes se envían a ambos sombreadores de vértices y de fragmentos, y contienen valores que se mantienen iguales en todo el fotograma que se renderiza. Un buen ejemplo de esto podría ser la posición de una luz.

  2. Los atributos son valores que se aplican a vértices individuales. Los atributos solo están disponibles para el sombreador de vértices. Esto podría ser algo así como que cada vértice tenga un color distinto. Los atributos tienen una relación uno a uno con los vértices.

  3. Los variaciones son variables declaradas en el sombreador de vértices que queremos compartir con el sombreador de fragmentos. Para ello, nos aseguramos de declarar una variable variable del mismo tipo y nombre tanto en el sombreador de vértices como en el sombreador de fragmentos. Un uso clásico de esto sería la normal de un vértice, ya que se puede utilizar en los cálculos de iluminación.

Más adelante, usaremos los tres tipos para que tengas una idea de cómo se aplican de verdad.

Ahora que hablamos sobre sombreadores de vértices y de fragmentos, y los tipos de variables con los que trabajan, vale la pena analizar los sombreadores más simples que podemos crear.

5. El mundo de Bonjourno

Aquí, entonces, está el Hello World de los sombreadores de vértices:

/**
* 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);
}   

y esto es lo mismo para el sombreador de fragmentos:

/**
* 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);
}

No es muy complicado, ¿verdad?

En el sombreador de vértices, Three.js nos envía algunos uniformes. Estos dos uniformes son matrices 4D, denominadas matrices de modelo-vista y matriz de proyección. Si bien no es necesario que sepas exactamente cómo funcionan, siempre es mejor comprender cómo funcionan las cosas en la medida de lo posible. La versión corta es que son cómo se proyecta la posición 3D del vértice en realidad en la posición 2D final de la pantalla.

De hecho, los dejé fuera del fragmento anterior porque Three.js los agrega a la parte superior del código del sombreador para que no tengas que preocuparte por hacerlo. A decir verdad, agrega mucho más que eso, como datos de luz, colores de vértices y normales de vértices. Si lo hicieras sin Three.js, tendrías que crear y configurar todos esos uniformes y atributos por tu cuenta. Historia real.

6. Cómo usar un MeshShaderMaterial

Ya configuramos un sombreador, pero ¿cómo lo usamos con Three.js? Resulta que es terriblemente fácil. Es más bien así:

/**
* 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 de allí, Three.js compilará y ejecutará los sombreadores adjuntos a la malla a la que proporciones ese material. En realidad, no es mucho más fácil. Probablemente sí, pero hablamos de la ejecución de 3D en tu navegador, por lo que supongo que esperas un cierto grado de complejidad.

En realidad, podemos agregar dos propiedades más a nuestro MeshShaderMaterial: uniformes y atributos. Ambos pueden tomar vectores, números enteros o números de punto flotante, pero, como mencioné antes, los uniformes son iguales para todo el marco, es decir, para todos los vértices, por lo que suelen ser valores únicos. Los atributos, sin embargo, son variables por vértice, por lo que se espera que sean un array. Debe haber una relación de uno a uno entre la cantidad de valores en el array de atributos y la cantidad de vértices en la malla.

7. Próximos pasos

Ahora vamos a dedicar un poco de tiempo a agregar un bucle de animación, atributos de vértices y un uniforme. También agregaremos una variable variable para que el sombreador de vértices pueda enviar algunos datos al sombreador de fragmentos. El resultado final es que nuestra esfera de color rosa parecerá iluminada desde arriba y hacia un lado, y va a palpitar. Es un poco extraño, pero esperamos que comprendas bien los tres tipos de variables, así como la manera en que se relacionan entre sí y con la geometría subyacente.

8. Una luz falsa

Actualicemos el color para que no sea un objeto de color plano. Podríamos ver cómo Three.js controla la iluminación, pero, como seguramente te darás cuenta, es más complejo de lo que necesitamos en este momento, así que lo falsificaremos. Deberías mirar totalmente los fantásticos sombreadores que forman parte de Three.js, así como los del increíble proyecto de WebGL reciente de Chris Milk y Google, Rome. Volvamos a los sombreadores. Actualizaremos nuestro sombreador de Vertex para proporcionar cada vértice normal al sombreador de fragmentos. Lo hacemos con diferentes elementos:

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

En el sombreador de fragmentos, configuraremos el mismo nombre de variable y, luego, usaremos el producto de punto del vértice normal con un vector que representa una luz que ilumina desde arriba y a la derecha de la esfera. El resultado neto de esto nos da un efecto similar a una luz direccional en un paquete 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);

}

La razón por la que el producto escalar funciona es que, dados dos vectores, viene con un número que te indica qué tan “similares” son los dos vectores. Con los vectores normalizados, si apuntan en la misma dirección, obtendrás un valor de 1. Si apuntan en direcciones opuestas, obtendrás un -1. Lo que hacemos es tomar ese número y aplicarlo a la iluminación. Por lo tanto, un vértice de la parte superior derecha tendrá un valor cercano o igual a 1, es decir, completamente iluminado, mientras que un vértice del lado tendría un valor cercano a 0 y redondear la parte posterior a -1. Ajustamos el valor a 0 para cualquier valor negativo, pero cuando conectas los números, obtienes la iluminación básica que vemos.

¿Qué sigue? Sería bueno intentar jugar con algunas posiciones de vértices.

9. Atributos

Lo que quiero que hagamos ahora es adjuntar un número al azar a cada vértice mediante un atributo. Usaremos este número para expulsar el vértice a lo largo de su normal. El resultado neto será una especie de bola de punta extraña que cambiará cada vez que actualices la página. No se animará aún más (eso será el próximo paso), pero si actualizas la página varias veces se te indicará que el contenido está aleatorizado.

Comencemos por agregar el atributo al sombreador de vértices:

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

¿Cómo se ve?

En realidad, no es muy diferente. Esto se debe a que no se configuró el atributo en MeshShaderMaterial, por lo que el sombreador usa un valor cero en su lugar. Es como un marcador de posición. En un segundo, agregaremos el atributo a MeshShaderMaterial en JavaScript, y Three.js vinculará ambos automáticamente.

También es necesario asignar la posición actualizada a una variable vec3 nueva porque el atributo original, como todos los atributos, es de solo lectura.

10. Actualiza MeshShaderMaterial

Pasemos directamente a actualizar nuestro MeshShaderMaterial con el atributo necesario para potenciar nuestro desplazamiento. Recuerda: los atributos son valores por vértice, por lo que necesitamos un valor por vértice en nuestra esfera. Para ello, puedes escribir lo siguiente:

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

Ahora vemos una esfera alterada, pero lo interesante es que todo el desplazamiento ocurre en la GPU.

11. Animación de esa demonios

Deberíamos hacer esta animación. ¿Cómo lo logramos? Hay dos cosas que necesitamos implementar:

  1. Un uniforme para animar el desplazamiento que se debe aplicar en cada fotograma. Podemos usar el seno o el coseno, ya que van de -1 a 1.
  2. Un bucle de animación en el JS

Vamos a agregar el uniforme tanto a MeshShaderMaterial como al sombreador de Vertex. Primero, el sombreador de Vertex:

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

A continuación, actualizamos 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()
});

Nuestros sombreadores están listos por ahora. Pero, a la derecha, parecería haber dado un paso hacia atrás. Esto se debe en gran medida a que el valor de amplitud está en 0 y, como lo multiplicamos por el desplazamiento, no vemos cambios. Tampoco configuramos el bucle de animación, por lo que nunca vemos ese cambio 0 a nada más.

Ahora, en JavaScript, debemos unir la llamada de renderización en una función y, luego, usar requestAnimationFrame para llamarla. Allí también necesitamos actualizar el valor del 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. Conclusión

Eso es todo. Ahora puedes ver que se mueve de una forma palpitante extraña (y un poco erótica).

Hay mucho más que podemos abordar sobre los sombreadores como tema, pero espero que esta introducción te haya resultado útil. Ahora deberías poder comprender los sombreadores cuando los veas, además de tener la confianza para crear tus propios sombreadores.