wandres.dev
SKINNING Y RIGGING · Personajes con esqueleto

SkinnedMesh, Skeleton y Bone: las tres piezas

Qué es exactamente cada una de las tres clases del sistema de skinning de Three.js, cómo se relacionan y cómo montar un esqueleto funcional desde cero sin cargar ningún modelo.

⏱ 20 min

Un personaje animado no deforma su malla vértice a vértice desde JavaScript: eso sería impensable a sesenta cuadros por segundo. Lo que hace es delegar la deformación a la GPU y describirla mediante una jerarquía de transformaciones que llamamos esqueleto. Three.js implementa esa idea con exactamente tres clases, y entender qué hace cada una —y sobre todo cuál de ellas no es lo que parece— es la diferencia entre depurar un rig en diez minutos o en dos días.

🎯 Al terminar esta lección sabrás
  • Distinguir la responsabilidad de Bone, Skeleton y SkinnedMesh sin confundir la jerarquía con el array.
  • Construir un esqueleto y una malla deformable desde cero, sin cargar ningún asset.
  • Explicar por qué los huesos tienen que estar dentro del grafo de escena para que el skinning funcione.
  • Identificar el orden correcto de las operaciones de enlace y qué falla si lo alteras.

Un hueso es un Object3D con otro nombre

Bone extiende Object3D y no añade absolutamente nada excepto la marca isBone. No tiene longitud, no tiene grosor, no tiene forma. Un hueso es un sistema de coordenadas con nombre que cuelga de otro sistema de coordenadas. Lo que en Blender ves como un octaedro alargado es una convención de dibujo: el hueso real es solo el origen, y el “hueso” que percibes es el segmento entre el origen de un Bone y el de su hijo.

Esa decisión de diseño explica un montón de comportamiento. Como los huesos son Object3D, se organizan con add() y remove(), se transforman con position, quaternion y scale, y sobre todo participan del recorrido normal de actualización de matrices. Object3D.updateMatrixWorld() baja por el árbol multiplicando la matriz local de cada nodo por la global de su padre. Ese recorrido es el que produce el matrixWorld de cada hueso, y ese matrixWorld es toda la información que el skinning necesita.

De ahí sale la primera regla práctica, y la que más veces rompe un rig hecho a mano: si el hueso raíz no cuelga de algo que esté en la escena, su matrixWorld nunca se actualiza. La malla se dibujará con las matrices del primer cuadro, congelada para siempre, y no habrá ningún error en consola. Lo habitual es colgar el hueso raíz de la propia SkinnedMesh, que ya está en la escena.

El esqueleto no es un grafo, es un array

Aquí viene la parte contraintuitiva. Skeleton no representa la jerarquía. La jerarquía ya está en los Bone, mediante sus relaciones padre-hijo. Lo que Skeleton aporta es una lista plana y ordenada de huesos, más una lista paralela de matrices inversas.

const skeleton = new THREE.Skeleton( bones );          // bones es un Array<Bone>
skeleton.bones;          // Array<Bone>, orden arbitrario pero fijo
skeleton.boneInverses;   // Array<Matrix4>, misma longitud
skeleton.boneMatrices;   // Float32Array de bones.length * 16
skeleton.boneTexture;    // DataTexture o null

El orden de ese array es lo que da sentido al atributo skinIndex de la geometría: cuando un vértice dice que lo influye el hueso 7, se refiere a skeleton.bones[7]. El array puede estar en cualquier orden mientras coincida con lo que la geometría espera; los cargadores lo construyen a partir de la lista de articulaciones del formato, no del recorrido del árbol.

Cada cuadro, antes de dibujar, el renderer llama a skeleton.update(), que hace exactamente esto:

for ( let i = 0; i < bones.length; i ++ ) {
  _offsetMatrix.multiplyMatrices( bones[ i ].matrixWorld, boneInverses[ i ] );
  _offsetMatrix.toArray( boneMatrices, i * 16 );
}
boneTexture.needsUpdate = true;

Es decir: para cada hueso compone su matriz mundial actual con la inversa de su matriz mundial en el momento del enlace, y aplana el resultado en un Float32Array. Ese producto es la transformación de desplazamiento del hueso: cuánto se ha movido respecto de la pose de reposo. Si el hueso no se ha movido, el producto es la identidad y el vértice se queda donde estaba.

Ese Float32Array viaja a la GPU dentro de una DataTexture en formato RGBA de coma flotante, cuatro téxeles por matriz. Lo crea skeleton.computeBoneTexture(), que el renderer invoca la primera vez que ve la malla. En Three.js moderno no existe un límite de huesos impuesto por los slots de uniforms: la vieja constante maxBones y las advertencias sobre modelos con más de 64 huesos pertenecen a la era WebGL 1 y ya no aplican.

Atar la malla al esqueleto

SkinnedMesh extiende Mesh y añade el enlace. El método clave es bind():

bind( skeleton, bindMatrix ) {
  this.skeleton = skeleton;
  if ( bindMatrix === undefined ) {
    this.updateMatrixWorld( true );
    this.skeleton.calculateInverses();
    bindMatrix = this.matrixWorld;
  }
  this.bindMatrix.copy( bindMatrix );
  this.bindMatrixInverse.copy( bindMatrix ).invert();
}

Si no le pasas una matriz de enlace, bind() fuerza una actualización completa de matrices, calcula las inversas de los huesos a partir de su posición en ese instante y toma la matriz mundial de la malla como matriz de enlace. Esa es la razón por la que el orden importa tanto: en el momento de llamar a bind(), los huesos tienen que estar ya en su pose de reposo y ya dentro del grafo.

La malla necesita además dos atributos de geometría, skinIndex y skinWeight, que la siguiente lección desmenuza. Sin ellos el material no activa el camino de skinning y la malla se dibuja rígida.

Montar un esqueleto a mano

Un cilindro con cinco huesos en cadena, con los pesos repartidos linealmente entre cada par de huesos consecutivos. Es el ejemplo mínimo completo y funciona tal cual:

import * as THREE from 'three';

const ALTURA = 4;
const HUESOS = 5;
const SEGMENTOS = 24;   // divisiones a lo largo del eje: la resolucion de la deformacion

const geometry = new THREE.CylinderGeometry( 0.35, 0.35, ALTURA, 16, SEGMENTOS, true );
geometry.translate( 0, ALTURA / 2, 0 );   // base en y = 0, como los huesos

// 1. La cadena de huesos, cada uno hijo del anterior
const bones = [];
let padre = null;
const paso = ALTURA / ( HUESOS - 1 );

for ( let i = 0; i < HUESOS; i ++ ) {
  const bone = new THREE.Bone();
  bone.position.y = i === 0 ? 0 : paso;   // posicion LOCAL respecto del padre
  if ( padre !== null ) padre.add( bone );
  bones.push( bone );
  padre = bone;
}

// 2. Pesos: cada vertice se reparte entre los dos huesos que lo rodean
const posicion = geometry.attributes.position;
const indices = [];
const pesos = [];

for ( let i = 0; i < posicion.count; i ++ ) {
  const t = posicion.getY( i ) / paso;                    // en que tramo cae
  const inferior = Math.min( Math.floor( t ), HUESOS - 2 );
  const w = t - inferior;                                 // 0 abajo, 1 arriba
  indices.push( inferior, inferior + 1, 0, 0 );
  pesos.push( 1 - w, w, 0, 0 );
}

geometry.setAttribute( 'skinIndex', new THREE.Uint16BufferAttribute( indices, 4 ) );
geometry.setAttribute( 'skinWeight', new THREE.Float32BufferAttribute( pesos, 4 ) );

// 3. Malla, esqueleto y enlace, en este orden
const material = new THREE.MeshStandardMaterial( { roughness: 0.6, metalness: 0.1 } );
const mesh = new THREE.SkinnedMesh( geometry, material );
const skeleton = new THREE.Skeleton( bones );

mesh.add( bones[ 0 ] );    // el hueso raiz entra en el grafo colgando de la malla
mesh.bind( skeleton );     // sin bindMatrix: usa mesh.matrixWorld y calcula las inversas

scene.add( mesh );

// 4. Animar es mover huesos, nada mas
const reloj = new THREE.Timer();
reloj.connect( document );

function tick( tiempo ) {
  reloj.update( tiempo );
  const t = reloj.getElapsed();
  for ( let i = 1; i < bones.length; i ++ ) {
    bones[ i ].rotation.z = Math.sin( t * 1.5 + i * 0.6 ) * 0.35;
  }
  renderer.render( scene, camera );
  requestAnimationFrame( tick );
}
tick();

Fíjate en lo que no hay: ni una sola línea que toque geometry.attributes.position después de la creación. Todo el trabajo por cuadro es mover cinco Object3D y dejar que el vertex shader haga el resto. Ese es exactamente el negocio del skinning.

El orden de bind decide cual es la pose de reposo

mesh.bind( skeleton ) sin matriz explícita hace tres cosas encadenadas y en este orden: updateMatrixWorld( true ) sobre la malla, skeleton.calculateInverses() y la captura de mesh.matrixWorld como matriz de enlace. La segunda es la peligrosa: calculateInverses() recorre los huesos y guarda la inversa de su matrixWorld actual. Lo que ese instante congela es la definición de “pose de reposo”. Si llamas a bind() con el esqueleto ya deformado, esa deformación pasa a ser el cero del sistema y todo lo que animes después será relativo a ella. Y si llamas a bind() antes de haber añadido el hueso raíz al grafo, updateMatrixWorld no llega a los huesos, sus matrixWorld valen la identidad, las inversas también, y obtienes una malla que se dispara al infinito en cuanto muevas el primer hueso. El corolario práctico: nunca vuelvas a llamar a bind() sobre un modelo cargado con GLTFLoader. El formato glTF trae las inversas en el accessor inverseBindMatrices y el cargador se las pasa al constructor de Skeleton; rebindar a mano las tira a la basura y las recalcula desde la pose en la que esté el modelo en ese momento, que casi nunca es la de reposo.

⚔️ Reto práctico

Cambia SEGMENTOS de 24 a 3 y vuelve a ejecutar. Verás que la deformación deja de ser una curva y pasa a ser una cadena de tres tramos rectos. Anota el número de vértices en cada caso y comprueba que el coste de la animación en CPU (mover cinco huesos) no cambia en absoluto: lo único que cambia es cuántas veces corre el vertex shader.