Coordenadas homogéneas: por qué hacen falta cuatro dimensiones
La traslación no es lineal, y la solución es meter el espacio tridimensional dentro de uno de cuatro dimensiones. Qué significa la cuarta componente y por qué la perspectiva la necesita.
Toda la maquinaria gráfica usa matrices de cuatro por cuatro para transformar un espacio de tres dimensiones. La explicación que se suele dar —«hace falta una dimensión extra para la traslación»— es correcta pero no explica nada. Lo que ocurre de verdad es más bonito: el espacio tridimensional se coloca como una rebanada de uno de cuatro dimensiones, y una operación perfectamente lineal en el espacio grande se ve como una traslación desde dentro de la rebanada.
- Demostrar que la traslación no es una transformación lineal.
- Explicar geométricamente cómo un cizallamiento en cuatro dimensiones produce una traslación en tres.
- Distinguir puntos de direcciones por el valor de la cuarta componente y predecir su comportamiento.
- Relacionar la división por la cuarta componente con la perspectiva.
La traslación no cabe en una matriz de tres por tres
La demostración es inmediata. Una transformación lineal deja el origen quieto, porque transformar el vector cero da siempre cero. Una traslación mueve el origen por definición. Por tanto ninguna matriz de tres por tres puede representar una traslación, por muchos números que le pongas.
Esto es un problema serio, no una curiosidad. Rotar y escalar se compone bien —el producto de matrices representa la aplicación sucesiva—, pero si la traslación queda fuera del sistema hay que arrastrarla aparte como una suma, y entonces componer transformaciones deja de ser multiplicar matrices y se convierte en una fórmula con casos. Toda la elegancia se derrumba.
Meter el espacio dentro de otro más grande
El truco consiste en dejar de ver el espacio tridimensional como el espacio total y verlo como una rebanada de uno de cuatro dimensiones: concretamente, el conjunto de puntos cuya cuarta coordenada vale uno. El punto (x, y, z) de nuestro mundo pasa a ser el punto (x, y, z, 1) del mundo grande.
Ahora viene lo bueno. En el espacio de cuatro dimensiones, considera un cizallamiento: una transformación perfectamente lineal que inclina los tres primeros ejes en función de la cuarta coordenada. Un punto con cuarta coordenada cero no se mueve. Un punto con cuarta coordenada uno se desplaza una cantidad fija.
Como todos nuestros puntos tienen cuarta coordenada uno, todos se desplazan exactamente la misma cantidad. Desde dentro de la rebanada, eso se ve como una traslación. Desde fuera, es un cizallamiento lineal como cualquier otro, que se compone multiplicando matrices igual que todos los demás.
Ese es el contenido real de las coordenadas homogéneas: no es un truco de notación, es un cambio de punto de vista que devuelve la traslación al mundo de lo lineal. Y de propina resuelve otro problema.
Puntos con uno, direcciones con cero
Si la traslación se aplica proporcionalmente a la cuarta componente, entonces un vector con cuarta componente cero no recibe traslación alguna. Rotaciones y escalados sí le afectan, porque están en la parte de tres por tres. Traslaciones no.
Eso es exactamente el comportamiento que queríamos para las direcciones. La distinción entre punto y dirección, que en la lección de vectores era una disciplina mental, aquí se convierte en un número que el álgebra respeta sola.
De ahí viene la convención que verás en todos los shaders del mundo: vec4(position, 1.0) para transformar una posición y vec4(direction, 0.0) para transformar una dirección. No es decorativo. Poner un uno donde iba un cero hace que la dirección de una luz se desplace cuando la cámara se mueve; poner un cero donde iba un uno deja el objeto pegado al origen.
Hay una lectura geométrica preciosa para el caso de la cuarta componente cero: son puntos en el infinito. Un vector (x, y, z, w) representa el punto que se obtiene dividiendo por w; cuando w tiende a cero, ese punto se va infinitamente lejos en la dirección (x, y, z). Por eso una luz direccional es literalmente un punto de luz situado en el infinito, y por eso sus rayos llegan paralelos.
import * as THREE from 'three';
const traslacion = new THREE.Matrix4().makeTranslation(10, 0, 0);
const rotacion = new THREE.Matrix4().makeRotationY(Math.PI / 2);
const transformacion = new THREE.Matrix4().multiplyMatrices(traslacion, rotacion);
// Un punto: cuarta componente uno. Recibe rotacion y traslacion.
const punto = new THREE.Vector4(0, 0, 1, 1).applyMatrix4(transformacion);
console.log('punto transformado:', punto.toArray()); // (11, 0, 0, 1)
// Una direccion: cuarta componente cero. Solo recibe la rotacion.
const direccion = new THREE.Vector4(0, 0, 1, 0).applyMatrix4(transformacion);
console.log('direccion transformada:', direccion.toArray()); // (1, 0, 0, 0)
// La misma direccion tratada por error como punto.
const mal = new THREE.Vector4(0, 0, 1, 1).applyMatrix4(transformacion);
console.log('el error clasico:', mal.toArray()); // (11, 0, 0, 1)
La tercera línea de salida es el bug con nombre y apellidos: una dirección que ha viajado diez unidades porque alguien puso un uno donde iba un cero. En un shader de iluminación, el resultado es que la luz cambia de dirección cuando el objeto se mueve.
La cuarta componente deja de valer uno: la perspectiva
Hasta aquí la cuarta componente ha valido uno o cero. La proyección en perspectiva es la operación que la convierte en otra cosa, y ahí está la segunda razón de ser de todo el sistema.
La perspectiva consiste en dividir por la profundidad: lo que está el doble de lejos se ve la mitad de grande. Dividir no es una operación lineal, así que no puede formar parte de una matriz. Pero sí se puede preparar con una matriz: basta con que la transformación coloque la profundidad en la cuarta componente, y que después el hardware divida las tres primeras por ella.
Eso es exactamente lo que hace la matriz de proyección. Su última fila no es (0, 0, 0, 1) como en las transformaciones afines, sino algo del estilo de (0, 0, -1, 0): copia la profundidad, cambiada de signo, a la cuarta componente. El resultado es el espacio de recorte, donde la cuarta componente ya no vale uno y todavía no se ha dividido.
La división posterior —la división perspectiva del pipeline— es lo que produce el efecto visual. Y como esa división ocurre en hardware, después del recorte, se explica sola la razón de que el espacio de recorte exista como paso separado.
Aquí hay un detalle de la implementación de Three.js que hay que conocer porque explica resultados desconcertantes en las dos direcciones. Vector4.applyMatrix4 hace lo que dice: multiplica las cuatro componentes y deja la cuarta como salga. Pero Vector3.applyMatrix4 no puede dejar una cuarta componente en ningún sitio, así que hace algo más: multiplica asumiendo que la cuarta componente vale uno y divide el resultado por la cuarta componente obtenida. Con matrices afines eso es inofensivo, porque la cuarta componente sale uno y dividir por uno no hace nada. Con una matriz de proyección, en cambio, applyMatrix4 sobre un Vector3 ejecuta la división perspectiva completa, en silencio. La consecuencia buena es que project funciona: internamente aplica la matriz de vista y la de proyección a un Vector3 y la división ocurre sola. La consecuencia mala es que si intentas trabajar en espacio de recorte con un Vector3 —por ejemplo para inspeccionar la cuarta componente y detectar puntos detrás de la cámara— el valor que buscas ya se ha usado y descartado antes de que puedas verlo. Para eso hay que usar Vector4 explícitamente. Es un caso de API que hace lo correcto el noventa por ciento de las veces y lo confuso el diez por ciento restante, y el diez por ciento cae siempre en el momento en que estás depurando algo difícil.
Qué se puede y qué no se puede hacer con una matriz de cuatro por cuatro
El conjunto de transformaciones que representa una matriz de cuatro por cuatro con última fila (0, 0, 0, 1) se llama afín: todas las lineales más la traslación. Rotar, escalar, cizallar, reflejar, trasladar y cualquier composición de esas cosas. Preserva rectas y paralelismo.
Si la última fila es otra cosa, la transformación es proyectiva: preserva rectas pero no paralelismo, que es justo lo que hace falta para que las vías del tren converjan en el horizonte.
Y lo que no se puede hacer con ninguna matriz: cualquier deformación no uniforme. Doblar, retorcer, ondular, inflar. Todo eso exige mover cada vértice con una fórmula propia, y por eso vive en el vertex shader y no en la transformación del objeto. Es la frontera exacta entre lo que se resuelve con object.position y lo que se resuelve con un shader.
import * as THREE from 'three';
const proyeccion = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 100).projectionMatrix;
const e = proyeccion.elements;
// Ultima fila de la matriz, en almacenamiento por columnas.
console.log('ultima fila:', [e[3], e[7], e[11], e[15]]); // (0, 0, -1, 0)
// Un punto en espacio de vista, a cinco unidades por delante de la camara.
const enVista = new THREE.Vector4(1, 0.5, -5, 1);
const enRecorte = enVista.clone().applyMatrix4(proyeccion);
console.log('w del recorte:', enRecorte.w); // 5, la profundidad
const ndc = new THREE.Vector3(
enRecorte.x / enRecorte.w,
enRecorte.y / enRecorte.w,
enRecorte.z / enRecorte.w
);
console.log('normalizado:', ndc.toArray().map((n) => +n.toFixed(4)));
// El mismo punto al doble de distancia ocupa la mitad en pantalla.
const lejos = new THREE.Vector4(1, 0.5, -10, 1).applyMatrix4(proyeccion);
console.log('mitad de tamano:', (lejos.x / lejos.w / (enRecorte.x / enRecorte.w)).toFixed(4));
La última línea imprime aproximadamente 0.5: el mismo punto al doble de profundidad se proyecta a la mitad de distancia del centro. Eso es la perspectiva, y son cuatro números en una fila de la matriz más una división que hace el hardware.