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.
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 ) * aspectY 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.
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.