Acotar OrbitControls: ángulos, distancias y estado
Cómo encerrar la cámara en el rango que tu escena tolera, qué límite corresponde a cada tipo de cámara, y cómo guardar, restaurar y mover la órbita por código.
Ángulos: el cono donde puede vivir la cámara
Los dos ángulos son polar —la elevación, medida desde el eje vertical— y acimutal —el giro alrededor de él.
controls.minPolarAngle = 0; // por defecto: cenitcontrols.maxPolarAngle = Math.PI; // por defecto: nadircontrols.minAzimuthAngle = - Infinity; // giro horizontal librecontrols.maxAzimuthAngle = Infinity;Para una escena con suelo, el ajuste habitual es impedir que la cámara pase por debajo del horizonte:
controls.maxPolarAngle = Math.PI / 2 - 0.05; // se queda justo por encima del suelocontrols.minPolarAngle = 0.2; // y no llega al cenit exactoEse 0.05 no es superstición. Aunque pusieras maxPolarAngle exactamente en Math.PI / 2, la cámara llegaría a mirar completamente horizontal y vería el plano del suelo de canto, que es un caso degenerado visualmente feo. Y en el otro extremo hay un detalle interno que conviene conocer: update() llama a Spherical.makeSafe(), que acota el ángulo polar entre 0.000001 y Math.PI − 0.000001. Es decir, por mucho que pongas cero y Math.PI, nunca llegarás al polo exacto. La razón es que en el polo el vector desde el objetivo a la cámara es paralelo al vector up, y lookAt no puede decidir el giro alrededor del eje de vista: la cámara daría un tirón aleatorio. Ese epsilon es el que evita el gimbal lock del control.
El límite acimutal tiene una regla que la propia documentación enuncia y que produce comportamientos raros si la incumples: si fijas los dos, el intervalo tiene que ser un subintervalo de -2π a 2π y su amplitud tiene que ser menor que 2π. El código normaliza ambos extremos al rango de -π a π y luego trata dos casos, según si el intervalo cruza o no la discontinuidad. Un rango de noventa grados centrado en el frente se escribe así:
controls.minAzimuthAngle = - Math.PI / 4;controls.maxAzimuthAngle = Math.PI / 4;Para leer los ángulos actuales hay tres métodos que devuelven exactamente lo que dicen: getPolarAngle(), getAzimuthalAngle() y getDistance(). Son la forma correcta de persistir un encuadre o de mostrarlo en una interfaz, mucho mejor que leer la posición de la cámara y deshacer la trigonometría a mano.
Distancia, zoom y objetivo
Aquí está una asimetría que confunde a mucha gente: los límites de distancia solo funcionan con cámara en perspectiva y los de zoom solo con ortográfica, y no hay ningún aviso si te equivocas.
// Cámara en perspectivacontrols.minDistance = 2;controls.maxDistance = 20;// Cámara ortográficacontrols.minZoom = 0.5;controls.maxZoom = 8;La razón es geométrica. En perspectiva, acercarse cambia el tamaño aparente, así que “acercar” y “hacer zoom” son la misma operación y se implementan moviendo la cámara. En ortográfica la distancia no afecta al tamaño en pantalla, así que el zoom tiene que cambiar el volumen de vista, y eso se hace con la propiedad zoom de la cámara. Si tienes un visor que alterna entre las dos proyecciones, tienes que ajustar los cuatro.
zoomToCursor cambia el punto de anclaje del zoom: en lugar de acercarse al objetivo, se acerca al punto bajo el cursor. Es lo que espera cualquiera que venga de un mapa o de un editor de imagen, y sin ello la navegación por una escena grande resulta torpe.
controls.zoomToCursor = true;Y ahora el límite que casi nadie conoce. target se mueve libremente al panear, así que un usuario puede alejar el punto de interés hasta el infinito y perder la escena. Los tres parámetros que lo acotan son cursor, que es el centro de la restricción, y minTargetRadius y maxTargetRadius, que definen la corona esférica en la que el objetivo puede estar:
controls.cursor.set( 0, 1, 0 ); // el centro de interés real de la escenacontrols.minTargetRadius = 0;controls.maxTargetRadius = 5; // el objetivo no se aleja más de 5 unidades del cursorEl código lo implementa con un clampLength sobre el vector desde cursor hasta target, así que la restricción es exactamente una esfera, no una caja. Con esto, el usuario puede panear con libertad dentro de la zona interesante y no puede irse a la nada. Es la mejor forma de acotar un visor de producto sin desactivar el paneo por completo, que es lo que hace la mayoría.
Si prefieres desactivarlo del todo, o cualquiera de los otros gestos:
controls.enablePan = false;controls.enableZoom = true;controls.enableRotate = true;Y un truco útil derivado de cómo se implementan los límites angulares: fijar el mínimo y el máximo al mismo valor congela ese eje. minPolarAngle y maxPolarAngle iguales dan una órbita puramente horizontal, que es exactamente lo que quieres para un carrusel de producto.
screenSpacePanning decide en qué plano se panea. Con true, el valor por defecto, el paneo es en el plano de la pantalla, que es lo natural para inspeccionar un objeto. Con false, el paneo es horizontal respecto al vector up, que es lo natural para recorrer un terreno.
Botones, gestos y teclado
Los tres mapas son propiedades públicas y se reasignan enteros:
import * as THREE from 'three';controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN};controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN};Esos son los valores por defecto. Poner null en cualquiera de ellos desactiva ese botón o ese gesto. Conviene saber que las teclas modificadoras están cableadas: mantener control, meta o mayúsculas mientras se arrastra intercambia rotar y panear, sin que haya ninguna propiedad para cambiarlo.
El teclado no está conectado por defecto, y esto sorprende a mucha gente: hay una propiedad keys pero los escuchadores no se instalan hasta que llamas a listenToKeyEvents.
controls.listenToKeyEvents( window );controls.keys = { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown' };controls.keyPanSpeed = 7; // píxeles por pulsacióncontrols.keyRotateSpeed = 1; // con control, meta o mayúsculas, las flechas rotanLos valores son códigos de event.code, no de event.key, así que son independientes de la distribución del teclado. Para desconectarlo, stopListenToKeyEvents().
En r184 hay además una propiedad nueva y pequeña que mejora mucho la percepción de la interfaz: cursorStyle, que acepta 'auto' o 'grab'. Con 'grab', el control pone el cursor de mano abierta en reposo y de mano cerrada mientras arrastras.
controls.cursorStyle = 'grab';Guardar, restaurar y mover por código
El control guarda un estado inicial en el constructor —posición de la cámara, objetivo y zoom— y expone dos métodos para trabajar con él:
controls.saveState(); // captura el encuadre actual como el estado de referenciacontrols.reset(); // vuelve a él, dispara 'change' y llama a update()reset() es lo que quieres detrás de un botón de “vista inicial”. La secuencia habitual es llamar a saveState() una vez, cuando la escena ya está colocada, y a partir de ahí reset() cuantas veces haga falta.
Para mover la órbita desde código sin tocar el estado interno, r184 expone cuatro métodos públicos que aplican el cambio y llaman a update() por ti:
controls.rotateLeft( THREE.MathUtils.degToRad( 15 ) ); // radianescontrols.rotateUp( THREE.MathUtils.degToRad( 5 ) );controls.dollyIn( 1.2 ); // factor de escala, mayor que uno acercacontrols.dollyOut( 1.2 );controls.pan( 40, 0 ); // píxeles, como si el usuario arrastraseSon la manera correcta de animar la cámara respetando todos los límites. La alternativa —mover camera.position a mano y llamar a update()— también funciona, porque update() deduce el estado esférico de la posición actual, pero se salta los límites en el instante intermedio y produce saltos si la posición cae fuera del rango permitido.
Un caso frecuente que merece receta propia: encuadrar un objeto. La forma robusta es calcular su esfera envolvente y despejar la distancia desde el campo de visión.
import * as THREE from 'three';function encuadrar( objeto, camera, controls, margen = 1.4 ) { const caja = new THREE.Box3().setFromObject( objeto ); const esfera = caja.getBoundingSphere( new THREE.Sphere() ); const fovVertical = THREE.MathUtils.degToRad( camera.fov ); const fovHorizontal = 2 * Math.atan( Math.tan( fovVertical / 2 ) * camera.aspect ); const fovMenor = Math.min( fovVertical, fovHorizontal ); const distancia = ( esfera.radius * margen ) / Math.sin( fovMenor / 2 ); const direccion = camera.position.clone().sub( controls.target ).normalize(); controls.target.copy( esfera.center ); camera.position.copy( esfera.center ).addScaledVector( direccion, distancia ); camera.near = Math.max( distancia - esfera.radius * 2, 0.01 ); camera.far = distancia + esfera.radius * 2; camera.updateProjectionMatrix(); controls.update();}Hay que usar el menor de los dos campos de visión, no el vertical: en una ventana estrecha el limitante es el horizontal, y si solo miras el vertical el objeto se sale por los lados. El ajuste de near y far al vuelo es el mismo criterio de precisión de profundidad que se trata al hablar de z-fighting, aplicado aquí de la forma más rentable posible: como conoces la distancia y el radio, puedes apretar el rango al mínimo necesario.