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.
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 personajeconst offset = new THREE.Vector3( 0, 3, - 8 ); // detras y arribaconst 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.
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.