wandres.dev
CONTROLES · Orbit, fly y los propios

MapControls y FlyControls: recorrer en lugar de orbitar

Qué cambia MapControls respecto a OrbitControls y cómo hace que el punto bajo el cursor no se mueva, y cómo funciona el vuelo libre de FlyControls con su delta obligatorio.

⏱ 16 min

Orbitar alrededor de un objeto y recorrer un espacio son dos gestos distintos y necesitan contratos distintos. MapControls no es un control nuevo: es OrbitControls con el ratón reasignado y el paneo reescrito, y ese detalle del paneo es lo que hace que la navegación por un terreno se sienta como un mapa y no como un objeto que se arrastra. FlyControls va al otro extremo: seis grados de libertad, sin objetivo, sin vertical privilegiada y con un delta de tiempo que no es opcional.

🎯 Al terminar esta lección sabrás
  • Enumerar las diferencias exactas entre MapControls y OrbitControls.
  • Explicar el paneo en espacio de mundo de r184 y por qué evita el deslizamiento del cursor.
  • Configurar FlyControls y pasarle correctamente el delta de tiempo.
  • Reconocer los límites de cada uno y en qué escena aplica cada contrato.

MapControls: el mismo motor, otro contrato

MapControls extiende OrbitControls y en r184 solo cambia tres propiedades y dos métodos internos. Las propiedades son estas:

// Lo que MapControls sobrescribe respecto a OrbitControls
screenSpacePanning = false;                                     // en Orbit es true
mouseButtons = { LEFT: MOUSE.PAN, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.ROTATE };
touches = { ONE: TOUCH.PAN, TWO: TOUCH.DOLLY_ROTATE };

Eso es todo el cambio de configuración: el botón izquierdo panea en lugar de rotar, y el paneo es horizontal respecto al vector up en lugar de estar en el plano de la pantalla. Con eso, arrastrar mueve el suelo bajo tus pies en vez de girar la escena, que es la convención de cualquier mapa desde hace veinte años.

import * as THREE from 'three';
import { MapControls } from 'three/addons/controls/MapControls.js';

const controls = new MapControls( camera, renderer.domElement );
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.maxPolarAngle = Math.PI / 2 - 0.1;   // que no se meta bajo el terreno
controls.minDistance = 5;
controls.maxDistance = 400;

renderer.setAnimationLoop( () => {
  controls.update();
  renderer.render( scene, camera );
} );

Todo lo demás —los límites, saveState, reset, zoomToCursor, los eventos, la obligación de llamar a update() con damping— se hereda tal cual y funciona igual. Si conoces OrbitControls, ya conoces MapControls.

Una diferencia menor en la firma: el constructor de MapControls es ( object, domElement ), sin el valor por defecto null que sí tiene el de OrbitControls. En la práctica da igual, pero si construyes sin elemento DOM esperando conectar después, MapControls te lo pasará como undefined y el comportamiento no es el mismo.

El paneo en espacio de mundo

Esta es la parte interesante y es reciente. Con el paneo en espacio de pantalla —el de OrbitControls— el desplazamiento se calcula a partir de píxeles arrastrados y de la distancia al objetivo. Funciona perfectamente cuando miras un objeto de frente, pero al inclinar la cámara sobre un terreno aparece un artefacto molesto: el punto del suelo que agarraste se desliza bajo el cursor, hacia adelante o hacia atrás según la inclinación, porque la relación entre píxeles y unidades de mundo no es constante a lo largo de la pantalla cuando el plano está en escorzo.

r184 lo resuelve reescribiendo _handleMouseDownPan y _handleMouseMovePan en MapControls. Cuando screenSpacePanning es false, el control ya no traduce píxeles a unidades: lanza un rayo desde el cursor contra un Plane definido por el vector up del objeto y el punto target, guarda el punto de intersección al empezar el arrastre, y en cada movimiento calcula la nueva intersección y aplica la diferencia directamente en espacio de mundo. El resultado es que el punto que agarraste se queda exactamente bajo el cursor, con cualquier inclinación de cámara.

Dos consecuencias prácticas se derivan de eso. La primera es que el plano de paneo pasa por target, así que si tu terreno tiene relieve y target está a la altura del valle, arrastrar sobre una montaña se sentirá desplazado. Ajustar target.y a la altura del terreno bajo el cursor, si tienes esa información, mejora mucho la sensación. La segunda es que si pones screenSpacePanning = true en un MapControls, el nuevo comportamiento se desactiva y la implementación delega en la de OrbitControls: es la forma de recuperar el paneo antiguo si lo prefieres.

Este código consume propiedades internas de OrbitControls —el acumulador de desplazamiento y el manejador de la clase padre—, lo que significa en la práctica que un MapControls está acoplado a la versión de OrbitControls que trae la misma release. No mezcles addons de versiones distintas de Three.js: es un consejo general y aquí es especialmente literal.

FlyControls: seis grados de libertad

FlyControls no orbita nada. No tiene target, no privilegia ninguna vertical, y permite alabeo, así que la cámara puede acabar boca abajo. Es el modelo de una nave, no el de una persona.

new FlyControls( object, domElement = null )

Sus propiedades públicas son exactamente cuatro, ni una más:

import { FlyControls } from 'three/addons/controls/FlyControls.js';

const controls = new FlyControls( camera, renderer.domElement );
controls.movementSpeed = 10;    // unidades por segundo
controls.rollSpeed = 0.5;       // radianes por segundo
controls.dragToLook = true;     // mirar solo mientras se arrastra
controls.autoForward = false;   // avanzar siempre sin pulsar nada

Y la diferencia de contrato con OrbitControls: update() recibe un delta en segundos y es obligatorio. No es una optimización ni una mejora opcional, es cómo funciona: el control multiplica el vector de movimiento por delta * movementSpeed y el de rotación por delta * rollSpeed. Sin delta, no hay movimiento.

import * as THREE from 'three';

const reloj = new THREE.Timer();
reloj.connect( document );

renderer.setAnimationLoop( ( tiempo ) => {
  reloj.update( tiempo );
  const delta = Math.min( reloj.getDelta(), 0.1 );
  controls.update( delta );
  renderer.render( scene, camera );
} );

El acotado del delta importa más aquí que en OrbitControls, porque el movimiento es proporcional al tiempo: al volver de una pestaña inactiva, un delta de tres segundos con movementSpeed a diez te teletransporta treinta unidades hacia adelante.

El mapa de teclas está fijado en el código y usa event.code, así que no depende de la distribución del teclado:

Tecla Acción
KeyW / KeyS Avanzar y retroceder
KeyA / KeyD Desplazarse a los lados
KeyR / KeyF Subir y bajar
ArrowUp / ArrowDown Cabecear
ArrowLeft / ArrowRight Guiñar
KeyQ / KeyE Alabear

Los escuchadores de teclado se instalan en window, no en el elemento DOM, así que FlyControls captura las teclas aunque el foco esté en otra parte de la página. Si tu página tiene campos de texto, eso es un problema y tendrás que desactivar el control mientras estén enfocados con controls.enabled = false.

Hay dos detalles del comportamiento del ratón que conviene saber. Con dragToLook en false, el movimiento del puntero rota la cámara siempre, sin necesidad de pulsar, y los botones izquierdo y derecho avanzan y retroceden. Con dragToLook en true, la cámara solo gira mientras arrastras y los botones dejan de mover. Para la mayoría de las aplicaciones web la segunda es la única aceptable, porque la primera secuestra el puntero de facto.

Y un hecho verificado en el código fuente de r184 que ahorra una tarde de depuración: las teclas de mayúsculas asignan una propiedad movementSpeedMultiplier que no está declarada en el constructor y no se usa en update(). Es código muerto. Mantener pulsado mayúsculas en FlyControls no acelera nada. Si necesitas un modificador de velocidad, tienes que implementarlo tú:

addEventListener( 'keydown', ( e ) => {
  if ( e.code === 'ShiftLeft' ) controls.movementSpeed = 40;
} );
addEventListener( 'keyup', ( e ) => {
  if ( e.code === 'ShiftLeft' ) controls.movementSpeed = 10;
} );

FlyControls emite un único evento, change, y solo cuando la posición o la rotación han cambiado de verdad. No emite start ni end, así que el patrón de bajar la calidad durante la interacción que funciona con OrbitControls aquí hay que montarlo sobre las pulsaciones de tecla.

Cuál de los dos

El criterio es qué representa la cámara. MapControls mantiene una vertical privilegiada y un punto de interés en el suelo: es el contrato de un observador que sobrevuela un plano. Sirve para terreno, para planos de edificio, para escenas urbanas, para cualquier cosa donde exista un “arriba” incuestionable y el usuario quiera cubrir distancia sin perder la orientación.

FlyControls no tiene vertical: la cámara puede acabar en cualquier orientación y no hay forma de “enderezarla” salvo que lo hagas tú. Eso es exactamente lo que quieres en el espacio, bajo el agua o dentro de una estructura compleja, y exactamente lo que no quieres en un terreno, porque el usuario acaba desorientado y sin manera de recuperarse. Si necesitas recorrido en primera persona sobre un suelo, el control adecuado es PointerLockControls, que sí bloquea el alabeo.

La vertical privilegiada no es una limitación: es la que hace que el control sea usable

La diferencia real entre MapControls y FlyControls no está en los botones sino en si el control mantiene un invariante: que el vector up de la cámara apunte siempre hacia arriba del mundo. OrbitControls y todos sus derivados lo hacen, y el precio es que no puedes alabear ni pasar por encima del polo. FlyControls no lo hace, y el precio es mucho mayor de lo que parece: el sistema vestibular humano no tiene modelo mental para una orientación arbitraria. Cuando pierdes la referencia vertical, dejas de poder predecir a dónde te lleva un gesto, y el resultado es que el usuario se desorienta en segundos y no sabe cómo volver. Los simuladores de vuelo llevan décadas peleando con esto y su solución no es mejorar el control, sino añadir referencias externas: un horizonte artificial, una rejilla del suelo, una brújula, un indicador de alabeo. Si vas a usar seis grados de libertad, ese instrumental no es un adorno, es parte del control, y sin él el usuario abandonará. Y hay un tercer camino que casi nadie considera y que suele ser el correcto: mantener el invariante de la vertical pero cambiar cuál es la vertical según el contexto. Un control que orbita alrededor de un planeta con camera.up apuntando siempre desde el centro del planeta hacia la cámara da libertad completa de recorrido sobre la esfera sin perder nunca la referencia local. Es más trabajo que instanciar un addon, y es la razón de que los buenos visores de escala planetaria se sientan tan distintos de un FlyControls con la velocidad bien ajustada.

⚔️ Compara los dos contratos
  1. Monta un terreno con MapControls e inclina la cámara al máximo permitido: comprueba que el punto agarrado no se desliza bajo el cursor.
  2. Pon screenSpacePanning = true en el mismo MapControls y observa la diferencia.
  3. Sube y baja target.y y describe cómo cambia el paneo sobre un relieve.
  4. Monta FlyControls sin acotar el delta, cambia de pestaña diez segundos y vuelve.
  5. Implementa el modificador de velocidad con mayúsculas que el addon no trae.