Saltar al contenido

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.

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:

GLSL
// Lo que smoothstep calcula, escrito a manofloat 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:

GLSL
// Antesvec3 color;if ( d < 0.25 ) color = colorA;else            color = colorB;// Despues, con corte durofloat m = step( 0.25, d );          // 0 si d < 0.25, 1 si novec3 color = mix( colorA, colorB, m );// Despues, con borde suave de 0.01 unidadesfloat 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.

GLSL
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:

GLSL
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í:

GLSL
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:

GLSL
// Devuelve 1 dentro, 0 fuera, con una transicion de un pixelfloat 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:

GLSL
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:

GLSL
float interseccion = a * b;                    // A y Bfloat unionDura = max( a, b );                 // A o Bfloat unionSuave = a + b - a * b;              // A o B, sin escalon donde se cruzanfloat diferencia = a * ( 1.0 - b );            // A pero no Bfloat exclusiva = abs( a - b );                // A o B pero no ambasfloat 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.

GLSL
// Un pulso que sube y baja, util para animar cualquier cosafloat pulso( float t, float inicio, float fin ) {  return smoothstep( inicio, ( inicio + fin ) * 0.5, t ) *         ( 1.0 - smoothstep( ( inicio + fin ) * 0.5, fin, t ) );}