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.
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)); // trueLa 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.
import * as THREE from 'three';const padre = new THREE.Object3D();padre.scale.set(3, 1, 1); // escala no uniformeconst hijo = new THREE.Object3D();hijo.rotation.y = Math.PI / 4; // rotado respecto al padrepadre.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 0Ese ú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.