Introducción
Primero, lee la introducción a Three.js, ya que es la base de este documento.
Quiero 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á ocasiones en las que quieras lograr un efecto específico o quieras profundizar un poco más en cómo apareció ese contenido increíble en tu pantalla, y los sombreadores casi con seguridad serán parte de esa ecuación. Es posible que quieras pasar de lo básico del último instructivo a algo un poco más complejo.
Trabajaré con la base de que estás usando Three.js, ya que hace gran parte del 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 más adelante 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 tus elementos de forma inmediata. Lo que sí 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 demás elementos en la pantalla. Los sombreadores forman parte de esta canalización y existen dos tipos:
- Sombreadores de vértices
- Sombreadores de fragmentos
Ambos, estoy seguro de que estarás de acuerdo, no significan absolutamente nada por sí mismos. 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 descargar todo lo que podamos en ellas, lo que deja a nuestra CPU para que realice 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 vértices
Toma una forma primitiva estándar, como una esfera. Está compuesto por vértices, ¿verdad? Un sombreador de vértices recibe cada uno de estos vértices y puede modificarlos. Depende del sombreador de vértices lo que realmente hace con cada uno, pero tiene una responsabilidad: en algún momento, debe establecer algo llamado gl_Position, un vector flotante de 4D, que es la posición final del vértice en la pantalla. En sí, es un proceso bastante interesante, ya que, en realidad, hablamos de obtener una posición en 3D (un vértice con x, y, z) en una pantalla en 2D, o proyectada en ella. Por suerte, si usamos algo como Three.js, tenemos una forma abreviada de establecer la gl_Position sin que las cosas se vuelvan demasiado pesadas.
3. Sombreadores de fragmentos
Ya tenemos nuestro objeto con sus vértices y los proyectamos en la pantalla 2D, pero ¿qué hay de los colores que usamos? ¿Qué hay de las texturas 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 una sola tarea obligatoria: debe establecer o descartar la variable gl_FragColor, otro vector flotante de 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. Por este motivo, 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 azul, pasando por el púrpura.
4. Variables de sombreador
Puedes realizar tres declaraciones de variables: Uniforms, Attributes y Varyings. Esto puede ser confuso, ya que no coinciden con ningún otro lenguaje común. Aquí te mostramos cómo puedes pensar en ellos:
Los uniformes se envían a ambos 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.
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.
Las variables 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 varying con el mismo tipo y nombre tanto en el sombreador de vértices como en el 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 en la realidad.
No, echa un vistazo a uno de los sombreadores más simples que puedes crear.
5. Bonjourno World
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 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. Estas dos variables uniformes son matrices 4D, llamadas matriz de vista de modelo y matriz de proyección. No necesitas saber exactamente cómo funcionan, aunque siempre es mejor comprender cómo hacen lo que hacen, si es posible. En resumen, 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 tienes que preocuparte por hacerlo. En realidad, agrega mucho más que eso, como datos de luz, colores de vértices y normales de vértices. Si hicieras esto sin Three.js, tendrías que crear y configurar todas esas variables uniformes y atributos por tu cuenta. Es una historia real.
6. Usa un MeshShaderMaterial
Bien, ya configuramos un sombreador, 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 asignes ese material. En realidad, no hay nada más fácil que eso. Bueno, probablemente sí, pero estamos hablando de 3D que se ejecuta en tu navegador, por lo que supongo que esperas una cierta cantidad de complejidad.
De hecho, podemos agregar dos propiedades más a nuestro MeshShaderMaterial: uniforms y attributes. Ambos pueden tomar vectores, números enteros o números de punto flotante, pero, como mencioné antes, los uniformes son los mismos para todo el fotograma o 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, que era rosa, parecerá iluminada desde arriba y desde un lado, y parpadeará. Es un poco extraño, pero esperamos que te ayude a comprender bien los tres tipos de variables, así como la forma en que se relacionan entre sí y con la geometría subyacente.
8. Luz artificial
Ahora, actualiza el color para que no sea un objeto de color plano. Podríamos ver cómo Three.js maneja la iluminación, pero, como seguramente puedes apreciar, es más complejo de lo que necesitamos, así que vamos a simularlo. Deberías revisar los fantásticos sombreadores que forman parte de Three.js y también los del increíble proyecto de 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. Para ello, usamos un valor 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 escalar de la normal del vértice con un vector que represente una luz que brilla desde arriba y hacia la derecha de la esfera. El resultado neto de esto nos da un efecto similar al de 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 producto escalar funciona porque, dados dos vectores, produce un número que indica qué tan "similares" son. 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 parte superior derecha tendrá un valor cercano o igual a 1 (completamente iluminado), mientras que un vértice en el costado tendrá un valor cercano a 0 y, en la parte posterior, será de -1. Fijamos el valor en 0 para cualquier valor negativo, pero cuando ingresas los números, obtienes la iluminación básica que estamos viendo.
Próximos pasos Bueno, sería bueno tal vez intentar jugar 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 con un atributo. Usaremos este número para empujar el vértice hacia afuera a lo largo de su normal. El resultado neto será una especie de bola con pinchos extraña que cambiará cada vez que actualices la página. Aún no se animará (eso sucederá más adelante), pero, si actualizas la página varias veces, verás que se genera de forma aleatoria.
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, de manera efectiva, el sombreador usa un valor de cero. Por ahora, es como un marcador de posición. En un segundo, agregaremos el atributo al MeshShaderMaterial en JavaScript, y Three.js unirá los dos automáticamente.
También es importante destacar 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
Pasemos directamente a actualizar nuestro MeshShaderMaterial con el atributo necesario para impulsar 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 deformada, pero lo genial es que todo el desplazamiento ocurre en la GPU.
11. Animarlo
Deberíamos animar esto. ¿Cómo lo hacemos? Hay dos cosas que debemos implementar:
- 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.
- Un bucle de animación en el JS
Agregaremos el uniforme tanto al MeshShaderMaterial como 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 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, terminamos con nuestros sombreadores. Pero, en este momento, parece que dimos un paso atrás. Esto se debe principalmente 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 otro valor.
En nuestro código de 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 de la variable 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 pulsante extraña (y un poco psicodélica).
Hay mucho más que podemos abarcar sobre los sombreadores, y espero que esta introducción te haya resultado útil. Ahora deberías poder comprender los sombreadores cuando los veas y tener la confianza para crear algunos sombreadores increíbles por tu cuenta.