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

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.

⏱ 18 min

Cambiar de cámara parece trivial —basta con pasarle otra al render()— hasta que descubres que el salto instantáneo desorienta al espectador y que la transición suave, hecha mal, produce trayectorias imposibles: cámaras que atraviesan el suelo, que giran ciento ochenta grados por el camino largo, o que hacen un zoom que nadie pidió. Hacerlo bien es interpolar tres cosas independientes —posición, orientación y proyección— y decidir conscientemente cuándo no interpolar nada.

🎯 Al terminar esta lección sabrás
  • Interpolar entre dos cámaras usando una tercera cámara de trabajo.
  • Mezclar fov, near y far recordando llamar a updateProjectionMatrix.
  • Elegir entre corte y transición según lo que comuniquen.
  • Gestionar un conjunto de cámaras con nombre y una sola cámara de render.

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.

⚠️
El camino recto no siempre es el bueno

lerpVectors recorre la línea recta entre los dos puntos. Si el encuadre de partida está a un lado de un edificio y el de destino al otro, esa línea recta atraviesa el edificio. Para transiciones en escenas con geometría, dos opciones: interpolar en coordenadas esféricas alrededor de un punto de interés común —lo que produce un arco natural— o definir puntos intermedios y recorrer una curva con CatmullRomCurve3, que Three.js incluye y admite tensión ajustable.

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

	}

}
El slerp elige el camino corto, y a veces el corto es el que no quieres

Quaternion.slerp está implementado para tomar siempre el arco menor entre las dos orientaciones. La razón es el doble recubrimiento: cada rotación en el espacio tiene dos representaciones como cuaternión, q y su negado, y las dos describen exactamente la misma orientación. Si los cuaterniones de origen y destino están en hemisferios opuestos, la interpolación directa daría la vuelta larga —hasta 360 grados de más— así que Three.js comprueba el signo del producto escalar y niega uno de los dos si hace falta. El resultado es que un giro nunca supera los 180 grados y siempre parece razonable. Eso es lo correcto el 95 % de las veces y es exactamente lo que no quieres el 5 % restante, que además es el caso que más llama la atención: una cámara que debe rodear un objeto describiendo tres cuartos de vuelta. Le pides que gire 270 grados y hace 90 en sentido contrario. Como la orientación final es la misma, no hay nada roto que depurar; simplemente el movimiento va al revés de lo que esperabas y no hay ningún parámetro que lo cambie. La solución no es pelearse con slerp, es descomponer el recorrido: si el giro pretendido supera los 180 grados, inserta uno o más encuadres intermedios y encadena las transiciones. Con un punto a mitad de camino, cada tramo mide 135 grados y slerp toma el sentido correcto en los dos. La lección general es la misma que aparece con las curvas de Bézier y con la interpolación de colores: cuando una interpolación tiene que respetar un camino concreto y no solo unos extremos, el camino hay que expresarlo como datos, no esperar que el algoritmo lo adivine. Un slerp conoce dos orientaciones y no tiene forma de saber que querías rodear el objeto por la izquierda.

⚔️ Un recorrido de cámaras
  1. Define cuatro encuadres con nombre alrededor de un modelo y un selector en la interfaz.
  2. Implementa la transición con lerpVectors y slerpQuaternions y compárala con el corte seco.
  3. Añade interpolación de fov y comprueba qué pasa si olvidas updateProjectionMatrix.
  4. Coloca dos encuadres a 270 grados uno del otro y observa el camino que toma slerp.
  5. Inserta un encuadre intermedio y verifica que ahora la cámara rodea el objeto en el sentido pretendido.