Campos de distancia con signo
Qué es exactamente una SDF, qué propiedad matemática la hace útil, y por qué representar geometría como una función cambia todo lo que puedes hacer con ella.
Hasta aquí toda la geometría ha sido explícita: una lista de vértices, una lista de índices, triángulos que el rasterizador convierte en fragmentos. El raymarching parte de la representación contraria. La forma no se enumera, se evalúa: una función que, dado un punto del espacio, devuelve a qué distancia está de la superficie. Con eso solo, y sin un triángulo, se puede dibujar cualquier cosa.
- Definir una SDF y explicar qué significan el signo y la magnitud.
- Distinguir una SDF exacta de una cota inferior y decir por qué importa.
- Escribir la SDF de una esfera y de un plano desde la definición.
- Enumerar qué operaciones son fáciles en representación implícita y difíciles en explícita.
La definición
Una función de distancia con signo —signed distance function, SDF— es una función que toma un punto del espacio y devuelve un número real:
float sdf( vec3 p );
El valor que devuelve es la distancia euclídea desde p hasta el punto más cercano de la superficie, con signo: negativo si p está dentro del sólido, positivo si está fuera, cero si está exactamente sobre la superficie.
La superficie es, por tanto, el conjunto de puntos donde la función vale cero. En la jerga clásica es una isosuperficie de nivel cero, y por eso a esta forma de representar geometría se la llama implícita: no se dice dónde están los puntos de la forma, se dice qué condición cumplen.
La SDF de una esfera de radio r centrada en el origen sale directamente de la definición:
float sdEsfera( vec3 p, float r ) {
return length( p ) - r;
}
Si p está a distancia tres del origen y el radio es uno, la función devuelve dos: la distancia a la superficie más cercana es exactamente dos. Si p está a distancia cero coma cinco, devuelve menos cero coma cinco: estás dentro, a media unidad de la cáscara. Cambiar el centro es trasladar el argumento, y esto vale para cualquier primitiva:
float d = sdEsfera( p - vec3( 0.0, 1.0, 0.0 ), 1.0 );
Un plano infinito con normal unitaria n que pasa por el origen es todavía más simple, porque el producto escalar ya es la distancia con signo:
float sdPlano( vec3 p, vec3 n, float h ) {
return dot( p, n ) + h;
}
Dos observaciones que conviene interiorizar ya. La primera: estas funciones están definidas en todo el espacio, no solo cerca de la superficie. Puedes preguntarle a la SDF de una esfera por un punto a mil unidades y te dará una respuesta útil. La segunda: la evaluación es de coste constante y no depende de la complejidad aparente de la forma. Una esfera y un fractal de mil iteraciones se dibujan con el mismo bucle; lo único que cambia es lo que cuesta cada evaluación.
Exactas, cotas y campos deformados
No todas las funciones que devuelven cero en una superficie sirven. La propiedad que hace útil una SDF es que su valor sea una distancia de verdad, o al menos una cota inferior de ella. Formalmente, la condición es que el gradiente tenga módulo uno en casi todo el dominio: length( gradiente ) == 1. Cuando eso se cumple, avanzar una distancia igual al valor de la función garantiza que no te has saltado la superficie, que es exactamente en lo que se apoya el algoritmo de la lección siguiente.
Hay tres categorías, y confundirlas es la causa del noventa por ciento de los artefactos de raymarching.
Exacta. El valor es la distancia real. sdEsfera lo es. sdPlano lo es.
Cota inferior. El valor es siempre menor o igual que la distancia real, pero puede ser estrictamente menor. Marchar con ella sigue siendo correcto —nunca te pasas—, solo que más lento, porque avanzas menos de lo que podrías. La SDF de la caja, que verás en la tercera lección, es exacta por fuera y una aproximación por dentro; muchas SDF de toros retorcidos y superficies deformadas son cotas.
Sobreestimación. El valor puede ser mayor que la distancia real. Esto rompe el algoritmo: das un paso más largo de lo seguro, atraviesas la superficie sin verla y el rayo sigue de largo. El síntoma es agujeros y bordes rotos que aparecen y desaparecen al mover la cámara.
El caso donde esto muerde de forma más habitual es el escalado no uniforme y las deformaciones del dominio. Si escalas un punto antes de evaluar la SDF, el campo resultante deja de tener gradiente unitario y hay que corregirlo dividiendo por el factor:
// Escalado uniforme: correcto porque se divide el resultado.
float d = sdEsfera( p / s, 1.0 ) * s;
Con escalado no uniforme no hay corrección exacta, y lo habitual es dividir por la componente menor del vector de escala, que convierte el campo en una cota inferior segura a costa de más pasos. Con torsiones y curvados del dominio pasa lo mismo: hay que dividir por una constante de Lipschitz que en general se estima a ojo, empezando por dos y bajando hasta que aparecen artefactos.
Bordes que se comen a sí mismos, agujeros que aparecen solo en silueta, superficies que titilan al orbitar. Si ves eso, no toques el número de pasos ni el epsilon: multiplica el resultado de tu SDF por 0.5 y mira si desaparece. Si desaparece, tu campo sobreestimaba y el factor es la corrección barata. Si no desaparece, el problema está en otra parte.
Qué se vuelve fácil
Representar geometría como función en lugar de como malla cambia qué operaciones son baratas, y el cambio es radical en las dos direcciones.
Se vuelven triviales las operaciones booleanas. Unir dos formas es tomar el mínimo de sus distancias; intersecarlas, el máximo; restar una de otra, el máximo de una y la negada de la otra. Tres líneas cada una, exactas, sin casos degenerados. Compara con lo que cuesta un booleano robusto entre dos mallas de triángulos, que es un problema de geometría computacional con literatura propia, casos patológicos y bibliotecas enteras dedicadas a él.
Se vuelve trivial la repetición infinita: aplicando módulo al punto antes de evaluar, una sola esfera se convierte en una retícula infinita de esferas sin coste adicional de memoria ni de evaluación. Se vuelven triviales el redondeo de aristas —restar una constante al campo—, el vaciado de un sólido para dejar una cáscara —valor absoluto menos grosor—, y la mezcla suave entre formas, que en mallas es un problema abierto y aquí son cuatro líneas.
Se vuelven caros o imposibles el resto. No hay atributos por vértice, así que las UV hay que inventárselas. No hay un pipeline de rasterizado que interpole, así que todo se calcula por píxel. No hay culling de geometría fuera de cámara, porque no hay geometría. No hay animación por esqueleto, ni morph targets, ni instancing en el sentido habitual. Y sobre todo: no hay ninguna herramienta de creación de contenido que exporte SDF, así que las formas se escriben a mano, con código.
Por eso el raymarching no compite con las mallas: convive con ellas. Se usa para lo que las mallas hacen mal —campos volumétricos, superficies infinitas, geometría procedural, mezclas orgánicas, fractales— y se deja el resto al rasterizador.
Es fácil quedarse con la idea de que una SDF “describe una superficie”. Lo hace, pero eso lo hace cualquier función implícita: length(p) - 1.0 y length(p)*length(p) - 1.0 tienen exactamente el mismo conjunto de nivel cero y describen la misma esfera. La segunda es completamente inútil para raymarching. La diferencia no está en la forma que describen, está en lo que puedes hacer con el valor cuando no es cero. Una SDF verdadera te da, en cada punto del espacio vacío, un radio de seguridad: la garantía de que dentro de esa bola no hay nada. Eso es lo que convierte un problema de búsqueda de raíces sin solución cerrada —encontrar dónde un rayo corta una superficie arbitraria— en un algoritmo que converge en unas decenas de iteraciones. La función no es la geometría; la función es la estructura de aceleración de la geometría, jugando el papel que en una escena de triángulos juegan el BVH y el árbol de particiones espaciales. Y de ahí salen dos consecuencias que reordenan todo lo demás. La primera: cuando escribas una SDF, la pregunta correcta no es “¿vale cero donde quiero?” sino “¿el valor que devuelvo lejos de la superficie sigue siendo una distancia útil?”. La segunda, más profunda: cada vez que aceptas convertir tu campo exacto en una cota inferior —dividiendo por dos tras una torsión, quedándote con la componente menor de una escala— no estás cambiando la forma, estás degradando tu estructura de aceleración, y lo pagas en pasos por píxel. Por eso un fractal con dominio muy deformado puede costar cinco veces más que una escena con diez veces más primitivas: no es que tenga más geometría, es que su acelerador es peor.