wandres.dev
SKINNING Y RIGGING · Personajes con esqueleto

skinIndex y skinWeight: cuatro influencias por vértice

Los dos atributos que describen a qué huesos pertenece cada vértice, por qué el límite es exactamente cuatro, y qué hacer cuando cuatro no bastan.

⏱ 17 min

Toda la información que la GPU tiene sobre a quién pertenece un vértice cabe en ocho números: cuatro índices de hueso y cuatro pesos. Ni uno más. Ese número, cuatro, no es una limitación de Three.js sino una convención de la industria que arrastra treinta años y que sigue viva porque la alternativa cuesta más de lo que aporta. Entender de dónde sale explica también por qué los rigs se diseñan como se diseñan.

🎯 Al terminar esta lección sabrás
  • Declarar correctamente los atributos skinIndex y skinWeight con el tipo y el tamaño de ítem adecuados.
  • Explicar por qué el límite de cuatro influencias es estructural y no un parámetro configurable.
  • Detectar y corregir pesos que no suman uno, y saber qué artefacto produce cada caso.
  • Decidir qué hacer cuando un modelo necesita más de cuatro influencias por vértice.

Dos atributos vec4 y nada más

La geometría de una SkinnedMesh lleva dos atributos adicionales, ambos de cuatro componentes por vértice:

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

skinIndex guarda cuatro índices dentro de skeleton.bones. skinWeight guarda cuánto manda cada uno de esos cuatro huesos sobre este vértice. Los pesos deben sumar uno; los índices con peso cero se ignoran, y por convención se rellenan con 0.

En el lado del shader, Three.js declara ambos en el prefijo que inyecta cuando el objeto es una SkinnedMesh:

#ifdef USE_SKINNING
  attribute vec4 skinIndex;
  attribute vec4 skinWeight;
#endif

Repara en el tipo: skinIndex es un vec4, no un ivec4. Aunque en memoria son enteros sin signo de 16 bits, el pipeline los convierte a coma flotante al leerlos porque el atributo se declara como flotante. Por eso la función que recupera la matriz del hueso recibe un float:

mat4 getBoneMatrix( const in float i ) {
  int size = textureSize( boneTexture, 0 ).x;
  int j = int( i ) * 4;
  ...
}

Y por eso también hay un techo real: un float de 32 bits representa enteros exactamente hasta 2 elevado a 24, así que el índice de hueso es fiable hasta unos dieciséis millones. Muy por encima de cualquier rig humano. El límite práctico lo pone el Uint16Array, que corta en 65535 huesos.

Por qué exactamente cuatro

Hay tres razones que se refuerzan, y ninguna es “porque sí”.

La primera es de ancho de banda. Cuatro pesos en Float32Array son 16 bytes por vértice, y cuatro índices en Uint16Array son 8 más. Veinticuatro bytes por vértice solo para el rigging, cuando la posición ocupa doce. Duplicar a ocho influencias duplica ese coste en memoria de vídeo y en el bus para un beneficio que solo se nota en un puñado de vértices de las axilas.

La segunda es de arquitectura del shader. vec4 es la unidad natural del hardware gráfico: un registro vectorial cabe exactamente cuatro flotantes, y una operación sobre un vec4 cuesta lo mismo que una sobre un float. Cuatro influencias caben en un solo registro y se procesan sin bucles. Ocho requieren dos atributos, dos registros y el doble de accesos a la textura de huesos, que son cuatro texelFetch por hueso.

La tercera es que el formato lo permite pero Three.js no lo lee. glTF define JOINTS_1 y WEIGHTS_1 como conjuntos adicionales para llegar a ocho influencias. El GLTFLoader de Three.js mapea únicamente JOINTS_0 a skinIndex y WEIGHTS_0 a skinWeight; los conjuntos secundarios se descartan en silencio. Si exportas un modelo con ocho influencias, cargará sin error y se deformará con las cuatro primeras, que además no tienen por qué ser las cuatro de mayor peso.

Aspecto Cuatro influencias Ocho influencias
Bytes por vértice 24 48
Lecturas de la textura de huesos 16 téxeles 32 téxeles
Soporte en glTF JOINTS_0 JOINTS_0 y JOINTS_1
Soporte en Three.js r184 no

Los pesos tienen que sumar uno

Si los cuatro pesos suman menos de uno, el vértice se encoge hacia el origen del espacio de enlace. Si suman más, se aleja. No hay validación ni aviso: la fórmula del skinning es una suma ponderada, y una suma ponderada cuyos pesos no suman uno deja de ser una interpolación y pasa a ser un escalado.

Los pesos suelen llegar desnormalizados por dos vías. La primera es la cuantización: glTF permite almacenar los pesos como enteros normalizados de 8 o 16 bits, y al redondear cuatro valores a 1/255 la suma se desvía. La segunda es el propio flujo de trabajo del artista: limitar a cuatro influencias en Blender descarta las sobrantes y, si no marcas la normalización, la suma queda por debajo de uno.

Three.js trae el remedio:

mesh.normalizeSkinWeights();

Que por dentro hace esto por cada vértice:

vector.fromBufferAttribute( skinWeight, i );
const scale = 1.0 / vector.manhattanLength();
if ( scale !== Infinity ) vector.multiplyScalar( scale );
else vector.set( 1, 0, 0, 0 );
skinWeight.setXYZW( i, vector.x, vector.y, vector.z, vector.w );

Un diagnóstico rápido antes de aplicar nada, porque conviene saber si el problema existe:

function auditarPesos( geometry ) {
  const w = geometry.attributes.skinWeight;
  let peor = 0;
  let ceros = 0;
  for ( let i = 0; i < w.count; i ++ ) {
    const s = w.getX( i ) + w.getY( i ) + w.getZ( i ) + w.getW( i );
    peor = Math.max( peor, Math.abs( s - 1 ) );
    if ( s === 0 ) ceros ++;
  }
  console.log( 'desviacion maxima de la suma:', peor.toFixed( 5 ) );
  console.log( 'vertices sin ninguna influencia:', ceros );
}

Un vértice con los cuatro pesos a cero es peor que uno desnormalizado: colapsa al origen del espacio de enlace y produce el clásico triángulo que sale disparado hacia el centro de la escena y atraviesa todo el modelo.

Cuándo cuatro no bastan

Cuatro influencias fallan en tres sitios concretos: la unión del hombro con el tronco cuando hay huesos de clavícula y de deltoides auxiliares, la base del pulgar, y cualquier prenda suelta —capa, falda, pelo— pintada sobre muchos huesos a la vez. En esos casos tienes cuatro salidas reales.

Reducir en el origen es la primera y casi siempre la correcta. En Blender, Weights y luego Limit Total con 4, seguido de Normalize All. Eso descarta las influencias menores y renormaliza. La pérdida visual es casi nula porque las influencias quinta y sexta suelen valer menos de 0.05.

Dividir la malla es la segunda: separar la capa en su propio SkinnedMesh que comparte el mismo Skeleton. Cada malla tiene sus propios cuatro slots y el esqueleto se reutiliza sin coste adicional, porque skeleton.boneTexture es una sola textura para todas.

Añadir huesos auxiliares es la tercera y la que usa la industria: en vez de que un vértice necesite seis influencias, se interpone un hueso intermedio cuya rotación es la media de los dos que lo rodean. El vértice pasa a necesitar dos. Es la técnica de los huesos de torsión y de los huesos de corrección.

La cuarta es aceptar el error y taparlo con blend shapes correctivas, que es lo que hace cualquier producción de personajes seria y lo que el sistema de morph targets de Three.js permite combinar con el skinning.

normalizeSkinWeights usa la norma L1, y eso rompe con pesos negativos

SkinnedMesh.normalizeSkinWeights() divide cada vector de pesos por Vector4.manhattanLength(), es decir por la suma de los valores absolutos de las cuatro componentes. Con pesos positivos eso es exactamente lo que quieres. Pero algunos exportadores —y algunas herramientas de retopología automática— producen pesos ligeramente negativos como resultado de una interpolación o de un suavizado. Con un vector como ( 0.9, 0.3, -0.2, 0.0 ), la suma real vale 1.0 y no hace falta tocar nada, pero la norma L1 vale 1.4, así que normalizeSkinWeights() divide todo por 1.4 y deja la suma en 0.714. Acabas de encoger ese vértice un 29 % llamando a la función que existía para arreglarlo. El síntoma es cruel porque solo afecta a unos pocos vértices y solo en la pose de reposo se ve bien: en cuanto animas, esos vértices se quedan atrás. Antes de normalizar, comprueba si hay negativos; si los hay, súbelos a cero con Math.max y luego normaliza. Y si el modelo viene de glTF con pesos cuantizados a entero sin signo, los negativos son imposibles por construcción, así que ahí puedes normalizar a ciegas.