wandres.dev
SHADERS V · Vertex shaders creativos

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.

⏱ 19 min

Interpolar entre dos formas es un mix de una línea, y sin embargo casi ningún morphing se ve bien a la primera. El motivo es que el mix presupone algo que casi nunca se cumple: que el vértice número mil de la primera forma se corresponde con el vértice número mil de la segunda. Resolver esa correspondencia es el noventa por ciento del trabajo, y el diez restante es que la línea recta entre dos posiciones es el camino más aburrido posible.

🎯 Al terminar esta lección sabrás
  • Implementar morphing con un atributo propio y con el sistema de morph targets de Three.js.
  • Generar correspondencia entre mallas distintas con MeshSurfaceSampler.
  • Escalonar el morphing por vértice para que deje de parecer una transición uniforme.
  • Explicar por qué interpolar normales entre dos formas produce una iluminación incorrecta.

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.

const origen = new THREE.IcosahedronGeometry( 1, 5 ).toNonIndexed();
const total = origen.attributes.position.count;

// Un destino cualquiera con el mismo numero de vertices
const 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() );
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.

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.

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 deltas

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

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

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 ) );
attribute vec3 aDestino;
attribute float aRetardo;
uniform float uProgreso;   // de 0 a 1

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

vec3 p = mix( position, aDestino, t );

// sin(PI * t) vale 0 en los extremos y 1 en el medio
float 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.

El morphing entre mallas distintas no existe: lo que existe es morphing entre nubes de puntos

Merece la pena decirlo sin rodeos porque ahorra semanas: transformar de forma continua una malla en otra con topología distinta es un problema abierto, no una función que Three.js no haya expuesto todavía. Se llama correspondencia de superficies y es un área de investigación con décadas de literatura: los métodos que funcionan requieren parametrizar las dos mallas sobre un dominio común —típicamente una esfera—, resolver un mapeo que preserve las características que importan, y remallar. Nada de eso corre en tiempo real, y todo requiere que las dos superficies sean topológicamente equivalentes, cosa que una taza y un donut sí son pero una taza y una esfera no. Por eso todo lo que ves en la web se resuelve de una de estas tres maneras y ninguna es morphing de mallas. Primera: partículas, muestreando las dos superficies y emparejando arbitrariamente, con lo que la ausencia de correspondencia deja de importar. Segunda: una malla común, esculpiendo las dos formas a partir del mismo modelo base con la misma topología, que es lo que hace cualquier animador de expresiones faciales y para lo que existen los morph targets. Tercera: una transición que no es un morph, con la primera forma desintegrándose y la segunda ensamblándose, que visualmente lee como una transformación y es infinitamente más fácil. Si lo que te piden es transformar un logo en un modelo 3D, la respuesta correcta es la primera o la tercera; intentar la segunda con dos mallas que no salieron del mismo modelo es empezar un proyecto de investigación sin saberlo.