Saltar al contenido

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.

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.

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

JS
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));            // coincidenconsole.log('factor horizontal:', e[0].toFixed(6));                        // el vertical entre el aspectoconsole.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.

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.

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