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.
#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.
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 / 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.
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.