Saltar al contenido

Morphing: interpolar entre dos formas

Las dos vías para transformar una malla en otra —atributo propio o morph targets de Three.js— con el problema de la correspondencia de vértices y el de las normales.

Vía uno: un atributo con el destino

La forma más directa. Se añade a la geometría un atributo con la posición de destino de cada vértice y se interpola con un uniform.

JS
const origen = new THREE.IcosahedronGeometry( 1, 5 ).toNonIndexed();const total = origen.attributes.position.count;// Un destino cualquiera con el mismo numero de verticesconst destino = new THREE.TorusKnotGeometry( 0.7, 0.25, 200, 32 ).toNonIndexed();if ( destino.attributes.position.count !== total ) {  throw new Error( 'Los recuentos de vertices no coinciden' );}origen.setAttribute( 'aDestino', destino.attributes.position.clone() );
GLSL
attribute vec3 aDestino;uniform float uProgreso;void main() {  vec3 p = mix( position, aDestino, uProgreso );  gl_Position = projectionMatrix * modelViewMatrix * vec4( p, 1.0 );}

Funciona, y es raro que los recuentos coincidan por casualidad. Cuando no coinciden, hay que fabricar la correspondencia.

Fabricar la correspondencia con MeshSurfaceSampler

MeshSurfaceSampler, de los addons de Three.js, reparte puntos por la superficie de una malla con densidad proporcional al área de cada triángulo. Muestreando las dos formas con el mismo número de puntos se obtienen dos nubes emparejables.

JS
import { MeshSurfaceSampler } from 'three/addons/math/MeshSurfaceSampler.js';function muestrearSuperficie( mesh, cantidad ) {  const muestreador = new MeshSurfaceSampler( mesh ).build();  const posiciones = new Float32Array( cantidad * 3 );  const normales = new Float32Array( cantidad * 3 );  const p = new THREE.Vector3();  const n = new THREE.Vector3();  for ( let i = 0; i < cantidad; i ++ ) {    muestreador.sample( p, n );    posiciones.set( [ p.x, p.y, p.z ], i * 3 );    normales.set( [ n.x, n.y, n.z ], i * 3 );  }  return { posiciones, normales };}const CANTIDAD = 40000;const a = muestrearSuperficie( mallaA, CANTIDAD );const b = muestrearSuperficie( mallaB, CANTIDAD );const geometry = new THREE.BufferGeometry();geometry.setAttribute( 'position', new THREE.BufferAttribute( a.posiciones, 3 ) );geometry.setAttribute( 'aDestino', new THREE.BufferAttribute( b.posiciones, 3 ) );

El emparejamiento es arbitrario: el punto número mil de A no tiene nada que ver con el número mil de B. Para una nube de puntos eso da igual y el resultado se ve estupendo, porque el ojo percibe la nube, no los puntos individuales. Para una malla continua no serviría, porque los triángulos se cruzarían.

Por eso esta técnica se usa siempre con THREE.Points, y por eso todos los morphings entre formas que has visto en la web son de partículas y no de superficies.

Vía dos: morph targets de Three.js

Si las dos formas comparten topología —lo normal cuando salen del mismo modelo esculpido— el sistema nativo es mejor, porque admite varios destinos con pesos independientes y porque los cargadores lo rellenan solos.

JS
const geometry = new THREE.BoxGeometry( 1, 1, 1, 16, 16, 16 );const inflado = geometry.attributes.position.clone();const v = new THREE.Vector3();for ( let i = 0; i < inflado.count; i ++ ) {  v.fromBufferAttribute( inflado, i ).normalize().multiplyScalar( 0.75 );  inflado.setXYZ( i, v.x, v.y, v.z );}geometry.morphAttributes.position = [ inflado ];geometry.morphTargetsRelative = false;   // posiciones absolutas, no deltasconst mesh = new THREE.Mesh( geometry, material );mesh.morphTargetInfluences[ 0 ] = 0.5;

En r184 los morph targets viajan a la GPU en una textura de array y el shader los lee con texelFetch, así que no consumen slots de atributo y no hay límite práctico en su número.

Lo bueno es que funcionan también en un ShaderMaterial, porque Three.js resuelve las directivas de inclusión en cualquier material y activa las macros correspondientes al detectar morphAttributes en la geometría:

GLSL
#include <morphtarget_pars_vertex>void main() {  vec3 transformed = position;  #include <morphtarget_vertex>  // A partir de aqui, transformed ya lleva el morphing aplicado  gl_Position = projectionMatrix * modelViewMatrix * vec4( transformed, 1.0 );}

La variable tiene que llamarse transformed: es el nombre que los chunks de Three.js esperan.

mesh.morphTargetDictionary traduce nombres a índices, y GLTFLoader lo rellena a partir de los nombres de destino que traiga el archivo.

Que no parezca una interpolación

Un mix uniforme con el mismo progreso para todos los vértices se ve exactamente a lo que es. Dos añadidos baratos lo transforman.

Escalonar por vértice. Un atributo de retardo hace que cada vértice empiece su transición en un momento distinto:

JS
const retardos = new Float32Array( total );for ( let i = 0; i < total; i ++ ) retardos[ i ] = Math.random() * 0.5;geometry.setAttribute( 'aRetardo', new THREE.BufferAttribute( retardos, 1 ) );
GLSL
attribute vec3 aDestino;attribute float aRetardo;uniform float uProgreso;   // de 0 a 1void main() {  // Cada vertice tarda 0.5 y empieza en un momento entre 0 y 0.5  float t = clamp( ( uProgreso - aRetardo ) / 0.5, 0.0, 1.0 );  t = t * t * ( 3.0 - 2.0 * t );      // suavizado  vec3 p = mix( position, aDestino, t );  gl_Position = projectionMatrix * modelViewMatrix * vec4( p, 1.0 );}

Curvar la trayectoria. El camino recto entre dos puntos es el más corto y el menos interesante. Empujar cada vértice hacia fuera a mitad de recorrido produce un abombamiento orgánico:

GLSL
vec3 p = mix( position, aDestino, t );// sin(PI * t) vale 0 en los extremos y 1 en el mediofloat arco = sin( 3.141592653589793 * t );p += normalize( mix( normal, aNormalDestino, t ) ) * arco * uCurvatura;

Con uCurvatura negativa el efecto se invierte y la forma se contrae antes de expandirse, que suele quedar mejor cuando el destino es más grande que el origen.

El problema de las normales

Interpolar posiciones es correcto. Interpolar normales no lo es, por el mismo motivo por el que promediar matrices de rotación no da una rotación: la normal de la superficie interpolada no es la interpolación de las normales.

En una zona donde las dos formas tienen orientaciones muy distintas —una cara plana que se convierte en una cresta— la normal interpolada apunta a una dirección que no es perpendicular a ninguna superficie real, y la iluminación queda plana o invertida durante la transición.

Tres soluciones, en orden de coste:

Aportar las normales de destino a Three.js. Si usas morph targets, geometry.morphAttributes.normal recibe las normales de cada destino y el motor las mezcla igual que las posiciones. Sigue siendo una interpolación lineal, pero al menos parte de normales correctas en los dos extremos y el error se concentra en el centro de la transición.

Recalcular numéricamente. Evaluar la posición interpolada en el vértice y en dos vecinos desplazados, y construir la normal con un producto vectorial. Es exacto para la superficie que realmente estás dibujando y es el tema de la última lección de este nivel.

Ignorarlo y usar un material que no dependa tanto de la normal. Con MeshBasicMaterial, con puntos, o con un shader de contorno, la normal no importa. Es una respuesta legítima y la más frecuente en la práctica, porque los morphings suelen ser de partículas.