Transiciones entre cámaras
Cómo se pasa de una cámara a otra sin cortar: interpolar posición y orientación con slerp, mezclar parámetros de proyección, cuándo un corte es mejor que una transición, y cómo gestionar varias cámaras en una escena.
Una cámara de render y varias de referencia
El patrón que evita casi todos los problemas: la escena tiene una única cámara que renderiza, y todas las demás son objetos vacíos que marcan encuadres. Nunca cambias qué cámara pasas a render(); cambias hacia dónde interpola la única que existe.
// La unica que dibuja.const camara = new THREE.PerspectiveCamera( 50, aspecto, 0.1, 200 );scene.add( camara );// Encuadres de referencia: Object3D vacios con posicion y orientacion.const encuadres = new Map();function definirEncuadre( nombre, posicion, mira, fov = 50 ) { const nodo = new THREE.Object3D(); nodo.position.copy( posicion ); nodo.lookAt( mira ); nodo.userData.fov = fov; scene.add( nodo ); encuadres.set( nombre, nodo ); return nodo;}definirEncuadre( 'general', new THREE.Vector3( 0, 8, 14 ), new THREE.Vector3( 0, 1, 0 ), 50 );definirEncuadre( 'detalle', new THREE.Vector3( 1.5, 1.2, 2 ), new THREE.Vector3( 0, 1, 0 ), 32 );definirEncuadre( 'cenital', new THREE.Vector3( 0, 20, 0.01 ), new THREE.Vector3( 0, 0, 0 ), 60 );Las ventajas son varias. No hay que reconfigurar aspect en varias cámaras al redimensionar. No hay que preocuparse de qué cámara está activa cuando algo consulta la posición del observador. Y los encuadres pueden ser hijos de otros objetos, moverse con ellos, o venir importados de un glTF exportado desde Blender, donde las cámaras se convierten en PerspectiveCamera que puedes usar directamente como referencia.
El 0.01 en la Z del encuadre cenital no es un descuido: una cámara exactamente encima del objetivo mirando hacia abajo tiene el vector de mirada paralelo al vector up, y lookAt produce una orientación degenerada. Desplazar un milímetro lo evita.
La transición
Interpolar entre dos transformaciones son dos operaciones: lerp para la posición y slerp para la orientación.
const posInicio = new THREE.Vector3();const rotInicio = new THREE.Quaternion();let fovInicio = 50;const objetivo = { nodo: null, duracion: 1, t: 1, curva: suavizarEntradaSalida };function irA( nombre, duracion = 1.2 ) { const nodo = encuadres.get( nombre ); if ( ! nodo ) return; // Congelar el estado actual como punto de partida. camara.getWorldPosition( posInicio ); camara.getWorldQuaternion( rotInicio ); fovInicio = camara.fov; objetivo.nodo = nodo; objetivo.duracion = duracion; objetivo.t = 0;}function actualizarTransicion( dt ) { if ( objetivo.t >= 1 || objetivo.nodo === null ) return; objetivo.t = Math.min( 1, objetivo.t + dt / objetivo.duracion ); const k = objetivo.curva( objetivo.t ); const destinoPos = new THREE.Vector3(); const destinoRot = new THREE.Quaternion(); objetivo.nodo.getWorldPosition( destinoPos ); objetivo.nodo.getWorldQuaternion( destinoRot ); camara.position.lerpVectors( posInicio, destinoPos, k ); camara.quaternion.slerpQuaternions( rotInicio, destinoRot, k ); const fovDestino = objetivo.nodo.userData.fov ?? camara.fov; if ( camara.fov !== fovDestino ) { camara.fov = THREE.MathUtils.lerp( fovInicio, fovDestino, k ); camara.updateProjectionMatrix(); }}function suavizarEntradaSalida( t ) { return t * t * ( 3 - 2 * t ); // smoothstep}Tres cosas que hay que tener presentes.
Se usa getWorldPosition y getWorldQuaternion, no position y quaternion. Si los encuadres cuelgan de objetos transformados, sus valores locales no sirven. Con la cámara colgando directamente de la escena, la interpolación en mundo es la correcta.
updateProjectionMatrix es obligatorio tras tocar fov, near, far, aspect o zoom. Cambiar la propiedad sin llamarlo no hace nada: la matriz de proyección está cacheada y solo se recompone en esa llamada. Es el fallo silencioso más común al animar campo de visión. En cambio no hace falta llamarlo tras mover la cámara: la matriz de vista se recalcula sola durante el render.
La curva importa. Una interpolación lineal en t produce una transición que arranca y frena de golpe, muy reconocible como programática. Un smoothstep da aceleración y deceleración suaves. Para transiciones largas, una curva con más frenada al final —t elevado a cierta potencia invertida— comunica llegada.
Interpolar la proyección, no solo la posición
El campo de visión es un parámetro perceptualmente potente y no lineal. Pasar de 50 a 25 grados no es “la mitad de campo”: es aproximadamente el doble de aumento, porque lo que escala con el fov es la tangente de su mitad.
// Interpolacion lineal del angulo: la sensacion acelera al final.camara.fov = THREE.MathUtils.lerp( 50, 25, k );// Interpolacion del aumento: perceptualmente uniforme.const tan0 = Math.tan( THREE.MathUtils.degToRad( 50 ) / 2 );const tan1 = Math.tan( THREE.MathUtils.degToRad( 25 ) / 2 );const tan = THREE.MathUtils.lerp( tan0, tan1, k );camara.fov = THREE.MathUtils.radToDeg( 2 * Math.atan( tan ) );camara.updateProjectionMatrix();Para cambios pequeños las dos son indistinguibles. Para un zoom fuerte, la segunda se siente uniforme y la primera parece que se para y luego se dispara.
El efecto dolly zoom —el vértigo de Hitchcock— sale de combinar las dos cosas en sentidos opuestos: acercar la cámara mientras se abre el campo de visión, manteniendo constante el tamaño aparente del sujeto. La condición que hay que preservar es que distancia * tan( fov / 2 ) no cambie:
const constante = distanciaInicial * Math.tan( THREE.MathUtils.degToRad( fovInicial ) / 2 );function dollyZoom( distancia ) { camara.fov = THREE.MathUtils.radToDeg( 2 * Math.atan( constante / distancia ) ); camara.updateProjectionMatrix(); // Colocar la camara a esa distancia del sujeto, en la misma direccion. camara.position.copy( sujeto.position ).addScaledVector( direccion, distancia );}near y far también se pueden interpolar, y a veces hay que hacerlo: un encuadre de detalle necesita un near pequeño y uno panorámico necesita un far grande. Pero cuidado con la precisión del buffer de profundidad, que depende del cociente entre los dos: pasar de un rango de 0,1 a 200 a uno de 0,01 a 2000 multiplica por cien la relación y puede hacer aparecer z-fighting a mitad de la transición.
Cuándo no interpolar
La transición suave no siempre es mejor. El lenguaje audiovisual lleva un siglo resolviendo esto y sus reglas se aplican tal cual.
El corte es la transición por defecto en cine. Un cambio de plano instantáneo es invisible cuando la nueva composición es clara, y comunica ritmo. Una transición suave entre dos puntos de vista muy distintos —un plano general y un primer plano— produce un movimiento de cámara que en rodaje real sería imposible, y por tanto se lee como artificio.
La transición suave sirve para preservar la continuidad espacial. Cuando quieres que el espectador entienda que el sitio es el mismo y solo ha cambiado el punto de vista, mover la cámara lo comunica. Es lo correcto en un configurador de producto, donde el usuario tiene que mantener el modelo mental del objeto.
La regla de los treinta grados. Si dos encuadres difieren en menos de treinta grados de ángulo respecto al sujeto, cortar entre ellos produce un salto desagradable —un jump cut— porque la imagen es casi la misma pero no del todo. En ese caso o mueves más la cámara o haces la transición suave.
Una heurística implementable a partir de eso:
function transicionAutomatica( nombre ) { const nodo = encuadres.get( nombre ); const dirActual = new THREE.Vector3( 0, 0, - 1 ).applyQuaternion( camara.quaternion ); const dirNueva = new THREE.Vector3( 0, 0, - 1 ).applyQuaternion( nodo.quaternion ); const angulo = THREE.MathUtils.radToDeg( dirActual.angleTo( dirNueva ) ); const distancia = camara.position.distanceTo( nodo.position ); if ( angulo > 60 && distancia > 15 ) { cortarA( nombre ); // muy distinto: mejor cortar } else { irA( nombre, 0.9 ); // relacionado: transicion }}