Saltar al contenido

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.

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.

JS
// Medida directa del coste por cuadro, sin herramientas externasconst inicio = performance.now();mixer.update( delta );          // esto dispara el movimiento de los huesosscene.updateMatrixWorld( true ); // y esto propaga las matricesconsole.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:

Texto
 8x8  pixeles ->   16 huesos16x16 pixeles ->   64 huesos32x32 pixeles ->  256 huesos64x64 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:

JS
// 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.

JS
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:

JS
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.