Introducción a Shaders

Starter pack

Es finalmente momento de sumergirnos en el mundo de shaders. Son esenciales para crear todo tipo de efectos visuales. En este capítulo, aprenderemos sobre shaders, lo que podemos lograr con ellos y cómo usarlos en React Three Fiber.

Preludio

Antes de comenzar, quiero mencionar que los shaders pueden tomar un tiempo antes de que te acostumbres a ellos. Funcionan de manera diferente al resto del código que hemos escrito hasta ahora. Es una nueva forma de pensar y crear efectos visuales. Pero no te preocupes, te guiaré a través del proceso; comenzaremos con lo básico y gradualmente avanzaremos a temas más avanzados.

No te sientas desanimado si no entiendes todo al principio. Es normal. Es un nuevo concepto y requiere práctica para sentirse más cómodo con él. Te recomiendo que te tomes tu tiempo, experimentes y practiques, pero te prometo que vale la pena. Los shaders son increíblemente poderosos y te darán el control para crear cualquier efecto visual que puedas imaginar.

Además, todos tenemos diferentes estilos de aprendizaje. Algunas personas aprenden mejor leyendo, otras viendo videos y otras haciendo. Para este tema en particular, referenciar diferentes fuentes puede ser muy útil. Compartiré contigo recursos al final de este capítulo para consolidar tu conocimiento e ir más allá de lo que cubrimos aquí.

Espero no haberte asustado y que estés emocionado por aprender sobre shaders. ¡Comencemos!

¿Qué son los Shaders?

Shaders son pequeños programas que se ejecutan en la GPU (Unidad de Procesamiento Gráfico). Están escritos en un lenguaje llamado GLSL (OpenGL Shading Language), que es similar a C.

Se utilizan para posicionar los vértices de una mesh (Vertex Shader) y para colorear cada píxel de las caras (Fragment Shader).

De hecho, hemos estado usando shaders todo el tiempo. Cuando creamos un material, estamos usando un shader. Por ejemplo, cuando creamos un MeshBasicMaterial, estamos usando un shader que colorea la mesh con un solo color. Cuando creamos un MeshStandardMaterial, estamos usando un shader que simula iluminación, sombras y reflejos.

Vertex Shader

Un vertex shader es un programa que se ejecuta para cada vértice de una geometría. Su responsabilidad principal es transformar los vértices del espacio 3D (nuestro mundo 3D) al espacio 2D (nuestra pantalla o visor). Logra esta transformación utilizando varias matrices:

  • View Matrix: Esta matriz representa la posición y orientación de la cámara en la escena. Transforma vértices del espacio mundial al espacio de la cámara.
  • Projection Matrix: Esta matriz, ya sea perspectiva u ortográfica, convierte vértices del espacio de la cámara a coordenadas de dispositivo normalized (NDC), preparándolos para la proyección final en la pantalla 2D.
  • Model Matrix: Esta matriz encapsula la posición, rotación y escala de cada objeto individual en la escena. Transforma vértices del espacio del objeto al espacio mundial.

Además, el vertex shader también incorpora la posición original del vértice y cualquier otro atributo asociado con él.

Esquema del vertex shader

Para cada vértice de la geometría, se ejecutará el vertex shader.

Finalmente, la posición transformada del vértice en el espacio 2D se devuelve a través de la variable predefinida gl_Position. Después de que todos los vértices se transforman, la GPU interpola los valores entre ellos para generar las caras de la geometría, que luego se rasterizan y renderizan en la pantalla.

Fragment Shader

Un fragment shader, también conocido como pixel shader, es un programa ejecutado para cada fragmento (o píxel) generado por el proceso de rasterización. Su tarea principal es determinar el color final de cada píxel en la pantalla.

Schema of the fragment shader

Para cada fragmento generado durante la rasterización, se ejecutará el fragment shader.

El fragment shader recibe valores interpolados del vertex shader, tales como colores, coordenadas de textura, normales y cualquier otro atributo asociado con los vértices de la geometría. Estos valores interpolados se llaman varyings, y proporcionan información sobre las propiedades de la superficie en cada ubicación de fragmento.

Además de los valores interpolados, el fragment shader también puede muestrear texturas y acceder a variables uniformes, que son constantes en todos los fragments. Estas variables uniformes pueden representar parámetros como posiciones de luz, propiedades del material, o cualquier otro dato requerido para los cálculos de shading.

Volveremos a attributes y uniforms más adelante en esta lección.

Usando los datos de entrada, el fragment shader realiza varios cálculos para determinar el color final del fragmento. Esto puede involucrar cálculos de iluminación complejos, mapeo de texturas, efectos de shading, o cualquier otro efecto visual deseado en la escena.

Una vez que se completa el cálculo del color, el fragment shader genera el color final del fragmento utilizando la variable predefinida gl_FragColor.

Intenté en la medida de lo posible explicar los shaders de una manera simple y omití intencionalmente algunos detalles técnicos, pero entiendo que aún puede ser un poco abstracto. Vamos a crear un simple shader para ver cómo funciona en la práctica.

Tu Primer Shader

Vamos a ejecutar el paquete de inicio. Deberías ver este frame con un plano negro en el centro de la pantalla:

A frame with a black plane

Abre el archivo ShaderPlane.jsx, contiene un simple mesh con una geometría de plane y un material básico. Reemplazaremos este material con un material de shader personalizado.

shaderMaterial

Para crear un material de shader, utilizamos la función shaderMaterial de la Drei library.

Toma 3 parámetros:

  • uniforms: Un objeto que contiene las variables uniformes utilizadas en el shader. Déjalo vacío por ahora.
  • vertexShader: Una cadena que contiene el código GLSL para el vertex shader.
  • fragmentShader: Una cadena que contiene el código GLSL para el fragment shader.

En la parte superior de nuestro archivo, declaremos un nuevo material de shader llamado MyShaderMaterial:

import { shaderMaterial } from "@react-three/drei";

const MyShaderMaterial = shaderMaterial(
  {},
  `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  `
  void main() {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
  }
  `
);

Nos adentraremos en el código del shader en un momento.

Para poder utilizarlo declarativamente con React Three Fiber, utilizamos el método extend:

import { extend } from "@react-three/fiber";
// ...

extend({ MyShaderMaterial });

Ahora podemos reemplazar el <meshBasicMaterial> con nuestro nuevo material de shader:

import { shaderMaterial } from "@react-three/drei";
import { extend } from "@react-three/fiber";

const MyShaderMaterial = shaderMaterial(
  {},
  `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  `
  void main() {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
  }
  `
);

extend({ MyShaderMaterial });

export const ShaderPlane = ({ ...props }) => {
  return (
    <mesh {...props}>
      <planeGeometry args={[1, 1]} />
      <myShaderMaterial />
    </mesh>
  );
};

Deberías ver el mismo plano negro que antes. No cambiamos nada todavía, pero ahora estamos usando un material de shader personalizado.

Para verificar que está funcionando, cambiemos el color que estamos devolviendo en el fragment shader. Reemplaza la línea gl_FragColor con lo siguiente:

gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);

gl_FragColor es una variable predefinida que representa el color del fragmento. Es un vec4 (un vector con 4 componentes) que representa los canales de color rojo, verde, azul y alfa. Cada componente es un float entre 0 y 1.

Al establecer el primer componente a 1.0, estamos estableciendo el canal rojo a su valor máximo, lo que resultará en un color rojo.

Deberías ver un plano rojo en el centro de la pantalla:

A frame with a red plane

¡Felicidades! Acabas de crear tu primer material de shader. Es uno simple, pero es un comienzo.

Código Shader

Antes de continuar, vamos a configurar nuestro entorno de desarrollo para escribir shaders más cómodamente.

Tienes dos opciones para escribir el código shader:

  • Inline: Puedes escribir el código shader directamente en el archivo JavaScript.
  • Externo: Puedes escribir el código shader en un archivo separado con la extensión .glsl y luego importarlo en tu archivo JavaScript.

Usualmente prefiero el enfoque inline dentro de archivos de material adecuados, de esta manera el código shader está cerca de la declaración del material.

Pero para que sea más fácil de escribir y leer, recomiendo usar un resaltador de sintaxis para GLSL. Puedes usar la extensión Comment tagged templates para Visual Studio Code. Resaltará el código GLSL dentro de las cadenas de plantilla.

GLSL syntax highlighter

Una vez instalada, para habilitar el resaltador de sintaxis, necesitas agregar el siguiente comentario al principio del código shader:

const MyShaderMaterial = shaderMaterial(
  {},
  /* glsl */ `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  /* glsl */ `
  void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }
  `
);

Deberías ver el código GLSL resaltado en las cadenas de plantilla:

GLSL syntax highlighter in action

El vertex shader en la parte superior ahora tiene el resaltado de sintaxis correcto y es más fácil de leer.

Esto es todo lo que necesitas para el código shader inline. Aún puedes decidir usar un archivo externo si prefieres mantener tu código shader separado. Veamos cómo hacerlo.

Importar archivos GLSL

Primero, crea una nueva carpeta llamada shaders en la carpeta src. Dentro de esta carpeta, crea dos archivos: myshader.vertex.glsl y myshader.fragment.glsl y copia el respectivo código de shader en cada archivo.

myshader.vertex.glsl:

void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

myshader.fragment.glsl:

void main() {
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

Siéntete libre de usar la convención de nomenclatura que prefieras y agrupar tus shaders en subcarpetas si tienes muchos.

Luego, para poder importar estos archivos en nuestro archivo JavaScript, necesitamos instalar el plugin vite-plugin-glsl como una dependencia de desarrollo:

yarn add vite-plugin-glsl --dev

Luego, en tu archivo vite.config.js, importa el plugin y agrégalo al array de plugins:

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import glsl from "vite-plugin-glsl";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), glsl()],
});

Ahora puedes importar los archivos GLSL en tu archivo JavaScript y usarlos como código de shader:

import myShaderFragment from "./shaders/myshader.fragment.glsl";
import myShaderVertex from "./shaders/myshader.vertex.glsl";

const MyShaderMaterial = shaderMaterial({}, myShaderVertex, myShaderFragment);

Ahora que tenemos una forma cómoda de escribir e importar código de shader, podemos empezar a explorar las diferentes partes del código de shader.

GLSL

El código shader se escribe en GLSL (OpenGL Shading Language). Es un lenguaje similar a C, veamos lo más básico.

Tipos

GLSL tiene varios tipos, pero los más comunes son:

  • bool: Un valor booleano (true o false).
  • int: Un número entero.
  • float: Un número de punto flotante.
  • vectores: Una colección de números. vec2 es una colección de 2 números de punto flotante (x y y), vec3 es una colección de 3 números de punto flotante (x, y y z), y vec4 es una colección de 4 números de punto flotante (x, y, z y w). En lugar de usar x, y, z y w, también puedes usar r, g, b y a para colores, son intercambiables.
  • matrices: Una colección de vectores. Por ejemplo, mat2 es una colección de 2 vectores, mat3 es una colección de 3 vectores, y mat4 es una colección de 4 vectores.

Swizzling y manipulaciones

Puedes acceder a los componentes de un vector usando swizzling. Por ejemplo, puedes crear un nuevo vector utilizando los componentes de otro vector:

vec3 a = vec3(1.0, 2.0, 3.0);
vec2 b = a.xy;

En este ejemplo, b será un vector con los componentes x y y de a.

También puedes usar swizzling para cambiar el orden de los componentes:

vec3 a = vec3(1.0, 2.0, 3.0);
vec3 b = a.zyx;

En este ejemplo, b será igual a vec3(3.0, 2.0, 1.0).

Para crear un nuevo vector con todos los mismos componentes, puedes usar el constructor:

vec3 a = vec3(1.0);

En este ejemplo, a será igual a vec3(1.0, 1.0, 1.0).

Operadores

GLSL tiene los operadores aritméticos comunes: +, -, *, /, +=, /=, *= y operadores de comparación comunes: ==, !=, >, <, >=, <=.

Deben ser usados con los tipos correctos. Por ejemplo, no se puede sumar un entero a un float, primero se necesita convertir el entero a un float:

int a = 1;
float b = 2.0;
float c = float(a) + b;

También puedes realizar operaciones con vectores y matrices:

vec3 a = vec3(1.0, 2.0, 3.0);
vec3 b = vec3(4.0, 5.0, 6.0);
vec3 c = a + b;

Esto es lo mismo que:

vec3 c = vec3(a.x + b.x, a.y + b.y, a.z + b.z);

Funciones

El punto de entrada de los vertex y fragment shaders es la función main. Es la función que se ejecutará cuando se llame al shader.

void main() {
  // Tu código aquí
}

void es el tipo de retorno de la función. Significa que la función no retorna nada.

También puedes definir tus propias funciones:

float add(float a, float b) {
  return a + b;
}

Luego puedes llamar a esta función en la función main:

void main() {
  float result = add(1.0, 2.0);
  // ...
}

GLSL proporciona muchas funciones integradas para operaciones comunes como sin, cos, max, min, abs, round, floor, ceil, y muchas otras útiles como mix, step, length, distance, y más.

Descubriremos las esenciales y practicaremos con ellas en la próxima lección.

Bucles y Condiciones

GLSL soporta for loops y declaraciones if. Funcionan de manera similar a JavaScript:

for (int i = 0; i < 10; i++) {
  // Tu código aquí
}

if (condition) {
  // Tu código aquí
} else {
  // Tu código aquí
}

Registro / Depuración

Como los programas de shaders se ejecutan en paralelo para cada vértice y fragmento, no es posible usar console.log para depurar tu código ni agregar puntos de interrupción. Esto hace que sea difícil depurar shaders.

Una forma común de depurar shaders es usar gl_FragColor para visualizar los valores de tus variables.

Errores de compilación

Si cometes un error en tu código shader, verás un error de compilación en la consola. Este te indicará la línea y el tipo de error. No siempre es fácil de entender, pero es una buena manera de saber dónde buscar el problema.

Vamos a eliminar el canal alfa de gl_FragColor y ver qué pasa:

void main() {
  gl_FragColor = vec4(1.0, 0.0, 0.0);
}

Deberías ver un error de compilación en la consola:

Compilation error

Nos indica que gl_FragColor espera 4 componentes, pero solo proporcionamos 3.

No olvides restaurar el canal alfa a 1.0 para eliminar el error.

Uniforms

Para pasar datos desde el código de JavaScript al shader usamos uniforms. Son constantes a través de todos los vértices y fragmentos.

El projectionMatrix, modelViewMatrix y position son ejemplos de uniforms integrados que se pasan automáticamente al shader.

Vamos a crear un uniform personalizado para pasar un color al shader. Lo usaremos para colorear el plano. Lo llamaremos uColor. Es una buena práctica prefijar el nombre del uniform con u para dejar claro que es un uniform en nuestro código.

Primero, vamos a declararlo en el objeto uniforms del shaderMaterial:

import { Color } from "three";
// ...
const MyShaderMaterial = shaderMaterial(
  {
    uColor: new Color("pink"),
  }
  // ...
);

// ...

Luego, podemos utilizarlo en el fragment shader:

uniform vec3 uColor;

void main() {
  gl_FragColor = vec4(uColor, 1.0);
}

Deberías ver el plano coloreado de rosa:

A frame with a pink plane

Aquí, el color rosa es el valor predeterminado del uniform. Podemos cambiarlo directamente en el material:

<MyShaderMaterial uColor={"lightblue"} />

A frame with a light blue plane

El plano ahora está coloreado de azul claro.

Tanto los vertex como los fragment shaders pueden acceder a los uniforms. Añadamos el tiempo como un segundo uniform al vertex shader para mover el plano hacia arriba y hacia abajo:

End of lesson preview

To get access to the entire lesson, you need to purchase the course.