wandres.dev
CÁMARAS · Perspectiva, ortográfica y el frustum

ArrayCamera y StereoCamera: varias vistas de una escena

Cómo renderizar varias vistas sin recorrer la escena varias veces, cómo funciona la proyección descentrada de una cámara estéreo, y el truco de capas que hace que un objeto solo lo vea un ojo.

⏱ 17 min

Casi todo lo que se renderiza dos veces se puede renderizar una vez y media. Pantalla partida, visores estéreo, minimapas, retrovisores: son casos en los que la escena es la misma y solo cambia la cámara, y llamar dos veces a render() duplica un trabajo de CPU que no hacía falta duplicar. Three.js tiene dos clases para esto, y la de estéreo además resuelve un detalle de proyección que casi nadie implementa bien a mano.

🎯 Al terminar esta lección sabrás
  • Montar una ArrayCamera con subcámaras y sus viewports en píxeles.
  • Explicar qué trabajo se ahorra frente a llamar varias veces a render().
  • Configurar una StereoCamera y entender por qué su proyección es descentrada y no rotada.
  • Usar las capas para que un objeto aparezca solo en un ojo o solo en una vista.

ArrayCamera: varias vistas, un recorrido

ArrayCamera extiende PerspectiveCamera y añade exactamente tres cosas: la bandera isArrayCamera, la bandera isMultiViewCamera —que vale false y sirve al camino de multivista— y el array cameras. No define ningún método propio.

new THREE.ArrayCamera( array = [] )

Cada subcámara del array tiene que llevar su propio viewport, un Vector4 en píxeles con la forma (x, y, ancho, alto). Ese requisito no lo impone el constructor: si se te olvida, la subcámara se dibuja sobre el lienzo entero y las vistas se pisan.

import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer( { canvas, antialias: true } );
renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) );
renderer.setSize( window.innerWidth, window.innerHeight );

const ANCHO = window.innerWidth * renderer.getPixelRatio();
const ALTO = window.innerHeight * renderer.getPixelRatio();

function subcamara( x, y, w, h, posicion ) {
  const c = new THREE.PerspectiveCamera( 45, w / h, 0.5, 200 );
  c.viewport = new THREE.Vector4( Math.floor( x ), Math.floor( y ), Math.ceil( w ), Math.ceil( h ) );
  c.position.copy( posicion );
  c.lookAt( 0, 0, 0 );
  c.updateMatrixWorld();
  return c;
}

const mitadX = ANCHO / 2;
const camaras = [
  subcamara( 0, 0, mitadX, ALTO, new THREE.Vector3( 0, 5, 20 ) ),
  subcamara( mitadX, 0, mitadX, ALTO, new THREE.Vector3( 20, 5, 0 ) )
];

const camera = new THREE.ArrayCamera( camaras );

renderer.setAnimationLoop( () => {
  renderer.render( scene, camera );   // una sola llamada, dos vistas
} );

Ojo con las unidades: el viewport va en píxeles del lienzo, no en píxeles CSS, así que hay que multiplicar por el ratio de píxeles del dispositivo. En una pantalla con ratio dos, olvidarlo dibuja las vistas en el cuarto inferior izquierdo.

Lo que se ahorra frente a dos llamadas a render() es concreto: el recorrido del grafo de escena, la actualización de matrices de mundo, la evaluación de luces y la construcción de la lista de render ocurren una sola vez. Lo que no se ahorra son las llamadas de dibujo, que se emiten una por objeto y por subcámara. Dicho de otro modo, ArrayCamera recorta el coste fijo de CPU por pasada, no el coste por objeto. Con escenas de pocos objetos la diferencia es despreciable; con escenas de miles, la construcción de la lista de render es una parte sustancial del presupuesto y ahorrarla la mitad de las veces se nota.

La consecuencia menos obvia es que el culling se hace con la cámara padre, es decir, con el ArrayCamera en sí, no con cada subcámara. Si tus vistas apuntan en direcciones muy distintas, tienes que asegurarte de que el frustum de la cámara padre las abarca a todas, o desactivar el culling. Ahí está el compromiso: para vistas próximas —los dos ojos de un visor— es casi gratis; para dos cámaras apuntando en sentidos opuestos, es probable que te salga mejor renderizar dos veces.

StereoCamera: la proyección descentrada

StereoCamera no extiende Camera: es una clase suelta que gestiona dos cámaras y no se pasa nunca a render(). Su firma no tiene parámetros y sus propiedades son cuatro:

const estereo = new THREE.StereoCamera();
estereo.aspect = 1;        // multiplica el aspecto de la cámara de entrada
estereo.eyeSep = 0.064;    // separación interpupilar en metros
// estereo.cameraL y estereo.cameraR son PerspectiveCamera ya construidas
estereo.update( camera );  // recalcula las dos a partir de una cámara normal

0.064 no es un número redondo por casualidad: es la distancia interpupilar media de un adulto en metros. Ese valor solo tiene sentido si tu escena está en metros. Si has modelado en centímetros, la separación correcta es 6.4, y si la dejas en el valor por defecto el efecto estéreo será casi nulo. Es el error más frecuente al montar estéreo por primera vez.

El detalle que hace útil esta clase es cómo construye las dos proyecciones. La forma ingenua de hacer estéreo es coger la cámara, moverla a un lado, y girarla un poco hacia dentro para que ambas converjan en el sujeto. Eso se llama convergencia rotada y está mal: al rotar, las dos imágenes dejan de tener sus planos de proyección paralelos, y la diferencia vertical entre lo que ve cada ojo deja de ser cero en las esquinas. El cerebro no tolera disparidad vertical y el resultado son dolores de cabeza en minutos.

Lo correcto es una proyección descentrada: las dos cámaras miran exactamente en la misma dirección, con los planos de proyección paralelos, y lo que se desplaza es el centro del frustum. Eso es lo que hace update(), y solo toca dos elementos de la matriz de proyección de cada ojo:

elements[0] = 2 · near / (xmax − xmin)
elements[8] = (xmax + xmin) / (xmax − xmin)

El primero es la escala horizontal y el segundo es el sesgo que descentra el frustum. La cantidad de descentrado es eyeSep/2 · near / focus, donde focus es la propiedad de la PerspectiveCamera de entrada: la distancia al plano donde la disparidad es cero, es decir, dónde parece estar la pantalla. Objetos más cerca que focus salen de la pantalla hacia el espectador; más lejos, se hunden detrás. Ajustar camera.focus es lo que controla la profundidad percibida, no eyeSep.

Con eso, renderizar en lado a lado son unas pocas líneas:

import * as THREE from 'three';

const estereo = new THREE.StereoCamera();
estereo.eyeSep = 0.064;

const tamano = new THREE.Vector2();

function renderEstereo( scene, camera ) {
  camera.updateWorldMatrix( true, false );
  estereo.update( camera );

  renderer.getSize( tamano );
  const w = Math.floor( tamano.width * renderer.getPixelRatio() / 2 );
  const h = Math.floor( tamano.height * renderer.getPixelRatio() );

  renderer.setScissorTest( true );

  renderer.setScissor( 0, 0, w, h );
  renderer.setViewport( 0, 0, w, h );
  renderer.render( scene, estereo.cameraL );

  renderer.setScissor( w, 0, w, h );
  renderer.setViewport( w, 0, w, h );
  renderer.render( scene, estereo.cameraR );

  renderer.setScissorTest( false );
}

El setScissorTest no es decorativo: sin él, el borrado del segundo render() limpiaría el lienzo entero y borraría el ojo izquierdo. La tijera limita también el borrado a la mitad correspondiente.

update() tiene una optimización interna que conviene conocer: recalcula las proyecciones solo si alguno de los parámetros ha cambiado, pero siempre recompone las matrices de mundo de los dos ojos a partir de la de la cámara de entrada. Es decir, puedes llamarlo cada frame sin coste apreciable.

Capas: objetos que solo ve un ojo

Aquí está el detalle que casi nadie conoce y que convierte a StereoCamera en algo más que un par de proyecciones. Al construirse, sus dos cámaras hacen esto:

this.cameraL.layers.enable( 1 );   // el ojo izquierdo ve también el canal 1
this.cameraR.layers.enable( 2 );   // el derecho ve también el canal 2

Como cualquier Object3D está por defecto en el canal 0, todo lo normal lo ven los dos ojos. Pero si pones un objeto en el canal 1, solo lo verá el izquierdo:

const marcaIzquierda = new THREE.Mesh( geometria, material );
marcaIzquierda.layers.set( 1 );    // exclusivo del ojo izquierdo
scene.add( marcaIzquierda );

Los usos son más de los que parece. El obvio es el anaglifo por contenido, donde cada ojo recibe una versión distinta del mismo objeto. El menos obvio y más útil: interfaz estereoscópica. Un retículo o un texto que quieras percibir a una profundidad concreta se puede duplicar y desplazar horizontalmente lo justo, con cada copia en su canal, y el cerebro lo sitúa exactamente donde tú quieras sin necesidad de que exista geometría 3D. Es la técnica clásica de los subtítulos en cine estereoscópico.

El mismo mecanismo sirve con ArrayCamera para vistas asimétricas: en una pantalla partida, cada jugador puede tener sus propios indicadores en su canal, y un solo grafo de escena sirve para las dos vistas sin duplicar objetos ni condicionar visible en cada frame.

La separación de los ojos no es un parámetro estético: es una escala

El error conceptual que arruina el noventa por ciento de los primeros intentos de estéreo es tratar eyeSep como un mando de intensidad, subirlo hasta que “se note el 3D” y dar el trabajo por bueno. eyeSep es una medida física, y cambiarlo no ajusta el efecto: cambia el tamaño aparente del mundo. La razón es que el cerebro deduce la escala de un objeto comparando la disparidad que ve con la separación que sabe que tienen sus propios ojos. Si renderizas con una separación del doble de la real, la disparidad de cada objeto se duplica, y la única interpretación consistente para tu sistema visual es que todo mide la mitad. Ese es exactamente el efecto de maqueta que se ve en el estéreo mal calibrado, y también, por cierto, el fundamento del efecto contrario: los planos aéreos de cine con cámaras separadas decenas de metros —lo que se llama hiperestéreo— hacen que una cordillera parezca una escultura de sobremesa. Así que la disciplina correcta es dejar eyeSep clavado en la separación real en las unidades de tu escena y controlar la sensación de profundidad con camera.focus, que es lo que decide qué plano cae sobre la pantalla y por tanto cuánto sale y cuánto se hunde. Y el corolario incómodo: si tu escena no tiene una escala física coherente, si el coche mide dos unidades y la casa treinta, no hay valor de eyeSep que funcione, porque no estás renderizando un mundo sino un collage. El estéreo es el detector de escalas inconsistentes más implacable que existe: te obliga a modelar en metros de verdad.

⚔️ Dos vistas de la misma escena
  1. Monta una pantalla partida con ArrayCamera y comprueba con renderer.info.render.calls cuántas llamadas emite frente a dos render().
  2. Gira las dos subcámaras en sentidos opuestos y observa cómo el culling de la cámara padre empieza a descartar objetos que sí se ven.
  3. Implementa el lado a lado con StereoCamera y verifica que sin setScissorTest el primer ojo desaparece.
  4. Cambia camera.focus y describe qué objetos salen de la pantalla y cuáles se hunden.
  5. Pon un retículo en el canal 1 y otro desplazado en el canal 2, y ajusta el desplazamiento hasta que se perciba a la profundidad de un objeto concreto.