wandres.dev
SKINNING Y RIGGING · Personajes con esqueleto

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.

⏱ 19 min

La fórmula del skinning tiene cuatro matrices por hueso y a primera vista dos parecen sobrar. No sobran: cada una traduce entre dos espacios de coordenadas concretos, y quitar cualquiera de ellas rompe un caso de uso real. Esta lección desarma la fórmula pieza a pieza hasta que las cuatro se vuelven obvias, porque a partir de ahí depurar un rig deja de ser adivinar y pasa a ser leer.

🎯 Al terminar esta lección sabrás
  • Escribir la fórmula completa del skinning lineal y nombrar el espacio de origen y de destino de cada matriz.
  • Explicar qué congela boneInverses y por qué se calcula una sola vez.
  • Diferenciar AttachedBindMode de DetachedBindMode y decidir cuál usar.
  • Leer el chunk de skinning de Three.js r184 sin traducirlo mentalmente.

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.

flowchart TB
A[espacio del objeto] -->|bindMatrix| B[mundo en la pose de enlace]
B -->|boneInverse i| C[espacio local del hueso i]
C -->|boneWorld i actual| D[mundo en la pose actual]
D -->|bindMatrixInverse| E[espacio del objeto deformado]
E -->|modelViewMatrix| F[espacio de vista]
F -->|projectionMatrix| G[clip space]
style A fill:#89b4fa,color:#11111b
style B fill:#cba6f7,color:#11111b
style C fill:#94e2d5,color:#11111b
style D fill:#cba6f7,color:#11111b
style E fill:#89b4fa,color:#11111b
style F fill:#f9e2af,color:#11111b
style G fill:#a6e3a1,color:#11111b

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 );
La malla no tiene por que estar en el mismo sitio que su esqueleto

La existencia de bindMatrix como matriz independiente es lo que permite el patrón que sostiene cualquier sistema de personalización de personajes: un esqueleto, muchas mallas. Cuerpo, camisa, pantalón, casco y capa son cinco SkinnedMesh distintas, cada una con su geometría, su material y sus pesos, todas enlazadas al mismo Skeleton. La textura de huesos se calcula una vez por cuadro y se comparte entre las cinco. El coste de añadir una prenda es una draw call más y ni un solo cálculo de esqueleto adicional. Lo que casi nadie hace bien es lo siguiente: al intercambiar una prenda por otra hay que enlazarla con la misma matriz de enlace que usó el cuerpo, no dejar que bind() la deduzca de la posición actual de la prenda nueva. Si la geometría de la prenda vino exportada en el mismo espacio que el cuerpo —y debería, si sale del mismo rig—, lo correcto es prenda.bind( skeleton, cuerpo.bindMatrix ). Si en cambio llamas a prenda.bind( skeleton ) a secas, Three recalculará boneInverses desde la pose en la que esté el esqueleto en ese instante, que si el personaje está animado no es la de reposo, y la prenda entera quedará deformada respecto del cuerpo de una manera que parece un problema de pesos y no lo es.