wandres.dev
CÁMARAS Y CINEMÁTICA · Movimiento con intención

Seguimiento suave: la cámara que persigue

Cómo se construye una cámara que sigue a un objeto con retardo intencionado, dónde va el punto de mira, por qué el offset debe vivir en el espacio del objetivo, y cómo evitar el jitter.

⏱ 17 min

Una cámara pegada rígidamente a un objeto produce el peor movimiento posible: cada temblor del objetivo se transmite a la pantalla amplificado, y el jugador pierde toda referencia del mundo. La cámara buena llega tarde a propósito. Ese retardo —unas décimas de segundo entre lo que hace el objetivo y lo que hace la cámara— es lo que comunica peso, velocidad y dirección, y montarlo bien requiere separar tres cosas que se suelen mezclar: dónde está la cámara, hacia dónde mira, y con qué inercia se mueve cada una.

🎯 Al terminar esta lección sabrás
  • Separar la posición deseada del punto de mira y suavizar cada uno por separado.
  • Colocar el offset en el espacio local del objetivo cuando la cámara debe girar con él.
  • Adelantar el punto de mira con la velocidad para que la cámara anticipe.
  • Diagnosticar el jitter que produce actualizar la cámara en el orden equivocado.

Tres piezas, no una

Una cámara de seguimiento se describe con tres valores independientes: la posición deseada, el punto de mira, y el suavizado de cada uno. Mezclarlas en una sola operación es lo que produce cámaras que se sienten mal sin que sepas por qué.

const objetivo = new THREE.Object3D();        // el coche, el personaje
const offset = new THREE.Vector3( 0, 3, - 8 ); // detras y arriba

const posDeseada = new THREE.Vector3();
const miraDeseada = new THREE.Vector3();
const miraActual = new THREE.Vector3();

function actualizarCamara( dt ) {

	// 1. Donde deberia estar la camara.
	posDeseada.copy( objetivo.position ).add( offset );

	// 2. Hacia donde deberia mirar: un poco por encima del objetivo.
	miraDeseada.copy( objetivo.position ).add( new THREE.Vector3( 0, 1.2, 0 ) );

	// 3. Suavizar cada una con su propia constante.
	suavizar( camera.position, posDeseada, 4, dt );
	suavizar( miraActual, miraDeseada, 8, dt );

	camera.lookAt( miraActual );

}

Que el punto de mira se suavice por separado y más rápido que la posición es la decisión que más cambia la sensación. Con la mira rígida sobre el objetivo, cada vez que la cámara se queda atrás la rotación compensa bruscamente. Con la mira suavizada a su propio ritmo, la cámara gira con una inercia distinta a la de su traslación, y eso es exactamente lo que hace una cámara de mano real.

Nota la constante: la posición usa 4 y la mira usa 8, es decir la mira converge el doble de rápido. Esa relación de dos a uno es un buen punto de partida para casi cualquier cámara de tercera persona.

La función suavizar es la pieza que hay que escribir bien; su forma correcta y por qué la ingenua está mal es el tema de la lección siguiente. De momento, la versión correcta:

import { MathUtils } from 'three';

function suavizar( vector, destino, lambda, dt ) {

	vector.x = MathUtils.damp( vector.x, destino.x, lambda, dt );
	vector.y = MathUtils.damp( vector.y, destino.y, lambda, dt );
	vector.z = MathUtils.damp( vector.z, destino.z, lambda, dt );

}

Offset local frente a offset de mundo

El ejemplo anterior suma el offset en coordenadas de mundo: la cámara siempre está a ocho unidades en el eje Z negativo global, mire donde mire el objetivo. Eso es lo correcto para una cámara isométrica o para una cámara de plataformas laterales, donde el ángulo de visión es una propiedad del juego y no del personaje.

Para una cámara que va detrás de un vehículo, en cambio, el offset tiene que girar con el objetivo:

const offsetLocal = new THREE.Vector3( 0, 3, - 8 );
const posDeseada = new THREE.Vector3();

function actualizarCamara( dt ) {

	posDeseada.copy( offsetLocal )
		.applyQuaternion( objetivo.quaternion )
		.add( objetivo.position );

	suavizar( camera.position, posDeseada, 4, dt );

	miraDeseada.copy( objetivo.position );
	suavizar( miraActual, miraDeseada, 8, dt );

	camera.lookAt( miraActual );

}

applyQuaternion rota el offset por la orientación del objetivo antes de sumarlo. Ahora la cámara se coloca siempre detrás en el sentido del vehículo.

Esa versión tiene un problema conocido: si el objetivo puede volcar o dar una vuelta completa, la cámara vuelca con él y el jugador se marea. La solución habitual es usar solo la componente de guiñada:

const soloYaw = new THREE.Quaternion();
const euler = new THREE.Euler();

function offsetConGiro( objetivo, offsetLocal, salida ) {

	euler.setFromQuaternion( objetivo.quaternion, 'YXZ' );
	soloYaw.setFromEuler( new THREE.Euler( 0, euler.y, 0, 'YXZ' ) );

	return salida.copy( offsetLocal ).applyQuaternion( soloYaw );

}

El orden 'YXZ' importa: pone la guiñada como primera rotación, así que euler.y contiene el giro alrededor del eje vertical de forma limpia incluso cuando hay cabeceo y alabeo. Con el orden por defecto 'XYZ' esa componente estaría contaminada.

💡
Alternativa sin Euler

Si prefieres evitar los ángulos de Euler por completo, puedes proyectar el vector de avance del objetivo sobre el plano horizontal y construir el offset con trigonometría: const avance = new THREE.Vector3( 0, 0, 1 ).applyQuaternion( objetivo.quaternion ); avance.y = 0; avance.normalize();. Es más código pero no tiene ningún caso degenerado salvo el objetivo mirando exactamente hacia arriba.

Anticipar con la velocidad

Una cámara que solo reacciona siempre va por detrás de la acción. Una cámara que anticipa desplaza el punto de mira en el sentido del movimiento, de forma que el jugador ve hacia dónde va antes de llegar.

const velocidad = new THREE.Vector3();
const anticipacion = new THREE.Vector3();

function actualizarCamara( dt ) {

	// La velocidad puede venir del motor de fisica o calcularse por diferencias.
	velocidad.copy( objetivo.position ).sub( posicionAnterior ).divideScalar( dt );
	posicionAnterior.copy( objetivo.position );

	// Mirar por delante, proporcional a la velocidad, con un tope.
	anticipacion.copy( velocidad ).multiplyScalar( 0.35 ).clampLength( 0, 6 );

	miraDeseada.copy( objetivo.position ).add( anticipacion ).add( alturaMira );

	suavizar( miraActual, miraDeseada, 6, dt );
	camera.lookAt( miraActual );

}

clampLength( 0, 6 ) es imprescindible: sin tope, un objeto que acelera mucho lanza el punto de mira al infinito y la cámara se descontrola. El factor de 0,35 segundos es un tiempo de anticipación: la cámara mira donde estará el objetivo dentro de un tercio de segundo.

La misma idea se aplica a la distancia: alejar ligeramente la cámara cuando la velocidad sube da sensación de aceleración sin tocar el campo de visión.

const distancia = 8 + Math.min( velocidad.length() * 0.15, 4 );
offsetLocal.set( 0, 3, - distancia );

El orden de actualización y el jitter

Un fallo que produce un temblor de un píxel, imposible de diagnosticar si no sabes dónde mirar: actualizar la cámara antes que el objetivo.

Si tu objetivo lo mueve la física y tu cámara lee objetivo.position, el orden dentro del frame tiene que ser: simular física, sincronizar mallas, actualizar cámara, renderizar. Si la cámara se actualiza antes de la sincronización, está leyendo la posición del frame anterior y persiguiendo un fantasma con un frame de retraso variable.

function bucle() {

	// 1. Fisica en pasos fijos.
	simularConAcumulador();

	// 2. Copiar transformaciones interpoladas a las mallas.
	sincronizarEscena( alfa );

	// 3. AHORA la camara, con las posiciones ya definitivas.
	actualizarCamara( deltaRender );

	// 4. Dibujar.
	renderer.render( scene, camera );

}

El segundo caso de jitter es más sutil: si la cámara es hija del objetivo en el grafo de escena, Three.js compone su matriz de mundo a partir de la del padre, y cualquier suavizado que apliques a la posición local se suma al movimiento del padre en lugar de sustituirlo. Una cámara de seguimiento debe colgar de la escena raíz, no del objetivo. La única excepción razonable es una cámara en primera persona rígidamente pegada a una cabeza, donde justamente no quieres suavizado.

El tercer caso aparece cuando el objetivo se mueve con la física interpolada. La interpolación introduce una pequeña variación de velocidad frame a frame, y si la cámara persigue esa posición con una constante de amortiguamiento muy alta, reproduce fielmente el ruido. La solución no es bajar la constante hasta que la cámara se sienta pastosa, sino perseguir la posición del cuerpo físico —que es estable a paso fijo— en lugar de la de la malla interpolada.

Toda cámara de seguimiento acaba siendo un sistema de restricciones

El código de arriba funciona y es donde para el 90 % de los tutoriales. Lo que descubres al usarlo en una escena real es que un suavizado hacia un punto deseado no basta, porque hay cosas que la cámara no puede hacer aunque el suavizado lo pida: no puede atravesar una pared, no puede meterse en el suelo, no puede alejarse tanto que el objetivo salga de plano, no puede pasar por debajo de un techo bajo. Y en cuanto añades una de esas reglas ad hoc, entras en una espiral: cada regla nueva interfiere con las anteriores, y acabas con un if de veinte ramas donde cambiar una cosa rompe otra. La forma en que la industria resolvió esto —y es la arquitectura de los sistemas de cámara de los motores comerciales— es dejar de pensar en “la posición deseada” y empezar a pensar en una lista ordenada de restricciones que se aplican en cadena. Cada restricción recibe la posición que le pasa la anterior y devuelve una corregida: primero el seguimiento propone un punto, después la restricción de colisión lanza un rayo desde el objetivo hacia ese punto y lo acerca si hay pared por medio, después la de altura mínima lo sube si está bajo el suelo, después la de encuadre comprueba que el objetivo sigue dentro de un margen de la pantalla, y finalmente el amortiguamiento suaviza el resultado de todo lo anterior. La clave está en el orden: el suavizado va al final, no al principio, porque si suavizas antes de corregir, las correcciones producen saltos duros; y si corriges antes de suavizar, el suavizado absorbe las correcciones y todo se siente continuo. Ese detalle de ordenación es la diferencia entre una cámara que atraviesa paredes un instante antes de corregirse —el artefacto que reconoces al momento en un juego mal pulido— y una que simplemente se acerca cuando hay un obstáculo. Si tu escena tiene geometría, monta la cadena desde el principio aunque solo tenga dos eslabones: añadir el tercero después es trivial, y refactorizar un if de veinte ramas no lo es.

⚔️ Una cámara que se sienta bien
  1. Monta un seguimiento con offset de mundo y compáralo con el mismo offset aplicado en local.
  2. Añade la separación entre suavizado de posición y de mira, y prueba relaciones de 1:1, 1:2 y 1:4.
  3. Introduce la anticipación por velocidad y calibra el factor hasta que la curva se sienta natural.
  4. Actualiza la cámara antes de sincronizar la física y observa el jitter que aparece.
  5. Añade una restricción de colisión con un raycast desde el objetivo hacia la cámara y aplícala antes del suavizado.