Saltar al contenido

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.

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.

JS
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.

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.

JS
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 profundidadconst 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.