Introducción a los sombreadores

Introducción

Primero, lee la introducción a Three.js, ya que es la base de este documento.

Lo que quiero hacer es hablar sobre los sombreadores. WebGL es brillante y, como dije antes, Three.js (y otras bibliotecas) hacen un trabajo fantástico para abstraer las dificultades. Sin embargo, habrá momentos en los que quieras lograr un efecto específico o profundizar un poco en cómo apareció ese contenido increíble en tu pantalla, y los sombreadores casi seguramente formarán parte de esa ecuación. Es posible que quieras pasar de lo básico del último instructivo a algo un poco más complicado.

Trabajaré en función de que usas Three.js, ya que hace mucho trabajo por nosotros en términos de poner en marcha el sombreador. Primero, aprenderás el contexto de los sombreadores y, luego, los pasos avanzados en este documento.

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 inmediato. Lo que sí ofrece es 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 que los vértices y demás se rendericen en la pantalla. Los sombreadores forman parte de esta canalización, y hay dos tipos:

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

Estoy seguro de que estarás de acuerdo en que ambos no significan 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 transferir todo lo que podamos a ellos, dejando que nuestra CPU haga otro trabajo. Una GPU moderna está muy optimizada para las funciones que requieren los sombreadores, por lo que es excelente poder usarla.

2. Sombreadores de vértices

Toma una forma primitiva estándar, como una esfera. Está formada por vértices, ¿verdad? A cada uno de estos vértices se le da un sombreador de vértices a su vez y se puede jugar con ellos. Depende del sombreador de vértices lo que realmente haga con cada uno, pero tiene una responsabilidad: en algún momento, debe establecer algo llamado gl_Position, un vector flotante 4D, que es la posición final del vértice en la pantalla. En sí mismo, es un proceso bastante interesante, porque en realidad estamos hablando de obtener una posición 3D (un vértice con x, y, z) en una pantalla 2D o proyectada en ella. Afortunadamente para nosotros, si usamos algo como Three.js, tenemos una forma abreviada de configurar gl_Position sin que las cosas se vuelvan demasiado pesadas.

3. Sombreadores de fragmentos

Tenemos nuestro objeto con sus vértices y los proyectamos a la pantalla 2D, pero ¿qué sucede con los colores que usamos? ¿Qué sucede con la texturización 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 tiene un solo trabajo obligatorio: debe establecer o descartar la variable gl_FragColor, otro vector 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 propósito de dibujar cada píxel en 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, pasando por el morado, al azul.

4. Variables de sombreador

Puedes hacer tres declaraciones de variables: Uniforms, Attributes y Varyings. Pueden ser confusas, ya que no coinciden con ningún otro lenguaje común. Aquí te mostramos cómo puedes pensarlas:

  1. Uniforms se envían a los sombreadores de vértices y de fragmentos, y contienen valores que permanecen 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 como que cada vértice tenga un color distinto. Los atributos tienen una relación de uno a uno con los vértices.

  3. Lasvariables 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 de tipo 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 normal 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 tener una idea de cómo se aplican realmente.

No, echa un vistazo a uno de los sombreadores más simples que puedes crear.

5. Bonjourno World

Aquí 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 aquí está lo mismo para el sombreador de fragmentos:

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

No es demasiado complicado, ¿verdad?

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

De hecho, los dejé fuera del fragmento de ejemplo porque Three.js los agrega a la parte superior del código del sombreador, por lo que no debes 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. Es verdad.

6. Usa un MeshShaderMaterial

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

De hecho, podemos agregar dos propiedades más a nuestro MeshShaderMaterial: uniformes y atributos. Ambos pueden tomar vectores, números enteros o flotantes, pero, como mencioné antes, los uniformes son los mismos para todo el fotograma o para todos los vértices, por lo que tienden a 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 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 que era rosa parecerá iluminada desde arriba y desde un costado, y va a pulsar. Es un poco extraño, pero esperamos que te lleve a comprender bien los tres tipos de variables, así como la forma en que se relacionan entre sí y la geometría subyacente.

8. Luz falsa

Ahora, actualiza el color para que no sea un objeto de color plano. Podríamos echar un vistazo a cómo Three.js controla la iluminación, pero, como seguramente podrás apreciar, es más complejo de lo que necesitamos, por lo que lo vamos a falsificar. Deberías consultar los fantásticos sombreadores que forman parte de Three.js y también los del increíble proyecto WebGL de Chris Milk y Google, Rome. Volvamos a nuestros sombreadores. Actualizaremos nuestro sombreador de vértices para proporcionar cada normal de vértice al sombreador de fragmentos. Lo hacemos con una variable:

// 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 de punto de la normal del vértice con un vector que representa una luz que brilla desde arriba y hacia 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 color
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

Por lo tanto, el motivo por el que funciona el producto de punto es que, dados dos vectores, se obtiene un número que indica qué tan "similares" son los dos vectores. Con 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 parte superior derecha tendrá un valor cercano o igual a 1 (completamente iluminado), mientras que un vértice en el costado tendría un valor cercano a 0 y la parte posterior sería -1. Fijamos el valor en 0 para cualquier valor negativo, pero cuando conectas los números, terminas con la iluminación básica que vemos.

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

9. Atributos

Lo que me gustaría que hagamos ahora es adjuntar un número aleatorio a cada vértice con 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 púas extraña que cambiará cada vez que actualices la página. Aún no se animará (eso sucederá a continuación), pero algunas actualizaciones de página te mostrarán que es aleatorio.

Para comenzar, agrega 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 un segundo, agregaremos el atributo a MeshShaderMaterial en JavaScript y Three.js los unirá automáticamente.

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

10. Actualiza el MeshShaderMaterial

Ve directamente a actualizar nuestro MeshShaderMaterial con el atributo necesario para potenciar nuestro desplazamiento. Recordatorio: 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 bueno es que todo el desplazamiento ocurre en la GPU.

11. Animarlo

Deberíamos hacer que se anime. ¿Cómo lo hacemos? Bueno, hay dos cosas que debemos tener en cuenta:

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

Agregaremos el uniforme a MeshShaderMaterial y al sombreador de vértices. Primero, el sombreador de vértices:

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 el 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 ya están listos. Pero ahora parecería que dimos un paso hacia atrás. Esto se debe en gran medida a que nuestro valor de amplitud es 0 y, como lo multiplicamos por el desplazamiento, no vemos ningún cambio. Tampoco configuramos el bucle de animación, por lo que nunca vemos que ese 0 cambie a otra cosa.

En nuestro JavaScript, ahora debemos incluir la llamada de renderización en una función y, luego, usar requestAnimationFrame para llamarla. Allí, también debemos 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);

Conclusión

Eso es todo. Ahora puedes ver que se anima de una manera extraña (y un poco extraña).

Hay mucho más que podemos cubrir sobre los sombreadores como tema, y espero que esta introducción te haya sido útil. Ahora deberías poder comprender los sombreadores cuando los veas y tener la confianza para crear algunos sombreadores increíbles por tu cuenta.