moloxe.io / blog / shaders-en-corto
Hola! esta vez intentaré plasmar lo que aprendí sobre shaders. Se implementarán algunos temas en el contexto de shaders. Con un poco de fe, espero sirva.
Los shaders se aplican sobre objetos 3D, por lo que es necesario contar con uno antes de usarlos (spoiler: solo se usa un cuadrado).
Los shaders son una forma especial de renderizar gráficos. A diferencia de mostrar un círculo en una pantalla (con algún lenguaje de programación), el equivalente en shaders sería saber si una coordenada pertenece a ese círculo y determinar que color asignarle.
Hay muchas suposiciones incompletas en esa definición, pero, es suficiente para saber que no son una forma tradicional de programar con gráficos.
GLSL es el lenguaje de programación para shaders. El cómo se comunica un shader con un programa depende mucho de la plataforma. P5js es una librería que facilita trabajar con shaders en el navegador usando WEBGL.
P5js ofrece la propiedad aPosition
como coordenadas del vértice.
En P5js los vértices están en el rango de [0, 1] y para renderizarlo como se "espera", es necesario escalarlo a un rango entre [-1, 1].
Para definir la posición del vértice, se tiene que asignar un vector a gl_Position
.
// shader.vert
attribute vec3 aPosition;
void main() {
vec4 pos = vec4(aPosition, 1.0);
pos = pos * 2.0 - 1.0;
gl_Position = pos;
}
Para definir el color a renderizar, se tiene que asignar un vector (que representa un color) a gl_FragColor
.
// shader.frag
precision mediump float;
void main() {
gl_FragColor = vec4(1.0, 0, 0, 1.0);
}
Nota: GLSL es estricto con el uso de coma flotante (float
), además, es necesario definir la precisión.
Para cargar un shader en P5js, es necesario especificar que se usará WEBGL al crear el canvas.
let myShader;
function preload() {
myShader = loadShader("shader.vert", "shader.frag");
}
function setup() {
createCanvas(320, 320, WEBGL);
}
function draw() {
background(0);
shader(myShader);
rect(0, 0, 0, 0);
}
Nota: El rect
se define con un tamaño 0. Esto es algo específico de P5js (con algunas figuras en 2D), cuando se carga un shader este ignora los parámetros definidos. Sin embargo, si se respetan las proporciones de los vértices.
Para probarlo es posible cargar los shaders en createShader
como strings.
Copia y pega lo siguiente en: https://editor.p5js.org/
const vertShader = `
attribute vec3 aPosition;
void main() {
vec4 pos = vec4(aPosition, 1.0);
pos = pos * 2.0 - 1.0;
gl_Position = pos;
}
`;
const fragShader = `
precision mediump float;
void main() {
gl_FragColor = vec4(1.0, 0, 0, 1.0);
}
`;
let myShader;
function setup() {
myShader = createShader(vertShader, fragShader);
createCanvas(320, 320, WEBGL);
}
function draw() {
background(0);
shader(myShader);
rect(0, 0, 0, 0);
}
Se usará este sketch como referencia en los siguientes ejemplos.
Para determinar un color en función de la posición, es necesario comunicar al fragment shader
la posición. Esto es posible definiendo variables las cuales pueden compartir ambos vertex shader
y fragment shader
.
// shader.vert
attribute vec3 aPosition;
varying vec2 vXY; // VARIABLE COMPARTIDA
void main() {
vec4 pos = vec4(aPosition, 1.0);
pos = pos * 2.0 - 1.0;
vXY = pos.xy;
gl_Position = pos;
}
Luego se puede recibir desde el fragment shader
la variable vXY
:
// shader.frag
precision mediump float;
varying vec2 vXY; // VARIABLE COMPARTIDA
void main() {
float radius = 0.5;
vec2 circle1 = vec2(-0.5, -0.5);
vec2 circle2 = vec2(0.5, 0.5);
float color = min(
step(radius, length(vXY - circle1.xy)),
step(radius, length(vXY - circle2.xy))
);
color = 1.0 - color;
gl_FragColor = vec4(vec3(color), 1.0);
}
Explicación: Para determinar el color, primero se calcula la distancia a alguno de los círculos. Con la función length
se puede obtener la distancia euclidiana al origen, en este caso se suma para hacer una traslación respecto al punto evaluado. Luego step
devuelve 1
si la distancia es mayor a radius
, por ese motivo se invierte el color antes de definirlo.
precision mediump float;
varying vec2 vXY;
void main() {
float size1 = 0.5;
vec2 square1 = vec2(0.5, 0.5);
float size2 = 0.25;
vec2 square2 = vec2(-0.5, -0.5);
float x = vXY.x;
float y = vXY.y;
float color = min((
step(size1, abs(square1.x - x)) +
step(size1, abs(square1.y - y))
), (
step(size2, abs(square2.x - x)) +
step(size2, abs(square2.y - y))
));
color = 1.0 - color;
gl_FragColor = vec4(vec3(color), 1.0);
}
Explicación: A diferencia del círculo, los cuadrados se pueden determinar con una distancia manhattan
que sería simplemente sumar si se encuentra en el rango del eje x
con si se encuentra en el eje y
.
Para compartir variables desde JS a los shaders, se utilizan los uniform
. En este ejemplo se comparte la posición y el radio como un vec3
.
const vertShader = `
attribute vec3 aPosition;
varying vec2 vXY;
void main() {
vec4 pos = vec4(aPosition, 1.0);
pos = pos * 2.0 - 1.0;
vXY = pos.xy;
gl_Position = pos;
}
`;
const fragShader = `
precision mediump float;
varying vec2 vXY;
uniform vec3 uCircle;
void main() {
float radius = uCircle.z;
float color = step(radius, length(vXY - uCircle.xy));
color = 1.0 - color;
gl_FragColor = vec4(vec3(color), 1.0);
}
`;
let myShader;
function setup() {
myShader = createShader(vertShader, fragShader);
createCanvas(320, 320, WEBGL);
}
function draw() {
const angle = (TWO_PI * (frameCount % 200)) / 200;
const x = 0.5 * cos(angle);
const y = 0.5 * sin(angle);
background(0);
shader(myShader);
myShader.setUniform("uCircle", [x, y, 0.5]);
rect(0, 0, 0, 0);
}
Con eso es suficiente para empezar a experimentar, lo que sigue no viene con explicación. Sin embargo, puedes obtener el código inspeccionando la página.
Lo otro es que puedes escribirme para compartirte el código, ya que no pienso subir los experimentos a un repositorio.
Nota: No se aprecia muy bien las rotaciones, para notarlas mejor se podría calcular desde JS y enviar las direcciones a los shaders. Para el color se escaló la distancia en ángulo entre el origen y el destino.
Se implementó una mejor forma de representar el campo vectorial ✨
En términos de hsv:
hue
: Representa el ángulo que se forma entré el origen y el destino.saturation
: Constante.value
: El brillo depende de la distancia euclidiana entre el origen y el destino.Para este experimento es necesaria una función que genere ruido. La más conocida es Perlin Noise y se usó la siguiente implementación:
Aquí es necesario hacer muchos cálculos al ojo, la técnica es si el ruido que va de [0, 1] está más cerca a 1 se pinta de blanco, caso contrario celeste.
Además, se añadieron nubes lentas y más grandes para dar la sensación de densidad en ciertas zonas.
Nota: Si esperas a que termine el zoom, notarás el límite de la precisión para los decimales.
Adaptación de: https://editor.p5js.org/Taxen99/sketches/47CDg5-nV
Este fue el motivo por el cuál quería aprender shaders, calcular el conjunto de Mandelbulb en JS es muy lento. Incluso en Java esto toma su tiempo y el renderizado no es muy intuitivo.
Como pendiente, haré un post aparte intentando explicar cómo desarrollarlo. Cuando lo tenga listo pondré el enlace en esta sección. 🐵
Update (2024/06/15)
Lo prometido es deuda: Mandelbulb en GLSL
Nota mental: es increíble que todo esto pueda correr en el navegador 🤯
Aquí la idea es guardar el estado en JS (ya que glsl no puede guardar estados entre frames) y calcular el siguiente estado con los shaders.
Glsl permite uniforms de imágenes llamados sampler2D
, representan texturas y en este experimento se carga el estado inicial que es el canvas previo. El inicial son varios puntos aleatorios.
Mi intención era implementar Lenia, pero tiene DEMASIADAS reglas. Pueden verlo en este video: https://www.youtube.com/watch?v=6kiBYjvyojQ
Nota: Usar get
en P5js para enviar el estado actual, da error en mobile después de algunos frames. Esto no sucede al usar createGraphics
.