wandres.dev
CÁMARAS Y CINEMÁTICA · Movimiento con intención

Encuadre automático: calcular la distancia y fitToBox

Cómo se calcula la distancia exacta a la que un objeto llena el encuadre, por qué hay que considerar los dos ejes, y qué añade fitToBox de camera-controls sobre la fórmula a mano.

⏱ 17 min

“Enseña este objeto entero, sin recortarlo y sin dejar demasiado aire” es una petición que aparece en todo visor de modelos, todo configurador y toda herramienta de inspección. Resolverla bien exige dos cosas: obtener el volumen real del objeto —que no es trivial cuando hay jerarquías, escalas y animaciones— y despejar la distancia a partir del campo de visión, teniendo en cuenta que la cámara tiene dos ángulos distintos y el que limita depende de la relación de aspecto.

🎯 Al terminar esta lección sabrás
  • Obtener la caja envolvente de un subárbol completo con Box3.setFromObject.
  • Despejar la distancia de encuadre para los ejes vertical y horizontal y quedarse con la restrictiva.
  • Ajustar near y far al objeto para no perder precisión de profundidad.
  • Usar fitToBox de camera-controls y saber qué resuelve que la fórmula a mano no.

El volumen del objeto

Box3.setFromObject recorre el objeto y todos sus descendientes, aplica sus matrices de mundo y devuelve la caja alineada con los ejes que los contiene.

const caja = new THREE.Box3();
const centro = new THREE.Vector3();
const tamano = new THREE.Vector3();

// Importante: actualizar matrices antes, si acabas de mover algo.
modelo.updateWorldMatrix( true, true );

caja.setFromObject( modelo );
caja.getCenter( centro );
caja.getSize( tamano );

Hay un segundo argumento poco conocido y muy útil:

// Con precise en true, usa las posiciones reales de los vertices en lugar de
// las cajas envolventes de cada geometria. Mas lento y mas ajustado.
caja.setFromObject( modelo, true );

Sin precise, el método compone la caja a partir de la boundingBox de cada geometría transformada, lo que sobreestima cuando hay rotaciones: la caja envolvente de una caja rotada es mayor que la original. Con precise, recorre los vértices uno a uno. Para un modelo cargado una vez al arrancar, el coste extra es irrelevante y el encuadre queda notablemente más ajustado.

Dos casos donde la caja miente y hay que saberlo:

Objetos deformados en el vertex shader. La caja se calcula desde los atributos de posición originales. Un terreno con displacement, una bandera ondeando o una malla movida por TSL producen una caja que no corresponde a lo que se ve. No hay solución general; lo habitual es asignar una boundingBox manual holgada.

Objetos con esqueleto animado. Un SkinnedMesh en pose de reposo puede tener una caja muy distinta de la del personaje en movimiento. Three.js no recalcula la caja al animar.

La fórmula, con los dos ejes

La geometría del problema es un triángulo. Con una cámara en perspectiva, la mitad de la altura visible a una distancia d es d * tan( fovV / 2 ). Si queremos que la mitad de la altura del objeto quepa ahí:

d_vertical = ( altura / 2 ) / tan( fovV / 2 )

Pero la cámara también tiene un campo de visión horizontal, que Three.js no expone porque se deduce del vertical y de la relación de aspecto:

tan( fovH / 2 ) = tan( fovV / 2 ) * aspect

Y la distancia que hace caber la anchura es:

d_horizontal = ( anchura / 2 ) / tan( fovH / 2 )

Como el objeto tiene que caber en las dos, la distancia buena es la mayor de las dos: la más restrictiva.

function distanciaParaEncuadrar( camara, tamano, margen = 1.2 ) {

	const fovV = THREE.MathUtils.degToRad( camara.fov );
	const tanV = Math.tan( fovV / 2 );
	const tanH = tanV * camara.aspect;

	// Profundidad del objeto: hay que salir de su mitad para no meterse dentro.
	const mitadProfundidad = tamano.z / 2;

	const dV = ( tamano.y / 2 ) / tanV;
	const dH = ( tamano.x / 2 ) / tanH;

	return Math.max( dV, dH ) * margen + mitadProfundidad;

}

El margen deja aire alrededor: 1,0 encaja el objeto exactamente en el borde, 1,2 deja un 20 % de respiro, que es lo habitual en un visor de producto.

El término mitadProfundidad es el que casi todo el mundo olvida. La fórmula supone que el objeto es plano y está a distancia d; en realidad su cara delantera está a d - profundidad/2. Sin ese término, un objeto profundo aparece recortado por delante o directamente atraviesa el plano de recorte cercano.

Una versión más robusta usa la esfera envolvente en lugar de la caja, lo que da un encuadre que no cambia al rotar el objeto:

function encuadrarEsfera( camara, objeto, margen = 1.2 ) {

	const caja = new THREE.Box3().setFromObject( objeto, true );
	const esfera = caja.getBoundingSphere( new THREE.Sphere() );

	const fovV = THREE.MathUtils.degToRad( camara.fov );
	const fovH = 2 * Math.atan( Math.tan( fovV / 2 ) * camara.aspect );
	const menor = Math.min( fovV, fovH );

	const d = ( esfera.radius * margen ) / Math.sin( menor / 2 );

	// Colocar la camara en su direccion actual, a esa distancia del centro.
	const direccion = new THREE.Vector3();
	camara.getWorldDirection( direccion );

	camara.position.copy( esfera.center ).addScaledVector( direccion, - d );
	camara.lookAt( esfera.center );

	// Ajustar los planos de recorte al objeto.
	camara.near = Math.max( d - esfera.radius * 2, d / 1000 );
	camara.far = d + esfera.radius * 2;
	camara.updateProjectionMatrix();

	return { centro: esfera.center, distancia: d };

}

Con la esfera, el ángulo relevante es el menor de los dos y la relación es el seno, no la tangente: la esfera es tangente al cono de visión, no inscrita en el rectángulo. El resultado es un encuadre ligeramente más holgado y completamente estable ante rotaciones, que es lo que quieres en un visor orbital.

Ajustar near y far al objeto merece su párrafo. La precisión del buffer de profundidad depende del cociente far / near: cuanto mayor, peor. Una cámara con near = 0.1 y far = 10000 desperdicia la mayor parte de la precisión en el rango cercano y produce z-fighting a media distancia. Después de encuadrar sabes exactamente dónde está el objeto, así que puedes cerrar los planos a su alrededor y ganar varios órdenes de magnitud de precisión gratis.

💡
Ortográfica: no es la distancia, es el zoom

Con OrthographicCamera la distancia no cambia el tamaño aparente. Lo que hay que ajustar es zoom, y la fórmula es directa: camara.zoom = Math.min( ( camara.right - camara.left ) / anchura, ( camara.top - camara.bottom ) / altura ) / margen, seguido de updateProjectionMatrix(). La cámara sí hay que moverla, pero solo para que el objeto quede entre near y far.

fitToBox de camera-controls

La biblioteca camera-controls de Yomotsu es la alternativa madura a OrbitControls, y su método fitToBox resuelve el encuadre incluyendo cosas que la fórmula a mano no cubre.

import CameraControls from 'camera-controls';
import * as THREE from 'three';

CameraControls.install( { THREE } );

const controls = new CameraControls( camara, renderer.domElement );

// Acepta un Box3 o directamente un Object3D.
await controls.fitToBox( modelo, true, {
	paddingLeft: 0.5,
	paddingRight: 0.5,
	paddingTop: 0.2,
	paddingBottom: 0.2,
	cover: false
} );

El primer argumento es un Box3 o un Object3D; el segundo es si la transición debe ser animada o instantánea; el tercero es un objeto de opciones. Lo que aporta sobre la versión manual:

Padding por lado. Los cuatro márgenes son independientes, lo que sirve para dejar sitio a una interfaz que ocupa un lateral de la pantalla sin descentrar el objeto.

Modo cover. Con cover en true, el objeto llena la pantalla aunque se salga por el eje menos restrictivo, en lugar de caber entero. Es la misma distinción que object-fit: contain y object-fit: cover en CSS.

Devuelve una promesa. La transición está integrada con el sistema de amortiguamiento de la biblioteca, que usa un smoothTime en segundos, y la promesa resuelve al terminar. Eso permite encadenar movimientos de cámara con await sin gestionar estados a mano.

Actualiza el objetivo de la órbita. Después del encuadre, el punto alrededor del que orbita la cámara es el centro del objeto. Con la fórmula manual sobre OrbitControls tendrías que actualizar controls.target tú y llamar a controls.update().

Existe también fitToSphere( esferaOMalla, animar ), que hace lo mismo con una esfera envolvente y es más adecuado cuando el usuario va a rotar el objeto después.

camera-controls tiene además la ventaja de un amortiguamiento parametrizado por tiempo, con smoothTime y draggingSmoothTime separados, que es el enfoque correcto que viste en la lección del damping.

El encuadre correcto no es el que cabe, es el que compone

Toda la matemática de esta lección responde a la pregunta “¿a qué distancia cabe el objeto?”, y esa pregunta tiene una respuesta exacta. El problema es que casi nunca es la pregunta que de verdad importa. Un encuadre que mete el objeto justo dentro del rectángulo es geométricamente perfecto y visualmente mediocre, por tres motivos que ninguna fórmula captura. El primero: el centro de la caja envolvente rara vez es el centro de interés. En un coche, la caja está centrada a media altura del chasis, pero el ojo va al morro y a las ruedas; encuadrar respecto a la caja deja el sujeto sistemáticamente alto en el fotograma. La corrección habitual es un desplazamiento vertical del punto de mira de un 10 a un 20 % de la altura, hacia abajo, que aproxima la regla de los tercios. El segundo: el campo de visión no es un parámetro libre. Un fov de 70 grados deforma la perspectiva y hace que el objeto parezca más pequeño y más barato; uno de 30 aplana y produce imágenes que se leen como fotografía de producto. El encuadre no es solo dónde está la cámara, es la combinación de distancia y focal, y son dos decisiones distintas: primero eliges la focal por la sensación que quieres, después despejas la distancia. Si dejas que el algoritmo elija las dos, obtienes la que cabe, no la que comunica. Y el tercero, el más práctico: el aire alrededor del objeto no debería ser uniforme. En composición, dejar más espacio en el sentido en el que el sujeto mira o se mueve es lo que hace que la imagen respire; centrarlo con márgenes iguales por los cuatro lados produce ese aspecto de captura de pantalla de software CAD. Por eso fitToBox expone cuatro paddings independientes en lugar de uno solo: no es una comodidad para dejar sitio a la interfaz, es la manera de expresar una composición. La conclusión práctica es que la fórmula de esta lección es el punto de partida y no el resultado: calcula el encuadre que cabe, y después desplázalo a propósito.

⚔️ Encuadra un modelo de verdad
  1. Carga un glTF y encuádralo con la fórmula de la caja, con precise desactivado y activado.
  2. Cambia la relación de aspecto de la ventana de apaisada a vertical y comprueba que el eje limitante cambia.
  3. Añade el término de media profundidad y verifica qué pasaba sin él con un objeto muy alargado.
  4. Ajusta near y far al objeto y compara el z-fighting antes y después con dos superficies muy próximas.
  5. Instala camera-controls, usa fitToBox con paddings asimétricos y reserva el tercio izquierdo para un panel.