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.
La lección anterior sustituyó una condicional por una máscara. Ésta lleva la idea hasta su conclusión: si en vez de preguntar si un punto está dentro de una forma calculas a qué distancia está de su contorno, obtienes un campo continuo del que la máscara es solo una de las cosas que puedes extraer. Del mismo campo salen el relleno, el borde, la sombra, el brillo, la unión suave con otra forma y el antialiasing correcto, todo con dos o tres instrucciones cada uno.
- Escribir funciones de distancia con signo para las primitivas 2D fundamentales.
- Componer formas con las operaciones booleanas duras y suaves sobre distancias.
- Derivar relleno, contorno y resplandor del mismo campo.
- Distinguir una distancia exacta de una cota y saber cuándo importa.
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 ); }
// Relleno
float relleno = 1.0 - smoothstep( -aa( d ), aa( d ), d );
// Contorno de grosor g centrado en el borde
float contorno = 1.0 - smoothstep( g - aa( d ), g + aa( d ), abs( d ) );
// Resplandor exterior con caida exponencial
float resplandor = exp( -max( d, 0.0 ) * 30.0 );
// Sombra proyectada: el mismo campo evaluado con un desplazamiento
float sombra = 1.0 - smoothstep( -0.01, 0.03, campo( p - vec2( 0.02, -0.02 ) ) );
// Bandas concentricas, utiles para ver el campo mientras depuras
float 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.
La tentación al descubrir las funciones de distancia es querer dibujarlo todo con ellas, y ahí es donde el enfoque se estrella. Una SDF cuesta lo mismo por píxel esté donde esté el objeto, mientras que la geometría cuesta por vértice y solo se rasteriza donde está. Un rectángulo con esquinas redondeadas dibujado como SDF a pantalla completa evalúa el campo dos millones de veces; el mismo rectángulo como dos triángulos con una textura evalúa un muestreo en los píxeles que cubre. La comparación honesta no es SDF contra malla: es SDF contra textura. Contra una textura, la SDF gana en nitidez infinita, en peso cero de descarga, en poder animar los parámetros por cuadro sin resubir nada, y en que el mismo campo da relleno, borde y sombra sin generar tres imágenes. Pierde en coste por píxel, y pierde de forma catastrófica en cuanto la forma tiene muchas primitivas, porque el coste crece linealmente con ellas y se paga en cada fragmento aunque la primitiva esté a un metro de distancia. El punto de equilibrio es sorprendentemente bajo: por encima de unas diez o quince primitivas en el mismo campo, y sin ninguna aceleración espacial, una textura o una malla ganan. Por eso las SDF triunfaron exactamente en dos nichos —tipografía, donde hay una forma por quad y la nitidez lo es todo, y la geometría implícita del raymarching, donde no hay alternativa— y no como sustituto general del dibujo vectorial.