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.
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 entradaestereo.eyeSep = 0.064; // separación interpupilar en metros// estereo.cameraL y estereo.cameraR son PerspectiveCamera ya construidasestereo.update( camera ); // recalcula las dos a partir de una cámara normal0.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 1this.cameraR.layers.enable( 2 ); // el derecho ve también el canal 2Como 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 izquierdoscene.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.