wandres.dev
CÁMARAS · Perspectiva, ortográfica y el frustum

PerspectiveCamera: FOV vertical, aspecto y el frustum

Qué significa cada uno de los cuatro números del constructor, cómo se convierten en una matriz de proyección, y qué es exactamente el frustum que definen.

⏱ 20 min

Una cámara en Three.js no es un objeto que capta luz: es una matriz de cuatro por cuatro y un volumen en el espacio. Todo lo que hace PerspectiveCamera es tomar cuatro números —campo de visión, relación de aspecto, plano cercano y plano lejano— y construir con ellos la transformación que convierte el mundo en un cubo de recorte. Entender qué hace cada número con esa matriz es la diferencia entre configurar una cámara y adivinar valores hasta que la imagen deje de verse rara.

🎯 Al terminar esta lección sabrás
  • Explicar qué controla cada parámetro del constructor de PerspectiveCamera y en qué unidades.
  • Derivar el campo de visión horizontal a partir del vertical y del aspecto.
  • Construir un Frustum a partir de una cámara y consultar sus seis planos.
  • Distinguir el efecto de mover la cámara del efecto de cambiar el FOV.

Los cuatro números

La firma real en r184 es esta, con estos valores por defecto:

new THREE.PerspectiveCamera( fov = 50, aspect = 1, near = 0.1, far = 2000 )

fov es el campo de visión vertical en grados. No en radianes, y no horizontal. Esta decisión no es arbitraria: si el FOV fuera horizontal, cambiar la anchura de la ventana cambiaría cuánto se ve verticalmente, que es la dirección donde el usuario suele tener menos margen. Con FOV vertical fijo, ensanchar la ventana añade mundo a los lados y no recorta nada. Es el mismo criterio que usan casi todos los motores.

aspect es anchura partido por altura del lienzo. near y far son distancias a lo largo del eje de vista, siempre positivas, y ambas medidas desde la cámara.

El método que convierte esos números en matriz es updateProjectionMatrix(), y su primera línea es la que importa:

const top = near * Math.tan( THREE.MathUtils.DEG2RAD * 0.5 * fov ) / zoom;
const height = 2 * top;
const width = aspect * height;
const left = - 0.5 * width;
// ...y al final:
projectionMatrix.makePerspective( left, left + width, top, top - height, near, far, coordinateSystem, reversedDepth );

Léelo despacio, porque aquí está todo. La altura del rectángulo visible en el plano cercano sale del FOV vertical. La anchura sale de multiplicar esa altura por el aspecto. El plano cercano define la sección de la pirámide, y la pirámide se extiende hasta far. De ahí se deduce el FOV horizontal, que Three.js no expone como propiedad porque es un valor derivado:

import * as THREE from 'three';

function fovHorizontal( camera ) {
  const vertical = THREE.MathUtils.degToRad( camera.fov );
  const horizontal = 2 * Math.atan( Math.tan( vertical / 2 ) * camera.aspect );
  return THREE.MathUtils.radToDeg( horizontal );
}

Con fov: 50 y un monitor 16:9 (aspect ≈ 1.778) el FOV horizontal es de unos 80,7 grados. Con el mismo fov en un móvil vertical (aspect ≈ 0.46) baja a unos 24 grados. Por eso una escena que se ve bien en escritorio parece un teleobjetivo en el móvil: el FOV vertical se ha mantenido, pero el horizontal se ha desplomado con el aspecto.

Cualquier cambio en fov, aspect, near, far o zoom exige llamar a updateProjectionMatrix(). Three.js no lo hace por ti porque recalcular e invertir la matriz en cada frame por si acaso sería tirar trabajo a la basura.

import * as THREE from 'three';

const canvas = document.querySelector( '#escena' );
const renderer = new THREE.WebGLRenderer( { canvas, antialias: true } );
renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) );
renderer.setSize( window.innerWidth, window.innerHeight );

const scene = new THREE.Scene();
scene.background = new THREE.Color( 0x11111b );

const camera = new THREE.PerspectiveCamera(
  50,                                       // FOV vertical en grados
  window.innerWidth / window.innerHeight,   // aspecto
  0.1,                                      // near
  100                                       // far
);
camera.position.set( 0, 1.5, 5 );
camera.lookAt( 0, 0, 0 );

const cubo = new THREE.Mesh(
  new THREE.BoxGeometry( 1, 1, 1 ),
  new THREE.MeshNormalMaterial()
);
scene.add( cubo );

addEventListener( 'resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();          // sin esto la imagen sale estirada
  renderer.setSize( window.innerWidth, window.innerHeight );
} );

renderer.setAnimationLoop( () => {
  cubo.rotation.y += 0.01;
  renderer.render( scene, camera );
} );

Tres propiedades más pasan desapercibidas y son útiles. zoom divide la altura del plano cercano: subirlo estrecha la vista sin tocar fov, y OrbitControls lo usa en cámaras ortográficas. filmGauge (35 mm por defecto) y setFocalLength( mm ) traducen el FOV a lenguaje fotográfico, lo cual importa cuando un director de arte te pide “un 35 mm”. Y setViewOffset( fullWidth, fullHeight, x, y, width, height ) recorta una subregión de la imagen completa: es lo que se usa para renderizar una imagen gigante por baldosas, o para repartir una vista entre varios monitores.

El frustum: seis planos, no cuatro números

El volumen que esos parámetros definen es una pirámide truncada, y truncada por los dos extremos: el plano cercano le corta la punta y el lejano la base. Ese sólido tiene seis caras, y el recorte de la GPU consiste literalmente en comprobar los seis planos.

flowchart TB
cam[Camara en el origen del espacio de vista] --> proy[Matriz de proyeccion con fov aspecto near y far]
proy --> volumen[Volumen canonico de recorte]
volumen --> pn[Plano near]
volumen --> pf[Plano far]
volumen --> pi[Plano izquierdo]
volumen --> pd[Plano derecho]
volumen --> ps[Plano superior]
volumen --> pb[Plano inferior]
pn --> dentro[Se dibuja solo lo que queda dentro de los seis a la vez]
pf --> dentro
pi --> dentro
pd --> dentro
ps --> dentro
pb --> dentro
style cam fill:#89b4fa,color:#11111b
style proy fill:#cba6f7,color:#11111b
style volumen fill:#fab387,color:#11111b
style pn fill:#f38ba8,color:#11111b
style pf fill:#f9e2af,color:#11111b
style pi fill:#89b4fa,color:#11111b
style pd fill:#89b4fa,color:#11111b
style ps fill:#89b4fa,color:#11111b
style pb fill:#89b4fa,color:#11111b
style dentro fill:#a6e3a1,color:#11111b

Three.js expone ese volumen como la clase Frustum, y tú puedes construirlo desde cualquier cámara. La receta es multiplicar la matriz de proyección por la inversa de la matriz de mundo de la cámara, que es la transformación completa de mundo a recorte:

import * as THREE from 'three';

const frustum = new THREE.Frustum();
const matriz = new THREE.Matrix4();

function actualizarFrustum( camera ) {
  camera.updateMatrixWorld();
  matriz.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse );
  frustum.setFromProjectionMatrix( matriz, camera.coordinateSystem, camera.reversedDepth );
  return frustum;
}

// Uso: comprobar si un punto o una esfera del mundo entran en la vista.
const punto = new THREE.Vector3( 0, 0, - 30 );
const esfera = new THREE.Sphere( new THREE.Vector3( 10, 0, - 20 ), 2 );

actualizarFrustum( camera );
console.log( frustum.containsPoint( punto ) );   // true o false
console.log( frustum.intersectsSphere( esfera ) );

Los dos últimos argumentos de setFromProjectionMatrix no son decorativos. coordinateSystem distingue la convención de WebGL (profundidad de recorte en el intervalo de menos uno a uno) de la de WebGPU (de cero a uno), y reversedDepth cubre el caso de profundidad invertida. Si los omites y estás en WebGPURenderer, los planos cercano y lejano salen mal y el culling descarta cosas que sí se ven.

El array frustum.planes tiene seis Plane, y el propio código fuente de r184 solo etiqueta explícitamente dos: el índice 4 es el plano lejano y el 5 es el cercano. Los cuatro primeros son los laterales, extraídos por el método clásico de sumar y restar filas de la matriz. Los seis vienen normalizados, así que plane.distanceToPoint( p ) te da una distancia real en unidades de mundo: negativa si el punto está fuera por ese lado.

Ver más o ver mejor

Hay dos formas de que un objeto ocupe más pantalla: acercar la cámara o abrir el FOV. Producen imágenes completamente distintas, y confundirlas es el error de composición más frecuente en 3D web.

La altura de mundo visible a una distancia d es 2 · d · tan(fov/2). Three.js te la da directamente:

const tamano = new THREE.Vector2();
camera.getViewSize( 10, tamano );   // ancho y alto de mundo visibles a 10 unidades

Si acercas la cámara, d baja y esa altura baja con ella: el objeto crece, y también crece la diferencia de perspectiva entre su parte delantera y su parte trasera, porque la relación entre sus distancias respectivas cambia mucho. Si en lugar de acercarte abres el FOV, el objeto encogería, así que para mantenerlo del mismo tamaño tendrías que acercarte a la vez, y ahí está el truco del plano contrapicado de Hitchcock: mover la cámara y cambiar el FOV en sentidos opuestos manteniendo d · tan(fov/2) constante. El sujeto se queda del mismo tamaño y el fondo se desploma.

// Efecto vertigo: el sujeto a distancia `objetivo` conserva su tamaño en pantalla.
const constante = camaraDistancia * Math.tan( THREE.MathUtils.degToRad( camera.fov ) / 2 );

function ajustarFov( nuevoFov ) {
  camera.fov = nuevoFov;
  const nuevaDistancia = constante / Math.tan( THREE.MathUtils.degToRad( nuevoFov ) / 2 );
  camera.position.setLength( nuevaDistancia );   // suponiendo el objetivo en el origen
  camera.updateProjectionMatrix();
}

Para trabajo normal, un FOV vertical entre 35 y 60 grados es el rango que no llama la atención. Por debajo de 25 la escena se aplana como un teleobjetivo y la sensación de profundidad desaparece; por encima de 75 los objetos de los bordes se estiran de forma grotesca, porque la proyección en plano no puede repartir un ángulo tan grande sobre un rectángulo sin deformarlo.

El FOV no es una propiedad de la cámara: es una propiedad de la sala

La pregunta que casi nadie se hace es cuál es el FOV correcto, y tiene respuesta exacta. Una proyección en perspectiva es geométricamente fiel cuando el ángulo que la imagen renderizada subtiende en el ojo real del espectador coincide con el FOV con el que se renderizó. Si tu usuario está a 60 cm de un portátil cuya ventana mide 20 cm de alto, el ángulo vertical que esa ventana ocupa en su campo visual es 2 · atan(10/60), es decir, unos 19 grados. Renderizar con fov: 50 significa comprimir 50 grados de mundo dentro de 19 grados de ojo: todo se ve más lejos y más pequeño de lo que “debería”, y las líneas que en el mundo convergerían despacio convergen deprisa. Ese desajuste es la razón de que el 3D en pantalla siempre parezca una maqueta, y también la razón de que en realidad virtual no puedas elegir el FOV: el visor conoce la geometría óptica exacta y WebXRManager te impone la proyección, porque cualquier otra produce náuseas medibles. En pantalla plana nadie usa el FOV geométricamente correcto porque el resultado, aunque exacto, se percibe como un teleobjetivo y el usuario pierde contexto al girar la cámara: el estándar de la industria es exagerar entre dos y tres veces, exactamente igual que un director de fotografía usa un gran angular para que una habitación pequeña parezca habitable. Así que el criterio práctico no es “qué FOV es correcto” sino qué tipo de mentira te conviene: FOV alto para navegación y sensación de espacio, FOV bajo para presentar producto, donde la fidelidad de las proporciones importa más que la inmersión. Y una vez que lo entiendes así, deja de sorprenderte que un configurador de coches use 30 grados y un juego en primera persona use 75.

⚔️ Mide tu propia cámara
  1. Escribe la función fovHorizontal y comprueba los valores con la ventana en horizontal y en vertical.
  2. Calcula el FOV geométricamente correcto para tu monitor y tu distancia real, y renderiza la misma escena con él y con 50 grados.
  3. Construye un Frustum desde la cámara y pinta en consola qué objetos de la escena caen dentro con intersectsObject.
  4. Implementa el efecto vertigo con un deslizador de FOV y verifica que el sujeto no cambia de tamaño.
  5. Usa setViewOffset para renderizar la misma escena en cuatro baldosas y comprueba que encajan sin costura.