Componer transformaciones: el producto no es conmutativo
Por qué multiplicar matrices es encadenar funciones, en qué orden se lee la cadena, y por qué Three.js compone siempre escala, rotación y traslación en ese orden y no en otro.
Multiplicar dos matrices equivale a aplicar dos transformaciones una detrás de otra. Esa es toda la razón de que el producto de matrices exista y tenga la definición extraña que tiene. Y como aplicar dos operaciones en distinto orden da resultados distintos —girar y luego avanzar no es lo mismo que avanzar y luego girar—, el producto no puede ser conmutativo. De ahí sale una de las fuentes de error más persistentes del 3D.
- Justificar la definición del producto de matrices como composición de funciones.
- Leer una cadena de matrices en el orden correcto según la convención de vectores columna.
- Explicar por qué Three.js compone escala, rotación y traslación en ese orden exacto.
- Reconocer cuándo aparece cizallamiento en una jerarquía y qué se rompe entonces.
El producto es composición, y por eso se lee de derecha a izquierda
Si B transforma un vector y después A transforma el resultado, la transformación total es la que se escribe A por B. La razón es puramente notacional: con vectores columna, aplicar una matriz es multiplicarla por la izquierda, así que la primera que actúa es la que está pegada al vector, es decir, la de más a la derecha.
Esa convención confunde a mucha gente porque contradice el orden de lectura natural. La frase «escala, luego rota, luego traslada» se escribe con las matrices en el orden inverso al que se pronuncia. Merece la pena fijarlo de una vez: en Three.js, en GLSL y en cualquier sistema con vectores columna, la cadena se ejecuta de derecha a izquierda.
Hay bibliotecas y motores que usan vectores fila y multiplican por la derecha; ahí la cadena se lee al revés. Es una de las razones por las que un fragmento de código matricial traído de otro entorno produce transformaciones que parecen aleatorias.
La no conmutatividad se ve mejor con números que con argumentos.
import * as THREE from 'three';
const T = new THREE.Matrix4().makeTranslation(10, 0, 0);
const R = new THREE.Matrix4().makeRotationY(Math.PI / 2);
const punto = new THREE.Vector3(0, 0, 1);
// Primero rotar, despues trasladar: la traslacion va en el eje del mundo.
const rotarLuegoTrasladar = new THREE.Matrix4().multiplyMatrices(T, R);
console.log('T por R:', punto.clone().applyMatrix4(rotarLuegoTrasladar).toArray());
// (11, 0, 0)
// Primero trasladar, despues rotar: la rotacion arrastra tambien la traslacion.
const trasladarLuegoRotar = new THREE.Matrix4().multiplyMatrices(R, T);
console.log('R por T:', punto.clone().applyMatrix4(trasladarLuegoRotar).toArray());
// (1, 0, -10)
Dos resultados que no se parecen en nada, con las mismas dos operaciones. La interpretación geométrica es la que hay que memorizar: rotar después de trasladar hace girar el objeto alrededor del origen del mundo; rotar antes de trasladar lo hace girar sobre sí mismo. Esa distinción es la base de todos los sistemas de pivotes y de órbitas que verás más adelante.
En la API de Three.js, a.multiply(b) deja en a el producto a por b, es decir, añade b para que se aplique antes. Y a.premultiply(b) deja el producto b por a, añadiendo b para que se aplique después. Los dos nombres describen dónde se coloca el argumento, no cuándo actúa, y confundirlos es un clásico.
Por qué el orden canónico es escala, rotación, traslación
Cada objeto de Three.js tiene tres propiedades independientes —position, quaternion y scale— que se combinan en una sola matriz local. La combinación no es arbitraria: es siempre traslación por rotación por escala, lo que significa que la escala se aplica primero, después la rotación y por último la traslación. Ese orden se conoce como composición TRS y es universal en gráficos.
No es una convención caprichosa. Los otros órdenes están mal por motivos concretos.
La escala tiene que ir antes que la rotación porque una escala no uniforme está definida respecto a unos ejes. Escalar por dos en X significa «estirar en la dirección X del objeto». Si rotas primero y escalas después, el estiramiento ocurre en la dirección X del mundo, y el resultado es que un objeto girado cuarenta y cinco grados se cizalla en lugar de estirarse. Con escala uniforme daría igual, pero el sistema tiene que funcionar en el caso general.
La traslación tiene que ir la última porque si no, la escala y la rotación la afectarían. Un objeto trasladado diez unidades y luego escalado por dos acabaría a veinte unidades del origen, que casi nunca es lo que quieres: la escala debe cambiar el tamaño del objeto, no su distancia.
import * as THREE from 'three';
const objeto = new THREE.Object3D();
objeto.position.set(5, 0, 0);
objeto.rotation.y = Math.PI / 4;
objeto.scale.set(2, 1, 1);
objeto.updateMatrix();
// La matriz local es exactamente T por R por S.
const T = new THREE.Matrix4().makeTranslation(5, 0, 0);
const R = new THREE.Matrix4().makeRotationY(Math.PI / 4);
const S = new THREE.Matrix4().makeScale(2, 1, 1);
const aMano = new THREE.Matrix4().multiply(T).multiply(R).multiply(S);
console.log('coinciden:', objeto.matrix.equals(aMano)); // true
// compose hace lo mismo en una llamada, y decompose deshace la operacion.
const compuesta = new THREE.Matrix4().compose(
objeto.position, objeto.quaternion, objeto.scale
);
console.log('compose coincide:', compuesta.equals(objeto.matrix)); // true
La jerarquía es composición otra vez
La matriz de mundo de un objeto es la matriz de mundo de su padre multiplicada por su matriz local. Nada más. Toda la jerarquía de escena, con sus grupos anidados a diez niveles de profundidad, es una cadena de productos de matrices evaluada de la raíz hacia las hojas.
Léelo con la regla de derecha a izquierda y sale la interpretación correcta: primero actúa la transformación local del objeto, y después, sucesivamente, las de todos sus ancestros. Por eso un hijo se mueve cuando el padre se mueve, y por eso las coordenadas de un hijo están expresadas en el sistema de su padre y no en el del mundo.
También sale de aquí la propiedad de las inversas, que se usa constantemente: la inversa de un producto es el producto de las inversas en orden contrario. Deshacer «ponte los calcetines y luego los zapatos» es «quítate los zapatos y luego los calcetines». Esa identidad es la que permite calcular la matriz de vista como la inversa de la matriz de mundo de la cámara, y la que hace que worldToLocal funcione.
Esta es la trampa estructural del sistema TRS y explica varias advertencias de la documentación de Three.js que parecen arbitrarias. El conjunto de matrices que se pueden escribir como traslación por rotación por escala no es cerrado bajo composición: multiplicas dos de ellas y el resultado puede no ser expresable en esa forma. Ocurre exactamente cuando un ancestro tiene escala no uniforme y el descendiente está rotado respecto a él. La matriz de mundo resultante contiene cizallamiento, y el cizallamiento no cabe en las tres propiedades de un Object3D. Las consecuencias son concretas y desconcertantes cuando no sabes esto. decompose te devolverá una posición, un cuaternión y una escala cuyo producto no reconstruye la matriz original, porque extrae la escala de las longitudes de las columnas y la rotación de las columnas normalizadas, que ya no son perpendiculares entre sí. Por eso la documentación advierte de que attach y lookAt no funcionan con ancestros de escala no uniforme: ambos dependen de descomponer. El síntoma en pantalla es un objeto que al reparentarse cambia sutilmente de forma, o una cámara que apunta ligeramente mal sin motivo aparente. La regla profiláctica es dura pero simple: no pongas escala no uniforme en nodos que tengan hijos rotados. Si necesitas estirar algo, estíralo en la hoja del árbol, o construye la geometría ya estirada. Es una restricción que cuesta aceptar hasta que has perdido una tarde con ella.
import * as THREE from 'three';
const padre = new THREE.Object3D();
padre.scale.set(3, 1, 1); // escala no uniforme
const hijo = new THREE.Object3D();
hijo.rotation.y = Math.PI / 4; // rotado respecto al padre
padre.add(hijo);
padre.updateMatrixWorld(true);
// Intentar descomponer la matriz de mundo del hijo.
const posicion = new THREE.Vector3();
const rotacion = new THREE.Quaternion();
const escala = new THREE.Vector3();
hijo.matrixWorld.decompose(posicion, rotacion, escala);
const reconstruida = new THREE.Matrix4().compose(posicion, rotacion, escala);
console.log('reconstruye la original:', reconstruida.equals(hijo.matrixWorld)); // false
// El diagnostico: las columnas de la parte 3x3 ya no son perpendiculares.
const m3 = new THREE.Matrix3().setFromMatrix4(hijo.matrixWorld);
const e = m3.elements;
const colX = new THREE.Vector3(e[0], e[1], e[2]).normalize();
const colZ = new THREE.Vector3(e[6], e[7], e[8]).normalize();
console.log('coseno entre columnas X y Z:', colX.dot(colZ).toFixed(4)); // no es 0
Ese último número es el detector de cizallamiento: en una matriz TRS legítima las columnas normalizadas son mutuamente perpendiculares y sus productos escalares valen cero. Cuando no valen cero, la jerarquía ha producido algo que el sistema no sabe representar, y cualquier operación que descomponga va a dar resultados aproximados sin avisar.
Reglas prácticas para no equivocarse
Piensa en verbos, no en matrices. Escribe la secuencia de operaciones en castellano —«escala al doble, gira noventa grados, mueve cinco unidades»— y después inviértela para escribir el producto. Es más fiable que intentar razonar directamente con los factores.
Usa la jerarquía en vez de componer a mano. Casi todos los casos en los que uno se pone a multiplicar matrices se resuelven mejor con un objeto padre. Un pivote descentrado no es una matriz complicada: es un grupo desplazado con el objeto dentro.
Cuando una rotación gira alrededor del punto equivocado, el problema es de orden, no de ángulo. Es el diagnóstico más rentable de todos: si el objeto orbita cuando debería girar sobre sí mismo, la traslación está actuando antes que la rotación.
Desconfía de las escalas no uniformes en los padres. Por lo que acabas de leer.
Con la composición entendida, la cadena de matrices más importante de todo el 3D —la que va del modelo a la pantalla— se lee sin ningún misterio.