En este artículo expondré un shader para dibujar funciones matemáticas. Por ejemplo, la siguiente imagen muestra el resultado de dibujar con este shader la función \(f(x) = x\) en el rango \([0,1]\):
Mientras que la siguiente imagen muestra la función \(f(x) = x^2\) en el mismo rango.
Para empezar, hay que tener claro lo que un shader es: un programa que partiendo de unas entradas produce un color. En este caso, vemos que el shader produce dos colores distintos: negro para el fondo, y blanco para los puntos \((x, f(x))\).
Normalmente, este tipo de salidas de dos colores condicionados se pueden hacer mediante una interpolación de dos colores de la forma siguiente:
(1-val) * negro + val * blanco
Cuando val = 0, el resultado es negro y cuando val = 1, el resultado es blanco.
En definitiva, lo que queremos es que los píxeles que están encima y debajo de la función sean negros (produzcan val = 0), y los píxeles que están alrededor de la función sean blancos (produzcan val = 1). Digo los píxeles de alrededor porque si sólo pusiéramos en blanco los píxeles que coinciden con la función ésta apenas sería visible.
La función smoothstep() al rescate
La función smoothstep(inferior, superior, valor) produce una interpolación Hermite de la variable valor entre dos extremos (inferior y superior). De esta forma, si valor es menor que inferior, el resultado de esta función es 0; si valor es mayor que superior, el resultado es 1. Y si valor está entre inferior y superior, se realiza una interpolación entre 0 y 1 siguiendo la curva siguiente:
Lo primero que queremos es evaluar la función en el rango \([0,1]\). Eso es fácil, puesto que podemos normalizar las coordenadas del píxel que estamos evaluando conociendo sus coordenadas globales (en píxeles) y la resolución de la escena. En ShaderToy, esto sería:
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
vec2 uv = fragCoord/iResolution.xy;
//...
}
Y ahora podemos calcular \(f(x)\). Supongamos que queremos dibujar \(f(x) = x\):
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
vec2 uv = fragCoord/iResolution.xy;
float fx = uv.x;
//...
}
Otra cosa que sabemos y que podemos definir es el color del fondo, que va a ser negro. Además, sabemos que va a haber un valor que será el que usemos para la interpolación entre este fondo y el blanco de la función, que aún no sabemos cómo calcular:
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
vec2 uv = fragCoord/iResolution.xy;
float fx = uv.x;
vec4 color = vec4(0.0);
float val = 0.0; //pendiente de calcular bien
color = (1.0-val)*color + val*vec4(1.0, 1.0, 1.0, 1.0);
fragColor = color
}
Ahora mismo lo que tendríamos es simplemente un fondo negro.Todavía no tenemos claro cómo usar la función smoothstep(), así que vamos a probar algo un poco al azar para ver bien cómo funciona:
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
vec2 uv = fragCoord/iResolution.xy;
float fx = uv.x;
vec4 color = vec4(0.0);
float val = smoothstep(fx-0.2, fx, uv.y);
color = (1.0-val)*color + val*vec4(1.0, 1.0, 1.0, 1.0);
fragColor = color
}
Con el código de arriba, val valdrá 0 (es decir, tendremos color negro) para los píxeles cuya coordenada y sea menor que el valor de la función (en este caso, la coordenada x del píxel) menos 0.2. Por otro lado, val valdrá 1 (tendremos el color blanco) para los píxeles cuya coordenada y sea mayor que el valor de la función. Y val valdrá entre 0 y 1 (degradado de negro a blanco) en el tramo intermedio. Esto es lo que se vería concretamente:
A medida que reducimos el intervalo, el degradado es menos perceptible y el negro sube. Por ejemplo, si hacemos:
float val = smoothstep(fx-0.01, fx, uv.y);
El resultado es:
Parece ya claro que si queremos dibujar una línea vamos a tener que usar algo más, en concreto, más de una función smoothstep. Vamos a fijarnos ahora en la siguiente definición de val:
float val = smoothstep(fx-0.01, fx, uv.y) - smoothstep(fx, fx+0.01, uv.y);
Con esto tenemos claro lo siguiente: los píxeles cuya coordenada y es menor que fx-0.01 van a ser negros (val = 0), mientras que los píxeles cuya coordenada y es mayor que fx+0.01 van a ser negros también, porque las dos funciones smoosthstep devolverán 1. Los píxeles cuya coordenada y coincida con el valor de la función (en el ejemplo de la línea, con su coordenada x) tendrán el máximo blanco (val = 1), porque la primera función smoothstep devolverá 1 y la segunda devolverá 0. Finalmente, los píxeles cuya coordenada y esté comprendida en el rango \((fx-0.01, fx+0.01)\) tendrán un degradado (muy sutil, por ser números tan pequeños) y producirán \(0 < val < 1\). El resultado, por tanto, es la línea que andábamos buscando:
El código final (en ShaderToy) es el siguiente:
float plot(in float pixelY, in float fx)
{
return smoothstep(fx-0.01, fx, pixelY) - smoothstep(fx, fx+0.01, pixelY);
}
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec2 uv = fragCoord/iResolution.xy;
float fx = uv.x;
vec4 color = vec4(0.0);
float val = plot(uv.y, fx);
color = (1.0-val)*color + val*vec4(1.0, 1.0, 1.0, 1.0);
fragColor = color;
}
Si quisiéramos dibujar otra función, sólo tendríamos que cambiar la línea donde se asigna la variable fx. Por ejemplo, si quisiéramos dibujar la función \(x^3\), haríamos:
float fx = pow(uv.x, 3.0);
Con lo que obtendríamos:
Y aquí dejo el código embebido de ShaderToy para que puedas experimentar.
Muy bien explicado para principiantes, muchas gracias
¡Gracias por tu comentario! Vuestro feedback es muy útil, tanto el positivo para motivarme a seguir escribiendo como el negativo para motivarme a mejorar. Un saludo.