Pensar en distancias y en máscaras
El patrón que sustituye a las condicionales por completo: representar la forma como un campo de distancia, componer con min y max, y convertir a máscara al final.
Qué es una función de distancia con signo
Una función de distancia con signo, o SDF, toma un punto y devuelve la distancia al contorno de una forma, con el convenio de que es negativa dentro y positiva fuera. El contorno es el conjunto de puntos donde vale cero.
Para un círculo centrado en el origen es inmediato:
float sdCirculo( vec2 p, float r ) { return length( p ) - r;}Para un rectángulo centrado en el origen, con semiejes b, la fórmula exacta —de Inigo Quilez— es ésta:
float sdCaja( vec2 p, vec2 b ) { vec2 d = abs( p ) - b; return length( max( d, 0.0 ) ) + min( max( d.x, d.y ), 0.0 );}Merece la pena entenderla porque el patrón se repite. abs( p ) pliega el plano al primer cuadrante, con lo cual basta resolver una esquina. d es la distancia a las dos caras en cada eje. Fuera de la caja, al menos una componente de d es positiva, y length( max( d, 0.0 ) ) da la distancia euclídea correcta tanto por una cara como por una esquina. Dentro, las dos componentes son negativas, el primer término es cero y min( max( d.x, d.y ), 0.0 ) devuelve la distancia negativa a la cara más cercana.
Un segmento con extremos a y b:
float sdSegmento( vec2 p, vec2 a, vec2 b ) { vec2 pa = p - a; vec2 ba = b - a; float h = clamp( dot( pa, ba ) / dot( ba, ba ), 0.0, 1.0 ); return length( pa - ba * h );}El clamp es lo que convierte una recta infinita en un segmento: proyecta el punto sobre la recta, limita el parámetro al tramo, y mide desde ahí. Un segmento no tiene interior, así que esta función nunca devuelve negativo; para darle grosor se le resta un radio, y entonces sí.
Un rectángulo con esquinas redondeadas es el mismo sdCaja con un radio restado, que es una propiedad general: restar un valor a una SDF redondea la forma y la engorda.
float sdCajaRedondeada( vec2 p, vec2 b, float r ) { return sdCaja( p, b - r ) - r;}Componer
Las operaciones de conjunto sobre distancias son elementales:
float unir( float a, float b ) { return min( a, b ); }float intersecar( float a, float b ) { return max( a, b ); }float restar( float a, float b ) { return max( a, -b ); }min funciona porque un punto pertenece a la unión si está dentro de alguna, y estar dentro es tener distancia negativa; el mínimo de las dos distancias es la del contorno más cercano. max( a, -b ) resta B de A porque -b es la SDF del complementario de B.
Y la operación que hace que las SDF sean interesantes de verdad, la unión suave polinomial:
float unirSuave( float a, float b, float k ) { float h = clamp( 0.5 + 0.5 * ( b - a ) / k, 0.0, 1.0 ); return mix( b, a, h ) - k * h * ( 1.0 - h );}k es el radio del empalme. Con k tendiendo a cero recupera el min duro; con k grande las dos formas se funden como gotas de mercurio. Ese término final, - k * h * ( 1.0 - h ), es lo que excava el filete cóncavo en la unión.
Del campo a la imagen
Con el campo d construido, todo lo demás son dos líneas cada cosa.
float aa( float d ) { return max( fwidth( d ), 1e-5 ); }// Rellenofloat relleno = 1.0 - smoothstep( -aa( d ), aa( d ), d );// Contorno de grosor g centrado en el bordefloat contorno = 1.0 - smoothstep( g - aa( d ), g + aa( d ), abs( d ) );// Resplandor exterior con caida exponencialfloat resplandor = exp( -max( d, 0.0 ) * 30.0 );// Sombra proyectada: el mismo campo evaluado con un desplazamientofloat sombra = 1.0 - smoothstep( -0.01, 0.03, campo( p - vec2( 0.02, -0.02 ) ) );// Bandas concentricas, utiles para ver el campo mientras depurasfloat bandas = 0.5 + 0.5 * cos( d * 120.0 );Esa última línea es la herramienta de depuración: pintar bandas muestra las curvas de nivel del campo, y de un vistazo se ve si la distancia es correcta o si la función miente.
Un ejemplo completo que junta todo:
varying vec2 vUv;uniform vec2 uResolucion;uniform vec3 uColor;uniform vec3 uFondo;float sdCirculo( vec2 p, float r ) { return length( p ) - r; }float sdCaja( vec2 p, vec2 b ) { vec2 d = abs( p ) - b; return length( max( d, 0.0 ) ) + min( max( d.x, d.y ), 0.0 );}float unirSuave( float a, float b, float k ) { float h = clamp( 0.5 + 0.5 * ( b - a ) / k, 0.0, 1.0 ); return mix( b, a, h ) - k * h * ( 1.0 - h );}float campo( vec2 p ) { float caja = sdCaja( p, vec2( 0.22, 0.12 ) ); float bola = sdCirculo( p - vec2( 0.26, 0.0 ), 0.13 ); float agujero = sdCirculo( p, 0.06 ); return max( unirSuave( caja, bola, 0.08 ), -agujero );}void main() { vec2 p = ( vUv - 0.5 ) * vec2( uResolucion.x / uResolucion.y, 1.0 ); float d = campo( p ); float w = max( fwidth( d ), 1e-5 ); float relleno = 1.0 - smoothstep( -w, w, d ); float contorno = 1.0 - smoothstep( 0.008 - w, 0.008 + w, abs( d ) ); vec3 color = uFondo; color = mix( color, uColor, relleno ); color = mix( color, vec3( 1.0 ), contorno * 0.8 ); gl_FragColor = vec4( color, 1.0 );}Ni un if, ni una geometría, y la forma es infinitamente nítida a cualquier resolución porque no hay imagen: hay una función.
Exacta o cota
No todas las funciones que se llaman SDF lo son. Muchas de las que circulan devuelven una cota inferior de la distancia: nunca sobreestiman, pero pueden quedarse cortas. El caso típico es una forma escalada de forma no uniforme, o una SDF a la que se le ha aplicado una deformación del dominio.
// Esto NO es una distancia exacta: escalar el espacio en dos ejes// distintos rompe la metrica.float sdElipseAproximada( vec2 p, vec2 r ) { return length( p / r ) - 1.0; // cota, no distancia}Para una máscara con antialiasing eso no importa nada, porque fwidth mide el gradiente real del campo tal como lo has escrito y compensa el error de escala automáticamente. Sí importa para tres cosas: el grosor de un contorno, que saldrá desigual; una caída de resplandor, que no será uniforme; y el tamaño del paso en un bucle de marcha por rayos, donde una sobreestimación atraviesa la superficie.
La regla práctica: si solo vas a hacer smoothstep con fwidth, una cota vale. En cuanto uses la magnitud del campo para algo, comprueba las curvas de nivel con el truco de las bandas.