2 puntos por GN⁺ 2023-10-27 | 1 comentarios | Compartir por WhatsApp
  • 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

 
GN⁺ 2023-10-27
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

    • Me gustó mucho que lo publicara en su blog personal y lo hiciera como un artículo interactivo. Es distinto de esa forma de alimentar al algoritmo de Google o al copy-paste de IA con feeds horribles de Notion/Medium/Dev
      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
    • ¡Bienvenido a internet, superMayo!
      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
    • Si estás dispuesto a usar la extensión OES_standard_derivatives, que tiene muy buen soporte, o WebGL 2, puedes mejorar el antialiasing. En vez de elegir una constante al tanteo como smoothstep(0.0f, 0.01f, dist);, puedes usar smoothstep(fwidth(dist), -fwidth(dist), dist);
    • Considerando que los shaders son un objeto completamente gráfico, este artículo se siente como una forma casi ideal de introducirse al tema. Funciona muy bien que el código interactivo esté embebido directamente en el texto en vez de mandarte a un enlace de práctica aparte
  • 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í

    • Como alguien que ha escrito un renderizador de curvas 2D, necesitaba cómputo paralelo y alto ancho de banda, y el GPU da eso. Más todavía con interfaces nuevas que soportan scatter-write, aunque no sé qué tan extendidas estén en navegadores
      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 for loop a nivel de arquitectura
      Releyé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
    • Visto desde el lado contrario, la mayoría de los programas no necesita realmente un GPU para obtener el resultado que quiere. En cambio, en 3D esa paralelización es casi indispensable una vez que pasas de una escala muy pequeña
      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
    • No entiendo qué parte de los pixel/fragment shaders supuestamente no se mapea bien a los pixmaps
  • ¡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

    • Inigo Quilez es un mago, y la comunidad de shaders realmente le debe muchísimo
  • 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 shading

  • Está 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í

    • Lo siguiente es una explicación simplificada del proceso clásico de forward rendering; el llamado deferred rendering es un poco distinto
      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
    • Los shaders sí hacen todo el dibujo, pero está dividido en varias etapas. No voy a explicar todo el graphics pipeline[1], pero gran parte de lo que la gente llama de forma general “shader” para efectos de iluminación, más del 90%, corresponde a la etapa de fragment/pixel shader del renderer
      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
    • Sí, al final un programa shader determina el color de cada píxel, pero como imaginas es más complejo que eso
      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
    • En general lo entendiste bien. Hasta donde sé, a todo tipo de código que corre en la GPU se le llama shader. Por ejemplo, operaciones especiales sobre datos también se llaman compute shaders, aunque su naturaleza es distinta
      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
    • Se puede dibujar cualquier objeto con fragment, es decir, pixel shaders. Esto es así porque puedes usar técnicas matemáticas específicas para dibujar formas, independientemente de la tecnología, por ejemplo SDF o trigonometría
      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

    • Qué raro. ¿Qué navegador estás usando?
  • 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?

    • No exactamente. Primero, la GPU no puede ejecutar el pixel shader al mismo tiempo para todos los píxeles. Una pantalla común tiene alrededor de 2 millones de píxeles, pero los hilos de ejecución simultáneos de la GPU son, como mucho, unos miles, así que en la práctica dibuja en bloques de unos cuantos miles de píxeles.
      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.