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