wandres.dev
CONTROLES · Orbit, fly y los propios

Escribir un control de cámara desde cero

Los eventos de puntero que hay que manejar bien, el modelo en coordenadas esféricas, y un control de órbita completo en menos de cien líneas con amortiguación independiente del framerate.

⏱ 22 min

Escribir tu propio control no es un ejercicio: es la única forma de entender qué hacen los de la librería y, sobre todo, qué no hacen. Un control de órbita completo con inercia, pellizco para el zoom y liberación limpia de recursos cabe en menos de cien líneas, y en el camino se aprenden tres cosas que se aplican a cualquier interacción en la web: cómo funciona la captura de puntero, por qué se normaliza por la altura y no por la anchura, y por qué la amortiguación de OrbitControls depende de la frecuencia de refresco cuando no tendría por qué.

🎯 Al terminar esta lección sabrás
  • Manejar correctamente los eventos de puntero, incluida la captura y el gesto de dos dedos.
  • Modelar la órbita en coordenadas esféricas usando Spherical y sus conversiones.
  • Implementar una amortiguación exponencial que no dependa del framerate.
  • Liberar todos los escuchadores y restaurar el estilo del elemento al destruir el control.

Eventos de puntero: lo que hay que hacer bien

Los eventos de puntero unifican ratón, dedo y lápiz en una sola API, así que no hace falta escribir manejadores separados. Lo que sí hace falta es respetar cuatro detalles que casi todo el mundo se salta.

Captura de puntero. Si solo escuchas pointermove en el elemento, el arrastre se corta en cuanto el cursor sale del lienzo. La solución no es escuchar en el documento —que trae sus propios problemas con iframes y otros manejadores— sino llamar a setPointerCapture( pointerId ) en pointerdown: a partir de ahí, todos los eventos de ese puntero se dirigen a tu elemento aunque el cursor esté en la otra punta de la pantalla, y pointerup llega siempre.

touch-action. Sin esto, el navegador interpreta el arrastre como desplazamiento de página y tus eventos dejan de llegar a mitad del gesto. Se pone en el elemento y se quita al destruir el control:

elemento.style.touchAction = 'none';

pointercancel. El navegador puede cancelar un puntero por su cuenta: una llamada entrante, un gesto del sistema, el ratón que sale de la ventana en algunos navegadores. Si solo escuchas pointerup, el estado se queda pensando que el botón sigue pulsado y el control se vuelve loco. Trátalo exactamente igual que pointerup.

El menú contextual y la rueda. El botón derecho abre el menú contextual y hay que cancelarlo. Y wheel tiene que registrarse con { passive: false } explícitamente, porque los navegadores lo tratan como pasivo por defecto en elementos de desplazamiento y sin eso preventDefault() no funciona y la página se desplaza mientras haces zoom.

Un detalle sobre la rueda que se pasa por alto: event.deltaMode puede valer cero (píxeles), uno (líneas) o dos (páginas), y el mismo gesto entrega números de magnitudes muy distintas según el navegador y el dispositivo. OrbitControls lo normaliza multiplicando por dieciséis y por cien respectivamente, y conviene copiar ese apaño.

Coordenadas esféricas

Podrías implementar la órbita con cuaterniones, y sería más general. Para una órbita con vertical privilegiada, las coordenadas esféricas son más simples y directamente representan las restricciones que quieres imponer. Spherical de Three.js tiene tres campos y las conversiones en ambos sentidos:

new THREE.Spherical( radius = 1, phi = 0, theta = 0 )

radius es la distancia al objetivo, phi es el ángulo polar medido desde el eje vertical —cero mirando desde arriba, Math.PI desde abajo— y theta es el acimut. Las conversiones son estas y merece la pena conocerlas por dentro:

// Cartesianas a esféricas: Spherical.setFromCartesianCoords
radius = Math.sqrt( x * x + y * y + z * z );
theta  = Math.atan2( x, z );                 // ojo: x sobre z, no z sobre x
phi    = Math.acos( clamp( y / radius, - 1, 1 ) );

// Esféricas a cartesianas: Vector3.setFromSphericalCoords
const sinPhiRadius = Math.sin( phi ) * radius;
x = sinPhiRadius * Math.sin( theta );
y = Math.cos( phi ) * radius;
z = sinPhiRadius * Math.cos( theta );

Ese atan2(x, z) en lugar del habitual atan2(z, x) es una convención de Three.js: hace que theta valga cero cuando la cámara está sobre el eje Z positivo, que es donde está por defecto.

Y el método que salva la implementación es makeSafe(), cuyo cuerpo entero es este:

makeSafe() {
  const EPS = 0.000001;
  this.phi = clamp( this.phi, EPS, Math.PI - EPS );
  return this;
}

Acota el ángulo polar para que nunca alcance exactamente el polo. Sin él, cuando phi llega a cero el vector desde el objetivo a la cámara es paralelo a up, y lookAt no tiene forma de decidir el giro alrededor del eje de vista: la cámara pega un tirón de orientación aleatoria. Es la manifestación concreta del gimbal lock en un control de órbita, y se resuelve con un epsilon.

El control completo

Con eso ya está todo. La única decisión de diseño que merece explicación es que este control mantiene dos estados esféricos: un destino, que la entrada modifica y sobre el que se aplican los límites, y un actual, que se acerca al destino exponencialmente. Es un modelo distinto del de OrbitControls, que acumula un delta pendiente y lo decae, y es mejor por dos razones: los límites son exactos, porque nunca se sobrepasan ni siquiera de forma transitoria, y la amortiguación es trivialmente independiente del framerate.

import * as THREE from 'three';

export class OrbitaMinima {

  constructor( camera, elemento ) {
    this.camera = camera;
    this.elemento = elemento;
    this.target = new THREE.Vector3();

    this.vueltasPorPantalla = 1;    // arrastrar el alto del lienzo equivale a una vuelta
    this.amortiguacion = 12;        // inverso de segundos: mayor, más rápido llega
    this.phiMin = 0.05;
    this.phiMax = Math.PI - 0.05;
    this.radioMin = 1;
    this.radioMax = 100;

    this._destino = new THREE.Spherical();
    this._actual = new THREE.Spherical();
    this._v = new THREE.Vector3();
    this._punteros = new Map();
    this._pellizcoPrevio = 0;

    this._v.copy( camera.position ).sub( this.target );
    this._destino.setFromVector3( this._v );
    this._actual.copy( this._destino );

    this._down = this._onDown.bind( this );
    this._move = this._onMove.bind( this );
    this._up = this._onUp.bind( this );
    this._wheel = this._onWheel.bind( this );
    this._menu = ( e ) => e.preventDefault();

    elemento.style.touchAction = 'none';
    elemento.addEventListener( 'pointerdown', this._down );
    elemento.addEventListener( 'pointermove', this._move );
    elemento.addEventListener( 'pointerup', this._up );
    elemento.addEventListener( 'pointercancel', this._up );
    elemento.addEventListener( 'wheel', this._wheel, { passive: false } );
    elemento.addEventListener( 'contextmenu', this._menu );
  }

  _onDown( e ) {
    this.elemento.setPointerCapture( e.pointerId );
    this._punteros.set( e.pointerId, new THREE.Vector2( e.clientX, e.clientY ) );
    if ( this._punteros.size === 2 ) this._pellizcoPrevio = this._distancia();
  }

  _onMove( e ) {
    const previo = this._punteros.get( e.pointerId );
    if ( previo === undefined ) return;

    const dx = e.clientX - previo.x;
    const dy = e.clientY - previo.y;
    previo.set( e.clientX, e.clientY );

    if ( this._punteros.size === 1 ) {
      const alto = this.elemento.clientHeight;
      const k = 2 * Math.PI * this.vueltasPorPantalla / alto;
      this._destino.theta -= dx * k;
      this._destino.phi -= dy * k;
      this._limitar();
    } else if ( this._punteros.size === 2 ) {
      const d = this._distancia();
      if ( this._pellizcoPrevio > 0 && d > 0 ) this._zoom( this._pellizcoPrevio / d );
      this._pellizcoPrevio = d;
    }
  }

  _onUp( e ) {
    if ( this.elemento.hasPointerCapture( e.pointerId ) ) {
      this.elemento.releasePointerCapture( e.pointerId );
    }
    this._punteros.delete( e.pointerId );
    this._pellizcoPrevio = 0;
  }

  _onWheel( e ) {
    e.preventDefault();
    let dy = e.deltaY;
    if ( e.deltaMode === 1 ) dy *= 16;        // líneas
    else if ( e.deltaMode === 2 ) dy *= 100;  // páginas
    this._zoom( Math.pow( 1.0015, dy ) );
  }

  _zoom( factor ) {
    this._destino.radius *= factor;
    this._limitar();
  }

  _distancia() {
    const [ a, b ] = [ ...this._punteros.values() ];
    return a.distanceTo( b );
  }

  _limitar() {
    this._destino.phi = THREE.MathUtils.clamp( this._destino.phi, this.phiMin, this.phiMax );
    this._destino.radius = THREE.MathUtils.clamp( this._destino.radius, this.radioMin, this.radioMax );
    this._destino.makeSafe();
  }

  update( delta ) {
    const k = 1 - Math.exp( - this.amortiguacion * delta );

    this._actual.theta += ( this._destino.theta - this._actual.theta ) * k;
    this._actual.phi += ( this._destino.phi - this._actual.phi ) * k;
    this._actual.radius += ( this._destino.radius - this._actual.radius ) * k;
    this._actual.makeSafe();

    this._v.setFromSpherical( this._actual );
    this.camera.position.copy( this.target ).add( this._v );
    this.camera.lookAt( this.target );
  }

  dispose() {
    const el = this.elemento;
    el.removeEventListener( 'pointerdown', this._down );
    el.removeEventListener( 'pointermove', this._move );
    el.removeEventListener( 'pointerup', this._up );
    el.removeEventListener( 'pointercancel', this._up );
    el.removeEventListener( 'wheel', this._wheel );
    el.removeEventListener( 'contextmenu', this._menu );
    el.style.touchAction = '';
    this._punteros.clear();
  }

}

Y su uso:

import * as THREE from 'three';

const controls = new OrbitaMinima( camera, renderer.domElement );
const reloj = new THREE.Timer();
reloj.connect( document );

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

Dos decisiones del código merecen justificación explícita.

Los dos ejes se normalizan por la altura. Es lo mismo que hace OrbitControls, y el comentario en su código fuente es literalmente “sí, altura”. La razón es que si normalizaras el eje horizontal por la anchura, la velocidad angular por píxel dependería de la relación de aspecto: el mismo arrastre giraría más en una ventana estrecha que en una ancha. Normalizando ambos por la misma dimensión, el gesto se siente idéntico en cualquier ventana y además el movimiento diagonal es consistente.

La amortiguación usa 1 - Math.exp(-k * delta). Esa expresión es la fracción del camino restante que hay que recorrer en un intervalo delta para que el decaimiento exponencial sea el mismo independientemente de cómo se trocee el tiempo. Con ella, la cámara tarda exactamente lo mismo en asentarse a 30, a 60 o a 240 fotogramas por segundo. OrbitControls no lo hace, y por eso su inercia se siente distinta en cada monitor.

Lo que la librería hace y esto no

Merece la pena inventariar la distancia, porque es la respuesta honesta a “entonces para qué existe el addon”.

Este control no tiene paneo. Añadirlo son unas quince líneas más: mover target en el plano de la pantalla usando las columnas de la matriz de la cámara, con el mismo factor de escala que usa OrbitControls, que depende de la distancia al objetivo y de la tangente de medio FOV. No tiene soporte para cámara ortográfica, donde el zoom tiene que cambiar camera.zoom en lugar del radio. No tiene teclado, ni saveState/reset, ni límites acimutales con la lógica de envoltura del intervalo, ni zoomToCursor, que requiere desproyectar el cursor y desplazar la cámara a lo largo de ese rayo. No emite eventos, que son necesarios para el render bajo demanda. Y no distingue botones del ratón.

Esa lista es exactamente el valor que aporta el addon, y también la razón de que su código fuente ocupe más de mil líneas. Pero ahora ya sabes lo que hay dentro, y eso cambia dos cosas: puedes leer su fuente cuando algo no se comporta como esperas, y puedes decidir con criterio cuándo tu caso es lo bastante particular como para que salga más barato escribir cien líneas propias que pelearse con la configuración de las mil ajenas.

Todo control de cámara es un filtro sobre una señal de entrada, y ahí está el diseño

Si te quedas con una idea de esta lección, que sea esta: un control de cámara no es un traductor de gestos a transformaciones, es un filtro que convierte una señal de entrada ruidosa, discreta e irregular en una trayectoria continua. Verlo así reorganiza todas las decisiones de diseño en un solo eje. Sin filtro —aplicar cada evento directamente a la cámara— tienes latencia cero y ruido máximo, que es lo correcto para apuntar con precisión en un juego. Con un filtro de primer orden como el de esta lección, ganas suavidad a cambio de un retardo proporcional a la constante de tiempo, que es lo correcto para navegación e inspección. Y hay un tercer régimen que casi nadie explora en la web y que es lo que usan los motores de cine: un filtro de segundo orden, un muelle amortiguado con masa, que además de suavizar añade sobreimpulso y conserva inercia real. Es lo que hace que una cámara se sienta pesada de una forma que ningún decaimiento exponencial consigue, porque el decaimiento exponencial nunca sobrepasa el objetivo y una masa real sí. Implementarlo son tres líneas más que las que ya tienes: guarda una velocidad, acelera hacia el destino con una constante de muelle, aplica un rozamiento y integra. La razón de que casi nadie lo haga no es técnica, es que hay que ajustar dos parámetros en lugar de uno y la intuición no ayuda hasta que entiendes que uno es la frecuencia natural y el otro el factor de amortiguamiento. En cuanto lo entiendes, empiezas a reconocer qué visores usan cuál con solo arrastrar el ratón.

⚔️ Complétalo
  1. Añade el paneo con el botón derecho, moviendo target en el plano de la pantalla.
  2. Añade soporte para cámara ortográfica cambiando camera.zoom en lugar del radio.
  3. Emite eventos change, start y end y monta el render bajo demanda con ellos.
  4. Sustituye el filtro exponencial por un muelle amortiguado y compara la sensación.
  5. Comprueba que dispose() no deja ningún escuchador: monta y destruye el control mil veces y mira la memoria.