wandres.dev
CONTROLES · Orbit, fly y los propios

PointerLockControls y TrackballControls

El bloqueo del puntero y su gesto obligatorio, cómo mover al jugador con velocidad en lugar de posición, y el control de bola que gira sin vertical privilegiada.

⏱ 17 min

Los dos controles de esta lección resuelven problemas opuestos y comparten una virtud: los dos son delgados. PointerLockControls es poco más que un envoltorio sobre una API del navegador y dos ángulos acumulados, y su valor está en lo que no hace, porque el movimiento del jugador lo escribes tú. TrackballControls es el único de la familia que renuncia a la vertical privilegiada, y eso lo convierte en la herramienta correcta para inspeccionar un objeto sin ningún arriba definido.

🎯 Al terminar esta lección sabrás
  • Integrar el bloqueo del puntero respetando el requisito de gesto de usuario del navegador.
  • Mover al jugador con un modelo de velocidad y rozamiento en lugar de sumar a la posición.
  • Configurar TrackballControls y llamar a handleResize() cuando toca.
  • Identificar los tres detalles del código de r184 que producen bugs difíciles de reproducir.

El bloqueo del puntero

PointerLockControls envuelve la Pointer Lock API: cuando está activo, el cursor desaparece, el puntero deja de tener posición y los eventos de movimiento entregan solo desplazamientos relativos, sin límite de pantalla. Es lo que permite girar indefinidamente sin que el ratón choque con el borde del monitor.

new PointerLockControls( camera, domElement = null )

Sus propiedades públicas son cuatro y las tres configurables son estas:

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

const controls = new PointerLockControls( camera, renderer.domElement );
controls.pointerSpeed = 1.0;            // multiplicador de sensibilidad
controls.minPolarAngle = 0;             // límite de cabeceo hacia arriba
controls.maxPolarAngle = Math.PI;       // y hacia abajo
// controls.isLocked es de solo lectura

La sensibilidad base es una constante interna de 0.002 radianes por píxel de movimiento, y pointerSpeed la multiplica. Los límites polares son lo que impide que el jugador se doble el cuello hacia atrás; para un juego en primera persona, lo habitual es dejar un poco de margen en ambos extremos.

El requisito que no se puede esquivar es que el navegador solo concede el bloqueo dentro de un gesto de usuario. No puedes llamar a lock() al cargar la página ni desde un temporizador: tiene que salir de un clic o una pulsación de tecla. De ahí el patrón universal de la pantalla de “haz clic para jugar”:

const boton = document.querySelector( '#jugar' );

boton.addEventListener( 'click', () => controls.lock() );

controls.addEventListener( 'lock', () => { boton.hidden = true; } );
controls.addEventListener( 'unlock', () => { boton.hidden = false; } );

El navegador libera el bloqueo cuando el usuario pulsa escape, y no hay forma de impedirlo ni de detectarlo distinto de cualquier otra liberación: por eso el evento unlock tiene que devolver la interfaz a un estado usable siempre, no solo cuando lo has pedido tú.

lock() acepta un argumento opcional: lock( true ) pide movimiento sin ajustar, es decir, sin la aceleración del ratón del sistema operativo. Para un juego de puntería es lo correcto; para navegación, la aceleración del sistema suele sentirse mejor.

Y ahora el detalle que cuesta una tarde. El manejador interno de pointerlockchange despacha el evento antes de actualizar la bandera. Dentro de tu escuchador de lock, controls.isLocked todavía vale false; dentro del de unlock, todavía vale true. Si tu lógica de arranque lee la bandera en ese momento, no funciona y el motivo no es evidente en absoluto:

controls.addEventListener( 'lock', () => {
  console.log( controls.isLocked );   // false, aunque acabe de bloquearse
} );

La solución es no leer la bandera dentro de esos escuchadores: el evento ya te dice lo que ha pasado.

Otro cambio de r184 que rompe código antiguo: getObject() ya no existe. La cámara es controls.object, heredada de la clase base Controls.

Mover al jugador con velocidad

PointerLockControls solo gira la cámara. El desplazamiento lo aporta con dos métodos que mueven paralelos al plano horizontal, ignorando el cabeceo:

controls.moveForward( distancia );   // hacia donde mira, sin componente vertical
controls.moveRight( distancia );     // perpendicular, a la derecha
controls.getDirection( vector );     // dirección de vista normalizada, esta sí con cabeceo

Que ignoren el cabeceo es correcto: en un juego en primera persona, mirar al suelo no debe hacerte descender. Si quieres vuelo libre, usa getDirection y suma tú.

El error de principiante es sumar directamente en cada frame mientras la tecla esté pulsada. El resultado es un movimiento que arranca y para en seco, y que además depende de la frecuencia de refresco. El modelo correcto es de velocidad con rozamiento: las teclas aportan aceleración, la velocidad decae exponencialmente, y el desplazamiento es la velocidad por el delta.

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

const controls = new PointerLockControls( camera, renderer.domElement );
document.querySelector( '#jugar' ).addEventListener( 'click', () => controls.lock() );

const teclas = new Set();
addEventListener( 'keydown', ( e ) => teclas.add( e.code ) );
addEventListener( 'keyup', ( e ) => teclas.delete( e.code ) );

const ACELERACION = 60;   // unidades por segundo al cuadrado
const ROZAMIENTO = 10;    // inverso de segundos
let vAdelante = 0;
let vLateral = 0;

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

renderer.setAnimationLoop( ( tiempo ) => {
  reloj.update( tiempo );
  const delta = Math.min( reloj.getDelta(), 0.1 );

  if ( controls.isLocked ) {
    const adelante = ( teclas.has( 'KeyW' ) ? 1 : 0 ) - ( teclas.has( 'KeyS' ) ? 1 : 0 );
    const lateral  = ( teclas.has( 'KeyD' ) ? 1 : 0 ) - ( teclas.has( 'KeyA' ) ? 1 : 0 );
    const norma = Math.hypot( adelante, lateral ) || 1;   // la diagonal no acelera más

    vAdelante += ( adelante / norma ) * ACELERACION * delta;
    vLateral  += ( lateral / norma ) * ACELERACION * delta;

    const decaimiento = Math.exp( - ROZAMIENTO * delta );  // independiente del framerate
    vAdelante *= decaimiento;
    vLateral  *= decaimiento;

    controls.moveForward( vAdelante * delta );
    controls.moveRight( vLateral * delta );
  }

  renderer.render( scene, camera );
} );

La normalización de la diagonal no es cosmética: sin ella, avanzar y desplazarse a la vez da un vector de módulo raíz de dos y el jugador se mueve un cuarenta por ciento más rápido en diagonal, que es un bug clásico y explotable en cualquier juego. Y el decaimiento con Math.exp(-k * delta) es la forma correcta de un rozamiento independiente del framerate: multiplicar por una constante fija por frame, como hace el damping de OrbitControls, produce comportamientos distintos en cada monitor.

TrackballControls: girar sin arriba

TrackballControls es el único de la familia que no mantiene el vector up. La metáfora es una bola de cristal que envuelve la escena: arrastras sobre ella y la escena gira siguiendo tu dedo, en cualquier dirección, incluido el alabeo. Puedes acabar con la escena boca abajo y eso es intencionado.

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

const controls = new TrackballControls( camera, renderer.domElement );
controls.rotateSpeed = 1.0;
controls.zoomSpeed = 1.2;
controls.panSpeed = 0.3;
controls.staticMoving = false;          // con inercia
controls.dynamicDampingFactor = 0.2;    // cuánta inercia
controls.minDistance = 2;
controls.maxDistance = 50;

staticMoving es el interruptor de la inercia, invertido respecto a OrbitControls: false significa con inercia, true significa que el movimiento se detiene al soltar. Y como en OrbitControls, la inercia obliga a llamar a update() en el bucle, aquí sin argumentos:

renderer.setAnimationLoop( () => {
  controls.update();   // sin delta, a diferencia de FlyControls
  renderer.render( scene, camera );
} );

La propiedad keys es un array de tres códigos, no un objeto como en OrbitControls. Los índices corresponden a rotar, hacer zoom y panear, y mantener pulsada esa tecla fuerza que cualquier botón del ratón haga esa acción:

controls.keys = [ 'KeyA', 'KeyS', 'KeyD' ];   // rotar, zoom, panear

handleResize y los detalles que muerden

TrackballControls calcula la rotación a partir de la posición del puntero relativa al elemento, y para eso necesita conocer su rectángulo. Lo guarda en una propiedad screen que se rellena en handleResize(). El constructor lo llama una vez si le pasas el elemento DOM, y a partir de ahí es responsabilidad tuya:

addEventListener( 'resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize( innerWidth, innerHeight );
  controls.handleResize();   // sin esto, la rotación se descentra al redimensionar
} );

Si se te olvida, el síntoma es sutil y desquiciante: el control funciona bien al cargar y se vuelve cada vez más raro conforme la ventana cambia de tamaño, porque la bola virtual sigue centrada donde estaba el elemento antes.

Hay un tercer detalle, este un bug del propio addon en r184 que conviene conocer si lo usas con cámara ortográfica: en el cálculo del paneo, el factor vertical divide por la anchura del elemento en lugar de por la altura. En un lienzo cuadrado no se nota; en uno panorámico, el paneo vertical va desproporcionado respecto al horizontal. Si te topas con ello, la salida es escalar panSpeed a mano o usar MapControls para ese caso.

Sobre cuál elegir: TrackballControls es el mejor control que existe para inspeccionar un objeto sin orientación canónica —una molécula, una pieza mecánica, un escaneo, cualquier cosa que no tenga arriba ni abajo—, porque llegar a un ángulo arbitrario cuesta un solo gesto en lugar de dos. Para todo lo que sí tenga vertical, es peor que OrbitControls, porque el usuario acabará con el horizonte torcido y sin manera intuitiva de enderezarlo. El método reset() existe precisamente por eso, y un visor con TrackballControls sin botón de reinicio está incompleto.

El bloqueo del puntero cambia el modelo de entrada, no solo el cursor

Lo que hace la Pointer Lock API no es esconder el cursor: es cambiar de un modelo de entrada posicional a uno relativo, y esa diferencia tiene consecuencias que van mucho más allá del 3D. Con puntero normal, el navegador te entrega una posición absoluta ya procesada: aceleración del sistema aplicada, límites de pantalla respetados, coalescencia de eventos hecha. Con el puntero bloqueado recibes desplazamientos crudos, y de repente eres tú quien decide la curva de sensibilidad, la respuesta a los saltos y qué hacer cuando llegan tres eventos en el mismo frame. Es una responsabilidad que casi nadie asume: la práctica habitual es multiplicar el desplazamiento por una constante y ya está, y por eso tantos juegos en la web se sienten peor que sus equivalentes nativos aunque rendericen igual. Los dos detalles que marcan la diferencia son estos. Primero, acumular los eventos del frame en lugar de aplicar cada uno: un ratón de mil hercios entrega dieciséis eventos por frame a sesenta, y aplicarlos uno a uno a la rotación introduce un jitter que se percibe aunque el resultado matemático sea el mismo. Segundo, filtrar los saltos: cuando el sistema operativo se atasca o el usuario levanta y reposa el ratón, llega un desplazamiento enorme en un solo evento, y aplicarlo hace que la cámara pegue un latigazo. Descartar cualquier evento cuyo desplazamiento supere un umbral razonable —unos cientos de píxeles— cuesta una línea y elimina un artefacto que la gente atribuye a “que la web va mal”. PointerLockControls no hace ninguna de las dos cosas, porque es deliberadamente mínimo. Es una base, no una solución.

⚔️ Un primera persona que se sienta bien
  1. Monta el bloqueo de puntero con pantalla de inicio y comprueba que escape devuelve la interfaz a un estado usable.
  2. Lee isLocked dentro del escuchador de lock y verifica que vale false.
  3. Implementa el movimiento con velocidad y rozamiento, y compáralo con sumar directamente a la posición.
  4. Quita la normalización de la diagonal y mide cuánto más rápido va el jugador en diagonal.
  5. Monta TrackballControls, olvida handleResize() a propósito y describe cómo se degrada al redimensionar.