wandres.dev
RAYMARCHING · SDF y geometría implícita

El bucle de marcha y su criterio de parada

El algoritmo de sphere tracing entero, los cuatro parámetros que lo gobiernan, y por qué el epsilon debe crecer con la distancia.

⏱ 18 min

El algoritmo cabe en diez líneas y no tiene ningún truco: avanza por el rayo dando pasos del tamaño exacto que la SDF garantiza como seguro, y para cuando el paso se hace despreciable. Lo que sí tiene son cuatro constantes mágicas, y elegirlas mal es la diferencia entre una imagen limpia a sesenta frames y una imagen con bandas a doce.

🎯 Al terminar esta lección sabrás
  • Escribir el bucle de sphere tracing completo desde cero.
  • Explicar por qué el paso puede ser exactamente el valor de la SDF y no menos.
  • Elegir MAX_STEPS, MAX_DIST y el epsilon con un criterio, no a ojo.
  • Reconocer el artefacto de cada uno de los tres criterios de parada.

El algoritmo

Partimos de un origen de rayo ro y una dirección normalizada rd. Queremos el valor t tal que ro + rd * t está sobre la superficie.

La idea de sphere tracing, de John Hart, es esta: si la SDF en el punto actual vale d, entonces la bola de radio d centrada en ese punto está garantizadamente vacía. Por tanto puedo avanzar d a lo largo del rayo sin ningún riesgo de saltarme nada, sea cual sea la dirección. Repite hasta que d sea despreciable.

#define MAX_STEPS 128
#define MAX_DIST  100.0
#define SURF_EPS  0.0005

// Devuelve la distancia recorrida, o MAX_DIST si no ha chocado.
float marchar( vec3 ro, vec3 rd ) {

  float t = 0.0;

  for ( int i = 0; i < MAX_STEPS; i++ ) {

    vec3 p = ro + rd * t;
    float d = mapa( p );          // la SDF de toda la escena

    if ( d < SURF_EPS ) break;    // hemos llegado
    if ( t > MAX_DIST ) break;    // nos hemos ido

    t += d;

  }

  return t;

}

Eso es todo. La convergencia es geométrica cerca de la superficie y muy rápida en espacio abierto, donde un solo paso puede cruzar media escena.

La propiedad crítica es que el paso sea d y no d * 0.5 ni una constante fija. Un paso constante es ray marching ingenuo: funciona, pero necesita cientos de pasos y sigue pudiendo saltarse detalles finos. Un paso de d es óptimo bajo la garantía de la SDF: es el paso más largo que se puede dar sin riesgo. Y si tu campo es una cota inferior, sigue siendo seguro, solo que más corto de lo ideal.

Hay un caso en el que sí conviene multiplicar por un factor menor que uno: cuando sospechas que tu campo sobreestima. t += d * 0.7 es el remedio universal contra los agujeros en silueta, y su coste es proporcional.

Los cuatro parámetros

MAX_STEPS. Cuántas iteraciones como máximo. No es un parámetro de calidad global sino un presupuesto: el bucle solo lo agota en los píxeles difíciles, que son los que rasan la superficie. En espacio abierto convergen en cuatro o cinco pasos. Con 64 se pueden hacer escenas decentes; 128 es un valor cómodo; por encima de 256 estás pagando mucho por muy pocos píxeles. El artefacto de quedarse corto es un halo oscuro o de color de fondo alrededor de las siluetas, donde el rayo se quedó sin presupuesto justo antes de tocar.

MAX_DIST. El plano lejano. Rayos que se van al infinito tienen que parar en algún sitio, y este es el criterio. Ajústalo al tamaño real de tu escena: dejarlo enorme por si acaso solo hace que los rayos de fondo den más pasos inútiles.

SURF_EPS. Cuándo consideras que has llegado. Este es el delicado, y merece su propia sección.

El factor de paso. Uno por defecto, menos si tu campo sobreestima. Algunas implementaciones usan valores ligeramente mayores que uno con un mecanismo de retroceso —over-relaxation— para acelerar; es una optimización avanzada y no la necesitas para empezar.

El epsilon tiene que crecer con la distancia

Aquí está el detalle que casi ninguna explicación de raymarching menciona y que separa una imagen buena de una llena de bandas.

Un epsilon fijo trata igual a un objeto a dos unidades de la cámara y a otro a ochenta. Pero no son iguales en pantalla: el objeto lejano ocupa muchos menos píxeles, y la separación entre rayos vecinos crece linealmente con la distancia. En el objeto cercano, 0.0005 es una fracción minúscula del tamaño de un píxel proyectado; en el lejano puede ser mucho mayor. El resultado es que en superficies lejanas casi rasantes el bucle da cientos de pasos diminutos sin llegar nunca a cruzar el umbral, agota el presupuesto y produce bandas concéntricas de sombreado incorrecto.

La corrección es escalar el umbral con t:

float marchar( vec3 ro, vec3 rd ) {

  float t = 0.0;

  for ( int i = 0; i < MAX_STEPS; i++ ) {

    vec3 p = ro + rd * t;
    float d = mapa( p );

    // El umbral crece con la distancia recorrida: mantiene constante
    // el error medido en pixeles, no en unidades de mundo.
    if ( d < SURF_EPS * t ) break;
    if ( t > MAX_DIST ) break;

    t += d;

  }

  return t;

}

Con esa sola división el epsilon deja de ser una tolerancia en unidades de mundo y pasa a ser una tolerancia angular, que es la magnitud que de verdad importa cuando el resultado es una imagen. Las bandas desaparecen, el número medio de pasos baja y el fondo converge más rápido. El valor típico ronda 0.001, y como ahora es adimensional se comporta igual en escenas de escala distinta.

💡
Un epsilon relativo también hace tu escena independiente de la escala

Con umbral absoluto, duplicar el tamaño de toda la escena duplica el error relativo y te obliga a reajustar constantes. Con umbral relativo a t, la escena se comporta igual a cualquier escala. Es una de esas correcciones que quitan trabajo futuro además de arreglar el artefacto presente.

Rayos desde la cámara

Falta lo que alimenta el bucle. En un quad a pantalla completa, cada fragmento tiene una coordenada UV y hay que convertirla en un rayo. La forma directa, con la cámara en el origen mirando al eje negativo Z:

// uv en [0,1], aspecto = ancho / alto
vec2 p = ( uv - 0.5 ) * vec2( aspecto, 1.0 ) * 2.0;

float fovTan = tan( radians( fovGrados ) * 0.5 );

vec3 ro = camaraPos;
vec3 rd = normalize( camaraBase * vec3( p * fovTan, -1.0 ) );

donde camaraBase es la matriz 3x3 que lleva de espacio de cámara a espacio de mundo. Trabajando dentro de Three.js hay un atajo mucho más robusto: pasar la posición de la cámara y su matriz de mundo como uniforms y dejar que la librería mantenga la coherencia.

uniform vec3 uCamPos;
uniform mat4 uCamMundo;      // camera.matrixWorld
uniform float uTanFovMedio;  // tan( MathUtils.degToRad( camera.fov ) * 0.5 )
uniform float uAspecto;

void main() {
  vec2 p = ( vUv - 0.5 ) * 2.0;
  p.x *= uAspecto;

  vec3 dirCam = normalize( vec3( p * uTanFovMedio, -1.0 ) );
  vec3 rd = normalize( mat3( uCamMundo ) * dirCam );
  vec3 ro = uCamPos;

  float t = marchar( ro, rd );
  // ...
}

Con esto el raymarching respeta los OrbitControls, el FOV y el redimensionado sin ninguna lógica duplicada.

El coste no lo paga la geometría, lo pagan los rayos rasantes

La intuición que traes de las mallas dice que una escena cuesta en proporción a cuánta geometría tiene. En raymarching esa intuición es casi inservible, y sustituirla por la correcta cambia cómo diseñas las escenas. El coste de un píxel es el número de iteraciones que su rayo necesita, y ese número depende de una sola cosa: el ángulo con el que el rayo se aproxima a la superficie. Un rayo que incide perpendicular converge en cuatro o cinco pasos, porque cada paso se come casi toda la distancia restante. Un rayo que pasa rozando la superficie sin llegar a tocarla es el peor caso absoluto: la SDF le devuelve valores pequeños durante decenas de iteraciones, avanza a paso de tortuga por un corredor estrecho entre el rayo y la superficie, y muchas veces acaba agotando MAX_STEPS sin haber decidido nada. Y ahora la parte que reordena tu forma de trabajar: esos rayos rasantes se concentran exactamente en las siluetas, que es donde el ojo mira. Una esfera aislada sobre fondo vacío tiene un coste medio ridículo y un anillo de píxeles carísimo en su borde. Un plano infinito visto casi de canto es un desastre entero, porque todo él es rasante. Un objeto pequeño lejos cuesta más por píxel que el mismo objeto cerca, porque proporcionalmente más de sus píxeles son de silueta. De ahí salen decisiones concretas de diseño: prefiere formas cerradas y compactas a superficies extensas casi paralelas a la vista; corta los planos infinitos con un MAX_DIST ajustado en lugar de dejarlos correr; y cuando midas, no mires el frame time medio, mira el peor píxel, porque en una GPU los píxeles de un warp avanzan juntos y el más lento del grupo marca el ritmo de todos sus vecinos.