Saltar al contenido

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.

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.

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

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

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:

GLSL
// uv en [0,1], aspecto = ancho / altovec2 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.

GLSL
uniform vec3 uCamPos;uniform mat4 uCamMundo;      // camera.matrixWorlduniform 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.