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

OrthographicCamera: isométrico, sombras y editores

Por qué la cámara ortográfica no tiene aspecto, cómo montar un isométrico correcto, por qué la sombra de una luz direccional es una cámara ortográfica, y cuándo elegir cada proyección.

⏱ 18 min

La cámara ortográfica no es una cámara con el campo de visión a cero: es una proyección distinta, con una matriz distinta y un volumen de vista que es una caja en lugar de una pirámide. Lo que la define es que las líneas paralelas siguen paralelas y el tamaño en pantalla no depende de la distancia. Suena a limitación, y es exactamente lo que hace falta en tres situaciones donde la perspectiva estorba: los juegos isométricos, los editores, y —sin que casi nadie se dé cuenta— todas las sombras direccionales de tu escena.

🎯 Al terminar esta lección sabrás
  • Configurar los seis planos de una OrthographicCamera respetando el aspecto de la ventana.
  • Montar una vista isométrica correcta y explicar de dónde salen sus ángulos.
  • Ajustar la cámara ortográfica de una DirectionalLight para que las sombras no se corten ni se pixelen.
  • Elegir entre proyección ortográfica y en perspectiva con un criterio que no sea estético.

Los seis planos de una caja

La firma en r184 no tiene ningún parámetro de campo de visión ni de aspecto:

new THREE.OrthographicCamera( left = -1, right = 1, top = 1, bottom = -1, near = 0.1, far = 2000 )

Son las seis caras de un paralelepípedo, medidas en unidades de mundo. Todo lo que caiga dentro se dibuja a escala uno a uno, sin importar a qué profundidad esté. La ausencia de aspect es deliberada y también un incordio: como los planos laterales se dan en unidades de mundo, si la ventana cambia de forma y tú no cambias left y right, la escena sale estirada. Hay que hacerlo a mano:

import * as THREE from 'three';

const alturaVisible = 10;   // cuántas unidades de mundo caben en vertical
let aspecto = window.innerWidth / window.innerHeight;

const camera = new THREE.OrthographicCamera(
  - alturaVisible * aspecto / 2,
    alturaVisible * aspecto / 2,
    alturaVisible / 2,
  - alturaVisible / 2,
  0.1,
  100
);

addEventListener( 'resize', () => {
  aspecto = window.innerWidth / window.innerHeight;
  camera.left = - alturaVisible * aspecto / 2;
  camera.right = alturaVisible * aspecto / 2;
  camera.updateProjectionMatrix();
  renderer.setSize( window.innerWidth, window.innerHeight );
} );

El patrón es fijar la altura visible y derivar la anchura, igual que la perspectiva fija el FOV vertical y deriva el horizontal. Si prefieres no recalcular los seis planos, camera.zoom divide el tamaño del volumen sin tocarlos: es lo que usa OrbitControls cuando la cámara es ortográfica, y por eso en ese caso los límites son minZoom y maxZoom y no minDistance y maxDistance.

Hay una propiedad de la proyección ortográfica que merece una sección propia y solo va a ocupar un párrafo: la profundidad es lineal. Sin división por w no hay hipérbola, así que el valor almacenado es simplemente (d - near) / (far - near). Con un buffer de 24 bits y un rango de mil unidades, la separación mínima resoluble es de seis centésimas de milímetro en todo el rango, cerca y lejos por igual. En perspectiva, con esos mismos números, a cien metros era de seis milímetros. La cámara ortográfica no tiene z-fighting salvo que tus superficies sean literalmente coplanares, y esa es una de las razones por las que las sombras direccionales la usan.

Una consecuencia práctica del volumen en caja: near puede ser negativo. Es perfectamente legal y a veces útil, porque desplaza el volumen hacia atrás de la cámara y evita tener que colocarla lejos para que quepa todo.

Isométrico de verdad

Una vista isométrica es aquella en la que los tres ejes del mundo se proyectan con el mismo escorzo y separados por ciento veinte grados en pantalla. La receta es trivial y la explicación de por qué funciona lo es también:

const k = 20;   // solo afecta a dónde está la cámara, no al tamaño en pantalla
camera.position.set( k, k, k );
camera.lookAt( 0, 0, 0 );

Al mirar desde el vector (1, 1, 1) hacia el origen, los tres ejes forman el mismo ángulo con la dirección de vista, así que los tres se acortan igual. El ángulo de elevación resultante sobre el plano horizontal es atan(1/√2), unos 35,264 grados, y el acimut es 45. Esos son los números que aparecen en todos los tutoriales de arte isométrico, y salen de aquí, no de un convenio arbitrario.

Fíjate en que k no cambia nada del tamaño aparente: en ortográfica la distancia es irrelevante. Lo único que hace k es asegurarse de que la escena queda entre near y far. La escala se controla con alturaVisible o con zoom.

El isométrico de los videojuegos clásicos no es este, por cierto: es una variante con relación dos a uno en píxeles para que las diagonales caigan exactamente en la rejilla, lo que corresponde a una elevación de unos 26,57 grados. Si estás replicando ese estilo, el ángulo que buscas es atan(0.5):

const elevacion = Math.atan( 0.5 );          // 26,565 grados
const acimut = Math.PI / 4;                  // 45 grados
const r = 50;
camera.position.set(
  r * Math.cos( elevacion ) * Math.cos( acimut ),
  r * Math.sin( elevacion ),
  r * Math.cos( elevacion ) * Math.sin( acimut )
);
camera.lookAt( 0, 0, 0 );

La cámara ortográfica que ya estás usando

Una DirectionalLight modela un foco infinitamente lejano: todos sus rayos son paralelos. Renderizar su mapa de sombras significa renderizar la escena desde el punto de vista de la luz, y una proyección con rayos paralelos es, por definición, ortográfica. Por eso DirectionalLightShadow construye exactamente esto en r184:

new THREE.OrthographicCamera( - 5, 5, 5, - 5, 0.5, 500 )

Diez unidades de lado. Ese es el motivo real de los dos síntomas más comunes de las sombras direccionales: si tu escena es más grande de diez unidades, las sombras se cortan en seco a partir de cierta distancia; y si tu escena es mucho más pequeña, el mapa de sombras gasta casi toda su resolución en vacío y los bordes salen dentados. La cámara es tuya y se ajusta como cualquier otra:

import * as THREE from 'three';

const luz = new THREE.DirectionalLight( 0xffffff, 3 );
luz.position.set( 10, 20, 10 );
luz.castShadow = true;

const c = luz.shadow.camera;   // es una OrthographicCamera
c.left = - 30;
c.right = 30;
c.top = 30;
c.bottom = - 30;
c.near = 1;
c.far = 80;
c.updateProjectionMatrix();

luz.shadow.mapSize.set( 2048, 2048 );   // por defecto 512 x 512
luz.shadow.bias = - 0.0005;
luz.shadow.normalBias = 0.02;

scene.add( luz );
scene.add( new THREE.CameraHelper( c ) );   // dibuja la caja: úsalo siempre al ajustar

CameraHelper es aquí obligatorio, no opcional. Pinta el volumen exacto y te deja ver de un vistazo si la escena sobresale o si estás desperdiciando la mitad del mapa. El compromiso es puramente aritmético: la resolución efectiva de la sombra es el tamaño del mapa dividido entre el ancho del volumen. Con mapSize de 2048 y un volumen de 60 unidades, cada téxel cubre casi tres centímetros. Reducir el volumen a 20 unidades triplica el detalle sin costar un solo byte más.

Los planos near y far de esta cámara importan por la misma razón que en cualquier otra, con el matiz de que aquí la profundidad es lineal, así que el problema no es la precisión sino el recorte: cualquier objeto por delante de near o por detrás de far no proyecta sombra.

Editores, ejes y cuándo cada una

Las vistas ortogonales de un editor —planta, alzado, perfil— son la aplicación canónica, y por una razón concreta: son las únicas en las que medir en pantalla equivale a medir en el mundo. Dos segmentos del mismo tamaño real ocupan el mismo número de píxeles, estén donde estén. Eso hace que las guías, las cotas, los ajustes a rejilla y la selección por rectángulo funcionen sin corrección de perspectiva.

// Cuatro vistas clásicas de editor, todas con la misma OrthographicCamera reposicionada.
const vistas = {
  planta:  { pos: [ 0, 50, 0 ],  up: [ 0, 0, - 1 ] },
  alzado:  { pos: [ 0, 0, 50 ],  up: [ 0, 1, 0 ] },
  perfil:  { pos: [ 50, 0, 0 ],  up: [ 0, 1, 0 ] },
  iso:     { pos: [ 30, 30, 30 ], up: [ 0, 1, 0 ] }
};

function ponerVista( nombre ) {
  const v = vistas[ nombre ];
  camera.up.set( ...v.up );
  camera.position.set( ...v.pos );
  camera.lookAt( 0, 0, 0 );
  camera.updateProjectionMatrix();
}

La vista de planta necesita cambiar up, porque mirar recto hacia abajo con up en el eje vertical es degenerado: lookAt no puede decidir la rotación alrededor del eje de vista. Es el mismo problema que resuelve Spherical.makeSafe() en los controles de órbita, y ya que lo mencionamos, se trata en detalle al escribir un control de cámara desde cero.

Los Raycaster funcionan igual con las dos proyecciones: setFromCamera detecta el tipo y construye un rayo desde el punto correspondiente del plano cercano, con dirección paralela al eje de vista si la cámara es ortográfica. No hay nada que adaptar.

El criterio de elección, sin adornos. Usa ortográfica cuando la información que transmites es geométrica y no espacial: planos técnicos, editores, diagramas, juegos de estrategia con rejilla, minimapas, iconos de interfaz en 3D. Usa perspectiva cuando quieras que el usuario perciba profundidad y distancia: cualquier cosa que simule estar en un sitio. Y desconfía de la tentación intermedia de una perspectiva con FOV muy bajo para “parecer” isométrica: se ve casi igual, sigue teniendo escorzo, y hereda todos los problemas de precisión de profundidad de la perspectiva sin ninguna de sus ventajas.

La ortográfica es el límite de la perspectiva, y eso tiene una consecuencia práctica

Formalmente, una proyección ortográfica es lo que obtienes con una cámara en perspectiva cuando el FOV tiende a cero y la distancia tiende a infinito manteniendo el producto constante. Eso no es solo una curiosidad: es la receta exacta para transicionar entre las dos sin salto visual, que es un efecto que piden mucho en visores técnicos y que la gente resuelve mal con un fundido. La transición correcta consiste en interpolar la altura visible del plano de enfoque —no el FOV, que se dispararía— y sustituir la cámara justo en el punto donde el escorzo residual ya no se percibe, típicamente por debajo de unos cinco grados de FOV. Si mantienes d · tan(fov/2) igual a la mitad de la altura visible de la ortográfica, los dos encuadres coinciden en el plano del objeto y solo difieren en lo que hay delante y detrás, así que el corte es invisible salvo en escenas muy profundas. El detalle que casi nadie tiene en cuenta es que hay que apagar también el z-fighting en el camino: al alejar la cámara para reducir el FOV, far crece y la relación far/near se dispara, así que si no reajustas near en la misma interpolación el usuario ve aparecer franjas justo en mitad de la transición y no entiende de dónde salen. Interpola los cuatro números a la vez —posición, FOV, near y far— o no interpoles ninguno.

⚔️ Monta las tres cámaras
  1. Escribe el manejador de redimensionado ortográfico y comprueba que un círculo no se deforma al cambiar la ventana.
  2. Monta las dos variantes isométricas y superpón una rejilla para ver cuál cae limpia en píxeles.
  3. Añade un CameraHelper a la sombra de una luz direccional y ajusta el volumen hasta que la escena quepa justa.
  4. Mide la diferencia de nitidez de la sombra al reducir el volumen a la mitad con el mismo mapSize.
  5. Implementa la transición entre perspectiva y ortográfica interpolando posición, FOV, near y far a la vez.