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

El coste real y el quad a pantalla completa

Por qué el raymarching vive en un triángulo que cubre la pantalla, cómo integrarlo en una escena de Three.js con profundidad correcta, y cuándo no usarlo.

⏱ 19 min

Un shader de raymarching no dibuja geometría: dibuja una imagen. Necesita, por tanto, una excusa para que el rasterizador ejecute su fragment shader en cada píxel de la pantalla, y esa excusa es una superficie plana que cubra el viewport entero. Entender por qué esa es la única forma sensata de hacerlo explica también sus dos limitaciones más serias y cómo sortearlas.

🎯 Al terminar esta lección sabrás
  • Montar un raymarcher dentro de una escena Three.js con la cámara sincronizada.
  • Explicar por qué se usa un triángulo y no un plano ni un cubo.
  • Escribir la profundidad correcta para que el raymarching se mezcle con mallas.
  • Decidir con criterio cuándo esta técnica es la adecuada y cuándo no.

La superficie portadora

El fragment shader solo se ejecuta donde hay geometría rasterizada. Como el raymarching calcula el color de cada píxel a partir de un rayo, lo que necesita es que el shader corra en todos los píxeles, sin importar qué geometría lo provoque. Las tres opciones habituales:

Un plano delante de la cámara. Funciona, pero hay que mantenerlo pegado a la cámara y ajustar su tamaño al FOV y al aspecto. Es trabajo innecesario.

Un cubo que contiene la escena. Se usa cuando el raymarching representa un volumen delimitado —humo, nubes, un material translúcido— porque el rasterizador se encarga del recorte y solo pagas los píxeles donde el volumen está. Es la técnica correcta para volumétricos, no para escenas de SDF a pantalla completa.

Un triángulo en coordenadas de recorte. Lo estándar. Tres vértices ya en espacio normalizado, sin matriz de proyección, cubriendo de sobra el cuadrado visible:

const geometria = new THREE.BufferGeometry();
geometria.setAttribute( 'position', new THREE.Float32BufferAttribute(
  [ -1, 3, 0,  -1, -1, 0,  3, -1, 0 ], 3
) );
geometria.setAttribute( 'uv', new THREE.Float32BufferAttribute(
  [ 0, 2,  0, 0,  2, 0 ], 2
) );

Ese es exactamente el triángulo que usa FullScreenQuad en los addons de post-procesado de Three.js. Un triángulo y no dos porque los dos triángulos de un quad se tocan en la diagonal, y los píxeles de esa diagonal se rasterizan dos veces por el modo en que la GPU procesa cuadrados de dos por dos fragmentos. Es un desperdicio pequeño pero perfectamente evitable, y con un fragment shader tan caro como el de un raymarcher, evitarlo es gratis.

El vertex shader no transforma nada:

varying vec2 vUv;

void main() {
  vUv = uv;
  gl_Position = vec4( position, 1.0 );
}

Montaje completo en Three.js

La pieza que falta es sincronizar la cámara. Como el triángulo no usa la matriz de proyección, hay que pasarle a mano lo que el shader necesita para construir los rayos.

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const renderer = new THREE.WebGLRenderer( { antialias: false } );
renderer.setPixelRatio( Math.min( devicePixelRatio, 2 ) );
renderer.setSize( innerWidth, innerHeight );
document.body.appendChild( renderer.domElement );

const escena = new THREE.Scene();

// La camara existe para que OrbitControls la mueva; el shader lee sus matrices.
const camara = new THREE.PerspectiveCamera( 50, innerWidth / innerHeight, 0.1, 100 );
camara.position.set( 3, 2, 5 );

const controles = new OrbitControls( camara, renderer.domElement );
controles.enableDamping = true;

const material = new THREE.RawShaderMaterial( {
  glslVersion: THREE.GLSL3,
  vertexShader: vs,
  fragmentShader: fs,
  uniforms: {
    uCamPos:      { value: new THREE.Vector3() },
    uCamMundo:    { value: new THREE.Matrix4() },
    uTanFovMedio: { value: 0 },
    uAspecto:     { value: 1 },
    uTiempo:      { value: 0 }
  },
  depthTest: false,
  depthWrite: false
} );

const pantalla = new THREE.Mesh( geometria, material );
pantalla.frustumCulled = false;   // imprescindible: su bounding box no significa nada
escena.add( pantalla );

function redimensionar() {
  renderer.setSize( innerWidth, innerHeight );
  camara.aspect = innerWidth / innerHeight;
  camara.updateProjectionMatrix();
  material.uniforms.uAspecto.value = camara.aspect;
}
addEventListener( 'resize', redimensionar );
redimensionar();

renderer.setAnimationLoop( ( ms ) => {

  controles.update();

  const u = material.uniforms;
  u.uTiempo.value = ms * 0.001;
  u.uCamPos.value.copy( camara.position );
  u.uCamMundo.value.copy( camara.matrixWorld );
  u.uTanFovMedio.value = Math.tan( THREE.MathUtils.degToRad( camara.fov ) * 0.5 );

  renderer.render( escena, camara );

} );

Dos detalles que se olvidan siempre y producen una pantalla en negro. frustumCulled = false, porque el triángulo está en coordenadas de recorte y su caja envolvente no tiene relación con dónde se ve, así que Three.js lo descarta en cuanto la cámara se mueve. Y depthTest: false con depthWrite: false, porque el triángulo está en z = 0 de espacio de recorte y participaría en el test de profundidad con un valor que no significa nada.

Mezclarlo con geometría de verdad

Desactivar la profundidad funciona mientras el raymarching sea toda la escena. En cuanto quieras poner una malla dentro, hay que escribir la profundidad correcta: la del punto de impacto, en el mismo espacio que usan las mallas.

// t es la distancia recorrida por el rayo hasta el impacto.
vec3 pImpacto = ro + rd * t;

// A espacio de recorte con las matrices reales de la camara.
vec4 recorte = uProyeccion * uVista * vec4( pImpacto, 1.0 );

// De NDC [-1,1] al rango de profundidad [0,1] de WebGL.
gl_FragDepth = ( recorte.z / recorte.w ) * 0.5 + 0.5;

Con uProyeccion y uVista alimentadas desde camera.projectionMatrix y camera.matrixWorldInverse, y con depthWrite: true y depthTest: true en el material, el raymarching se ocluye correctamente con las mallas y las mallas con él.

Tiene un coste que hay que conocer: escribir gl_FragDepth desactiva el early depth test de la GPU. Ese mecanismo descarta fragmentos ocultos antes de ejecutar el fragment shader, y es exactamente el que te salvaría de pagar cien pasos de marcha por un píxel que va a quedar tapado. Al escribir profundidad manualmente el hardware ya no puede saber de antemano qué valor vas a producir, así que ejecuta el shader entero siempre. En una escena donde la mitad del raymarching queda oculto tras mallas, esa pérdida puede duplicar el coste.

El compromiso habitual es dibujar primero todas las mallas opacas, dejar el raymarching para el final, y aceptar el sobrecoste solo donde de verdad hace falta la mezcla.

Cuándo sí y cuándo no

Situación Veredicto
Geometría procedural infinita, fractales, campos sí, es lo único que lo hace
Fusiones orgánicas entre formas, metaballs sí, smin no tiene equivalente en mallas
Volumétricos, nubes, humo, translucidez densa sí, pero dentro de un cubo delimitador
Escenas con modelos importados de Blender no, no hay forma de convertirlos
Personajes animados por esqueleto no
Muchas primitivas distintas, cientos de objetos no, el coste es lineal y sin culling
Móvil con presupuesto de batería con mucho cuidado y a resolución reducida

Y una técnica que salva muchos proyectos: renderizar el raymarching a media resolución en un render target y escalarlo al componerlo. El coste por píxel es tan alto que dividir por cuatro el número de píxeles es la optimización de mayor relación beneficio-esfuerzo que existe aquí, y en escenas sin bordes muy duros la pérdida de nitidez apenas se nota. Con un pase de mejora guiada por profundidad para reconstruir los bordes, se nota todavía menos.

El raymarching invierte el eje que gobierna el coste, y por eso escala al revés

Merece la pena decirlo explícitamente porque reorganiza todas las decisiones de arquitectura de una escena. Un rasterizador escala mal con la geometría y bien con los píxeles: duplicar el número de triángulos duplica el trabajo de la fase de vértices, mientras que subir la resolución solo multiplica un fragment shader que suele ser corto. Por eso las escenas de mallas se optimizan reduciendo draw calls, haciendo culling, bajando el número de triángulos y usando LOD. El raymarching escala exactamente al revés: es casi gratis en geometría —una esfera y un fractal de cien iteraciones ocupan el mismo cero en el bus de datos, y no hay atributos, ni índices, ni subida a la GPU— y brutalmente caro en píxeles, porque cada uno arrastra decenas de evaluaciones de la escena completa. Las consecuencias son concretas y todas contrarias a la intuición del rasterizado. El devicePixelRatio deja de ser un ajuste de nitidez y se convierte en el parámetro de rendimiento dominante: pasar de 1 a 2 en una pantalla retina cuadruplica el coste del frame, y es lo primero que hay que limitar. La resolución reducida deja de ser una chapuza y se convierte en la estrategia estándar. El culling y el LOD, las dos herramientas centrales del oficio en mallas, no tienen aquí ningún equivalente útil. Y la métrica que debes vigilar mientras desarrollas no es “cuántos objetos tengo” sino “cuántas evaluaciones de mapa() gasta el píxel más caro”. Cuando internalizas esa inversión, deja de sorprenderte que una escena de raymarching con tres esferas vaya más lenta que una ciudad entera de triángulos, y empiezas a tomar las decisiones correctas: menos píxeles, menos pasos, mapa() más barata, y mallas para todo lo que las mallas hagan bien.

⚔️ Ponlo en pantalla
  1. Monta el triángulo a pantalla completa y comprueba que sobrevive a mover la cámara con frustumCulled = false.
  2. Dibuja el número de pasos como color y localiza visualmente los píxeles caros.
  3. Escribe gl_FragDepth y coloca un cubo de Three.js atravesando tu SDF.
  4. Renderiza a media resolución en un render target y compara frame time y nitidez.
  5. Limita el devicePixelRatio a 1 y mide la diferencia en un portátil.