wandres.dev
CONTROLES · Orbit, fly y los propios

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.

⏱ 17 min

Un OrbitControls sin límites deja al usuario meterse dentro de la geometría, mirar el suelo por debajo, alejarse hasta que la escena es un punto y panear hasta perderla de vista. Poner límites no es una cuestión de pulido: es lo que convierte un juguete en un visor. Y hay más límites de los que parece, algunos aplican solo a un tipo de cámara, y uno de ellos —el que acota el paneo— casi nadie sabe que existe.

🎯 Al terminar esta lección sabrás
  • Acotar la órbita con los cuatro límites angulares y explicar el clamp interno que impide los polos.
  • Elegir entre límites de distancia y límites de zoom según el tipo de cámara.
  • Confinar el paneo con cursor, minTargetRadius y maxTargetRadius.
  • Guardar y restaurar el encuadre y mover la órbita desde código sin pelearse con el estado interno.

Á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: cenit
controls.maxPolarAngle = Math.PI;      // por defecto: nadir
controls.minAzimuthAngle = - Infinity; // giro horizontal libre
controls.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 suelo
controls.minPolarAngle = 0.2;                  // y no llega al cenit exacto

Ese 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 y su amplitud tiene que ser menor que . 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 perspectiva
controls.minDistance = 2;
controls.maxDistance = 20;

// Cámara ortográfica
controls.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 escena
controls.minTargetRadius = 0;
controls.maxTargetRadius = 5;      // el objetivo no se aleja más de 5 unidades del cursor

El 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ón
controls.keyRotateSpeed = 1;  // con control, meta o mayúsculas, las flechas rotan

Los 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 referencia
controls.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 ) );  // radianes
controls.rotateUp( THREE.MathUtils.degToRad( 5 ) );
controls.dollyIn( 1.2 );      // factor de escala, mayor que uno acerca
controls.dollyOut( 1.2 );
controls.pan( 40, 0 );        // píxeles, como si el usuario arrastrase

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

Los límites no son restricciones: son la especificación de tu escena

Hay una manera de pensar los límites que cambia cómo se configuran, y es dejar de verlos como una lista de propiedades que se ajustan a ojo y verlos como la descripción formal del espacio de encuadres válidos. Cada escena tiene uno, aunque no lo hayas escrito: hay una distancia mínima por debajo de la cual el plano cercano corta la geometría, una máxima por encima de la cual el objeto es menor que unos pocos píxeles y la escena deja de comunicar nada, un ángulo por debajo del cual se ve por debajo del suelo y se rompe la ilusión, y una región de paneo fuera de la cual no hay nada modelado. Esos cuatro números existen objetivamente y se pueden calcular a partir de la caja envolvente de la escena, del campo de visión y del tamaño del lienzo; ajustarlos arrastrando el ratón hasta que “queda bien” es adivinarlos. La consecuencia práctica es que en un visor que carga modelos arbitrarios —un configurador, un catálogo, cualquier cosa que reciba un glTF que tú no has hecho— los límites deberían derivarse en tiempo de carga, no configurarse en el código: minDistance a partir del radio de la esfera envolvente, maxDistance a partir del tamaño mínimo aceptable en pantalla, maxTargetRadius a partir de la propia caja. El día que alguien suba un modelo mil veces más grande que tus pruebas, la diferencia entre las dos aproximaciones es que una sigue funcionando y la otra muestra un punto blanco en el centro de la pantalla y un usuario que no sabe qué ha hecho mal.

⚔️ Acota un visor de verdad
  1. Deriva minDistance y maxDistance de la esfera envolvente de la escena en lugar de escribirlos a mano.
  2. Congela el ángulo polar igualando mínimo y máximo y comprueba que la órbita queda horizontal.
  3. Confina el paneo con cursor y maxTargetRadius y verifica que el objetivo no puede salir de la esfera.
  4. Alterna entre cámara en perspectiva y ortográfica y comprueba qué límites deja de respetar cada una.
  5. Implementa encuadrar y aplícalo a tres modelos de escalas muy distintas sin tocar ninguna constante.