Introducción a los sombreadores

Introducción

Anteriormente, te di una introducción a Three.js. Si no lo leíste, te recomendamos que lo hagas, ya que es la base sobre la que trabajaré en este artículo.

Quiero hablar sobre los sombreadores. WebGL es excelente y, como ya lo dije, Three.js (y otras bibliotecas) hacen un trabajo fantástico para abstraer las dificultades. Sin embargo, habrá ocasiones en las que quieras lograr un efecto específico o quieras profundizar un poco más en cómo apareció ese material asombroso en la pantalla, y es casi seguro que los sombreadores formarán parte de esa ecuación. Además, si eres como yo, quizás quieras pasar de las cosas básicas del último instructivo a algo un poco más complicado. Trabajaré en función de que usas Three.js, ya que hace mucho del trabajo pesado por nosotros en términos de poner en marcha el sombreador. También te adelanto que, al principio, explicaré el contexto de los sombreadores y que, en la última parte de este instructivo, nos adentraremos en un territorio un poco más avanzado. Esto se debe a que los sombreadores son inusuales a primera vista, así que deberás explicar 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 tu contenido de forma predeterminada. Sin embargo, ofrece la canalización Programable, que es más potente, pero también más difícil de comprender 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 existen dos tipos:

  1. Sombreadores de Vertex
  2. Sombreadores de fragmentos

Estoy seguro de que estarás de acuerdo en que ambos no significan absolutamente nada por sí solos. Lo que debes saber sobre ellos es que ambos se ejecutan por completo en la GPU de tu tarjeta gráfica. Esto significa que queremos transferirles todo lo que podamos, lo que permite que nuestra CPU realice otras tareas. Una GPU moderna está muy optimizada para las funciones que requieren los sombreadores, por lo que es genial poder usarlas.

2. sombreadores de vértices

Toma una forma primitiva estándar, como una esfera. Está formado por vértices, ¿verdad? A un sombreador de vértices se le asigna cada uno de estos vértices a su vez y puede manipularlos. Depende del sombreador de vértices lo que hace en realidad con cada uno, pero tiene una responsabilidad: en algún momento, debe establecer 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í, es un proceso bastante interesante, ya que estamos hablando de obtener una posición 3D (un vértice con x, y, z) en una pantalla 2D o proyectada en ella. Por suerte, si usamos algo como Three.js, tendremos una forma abreviada de configurar la gl_Position sin que la carga se ponga demasiado pesada.

3. Fragment Shaders

Tenemos nuestro objeto con sus vértices y lo proyectamos a 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 también solo tiene una tarea obligatoria: debe establecer o descartar la variable gl_FragColor, otro vector 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 fin de dibujar cada píxel de ese triángulo. Debido a esto, los fragmentos reciben valores interpolados de sus vértices constituyentes. Si un vértice es de color rojo y su vecino es azul, veríamos que los valores de color se interpolan de rojo a púrpura y a azul.

4. Variables de sombreador

Cuando hablamos de variables, hay tres declaraciones que puedes hacer: Uniformes, Atributos y Variedades. Cuando escuché por primera vez sobre esos tres, me sentí muy confundido, ya que no coinciden con nada con lo que haya trabajado. Pero aquí te indicamos cómo puedes pensar en ellos:

  1. Los uniformes se envían a ambos sombreadores de vértices y sombreadores 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 de uno a uno con los vértices.

  3. Los variadores 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 en el sombreador de vértices y en el sombreador de fragmentos. Un uso clásico de esto sería la normalidad de un vértice, ya que se puede usar en los cálculos de iluminación.

Más adelante, usaremos los tres tipos para que puedas imaginarte cómo se aplican en la práctica.

Ahora que hablamos de los sombreadores de vértices y de fragmentos, y de 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

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

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

Pero no es muy complicado, ¿verdad?

En el sombreador de vértices, Three.js nos envía un par de uniformes. Estos dos uniformes son matrices 4D, denominadas matriz de vista modelo y matriz de proyección. No necesitas saber exactamente cómo funcionan, aunque siempre es mejor comprender cómo las cosas hacen lo que hacen, si es posible. La versión corta es que son la forma en que la posición 3D del vértice se proyecta en realidad en la posición 2D final de la pantalla.

De hecho, los omití 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 claros, colores 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 MeshShaderMaterial

Bien, tenemos configurado un sombreador, pero ¿cómo lo usamos con Three.js? Resulta que es muy fácil. Es algo 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 ahí, Three.js compilará y ejecutará los sombreadores adjuntos a la malla a la que le proporciones ese material. No hay nada más simple que eso. Es probable que sí, pero estamos hablando de contenido 3D que se ejecuta en tu navegador, así que supongo que esperas cierta complejidad.

De hecho, podemos agregar dos propiedades más a MeshShaderMaterial: uniformes y atributos. Pueden tomar vectores, números enteros o números de punto flotante, pero, como mencioné antes, los uniformes son los mismos para toda la trama, es decir, para todos los vértices, por lo que suelen ser valores únicos. Sin embargo, los atributos 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, dedicaremos 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 rosa parecerá iluminada desde arriba y hacia los lados, y vibrará. Es un poco alucinante, pero espero que te lleve a una buena comprensión de los tres tipos de variables, así como de cómo se relacionan entre sí y 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 puedes apreciar, es más complejo de lo que necesitamos en este momento, por lo que lo simularemos. Deberías revisar los fantásticos sombreadores que forman parte de Three.js y también los del increíble proyecto de WebGL reciente de Chris Milk y Google, Rome. Volvamos a nuestros sombreadores. Actualizaremos nuestro sombreador de vértices para proporcionar cada vértice normal al sombreador de fragmentos. Para ello, usamos los siguientes 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);
}

y en el sombreador de fragmentos, configuraremos el mismo nombre de variable y, luego, usaremos el producto punto de la normal del vértice con un vector que representa una luz que brilla 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);

}

Entonces, la razón por la que el producto punto funciona es que, dados los dos vectores, muestra un número que indica qué tan "similares" son los dos vectores. Con los vectores normalizados, si apuntan exactamente en la misma dirección, obtienes un valor de 1. Si apuntan en direcciones opuestas, obtienes un -1. Lo que hacemos es tomar ese número y aplicarlo a nuestra iluminación. Por lo tanto, un vértice en la esquina superior derecha tendrá un valor cercano o igual a 1, es decir, completamente iluminado, mientras que un vértice en el costado tendrá un valor cercano a 0 y en la parte posterior será -1. Fijamos el valor a 0 para cualquier resultado negativo, pero cuando conectas los números, obtienes la iluminación básica que vemos.

Próximos pasos Sería bueno tratar de manipular con algunas posiciones de vértices.

9. Atributos

Lo que me gustaría que hiciéramos ahora es adjuntar un número aleatorio a cada vértice mediante un atributo. Usaremos este número para empujar el vértice hacia afuera a lo largo de su vector normal. El resultado neto será una especie de punta extraña que cambiará cada vez que actualices la página. Aún no estará animado (eso sucederá a continuación), pero algunas actualizaciones de página te mostrarán que es aleatorio.

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

¿Qué aspecto tiene?

No es muy diferente. Esto se debe a que el atributo no se configuró en MeshShaderMaterial, por lo que el sombreador usa un valor cero. Es como un marcador de posición en este momento. En unos segundos, agregaremos el atributo a MeshShaderMaterial en JavaScript y Three.js los vinculará automáticamente.

También es importante tener en cuenta que tuve que 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

Comencemos a actualizar nuestro MeshShaderMaterial con el atributo necesario para potenciar nuestro desplazamiento. Recuerda que 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 deformada, pero lo interesante es que todo el desplazamiento se produce en la GPU.

11. Animando ese hilo

Deberíamos animar esto. ¿Cómo lo hacemos? Bueno, hay dos aspectos que debemos tener en cuenta:

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

Agregaremos el uniforme a ambos, a MeshShaderMaterial y al sombreador de vértices. 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()
});

Por ahora, nuestros sombreadores están listos. Pero, en realidad, parecemos haber dado un paso hacia atrás. Esto se debe en gran medida a que nuestro valor de amplitud está en 0 y, como lo multiplicamos por el desplazamiento, no vemos que cambie nada. Tampoco configuramos el bucle de animación para que nunca veamos que el 0 cambie a otra cosa.

En nuestro código JavaScript, ahora 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 anima de una manera pulsante extraña (y un poco alucinante).

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, así como tener la confianza para crear tus propios sombreadores increíbles.