wandres.dev
MATEMÁTICAS DEL 3D I · Vectores y espacios

Los cinco espacios que atraviesa un vértice

Modelo, mundo, vista, recorte y pantalla: qué problema resuelve cada cambio de espacio y qué convenciones concretas usa Three.js en cada uno.

⏱ 19 min

Un vértice cambia de sistema de coordenadas cuatro veces entre la memoria y el píxel. No es burocracia: cada espacio existe porque hay un cálculo que en él es trivial y en los demás es horrible. Y la mayor parte de los errores de 3D que no producen ningún mensaje —iluminación que gira con el objeto, objetos que aparecen del revés, efectos que solo funcionan si la cámara está en el origen— son un cálculo hecho en el espacio equivocado.

🎯 Al terminar esta lección sabrás
  • Nombrar los cinco espacios y qué problema resuelve la transición a cada uno.
  • Justificar por qué la proyección exige que la cámara esté en el origen.
  • Identificar en qué espacio hay que hacer cada tipo de cálculo.
  • Enumerar las convenciones concretas de Three.js y en qué se diferencian de otros motores.

La cadena y el porqué de cada eslabón

flowchart TB
modelo[Espacio de modelo] -->|matriz modelo| mundo[Espacio de mundo]
mundo -->|matriz vista| vista[Espacio de vista]
vista -->|matriz proyeccion| recorte[Espacio de recorte]
recorte -->|division por w| ndc[Coordenadas normalizadas de dispositivo]
ndc -->|transformacion de viewport| pantalla[Espacio de pantalla]
style modelo fill:#94e2d5,color:#11111b
style mundo fill:#a6e3a1,color:#11111b
style vista fill:#89b4fa,color:#11111b
style recorte fill:#cba6f7,color:#11111b
style ndc fill:#f9e2af,color:#11111b
style pantalla fill:#fab387,color:#11111b

Espacio de modelo. Es el sistema en el que se creó la geometría. El origen es el punto que el modelador eligió como pivote y los ejes son los suyos. Su razón de ser es la reutilización: una silla modelada una vez se coloca cien veces en la escena sin duplicar un solo vértice, porque cada instancia aporta su propia transformación. Todo lo que está en el atributo position de una BufferGeometry vive aquí.

Espacio de mundo. El escenario común. Es donde todos los objetos comparten origen y unidades, y por tanto el único sitio donde tiene sentido preguntar «qué distancia hay entre estos dos objetos» o «choca esto con aquello». La física, la lógica de juego y el audio espacial viven aquí.

Espacio de vista. El mundo visto desde la cámara: la cámara está en el origen y mira a lo largo de su eje Z negativo. Este espacio existe por una razón muy concreta: la proyección en perspectiva solo tiene una fórmula simple si el observador está en el origen mirando por un eje. Colocar la cámara en el origen no se hace moviendo la cámara, se hace moviendo el mundo entero con la transformación inversa de la cámara. De ahí el nombre técnico de la matriz de vista, que no es más que la inversa de la matriz de mundo de la cámara.

Espacio de recorte. El resultado de aplicar la proyección, todavía en cuatro componentes y todavía sin dividir. Existe precisamente para que el recorte contra el volumen de visión ocurra antes de la división, por el motivo aritmético que ya viste en la lección de rasterización: dividir por una componente que puede ser cero o negativa produce basura.

Coordenadas normalizadas de dispositivo. Después de dividir. El volumen visible es un cubo con lados de menos uno a uno en horizontal y vertical. La profundidad va de menos uno a uno en WebGL y de cero a uno en WebGPU, diferencia que Three.js abstrae pero que aparece si escribes shaders que tocan profundidad. Este espacio no depende de la resolución, y por eso es el que se usa para todo lo que deba funcionar igual a cualquier tamaño de ventana.

Espacio de pantalla. Píxeles reales. La transformación de viewport escala las coordenadas normalizadas al tamaño del destino, y aquí aparece la única inversión de eje de toda la cadena: en coordenadas normalizadas el eje vertical crece hacia arriba, y en coordenadas de pantalla del DOM crece hacia abajo.

Dónde hacer cada cálculo

La pregunta práctica no es cuántos espacios hay sino en cuál conviene trabajar. La respuesta no es única y cada motor elige.

Cálculo Espacio natural Por qué
Deformar una malla Modelo Es donde la forma tiene sentido y donde las coordenadas no dependen de dónde esté el objeto
Física, distancias, lógica Mundo Es el único espacio compartido por todos los objetos
Iluminación de los materiales de Three.js Vista Ahorra transformar la posición de la cámara, que en este espacio es el origen
Niebla por distancia Vista La profundidad es directamente la coordenada Z
Recorte y descarte Recorte Las pruebas son comparaciones lineales con la cuarta componente
Efectos a pantalla completa Normalizado o pantalla No dependen de la escena, solo de la imagen

La fila que sorprende a casi todo el mundo es la tercera: los materiales integrados de Three.js calculan la iluminación en espacio de vista, no en espacio de mundo. Por eso las normales se transforman con normalMatrix, que lleva de modelo a vista, y por eso las posiciones de las luces que ves en los uniformes de un material ya están en ese espacio. Si escribes un shader propio y mezclas una dirección de luz en espacio de mundo con una normal en espacio de vista, la iluminación girará al mover la cámara. Es un bug precioso porque parece un problema de la luz y es un problema de contabilidad.

Estas convenciones no son universales, y por eso el código copiado de otro motor falla de formas sutiles

La cadena de espacios es la misma en todos los motores, pero las convenciones concretas no, y cada diferencia produce un fallo distinto que nunca se anuncia como tal. Three.js usa un sistema dextrógiro con la Y hacia arriba y la cámara mirando hacia su Z negativo; DirectX y Unity usan uno levógiro donde la cámara mira hacia su Z positivo; hay motores con Z hacia arriba, herencia del CAD y de Blender. El rango de profundidad después de dividir es de menos uno a uno en OpenGL y WebGL, y de cero a uno en DirectX, Metal, Vulkan y WebGPU. La coordenada de textura vertical crece hacia arriba en OpenGL y hacia abajo en DirectX, lo que hace que las texturas aparezcan invertidas al portar. Y el espacio en el que se calcula la iluminación cambia entre motores e incluso entre materiales del mismo motor. Cuando pegas un fragmento de shader encontrado en una web de efectos y el resultado está espejado, invertido, negro por un lado o iluminado desde el sitio equivocado, la causa es casi siempre una de estas cinco convenciones, no un error del código original. Antes de depurar línea a línea, pregúntate para qué motor se escribió y compara las cinco: se resuelve en dos minutos en lugar de en dos horas.

Un vértice, paso a paso, con números

Merece la pena ver la cadena entera con valores concretos. El ejemplo reproduce a mano lo que hace el pipeline para un solo punto, usando las matrices que Three.js ya tiene calculadas.

import * as THREE from 'three';

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 100);
camera.position.set(0, 2, 6);
camera.lookAt(0, 0, 0);

const malla = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshBasicMaterial()
);
malla.position.set(2, 0, -1);
malla.scale.set(1, 2, 1);
scene.add(malla);

// Sin esto, las matrices de mundo son las del fotograma anterior.
scene.updateMatrixWorld(true);
camera.updateMatrixWorld(true);

// Una esquina del cubo, tal como esta en el atributo de posiciones.
const enModelo = new THREE.Vector3(0.5, 0.5, 0.5);
console.log('1. modelo:', enModelo.toArray());

const enMundo = enModelo.clone().applyMatrix4(malla.matrixWorld);
console.log('2. mundo:', enMundo.toArray());          // (2.5, 1, -0.5)

// La matriz de vista es la inversa de la matriz de mundo de la camara.
const enVista = enMundo.clone().applyMatrix4(camera.matrixWorldInverse);
console.log('3. vista:', enVista.toArray());          // Z negativa: esta delante

// En espacio de recorte hace falta la cuarta componente.
const enRecorte = new THREE.Vector4(enVista.x, enVista.y, enVista.z, 1)
  .applyMatrix4(camera.projectionMatrix);
console.log('4. recorte:', enRecorte.toArray());

// La division por w produce las coordenadas normalizadas.
const ndc = new THREE.Vector3(
  enRecorte.x / enRecorte.w,
  enRecorte.y / enRecorte.w,
  enRecorte.z / enRecorte.w
);
console.log('5. normalizado:', ndc.toArray());

// Y el atajo que hace los pasos 2 a 5 de una vez.
const atajo = enModelo.clone().applyMatrix4(malla.matrixWorld).project(camera);
console.log('igual que project:', atajo.distanceTo(ndc) < 1e-9);

Tres observaciones sobre ese código. La primera: matrixWorldInverse de la cámara es la matriz de vista; Three.js no la llama así, pero es exactamente eso. La segunda: el paso a espacio de recorte necesita un Vector4 porque la cuarta componente deja de valer uno en cuanto interviene la proyección, y ese es el tema del nivel de coordenadas homogéneas. La tercera: updateMatrixWorld no es opcional. Leer una matriz de mundo sin haberla actualizado devuelve el valor del fotograma anterior, y ese es un error tan silencioso como frecuente.

Un espacio más que aparecerá luego

La lista de cinco es la cadena obligatoria, pero no agota los espacios que se usan en gráficos. Hay al menos uno más que conviene tener anotado desde ahora: el espacio tangente, un sistema local a cada punto de una superficie donde el eje Z es la normal y los otros dos siguen las direcciones del desplegado de textura. Existe porque permite guardar un mapa de normales que sea independiente de la orientación del objeto: la misma textura de ladrillo sirve para una pared vertical y para un suelo, porque sus valores están expresados respecto a la superficie y no respecto al mundo.

No hace falta entenderlo todavía. Lo que sí conviene retener es el principio general que ejemplifica, y que es la conclusión de esta lección: un espacio de coordenadas se inventa cuando hace falta que un dato sea independiente de algo. De la posición del objeto, de la de la cámara, de la resolución de la pantalla o de la orientación de la superficie. Cada espacio es una independencia comprada al precio de una transformación.

Cómo se hacen esas transformaciones en la práctica es el contenido de la siguiente lección, y por qué se representan con matrices es el del nivel entero que viene después.