- Partiendo de una animación simple de
border-radius en CSS en la página de inicio, implementa directamente una animación fluida de blob con unas pocas líneas de fragment shader
- Un shader es un pequeño programa que convierte coordenadas de píxeles en colores en la GPU; gracias a su ejecución en paralelo es rápido, pero tiene fuertes limitaciones para compartir estado y usar abstracciones de alto nivel
- El ejemplo básico de GLSL mapea las coordenadas
vUv a los valores RGBA de gl_FragColor, en un recorrido para aprender sintaxis como varying, uniform, tipos vectoriales y swizzling
- Los círculos y blobs se crean con
distance(), step(), smoothstep() y Signed Distance Function (SDF), y varias SDF se combinan con min() o smooth minimum
- Al crear el movimiento de las bolas con
u_time y agregar u_mouse al arreglo de centros, se extiende a un shader interactivo en el que el usuario manipula una parte con el mouse
Qué hacen los shaders y sus limitaciones
- Un shader es un pequeño programa que se ejecuta en la GPU y, como mínimo, recibe coordenadas de píxeles como entrada y produce un color como salida
- En los videojuegos se usa para efectos visuales como iluminación, efectos especiales y renderizado estilo caricatura, y es una base importante de los gráficos modernos en juegos
- La clave de su velocidad está en la paralelización, ejecutándose simultáneamente en muchos píxeles
- Esta introducción se centra en OpenGL Shading Language, amigable para el navegador, es decir, GLSL
- A cambio del rendimiento, los shaders deben escribirse de forma pequeña y de bajo nivel
- Es difícil apoyarse en abstracciones de alto nivel o importar bibliotecas
- Por la naturaleza de la ejecución en paralelo, funcionan de manera sin estado y sin memoria, sin posibilidad de almacenar o compartir datos entre píxeles
Primer ejemplo de GLSL: convertir coordenadas en color
- Un shader convierte coordenadas normalizadas en color RGBA
- Las coordenadas normalmente se normalizan entre 0 y 1
(0, 0) es la esquina inferior izquierda y (1, 1) es la esquina superior derecha
- Por convención, estas coordenadas se llaman
st o uv
- El ejemplo más simple es un gradiente en el que el rojo aumenta a medida que crece la coordenada x, y el verde aumenta a medida que crece la coordenada y
varying vec2 vUv;
void main() {
vec2 st = vUv;
gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
}
- Un gradiente azul se crea poniendo la coordenada x en el canal azul, como en
gl_FragColor = vec4(0.0, 0.0, st.x, 1.0);
- Los elementos importantes de la sintaxis son los siguientes
varying: entrada cuyo valor cambia para cada píxel
uniform: entrada igual para todos los píxeles
vec2, vec3, vec4, mat2, mat3: tipos de vectores y matrices con tipos explícitos, como en C
- swizzling: notación para extraer partes de un vector, como
vec4(1, 2, 3, 4).xy
gl_FragColor: salida que determina el color de cada píxel al final de main()
Crear círculos con distance(), step() y smoothstep()
- Incluso al dibujar figuras nítidas como círculos, no se usa una función como
drawCircle(), sino distancia matemática
- La distancia entre el píxel actual y el centro del círculo se puede calcular con
distance(vec2 p1, vec2 p2)
- Si la distancia se mapea directamente a color, se obtiene un gradiente circular; un círculo sólido se crea con
step(float threshold, float value)
- Si la distancia supera el umbral, devuelve 1
- Si no, devuelve 0
step() tiene una transición abrupta, lo que puede producir aliasing en el borde del círculo
- Un borde más suave puede crearse con
smoothstep(float t_start, float t_end, float x)
Representar formas con Signed Distance Function
- Una Signed Distance Function (SDF) representa, con una distancia con signo, qué tan lejos está un punto del espacio de una figura
- Dentro de la figura es negativa
- Fuera es positiva
- En el borde es 0
- La SDF de un círculo puede crearse restando el radio a la distancia respecto del centro
float circleSDF(vec2 p, float r) {
return length(p) - r;
}
- Si se desplaza el punto tomando como base la posición del píxel
uv, se puede calcular la distancia a un círculo ubicado en cualquier posición del espacio UV
- Si
d < 0.0, el píxel está dentro del círculo, por lo que puede pintarse con otro color
- Se pueden ver más figuras SDF 2D en la lista completa de Inigo Quilez
Combinar varias SDF para crear un blob
- Las SDF facilitan crear nuevas figuras mediante operaciones booleanas
- La unión de dos SDF se obtiene tomando el
min() de ambas distancias
- Si cualquiera de las dos figuras contiene el punto, la distancia mínima será negativa
- Si ambas están por fuera, la distancia mínima será positiva
- La razón para usar
1. - smoothstep() es que step() y smoothstep() devuelven 1 para distancias mayores que el umbral, es decir, fuera de la figura
- Un
min() simple crea una discontinuidad marcada en el punto donde se encuentran dos círculos
- Para mezclarlos suavemente como un blob, se usa smooth minimum
- El argumento adicional
k controla la intensidad de la suavidad
- El ejemplo usa una función polynomial smooth min
float smin(float a, float b, float k)
{
float h = max( k-abs(a-b), 0.0 )/k;
return min( a, b ) - h*h*k*(1.0/4.0);
}
Crear una animación de metaballs con un uniform de tiempo
- A un shader se le pueden pasar valores arbitrarios como uniform, igual que un slider
- La animación se logra pasando al shader como entrada el
u_time creado en JavaScript y usándolo para calcular las coordenadas de los centros de los círculos
- Por defecto, el shader se actualiza 60 veces por segundo con un nuevo valor de
u_time, creando un movimiento fluido
- Los centros de los círculos pueden oscilar con funciones periódicas como
sin y cos
- Al combinar varios círculos como metaballs, las coordenadas de los centros se guardan en un arreglo y se acumulan las SDF con un bucle
vec2 centers[4] = vec2[4](c1,c2,c3,c4);
float d = 99.;
for (int i = 0; i < 4; i++) {
vec2 c = centers[i];
float sdf = circleSDF(uv, c, .1*u_slider);
d = smin(d, sdf, K);
}
- El blob resultante funciona correctamente, pero es de un solo color, así que luego se agregan color e interacción
Interacción final controlada con el mouse
- El paso final es recibir las coordenadas del mouse como un uniform
u_mouse y hacer que el usuario controle directamente una de las bolas dentro del blob
uniform vec2 u_mouse;
- Al agregar las coordenadas del mouse al arreglo de centros de las bolas, la interacción se incorpora en una sola línea
vec2 centers[5] = vec2[5](c1,c2,c3,c4,u_mouse);
- El shader final invierte el eje y del mouse para ajustarlo a las coordenadas del canvas, y combina los 4 centros en movimiento junto con el centro del mouse
- El color se compone usando varias veces
mix(colorA, colorB, percent)
- Cuando
percent se usa como booleano, funciona de forma parecida a if/else
- El valor de
metaball, la distancia al centro, shine y el cálculo de membrane se reflejan en el color final
- Entender el mismo principio también ayuda a comprender mejor cómo funcionan internamente los editores de shaders basados en nodos, como Blender’s shader nodes o Unity’s Shader Graph
Recursos para seguir explorando
1 comentarios
Comentarios de Hacker News
Por fin reuní el valor para escribir en internet y mostrarme. Desde hace tiempo quería aprender sobre shaders, y pensé que sería bueno documentar el proceso de aprendizaje y compartirlo con otras personas
El blogging honesto se está muriendo en silencio bajo la sombra del algoritmo todopoderoso, y se siente como si internet ya hubiera llegado a plena mitad de Star Wars Episodio IV. Solo tengo un comentario: gracias, y ojalá escribas más
Si quieres ver lo que pueden hacer los expertos con shaders, me gustaría recomendar a Inigo Quilez y su arte con shaders: https://www.youtube.com/watch?v=BFld4EBO2RE
Edit: no me había dado cuenta de que eras el autor. El artículo quedó muy bien, y justo estaba buscando un tutorial que tratara el arte de programar shaders de una forma más accesible e interactiva
smoothstep(0.0f, 0.01f, dist);, puedes usarsmoothstep(fwidth(dist), -fwidth(dist), dist);El artículo está bastante bien, pero pasa demasiado por encima del problema central de los shaders.
Los shaders son, en su mayor parte, una molestia que la mayoría de los programas y aplicaciones no quiere. El 3D ama los triángulos y el GPU encaja bien con esa abstracción, y los shaders son útiles para interpolar sobre esos triángulos
Pero fuera del 3D, los triángulos apestan casi en todas partes. El renderizado 2D quiere rutas, el renderizado de fuentes quiere rutas o pixmaps, las GUI funcionan mucho mejor con rutas y pixmaps, los compositores quieren pixmaps, y los decodificadores de video quieren pixmaps y renderizado en paralelo
Lo que quiere el mundo no 3D son pixmaps rectangulares y acceso computacional directo a esos pixmaps, pero al GPU eso no le gusta mucho y los shaders tampoco encajan bien ahí
Es cierto que no es la forma que uno quiere en un nivel alto, pero como base para implementar abstracciones de más alto nivel es más que suficiente. El hardware podría soportarlo directamente, pero no está claro que haya una ventaja fuerte. Nadie se queja de que el CPU no soporte un
forloop a nivel de arquitecturaReleyéndolo, no me queda claro cuál sería el problema con el GPU. Puedes ignorar por completo el pipeline de vértices y simplemente dibujar un rectángulo de pantalla completa o usar un compute shader, y el GPU maneja eso muy bien. El artículo enlazado también habla de este tipo de casos
Al menos así era la forma de pensar en los 2000. A medida que el software se volvió más complejo, el GPU puede convertirse en la optimización deseada, pero el pipeline del GPU siempre ha sido rígido y cerrado, y el cambio de programación de un solo núcleo a varios núcleos muchas veces exige algoritmos distintos
La programación general sobre GPU te libera de estar atado a triángulos, pero para aprovechar esa paralelización sigue haciendo falta un enfoque completamente diferente
¡Buenísimo! Últimamente he estado metiéndome en la madriguera de SDF. Me alegró ver que incluyera un enlace al sitio de iq; su material es realmente muy bueno
No puedo dejar de enlazar su shader “happy bouncing”. Personalmente me parece asombroso: https://www.shadertoy.com/view/3lsSzf
También hay un video de YouTube de 6 horas mostrando el proceso de creación. Son unas 500 líneas de código muy sustanciosas
Había intentado interesarme en este tema varias veces, pero no lograba encontrar una puerta de entrada accesible; la encontré en esta introducción. Me gusta mucho que lo aborde de una forma divertida y juguetona, y ya quiero leer el siguiente artículo
Es una observación menor, pero al mencionar cel shading escribió
cell. El término viene de los cels usados en animación dibujada a mano y de los tonos cuantizados de ese tipo de shadingEstá muy bien. Como alguien que fue artista y luego programador, a veces me da por querer explorar la programación gráfica
He escrito algunos shaders muy básicos, pero cuando empieza a entrar la matemática, en realidad bastante pronto, me estrello contra una pared. Estudié arte, no ciencias de la computación, así que en la práctica casi no tengo base matemática
De todos modos, está muy bien escrito y me gustó el artículo
Excelente artículo introductorio; ojalá continúe. Muy seguido este tipo de textos arranca con una introducción genial y luego se queda inconcluso
Nunca había trabajado con shaders, así que quizá esta sea una pregunta muy básica. En un frame de un juego, ¿esencialmente el shader dibuja todo?
¿O existen formas primitivas como triángulos, rectángulos y círculos, y el shader solo agrega cosas encima, como sombras o suavizado de bordes?
Por los ejemplos, parece que se puede crear un shader que dibuje cualquier objeto de una escena y luego combinar otros shaders para obtener cosas como sombras e iluminación. En mi experiencia muy limitada dibujé formas, no con shaders. Siempre había pensado que el shader no dibujaba el objeto en sí
La GPU convierte formas vectoriales abstractas como triángulos, definidos por tres vértices y datos asociados a cada vértice como las normales, en un flujo de fragmentos, uno por cada píxel que cubre la forma en el búfer de salida, o más de uno si hay multisampling. Toda esta parte la maneja el hardware
Un fragmento está compuesto por coordenadas de píxel y datos proporcionados por el usuario. Esos datos pueden ser uniforms constantes o varyings, que son los datos de vértice mencionados antes interpolados a lo largo de toda la superficie del triángulo. Esa interpolación también la hace el hardware y no se puede programar
El fragment shader toma un fragmento como entrada, calcula un color según esos datos y, tras algunos pasos más, lo produce como el color de ese píxel en pantalla o en un búfer fuera de pantalla. Puede ser un color sólido o un cálculo de iluminación complejo
En el renderizado con GPU, todo esto ocurre en paralelo a gran escala, procesando muchísimos fragmentos al mismo tiempo. Los shaders son funciones puras y sin estado, así que solo pueden acceder a sus entradas, y lo único que pueden hacer es devolver unas cuantas salidas como el color y el valor de profundidad
En resumen, el hardware de la GPU calcula qué píxeles deben rellenarse para dibujar cada triángulo, y el fragment shader decide el valor de color de cada uno de esos píxeles
Antes de que el fragment shader dibuje “encima” de la escena, otras etapas como vértices y teselación dibujan las formas primitivas
Incluso sobre el fragment shader hay muchísimo más de lo que ya se explicó. Por ejemplo, está el deferred rendering[2], que por sí solo ya es un tema grande
1: https://vulkan-tutorial.com/Drawing_a_triangle/Graphics_pipe...
2: https://learnopengl.com/Advanced-Lighting/Deferred-Shading
Existe un graphics pipeline que mezcla etapas de hardware de función fija con etapas programables. A alto nivel, 1) la GPU recibe del CPU un conjunto de triángulos 3D, 2) el vertex shader aplana los vértices de esos triángulos 3D convirtiéndolos en vértices de triángulos 2D con coordenadas de píxel, 3) la GPU rasteriza los triángulos 2D para decidir exactamente qué píxeles quedan cubiertos por ellos, 4) para cada píxel cubierto se ejecuta un pixel shader para determinar su color, y 5) el color resultante del píxel se guarda en el frame buffer. En este paso puede mezclarse con el color existente
Este pipeline se repite muchas veces con distintas mallas triangulares y shaders hasta que se dibuja el frame completo
Cosas como color, sombras, shading, efectos de imagen y procesamiento general de imágenes se hacen todas como cálculos paralelos que combinan varios arreglos de datos. Ahí entran vértices y sus atributos, texturas de origen, funciones precalculadas, texturas de destino, búferes, etc.
Por ejemplo, para obtener luz y sombras, el shader necesita poder acceder a cosas como la posición y dirección de un reflector, probablemente mediante variables globales. La iluminación compuesta muchas veces se obtiene combinando varios shader passes. Por ejemplo, un pass base para iluminación global y un pass por cada luz, donde cada pass literalmente suma luz
Ahora, para no sumar luz a los píxeles donde la fuente de luz está bloqueada, es decir, en sombra, la técnica más común es usar algo llamado Z-buffer. En esencia es una textura de punto flotante. Como quieres saber hasta dónde llega cada luz en la escena, antes de aplicar toda la iluminación configuras un único shader pass que combina toda la geometría sólida de la escena, usando la posición y dirección de la luz como si fueran una transformación de cámara, y empleas un shader especial que solo escribe en el Z-buffer la distancia hasta los objetos
Después, cada vez que quieres saber si la luz llega a un punto en el espacio, tras un poco de cálculo geométrico haces sample de ese Z-buffer y comparas el valor guardado en esa dirección con la distancia a ese punto. Puede tener muchos bugs y los errores de precisión son comunes. Un motor serio ya hace este trabajo, pero te deja modificar el proceso
Todo lo demás son variantes de este tema. El deferred rendering consiste en renderizar datos a texturas intermedias en lugar de color, y procesarlos después para obtener el color. Un efecto de blur se hace con una convolución 2D sobre una render texture, por ejemplo con un kernel gaussiano. Los tessellation shaders tienen que ver con generar geometría nueva desde el vertex shader. Incluso dibujar texto se hace con un atlas de fuentes y pequeños rectángulos
Por eso artistas talentosos llevan al límite lo que se puede hacer dentro de un fragment shader y pelean constantemente con los compromisos de rendimiento
Más comúnmente, los fragment shaders se usan para efectos de filtro de pantalla completa, por ejemplo corrección de color
Los shaders también se usan para crear las texturas y materiales de los objetos base. Los artistas de materiales muchas veces generan texturas con matemática de shaders
Muchos efectos visuales se crean usando shaders de forma creativa
Los shaders se ejecutan en la GPU de una manera paralela, parecida a oleadas. Muchísimos hilos se ejecutan sobre los mismos datos dentro de una wave
En algunos casos, un shader es mucho más rápido que código con ramas en el CPU. Los shaders también pueden acceder con más facilidad a ciertos datos de renderizado
Por eso es un buen espacio para crear efectos especiales creativos. En los juegos, los objetos con mucho detalle en la superficie suelen ser candidatos comunes para trasladar ese detalle al shader. La superficie del mar, las mallas con teselación y similares son ejemplos, y como la GPU es potente y flexible, también hay muchos otros usos.
Si hay alguien para quien la imagen se ve como ruido parpadeante, lo resolví copiando la imagen en el navegador y pegándola en otro lado. Así se puede ver la imagen correctamente.
Es un enlace de Imgur. La primera imagen es una captura de pantalla de cómo se ve en el navegador, y las demás son las imágenes reales después de pegarlas en Imgur.
https://imgur.com/a/F4203rz
Mi entendimiento de los shaders es este.
Cuando la CPU dibuja una línea, es una función que recorre cada píxel entre el punto A y el punto B y los dibuja secuencialmente, un píxel a la vez. Tiene exactamente tantos pasos como píxeles haya en la línea y se ejecuta una vez.
Cuando la GPU dibuja una línea, es una función que verifica si ese píxel está sobre la línea y, si lo está, lo dibuja. Se ejecuta al mismo tiempo en todos los píxeles de la pantalla, incluyendo los que están muy lejos de la línea.
¿Es correcto?
Segundo, la GPU tampoco necesita ejecutar el pixel shader para toda la pantalla. Puede ejecutar el shader solo sobre la forma arbitraria que quieras usando triángulos. Por eso, una manera eficiente de dibujar una línea es enviar a la GPU dos triángulos que coincidan con la geometría de la línea deseada y ejecutar el pixel shader solo para los píxeles que se superponen con esos triángulos. Es mucho más eficiente.