Saltar al contenido

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.

Los cuatro números

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

JS
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:

JS
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:

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

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

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:

JS
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 falseconsole.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:

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

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