Morph targets: interpolar entre poses fijas
El modelo de datos de los morph targets, la diferencia entre objetivos absolutos y relativos, cómo r184 los empaqueta en una textura, y qué pueden y qué no pueden expresar.
Los morph targets resuelven un problema muy concreto: interpolar entre un puñado de poses que un artista ha modelado a mano. No son un mecanismo general de deformación y no pretenden serlo. Lo que los hace valiosos es que el coste por frame es constante y minúsculo —subir un array de pesos, unos pocos flotantes— mientras que la deformación ocurre entera en la GPU. Y en r184 el mecanismo interno ha cambiado lo suficiente como para que merezca la pena conocerlo, porque desaparecieron los límites que tenía la implementación antigua.
- Construir morph targets a mano y conectarlos a una malla.
- Distinguir objetivos absolutos de relativos y explicar el factor de influencia base.
- Describir cómo r184 empaqueta los objetivos en una textura de array.
- Calcular el coste en memoria y reconocer los límites del modelo.
El modelo: poses fijas y pesos
Un morph target es un atributo alternativo del mismo tamaño que el original. La geometría guarda una lista de ellos y la malla guarda un peso por cada uno:
geometria.morphAttributes.position = [ attr0, attr1, attr2 ];
geometria.morphAttributes.normal = [ normal0, normal1, normal2 ]; // opcional
geometria.morphAttributes.color = [ ... ]; // opcional
malla.morphTargetInfluences = [ 0.3, 0.0, 1.0 ];
malla.morphTargetDictionary = { sonrisa: 0, ceja: 1, boca: 2 };
El diccionario lo construye updateMorphTargets(), que el constructor de Mesh llama automáticamente. Los nombres salen de la propiedad name de cada BufferAttribute, y si está vacía se usa el índice convertido a cadena. Un detalle del código de r184 que conviene conocer: el método mira la primera clave de morphAttributes, no específicamente position. En la práctica es la misma, pero si construyes el objeto con normal primero, los nombres saldrán de ahí.
Un ejemplo completo y ejecutable:
import * as THREE from 'three';
const geometria = new THREE.PlaneGeometry( 4, 4, 32, 32 );
const base = geometria.getAttribute( 'position' );
// Objetivo 1: una cúpula.
const cupula = new Float32Array( base.array );
for ( let i = 0; i < base.count; i ++ ) {
const r = Math.hypot( base.getX( i ), base.getY( i ) );
cupula[ i * 3 + 2 ] = Math.max( 0, Math.cos( r * 0.6 ) ) * 1.5;
}
// Objetivo 2: una onda.
const onda = new Float32Array( base.array );
for ( let i = 0; i < base.count; i ++ ) {
onda[ i * 3 + 2 ] = Math.sin( base.getX( i ) * 1.5 ) * 0.8;
}
const attrCupula = new THREE.Float32BufferAttribute( cupula, 3 );
attrCupula.name = 'cupula';
const attrOnda = new THREE.Float32BufferAttribute( onda, 3 );
attrOnda.name = 'onda';
geometria.morphAttributes.position = [ attrCupula, attrOnda ];
geometria.morphTargetsRelative = false; // posiciones absolutas
const malla = new THREE.Mesh(
geometria,
new THREE.MeshStandardMaterial( { color: 0xa6e3a1, side: THREE.DoubleSide } )
);
scene.add( malla );
console.log( malla.morphTargetDictionary ); // { cupula: 0, onda: 1 }
const reloj = new THREE.Timer();
reloj.connect( document );
const iCupula = malla.morphTargetDictionary.cupula;
const iOnda = malla.morphTargetDictionary.onda;
renderer.setAnimationLoop( ( tiempo ) => {
reloj.update( tiempo );
const t = reloj.getElapsed();
malla.morphTargetInfluences[ iCupula ] = ( Math.sin( t ) + 1 ) / 2;
malla.morphTargetInfluences[ iOnda ] = ( Math.cos( t * 0.7 ) + 1 ) / 2;
renderer.render( scene, camera );
} );
Fíjate en lo que cuesta ese bucle: dos escrituras en un array. Nada más. La geometría no se toca, no hay subida de buffers, no hay recálculo de normales. Es la diferencia esencial con la deformación en CPU, y es lo que hace que sesenta morph targets sobre un personaje de veinte mil vértices sean gratis por frame.
Absoluto frente a relativo
morphTargetsRelative decide cómo se interpretan los valores del objetivo, y cambia por completo la fórmula.
Con false, el objetivo contiene posiciones absolutas: la pose completa. La combinación es una interpolación lineal en la que la base recibe el peso sobrante:
resultado = base · (1 − Σ influencias) + Σ ( objetivo_i · influencia_i )
Con true, el objetivo contiene desplazamientos respecto a la base, y la combinación es una suma pura:
resultado = base + Σ ( delta_i · influencia_i )
Ese 1 − Σ influencias es exactamente el uniform morphTargetBaseInfluence que el renderer calcula y envía. En modo relativo vale uno siempre.
La diferencia práctica es importante. En modo absoluto, las influencias deberían sumar como máximo uno; si suman más, el factor base se vuelve negativo y la base se resta, produciendo deformaciones exageradas. A veces es lo que quieres y a veces es un bug. En modo relativo no hay tal restricción: los deltas se suman sin más y superponer varios es natural.
Por eso el formato glTF usa objetivos relativos, y el cargador de Three.js pone morphTargetsRelative a true al importar. Es también la razón de que las expresiones faciales de un modelo glTF se puedan combinar libremente: cada una es un delta y sumar dos no interfiere con la tercera.
El mismo mecanismo aplica a los otros canales. Si defines morphAttributes.normal, las normales se interpolan igual y no hace falta recalcular nada. Es la única situación en toda la deformación en la que las normales se resuelven solas, y merece la pena aprovecharla: al construir un morph target a mano, construye también su normal.
Cómo llegan a la GPU en r184
La implementación antigua usaba atributos: cada morph target ocupaba una ranura de atributo del vertex shader, y como hay dieciséis en total y el material ya usa varias, el límite práctico eran ocho objetivos, cuatro si además querías normales. Ese límite era la queja principal de cualquiera que trabajase con expresiones faciales.
r184 lo hace de otra forma. Todos los objetivos se empaquetan en una textura de array de flotantes, y el vertex shader los lee con acceso directo por índice:
// morphtarget_pars_vertex.glsl.js, en esencia
uniform sampler2DArray morphTargetsTexture;
uniform ivec2 morphTargetsTextureSize;
vec4 getMorph( const in int vertexIndex, const in int morphTargetIndex, const in int offset ) {
int texelIndex = vertexIndex * MORPHTARGETS_TEXTURE_STRIDE + offset;
int y = texelIndex / morphTargetsTextureSize.x;
int x = texelIndex - y * morphTargetsTextureSize.x;
return texelFetch( morphTargetsTexture, ivec3( x, y, morphTargetIndex ), 0 );
}
Y la aplicación:
// morphtarget_vertex.glsl.js
transformed *= morphTargetBaseInfluence;
for ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {
if ( morphTargetInfluences[ i ] != 0.0 ) {
transformed += getMorph( gl_VertexID, i, 0 ).xyz * morphTargetInfluences[ i ];
}
}
Cuatro consecuencias salen de ahí. No hay límite de ocho objetivos: el límite lo pone el tamaño máximo de textura, que en la práctica son cientos. El desplazamiento offset selecciona el canal: cero para posición, uno para normal, dos para color. La comprobación de influencia distinta de cero salta el trabajo de los objetivos inactivos, lo cual importa mucho cuando tienes cincuenta expresiones faciales y solo tres están activas. Y la textura se cachea por geometría en un WeakMap y se libera al hacer dispose() de la geometría.
Hay un caso especial que merece mención: con InstancedMesh, las influencias no vienen de un uniform sino de una textura propia de la instancia, poblada con setMorphAt( indice, objeto ). Eso permite que mil instancias de la misma malla tengan cada una su propia combinación de morph targets, que es como se hace una multitud con caras distintas.
Lo que cuesta y lo que no puede hacer
El coste en memoria de vídeo es directo. Cada vértice y cada objetivo ocupan un téxel de cuatro flotantes por cada canal activo:
bytes = numObjetivos · numVertices · canales · 16
Para un personaje de veinte mil vértices con cincuenta expresiones y solo el canal de posición: 50 · 20 000 · 1 · 16 = 16 MB. Añadir el canal de normales lo duplica a treinta y dos. No es trivial, y es la razón de que en producción se acabe reduciendo el número de objetivos o el número de vértices afectados por cada uno.
El coste de cómputo es un bucle por vértice sobre todos los objetivos, con la comprobación que salta los inactivos. Con cincuenta objetivos y tres activos, el trabajo real es de tres lecturas de textura por vértice, más cincuenta comparaciones. Barato.
Y ahora los límites del modelo, que son la razón de que los morph targets no sustituyan al vertex shader.
Solo interpolan entre poses que existen. Una ola infinita, un ruido, una deformación que dependa de una posición del ratón o de un tiempo continuo no se pueden expresar como combinación de un número finito de poses. Los morph targets son para animación autorizada, no para deformación procedural.
La interpolación es lineal por vértice, y eso rompe las rotaciones. Si un objetivo representa un brazo girado noventa grados, interpolar al cincuenta por ciento no da un brazo a cuarenta y cinco grados: da un brazo acortado, porque la interpolación lineal entre dos puntos de un arco pasa por la cuerda. Ese artefacto de encogimiento es la razón de que las articulaciones se animen con esqueleto y no con morphs, y de que los morphs se reserven para deformaciones locales sin rotación: caras, músculos, telas, correcciones.
El número de vértices tiene que ser idéntico. Los objetivos son atributos paralelos, así que no puede haber cambios de topología. Dos poses con distinto número de vértices no se pueden interpolar en absoluto.
Un apunte que sí funciona y sorprende: el raycasting tiene en cuenta los morph targets. Mesh reconstruye la posición interpolada de cada vértice al comprobar intersecciones, respetando morphTargetsRelative y saltando las influencias nulas. Es el único caso de toda la deformación en GPU en el que Three.js se molesta en replicar el cálculo en la CPU, y es una diferencia real frente al desplazamiento en el vertex shader.
La forma útil de entender los morph targets es que no resuelven un problema técnico, resuelven un problema de flujo de trabajo. Cualquier deformación que expresen se podría escribir en un vertex shader con menos memoria y más flexibilidad; lo que no se puede escribir en un vertex shader es la intención de un artista. Cuando un modelador esculpe la expresión de sorpresa de un personaje, está tomando cientos de decisiones locales que ninguna función matemática captura: cuánto se levanta cada ceja, cómo se estira el párpado, dónde exactamente se forma el pliegue. Los morph targets son el formato en el que ese trabajo llega al motor sin perderse. Y por eso su diseño está optimizado para lo que un artista necesita y no para lo que un programador esperaría: los objetivos son relativos porque así se combinan libremente sin que uno anule a otro, tienen nombre porque el rig los referencia por nombre, y admiten canal de normales porque el artista quiere controlar el sombreado de un pliegue y no aceptar el que salga de promediar caras. La consecuencia práctica es que la pregunta correcta para decidir entre morphs y shader no es cuál es más eficiente, sino de dónde viene la deformación. Si viene de una fórmula, del tiempo, de una interacción o de una simulación, es un shader; si viene de alguien que la ha esculpido, son morphs, y meterla en un shader significa tirar el trabajo y aproximarlo. Los sistemas que mejor funcionan usan los dos a la vez y no se plantean elegir: los morphs llevan la actuación y el shader añade encima el viento, la respiración y el temblor.
- Monta el ejemplo de los dos objetivos y comprueba el contenido de
morphTargetDictionary. - Pon las dos influencias a uno en modo absoluto y explica el resultado a partir del factor base.
- Convierte los objetivos a relativos restando la base y activa
morphTargetsRelative. - Añade
morphAttributes.normaly verifica que la iluminación se interpola sin recalcular nada. - Comprueba que un raycast contra la malla deformada intersecta la superficie visible y no la de reposo.