El coste real del skinning y cómo depurarlo
Dónde se gasta el tiempo un personaje con esqueleto, por qué el número de huesos casi no importa, y cómo usar SkeletonHelper para ver lo que el shader ve.
Existe la creencia de que un esqueleto con muchos huesos es caro. Con la arquitectura de Three.js r184 es falsa, y la creencia contraria —que el skinning es gratis porque va en GPU— también lo es. El coste está en sitios concretos y medibles, y ninguno de ellos es el que suele preocupar a quien pregunta cuántos huesos puede permitirse. Esta lección los enumera y termina con la herramienta que hace visible lo invisible.
- Localizar los cuatro puntos donde un personaje con esqueleto consume tiempo.
- Explicar por qué la cuenta de huesos afecta poco y la de vértices afecta mucho.
- Diagnosticar el problema de culling que sufren las mallas animadas y elegir un remedio.
- Instrumentar un rig con
SkeletonHelpery leer lo que muestra.
Los cuatro sitios donde se va el tiempo
Uno: el bucle de CPU de Skeleton.update(). Recorre los huesos, multiplica dos matrices de cuatro por cuatro y aplana el resultado. Son unas cien operaciones de coma flotante por hueso. Con sesenta huesos son seis mil, despreciable. Con cien personajes de sesenta huesos son seiscientas mil por cuadro, ya no tanto.
Dos: la subida de la textura de huesos. Aquí sí duele. Skeleton.update() termina con boneTexture.needsUpdate = true, y eso obliga a un texImage2D completo por esqueleto y por cuadro. Para sesenta huesos, computeBoneTexture() crea una textura de 16 por 16 téxeles RGBA de coma flotante, o sea 4 KB. Cien personajes son cien subidas de textura y 400 KB de tráfico por cuadro. El volumen no es el problema; la cantidad de llamadas sí, porque cada una es una sincronización con el driver.
Tres: el vertex shader. Cuatro getBoneMatrix, y cada uno son cuatro texelFetch. Dieciséis lecturas de textura por vértice, más cuatro productos de matriz por vector para la posición, más la construcción de skinMatrix para la normal. Es sustancial, y escala con vértices, no con huesos. Un esqueleto de doscientos huesos sobre una malla de cinco mil vértices es más barato que uno de veinte huesos sobre medio millón.
Cuatro: la duplicación por sombras. Si el personaje proyecta sombra, todo el trabajo del vertex shader se repite una vez por cada luz que genere shadow map. Con tres luces direccionales con sombra, el skinning se ejecuta cuatro veces por cuadro sobre los mismos vértices.
// Medida directa del coste por cuadro, sin herramientas externas
const inicio = performance.now();
mixer.update( delta ); // esto dispara el movimiento de los huesos
scene.updateMatrixWorld( true ); // y esto propaga las matrices
console.log( 'CPU de animacion:', ( performance.now() - inicio ).toFixed( 2 ), 'ms' );
console.log( 'draw calls:', renderer.info.render.calls );
console.log( 'triangulos:', renderer.info.render.triangles );
console.log( 'texturas vivas:', renderer.info.memory.textures );
Lo que no existe: el límite de huesos
Hasta WebGL 1, las matrices de huesos viajaban en un array de uniforms, y el número de slots de uniform del vertex shader ponía un techo duro. De ahí salieron el parámetro maxBones, las advertencias de consola sobre modelos con demasiados huesos y la costumbre de partir personajes en trozos.
Nada de eso queda. Skeleton.computeBoneTexture() empaqueta las matrices en una DataTexture cuadrada de cuatro téxeles por matriz, y el shader las lee con texelFetch. El comentario del propio código lo dice con números:
8x8 pixeles -> 16 huesos
16x16 pixeles -> 64 huesos
32x32 pixeles -> 256 huesos
64x64 pixeles -> 1024 huesos
Y la textura crece sin más. Lo único que sube es el tamaño de la subida por cuadro, que va con el cuadrado del lado. Si te encuentras un tutorial que habla de maxBones, de useVertexTexture o de dividir la malla por límite de huesos, está escrito para una versión de Three.js anterior a la retirada de WebGL 1.
El problema del culling, que sí es real
Éste es el defecto que muerde en producción y del que nadie avisa. Mesh.frustumCulled vale true por defecto, y el culling usa geometry.boundingSphere, calculada a partir de las posiciones sin deformar. Un personaje en pose de reposo tiene una esfera envolvente ajustada; ese mismo personaje saltando con los brazos en alto sobresale de ella. Cuando el centro de la esfera sale del frustum, el personaje desaparece de golpe aunque su brazo siguiera siendo visible.
SkinnedMesh ofrece computeBoundingBox() y computeBoundingSphere() propias que sí tienen en cuenta la pose, porque recorren los vértices aplicando applyBoneTransform. El problema es el coste: es una pasada de skinning en CPU sobre toda la geometría. Para una malla de 50000 vértices eso son 50000 iteraciones con cuatro multiplicaciones de matriz cada una. Hacerlo cada cuadro no es viable.
Las tres opciones sensatas, en orden de preferencia:
// A) Esfera manual generosa, calculada una vez. Casi siempre la respuesta.
mesh.geometry.computeBoundingSphere();
mesh.geometry.boundingSphere.radius *= 1.6;
// B) Recalcular cada N cuadros, no cada cuadro.
let contador = 0;
function tick() {
if ( ( contador ++ % 10 ) === 0 ) mesh.computeBoundingSphere();
...
}
// C) Renunciar al culling de este objeto. Correcto si siempre esta en pantalla.
mesh.frustumCulled = false;
La opción A gana casi siempre porque el culling existe para ahorrar draw calls, y fallar por exceso solo cuesta una draw call de vez en cuando, mientras que fallar por defecto produce un personaje que parpadea.
SkeletonHelper: ver lo que el shader ve
SkeletonHelper es un LineSegments que dibuja un segmento por cada hueso que tenga un padre que también sea hueso. Recorre el objeto que le pases buscando nodos con isBone, así que funciona igual con una SkinnedMesh, con un Group o con la escena entera.
const helper = new THREE.SkeletonHelper( mesh );
scene.add( helper );
Su material ya viene con depthTest: false, depthWrite: false y transparent: true, así que se dibuja por encima de la malla sin que tengas que tocar nada. Es la decisión correcta: un esqueleto que se esconde dentro del cuerpo no sirve para depurar.
Los colores por defecto son azul en el extremo del hijo y verde en el del padre, y se cambian con setColors. Eso hace visible la orientación de cada segmento, que es justo lo que necesitas cuando sospechas que un hueso está invertido:
helper.setColors( new THREE.Color( 0xf38ba8 ), new THREE.Color( 0xa6e3a1 ) );
Tres cosas que SkeletonHelper te dice y que ninguna otra herramienta te dice tan rápido. Si el esqueleto se mueve pero la malla no, el problema está en bind() o en los atributos de pesos, no en la animación. Si el esqueleto no se mueve, el problema está en el AnimationMixer o en que el hueso raíz no cuelga de nada actualizable. Y si el esqueleto aparece deformado en reposo, boneInverses se calculó desde una pose equivocada.
Un detalle de implementación que conviene conocer: el helper reconstruye su atributo de posiciones entero en cada updateMatrixWorld y marca needsUpdate. Con doscientos huesos eso es una subida de buffer por cuadro. No lo dejes en producción, y llama a helper.dispose() cuando lo quites.
Varias SkinnedMesh pueden apuntar al mismo objeto Skeleton. Cuerpo, ropa y pelo del mismo personaje deberían hacerlo siempre: una sola boneTexture, un solo Skeleton.update() por cuadro y tres draw calls en lugar de tres esqueletos completos. Hasta aquí, ganancia limpia. Lo que casi nadie mide es el escalón siguiente: una multitud de personajes independientes no se puede compartir, porque cada uno tiene su propia pose. Cien enemigos son cien esqueletos, cien Skeleton.update() y cien subidas de textura por cuadro, y ahí es donde un juego se queda sin CPU sin que el perfilador de GPU muestre nada raro. Three.js no ofrece en el núcleo ninguna malla instanciada con esqueleto: InstancedMesh extiende Mesh, no SkinnedMesh, y BatchedMesh tampoco hace skinning. La solución estándar de la industria para multitudes es la contraria a lo que enseña este nivel entero: hornear la animación a una textura —una fila por cuadro de animación, una columna por vértice— y leerla desde el vertex shader de un InstancedMesh, con el número de cuadro como atributo por instancia. Pierdes la posibilidad de mezclar clips y de tocar huesos por código, y ganas mil personajes en una draw call. Cuando veas a alguien preguntando cuántos huesos aguanta Three.js, la respuesta útil no es un número: es preguntarle cuántos personajes distintos hay en pantalla.