step, smoothstep y mix: la condicional que no diverge
La sustitución que cambia la forma de escribir shaders: de decidir con if a construir máscaras continuas, con antialiasing correcto mediante fwidth.
Si tuvieras que quedarte con una sola idea de todo el bloque de shaders, sería ésta. Sustituir las condicionales por máscaras continuas no es un truco de optimización: es un cambio de modelo mental que resuelve a la vez el problema del rendimiento, el del aliasing y el de la composición. Una vez que piensas en máscaras, la mitad de los efectos que parecían complicados se escriben en tres líneas.
- Definir con exactitud qué calculan
step,smoothstep,mixyclamp. - Traducir cualquier condicional sobre una magnitud continua a una máscara.
- Calcular el ancho de transición correcto con
fwidthpara obtener antialiasing. - Componer máscaras con las operaciones de unión, intersección y diferencia.
Las cuatro funciones, con su definición exacta
step( borde, x ) devuelve 0.0 si x es menor que borde, y 1.0 en caso contrario. Es la condicional en forma de número.
smoothstep( b0, b1, x ) devuelve 0.0 por debajo de b0, 1.0 por encima de b1, y entre medias una interpolación con derivada nula en los dos extremos:
// Lo que smoothstep calcula, escrito a mano
float t = clamp( ( x - b0 ) / ( b1 - b0 ), 0.0, 1.0 );
float resultado = t * t * ( 3.0 - 2.0 * t );
Esa curva se llama polinomio de Hermite de tercer grado, y su propiedad importante es que vale 0 y 1 en los extremos y su derivada vale cero en los dos. Una rampa lineal también va de 0 a 1, pero su derivada salta de golpe en los extremos, y el ojo humano detecta esas discontinuidades como líneas —el efecto de bandas de Mach. smoothstep no las produce.
mix( a, b, t ) interpola: a * ( 1.0 - t ) + b * t. Con t fuera del rango cero a uno extrapola, cosa que a veces es lo que quieres y a veces es un bug.
clamp( x, lo, hi ) es min( max( x, lo ), hi ).
Las cuatro son instrucciones de hardware o secuencias de dos o tres instrucciones. Ninguna es una función de librería con salto.
La sustitución
Toda condicional de la forma “si esta magnitud pasa de tal umbral, un valor, si no, otro” se traduce mecánicamente:
// Antes
vec3 color;
if ( d < 0.25 ) color = colorA;
else color = colorB;
// Despues, con corte duro
float m = step( 0.25, d ); // 0 si d < 0.25, 1 si no
vec3 color = mix( colorA, colorB, m );
// Despues, con borde suave de 0.01 unidades
float m = smoothstep( 0.245, 0.255, d );
vec3 color = mix( colorA, colorB, m );
Y a partir de ahí, la máscara m deja de ser un booleano disfrazado y se convierte en un valor con el que se puede seguir operando. Ésa es la ganancia real.
Un caso completo: un círculo con borde.
varying vec2 vUv;
uniform vec3 uRelleno;
uniform vec3 uBorde;
uniform vec3 uFondo;
void main() {
vec2 p = vUv - 0.5;
float d = length( p );
float dentro = 1.0 - smoothstep( 0.28, 0.29, d );
float anillo = ( 1.0 - smoothstep( 0.33, 0.34, d ) ) - ( 1.0 - smoothstep( 0.30, 0.31, d ) );
vec3 color = uFondo;
color = mix( color, uBorde, anillo );
color = mix( color, uRelleno, dentro );
gl_FragColor = vec4( color, 1.0 );
}
Sin una sola condicional, sin divergencia, y con los dos bordes suavizados. El apilado de mix es el patrón de capas: cada mix pinta una capa encima de lo anterior según su máscara, exactamente como un programa de dibujo.
El ancho correcto: fwidth
El problema del código anterior es que los umbrales están escritos a mano. Un borde de 0.01 unidades de UV mide muchos píxeles si el objeto está cerca y menos de uno si está lejos. Cerca se ve borroso; lejos, dentado.
La solución es medir cuánto cambia la magnitud entre un fragmento y el siguiente, que es exactamente lo que hace fwidth:
float fwidth( float x ); // equivale a abs( dFdx( x ) ) + abs( dFdy( x ) )
fwidth( d ) devuelve, en las unidades de d, cuánto vale un píxel de pantalla en ese punto. Con eso, un borde de exactamente un píxel se escribe así:
float w = fwidth( d );
float dentro = 1.0 - smoothstep( 0.28 - w, 0.28 + w, d );
Y como es la operación más repetida del oficio, merece su propia función:
// Devuelve 1 dentro, 0 fuera, con una transicion de un pixel
float mascara( float distancia ) {
float w = max( fwidth( distancia ), 1e-5 );
return 1.0 - smoothstep( -w, w, distancia );
}
El max con un épsilon protege del caso en que fwidth devuelve exactamente cero —una superficie perfectamente perpendicular, o una región constante—, porque smoothstep con los dos bordes iguales tiene resultado indefinido.
Con esa función, la máscara de un círculo de radio r es una línea:
float circulo = mascara( length( p ) - r );
Fíjate en la forma: se resta el radio para convertir la longitud en una distancia con signo al contorno, negativa dentro y positiva fuera. Ese cambio de representación es el que hace que todo lo demás componga.
En WebGL 2 las derivadas son parte del núcleo del lenguaje: no hay que habilitar ninguna extensión, y la propiedad material.extensions.derivatives que los tutoriales antiguos mencionan ya no existe en r184, donde extensions solo acepta clipCullDistance y multiDraw.
Componer máscaras
Con las máscaras en el rango cero a uno, las operaciones de conjunto son aritmética elemental:
float interseccion = a * b; // A y B
float unionDura = max( a, b ); // A o B
float unionSuave = a + b - a * b; // A o B, sin escalon donde se cruzan
float diferencia = a * ( 1.0 - b ); // A pero no B
float exclusiva = abs( a - b ); // A o B pero no ambas
float negacion = 1.0 - a;
Cuidado con el nombre: union es una palabra reservada en GLSL para uso futuro y no se puede usar como identificador, igual que class, enum, typedef, long, short, double, static o template. El compilador rechaza el shader con un error de sintaxis en la línea, sin explicar por qué.
max y a + b - a*b dan resultados distintos donde las dos máscaras valen entre cero y uno a la vez, es decir en los bordes suavizados. max mantiene el borde exacto de la máscara que domine; la versión probabilística lo suaviza más. Para composición de formas, max; para acumulación de intensidades, la otra.
Y hay dos usos de smoothstep que no tienen nada que ver con umbrales y que conviene incorporar al vocabulario.
Como remapeador de contraste. smoothstep( 0.4, 0.6, ruido ) toma un campo continuo y empuja los valores hacia los extremos, sin recortarlos de golpe. Es la herramienta para convertir ruido difuso en nubes definidas.
Como envolvente temporal. smoothstep( 0.0, 1.0, t ) sobre un progreso lineal produce una entrada y una salida suaves. Es la curva de aceleración más barata que existe y no necesita ninguna librería.
// Un pulso que sube y baja, util para animar cualquier cosa
float pulso( float t, float inicio, float fin ) {
return smoothstep( inicio, ( inicio + fin ) * 0.5, t ) *
( 1.0 - smoothstep( ( inicio + fin ) * 0.5, fin, t ) );
}
Parece razonable escribir smoothstep( 0.6, 0.4, x ) esperando obtener una rampa descendente, y en la mayoría de las GPU eso hace exactamente lo que esperabas. La especificación de GLSL dice otra cosa: el resultado es indefinido si el primer borde es mayor o igual que el segundo. El motivo está en la fórmula: el cociente ( x - b0 ) / ( b1 - b0 ) con b1 menor que b0 divide por un número negativo, y aunque el clamp posterior acabe devolviendo algo razonable en casi todos los compiladores, nada lo obliga. El caso realmente peligroso no es el de los bordes invertidos a propósito sino el de los bordes calculados: en el momento en que escribes smoothstep( centro - w, centro + w, x ) con un w que sale de fwidth, has abierto la puerta a que w valga cero en alguna región —una superficie exactamente paralela al plano de la pantalla, un fragmento donde la derivada se cancela, un objeto visto a una distancia en la que el valor no cambia entre píxeles vecinos— y entonces los dos bordes son idénticos y el resultado deja de estar definido. En la práctica eso se manifiesta como un punto o una línea de píxeles blancos o negros que aparecen y desaparecen al mover la cámara, y que nadie relaciona con smoothstep porque el código parece impecable. Las dos protecciones son de un carácter cada una: fuerza el orden con 1.0 - smoothstep( a, b, x ) en vez de invertir los argumentos, y garantiza el ancho con max( fwidth( d ), 1e-5 ). Ninguna de las dos cuesta nada medible y las dos eliminan una clase entera de artefactos irreproducibles.
Coge un shader tuyo que tenga condicionales sobre vUv o sobre una distancia y reescríbelo sin ninguna. Después compara los bordes a pantalla completa con el original: la diferencia de calidad es más visible que la de rendimiento, y ésa es la razón principal para hacerlo.