wandres.dev
MATEMÁTICAS DEL 3D II · Matrices y transformaciones

La matriz modelo-vista-proyección

Las tres matrices que llevan un vértice de la memoria a la pantalla, qué contiene cada una, y por qué Three.js precombina dos de ellas en la CPU en lugar de dejarlo para el shader.

⏱ 19 min

La línea más repetida de todos los shaders del mundo multiplica tres matrices por una posición. Detrás de esa línea hay tres decisiones de diseño que conviene entender: qué contiene exactamente cada matriz, por qué la de vista es una inversa, y por qué el motor combina dos de ellas antes de llegar al shader en lugar de pasarlas por separado. La tercera decisión tiene que ver con la precisión de los números y es la que más consecuencias tiene en escenas grandes.

🎯 Al terminar esta lección sabrás
  • Identificar qué transformación aporta cada una de las tres matrices.
  • Explicar por qué la matriz de vista es la inversa de la matriz de mundo de la cámara.
  • Leer la estructura de una matriz de proyección en perspectiva y saber de dónde sale cada término.
  • Justificar por qué Three.js entrega al shader una matriz modelo-vista ya combinada.

Tres matrices, tres preguntas

La matriz de modelo responde a dónde está el objeto en el mundo. Es la composición de las transformaciones locales de un objeto y de todos sus ancestros. En Three.js es object.matrixWorld, y el motor la actualiza cada fotograma.

La matriz de vista responde a desde dónde se mira. Y aquí está el detalle que hay que entender bien: no es la transformación de la cámara, es su inversa. La razón es que la proyección exige que el observador esté en el origen mirando por un eje, y como no podemos mover el observador, movemos el mundo entero en sentido contrario. Si la cámara está diez unidades a la derecha, la matriz de vista mueve el mundo diez unidades a la izquierda. En Three.js es camera.matrixWorldInverse.

La matriz de proyección responde a cómo se aplasta el volumen visible en un cubo. Contiene el campo de visión, la relación de aspecto y los planos de recorte cercano y lejano, y es la que prepara la división perspectiva colocando la profundidad en la cuarta componente. En Three.js es camera.projectionMatrix, y se recalcula cuando llamas a updateProjectionMatrix.

La cadena completa, leída de derecha a izquierda como manda la convención, es: aplica la matriz de modelo, después la de vista, después la de proyección.

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);
scene.add(malla);

scene.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
camera.updateProjectionMatrix();

// La matriz de vista es literalmente la inversa de la de mundo de la camara.
const vistaAMano = camera.matrixWorld.clone().invert();
console.log('vista coincide:', vistaAMano.equals(camera.matrixWorldInverse));

// La cadena completa, montada en el orden correcto.
const mvp = new THREE.Matrix4()
  .multiply(camera.projectionMatrix)
  .multiply(camera.matrixWorldInverse)
  .multiply(malla.matrixWorld);

const vertice = new THREE.Vector3(0.5, 0.5, 0.5);

// applyMatrix4 sobre un Vector3 hace tambien la division por w.
const porLaCadena = vertice.clone().applyMatrix4(mvp);
const porProject = vertice.clone().applyMatrix4(malla.matrixWorld).project(camera);
console.log('mismo resultado:', porLaCadena.distanceTo(porProject) < 1e-9);

Qué hay dentro de la matriz de proyección

No hace falta memorizarla, pero sí saber de dónde sale cada término, porque eso explica el comportamiento de la cámara.

Las dos primeras filas contienen un factor derivado del campo de visión: el inverso de la tangente de la mitad del ángulo vertical. Cuanto más cerrado el campo de visión, mayor el factor y mayor el zoom. La primera fila divide además por la relación de aspecto, para que un objeto cuadrado no se deforme en una ventana rectangular.

La tercera fila transforma la profundidad. Toma la coordenada Z del espacio de vista, que está entre el plano cercano y el lejano, y la lleva al rango del espacio normalizado. Es aquí donde se decide la distribución de la precisión de profundidad, y por eso los dos planos importan tanto.

La cuarta fila es (0, 0, -1, 0): copia la profundidad, con el signo cambiado, a la cuarta componente, para que la división perspectiva posterior produzca el efecto de perspectiva.

import * as THREE from 'three';

const camara = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 100);
const e = camara.projectionMatrix.elements;

const fovRad = THREE.MathUtils.degToRad(camara.fov);
const factorVertical = 1 / Math.tan(fovRad / 2);

console.log('factor vertical calculado:', factorVertical.toFixed(6));
console.log('factor vertical en la matriz:', e[5].toFixed(6));            // coinciden
console.log('factor horizontal:', e[0].toFixed(6));                        // el vertical entre el aspecto
console.log('vertical entre aspecto:', (factorVertical / camara.aspect).toFixed(6));

// Cambiar el campo de vision sin recalcular no tiene ningun efecto.
camara.fov = 25;
console.log('sin actualizar:', camara.projectionMatrix.elements[5].toFixed(6));
camara.updateProjectionMatrix();
console.log('tras actualizar:', camara.projectionMatrix.elements[5].toFixed(6));

Las dos últimas líneas son un recordatorio útil: fov, aspect, near y zoom son datos de entrada, no la matriz. Modificarlos sin llamar a updateProjectionMatrix no cambia nada, y es uno de los primeros tropiezos de cualquiera que redimensiona una ventana.

Por qué el shader recibe modelo-vista, y no modelo y vista

Si abres el vertex shader de cualquier material de Three.js encontrarás projectionMatrix * modelViewMatrix * vec4(position, 1.0). Dos matrices, no tres. La de modelo y la de vista llegan ya multiplicadas, en un uniforme por objeto que el renderer calcula en la CPU antes de dibujar.

Hay dos motivos y ambos merecen entenderse.

El primero es de trabajo. Multiplicar dos matrices de cuatro por cuatro son sesenta y cuatro multiplicaciones. Hacerlo una vez por objeto en la CPU cuesta lo mismo que hacerlo una vez; hacerlo en el vertex shader lo cuesta una vez por vértice. En una malla de cien mil vértices, la diferencia es de cinco órdenes de magnitud para un resultado idéntico. La regla general de la que esto es un caso particular: todo lo que sea constante para toda la malla debe calcularse fuera del shader.

El segundo es de precisión, y es el importante. Los uniformes y los cálculos del vertex shader usan coma flotante de treinta y dos bits, que tiene unos siete dígitos decimales significativos. Si tu escena tiene coordenadas del orden de cien mil unidades —un mapa a escala geográfica, una simulación astronómica, un mundo grande— esos siete dígitos se gastan en la parte entera y no queda precisión para los decimales. El resultado es el temblor característico de los mundos grandes: los vértices vibran de forma visible al mover la cámara, porque el redondeo cambia entre fotogramas.

Combinar modelo y vista en la CPU mitiga el problema de forma sustancial. JavaScript hace esa multiplicación en coma flotante de sesenta y cuatro bits, y el resultado es una matriz que expresa la posición del objeto relativa a la cámara. Como los objetos visibles están, por definición, cerca de la cámara, los números que llegan al shader son pequeños aunque las coordenadas del mundo sean enormes. Es una versión ligera de la técnica que los motores llaman render relativo a la cámara.

La mitigación de Three.js es parcial, y el límite se alcanza antes de lo que crees

Que el motor combine modelo y vista en doble precisión resuelve el temblor entre objetos, pero no lo resuelve dentro de un mismo objeto. Las posiciones de los vértices siguen almacenadas en el atributo como flotantes de treinta y dos bits en espacio de modelo, así que una única malla que abarque cientos de kilómetros seguirá teniendo un error de posición proporcional a su tamaño, independientemente de dónde esté la cámara. Y hay un segundo límite, más sutil: la matriz combinada llega al shader ya convertida a treinta y dos bits, de modo que si la posición del objeto respecto a la cámara es grande —un objeto lejano en un mundo enorme—, la precisión vuelve a perderse aunque la multiplicación se hiciera en doble. La consecuencia práctica, que conviene tener escrita antes de empezar un proyecto de mundo grande: el límite útil de un mundo con coordenadas absolutas está en el orden de decenas de miles de unidades, y a partir de ahí hay que cambiar de arquitectura. Las soluciones reales son tres y todas son de diseño, no de configuración: dividir el mundo en sectores con origen propio y recolocar el origen cuando el jugador cambia de sector; guardar las posiciones lógicas en doble precisión y pasar a la escena solo las diferencias respecto a la cámara; o reducir la escala del mundo para que los números vuelvan al rango seguro. Reconocer el síntoma es la mitad del trabajo: si los vértices tiemblan al mover la cámara pero el objeto está quieto, es precisión, y ningún ajuste de la cámara lo va a arreglar.

Las variables que el motor inyecta

Cuando escribes un ShaderMaterial, Three.js declara por ti un conjunto de uniformes y atributos. Merece la pena tener la lista, porque evita redeclararlos y evita calcular a mano cosas que ya están.

Nombre Tipo Qué contiene
modelMatrix matriz 4x4 De espacio de modelo a espacio de mundo
viewMatrix matriz 4x4 De espacio de mundo a espacio de vista
modelViewMatrix matriz 4x4 Las dos anteriores ya combinadas
projectionMatrix matriz 4x4 De espacio de vista a espacio de recorte
normalMatrix matriz 3x3 Transforma normales de modelo a vista
cameraPosition vector 3 Posición de la cámara en espacio de mundo
position atributo Posición del vértice en espacio de modelo
normal atributo Normal del vértice en espacio de modelo
uv atributo Coordenada de textura del vértice

Con esa tabla se resuelven las dudas más frecuentes al escribir un shader. ¿Quieres la posición del fragmento en espacio de mundo, por ejemplo para un efecto que dependa de la altura absoluta? Multiplica por modelMatrix y pásala como variable interpolada. ¿Quieres la dirección de la vista para un efecto Fresnel? Resta la posición de mundo del fragmento a cameraPosition. ¿Quieres trabajar en espacio de vista, que es donde los materiales integrados calculan la iluminación? Usa modelViewMatrix y normalMatrix.

import * as THREE from 'three';

const material = new THREE.ShaderMaterial({
  uniforms: { uColor: { value: new THREE.Color(0xcba6f7) } },
  vertexShader: `
    varying vec3 vPosicionMundo;
    varying vec3 vNormalMundo;

    void main() {
      // Posicion en espacio de mundo: cuarta componente uno, es un punto.
      vec4 mundo = modelMatrix * vec4(position, 1.0);
      vPosicionMundo = mundo.xyz;

      // Normal en espacio de mundo: cuarta componente cero, es una direccion.
      vNormalMundo = normalize((modelMatrix * vec4(normal, 0.0)).xyz);

      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform vec3 uColor;
    varying vec3 vPosicionMundo;
    varying vec3 vNormalMundo;

    void main() {
      vec3 haciaCamara = normalize(cameraPosition - vPosicionMundo);
      float fresnel = pow(1.0 - max(dot(normalize(vNormalMundo), haciaCamara), 0.0), 3.0);
      gl_FragColor = vec4(uColor * (0.2 + fresnel), 1.0);
    }
  `,
});

export default material;

Ese shader mezcla deliberadamente los dos espacios para que se vea la diferencia: la posición de recorte se calcula con modelViewMatrix, mientras que el efecto Fresnel se calcula en espacio de mundo porque necesita cameraPosition, que llega en ese espacio. Mezclar sin darse cuenta es el error; mezclar sabiendo qué hay en cada variable es lo normal.

Queda una matriz de la tabla sin explicar, y es la que casi nadie entiende: la matriz normal, que no es la matriz de modelo y cuya existencia tiene una razón geométrica preciosa.