La matriz de bind y su inversa
La cadena completa de espacios que atraviesa un vértice deformado por huesos, por qué hacen falta cuatro matrices y no dos, y qué significan bindMatrix y bindMatrixInverse.
Cuatro matrices, cuatro traducciones
Un vértice de una malla con esqueleto empieza en el espacio del objeto: las coordenadas que están literalmente en geometry.attributes.position. Tiene que acabar en ese mismo espacio, porque después el pipeline le aplicará modelViewMatrix y projectionMatrix como a cualquier otro vértice. Todo lo que ocurre en medio es un viaje de ida y vuelta.
La fórmula, para un vértice v con influencias de peso w sobre huesos i:
v' = bindMatrixInverse * SUMA_i [ w_i * ( boneWorld_i * boneInverse_i ) * bindMatrix * v ]Y ahora las cuatro traducciones, de dentro hacia fuera:
bindMatrix lleva el vértice del espacio del objeto al espacio mundial de la pose de enlace. Es la matrixWorld que tenía la malla en el instante en que se llamó a bind(). Existe porque los huesos viven en coordenadas mundiales y el vértice no; hay que ponerlos en el mismo sistema antes de poder comparar nada.
boneInverse_i lleva del espacio mundial de la pose de enlace al espacio local del hueso i. Es la inversa de bones[i].matrixWorld en el momento del enlace. Después de aplicarla, el vértice está expresado como “tanto a la izquierda y tanto por encima del origen del hueso”, que es una descripción que no cambia cuando el hueso se mueve. Ese es el truco central de todo el sistema.
boneWorld_i es bones[i].matrixWorld ahora. Coge esa descripción relativa al hueso y la pone de vuelta en coordenadas mundiales, pero con el hueso en su posición actual. Si el hueso ha rotado noventa grados, el vértice rota noventa grados con él.
bindMatrixInverse devuelve del espacio mundial al espacio del objeto, para que el resto del pipeline funcione como siempre.
La combinación boneWorld_i * boneInverse_i es lo único que cambia entre cuadros, y es exactamente lo que Skeleton.update() precalcula en CPU y sube a la textura de huesos. Si el hueso no se ha movido desde el enlace, ese producto es la identidad. Por eso una malla con el esqueleto en reposo se dibuja idéntica a la geometría original, hasta el último bit.
El chunk real de Three.js r184
Con la fórmula clara, el código del motor se lee solo. Son tres fragmentos que el renderer inserta en el vertex shader cuando el objeto es una SkinnedMesh.
Primero se recuperan las cuatro matrices desde la textura:
mat4 boneMatX = getBoneMatrix( skinIndex.x );mat4 boneMatY = getBoneMatrix( skinIndex.y );mat4 boneMatZ = getBoneMatrix( skinIndex.z );mat4 boneMatW = getBoneMatrix( skinIndex.w );Después se aplica la fórmula sobre la posición, que en el pipeline de Three.js viaja en una variable llamada transformed:
vec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );vec4 skinned = vec4( 0.0 );skinned += boneMatX * skinVertex * skinWeight.x;skinned += boneMatY * skinVertex * skinWeight.y;skinned += boneMatZ * skinVertex * skinWeight.z;skinned += boneMatW * skinVertex * skinWeight.w;transformed = ( bindMatrixInverse * skinned ).xyz;Y por último, la normal, que necesita un tratamiento distinto:
mat4 skinMatrix = mat4( 0.0 );skinMatrix += skinWeight.x * boneMatX;skinMatrix += skinWeight.y * boneMatY;skinMatrix += skinWeight.z * boneMatZ;skinMatrix += skinWeight.w * boneMatW;skinMatrix = bindMatrixInverse * skinMatrix * bindMatrix;objectNormal = vec4( skinMatrix * vec4( objectNormal, 0.0 ) ).xyz;Compara los dos últimos bloques con atención, porque no hacen lo mismo. Para la posición se transforma el punto con cada matriz y luego se promedian los puntos. Para la normal se promedian las matrices y luego se transforma el vector una sola vez. Matemáticamente el primer camino y el segundo dan el mismo resultado para una transformación afín, porque la suma ponderada conmuta con la multiplicación por matriz. La diferencia es de coste: promediar cuatro matrices y multiplicar una vez sale más barato que multiplicar cuatro veces y promediar, cuando además tienes que hacerlo también para la tangente.
Y fíjate en el 0.0 de la componente w de la normal: eso anula la parte de traslación de la matriz, que es lo correcto para un vector de dirección. Ese cuatro por cuatro con w a cero es, en la práctica, un tres por tres.
Attached y detached
SkinnedMesh.bindMode tiene dos valores y controla una sola línea, dentro de updateMatrixWorld:
if ( this.bindMode === AttachedBindMode ) { this.bindMatrixInverse.copy( this.matrixWorld ).invert();} else if ( this.bindMode === DetachedBindMode ) { this.bindMatrixInverse.copy( this.bindMatrix ).invert();}En AttachedBindMode, que es el predeterminado, bindMatrixInverse se recalcula cada cuadro a partir de la matriz mundial actual de la malla. Eso significa que si mueves la SkinnedMesh por la escena, la vuelta al espacio del objeto usa la transformación nueva y la malla acompaña al objeto sin deformarse de más. Es el comportamiento que quieres el 95 % de las veces, y el que hace que un personaje cargado de glTF se pueda mover con mesh.position.set(...) sin pensarlo.
En DetachedBindMode, bindMatrixInverse es siempre la inversa de la matriz de enlace, que no cambia nunca. La malla queda anclada al espacio en el que estaban los huesos, con independencia de dónde esté ella en el grafo. Es lo que necesitas cuando varias mallas comparten un esqueleto que vive en otra rama de la escena: el cuerpo, la ropa y el pelo cuelgan de sitios distintos y todos siguen al mismo Skeleton.
mesh.bindMode = THREE.DetachedBindMode;mesh.bind( skeleton, matrizDeEnlaceCompartida );