wandres.dev
SHADERS V · Vertex shaders creativos

Deformar la malla con una función

Mover vértices desde el vertex shader: senos, ondas de Gerstner y las tres cosas que se rompen en cuanto la geometría deja de estar donde Three.js cree.

⏱ 19 min

Deformar geometría en el vertex shader es la operación más rentable que existe en gráficos en tiempo real: la CPU no toca nada, el buffer de vértices no se resube, y el coste es una función evaluada una vez por vértice. La parte fácil es la deformación. La parte que hay que aprender es todo lo que Three.js sigue creyendo sobre una malla que ya no está donde estaba.

🎯 Al terminar esta lección sabrás
  • Desplazar vértices en el vertex shader eligiendo conscientemente el espacio de trabajo.
  • Implementar una onda de Gerstner con varias componentes y entender su desplazamiento horizontal.
  • Corregir el culling por frustum de una malla deformada.
  • Hacer que la sombra proyectada coincida con la geometría deformada.

Dónde ocurre el desplazamiento

El vertex shader recibe position en espacio del objeto: las coordenadas tal como están en el buffer, antes de aplicar la transformación de la malla. Desplazar ahí significa que la deformación viaja con el objeto: si rotas la malla, la onda rota con ella.

vec3 p = position;
p.z += sin( p.x * 4.0 + uTiempo ) * 0.1;
gl_Position = projectionMatrix * modelViewMatrix * vec4( p, 1.0 );

Si lo que quieres es que la deformación esté anclada al mundo —agua que sigue siendo la misma superficie aunque muevas la malla, viento que sopla en una dirección global— hay que pasar a espacio mundial primero y volver:

vec4 mundo = modelMatrix * vec4( position, 1.0 );
mundo.y += sin( mundo.x * 4.0 + uTiempo ) * 0.1;
gl_Position = projectionMatrix * viewMatrix * mundo;

Esa segunda forma es la que usan los shaders de vegetación: cada arbusto se deforma según su posición en el mundo, y dos arbustos idénticos con la misma geometría no se mueven al unísono.

En todos los ejemplos de este nivel el trabajo se hace sobre un PlaneGeometry, que nace en el plano XY con la normal apuntando a Z positivo. El desplazamiento va, por tanto, en z, y para tenerlo horizontal en la escena se rota la malla en JavaScript:

const geometry = new THREE.PlaneGeometry( 20, 20, 256, 256 );
const mesh = new THREE.Mesh( geometry, material );
mesh.rotation.x = -Math.PI / 2;

Esa convención ahorra confusiones más adelante, cuando haya que calcular normales: en el espacio local del plano, la normal sin deformar es exactamente ( 0, 0, 1 ).

La resolución es la de la malla

Una función continua evaluada en pocos puntos da una poligonal. PlaneGeometry( 20, 20, 1, 1 ) tiene cuatro vértices: cualquier onda que le apliques resulta en un cuadrilátero inclinado. La deformación no puede tener más detalle que la teselación.

La cuenta es directa: para una onda de longitud L sobre un plano de tamaño T, hacen falta al menos ocho segmentos por longitud de onda para que se vea suave, es decir 8 * T / L segmentos por eje. Con un plano de 20 unidades y ondas de 2 unidades son 80 segmentos; conviene redondear a 128 o 256.

Y ahí está el coste real de esta técnica: 256 por 256 segmentos son 66049 vértices y 131072 triángulos por un plano. El vertex shader corre 66049 veces por cuadro, más una vez por cada pasada de sombra.

Ondas de Gerstner

Un seno vertical produce ondas simétricas y redondeadas que no se parecen al agua. El agua real tiene crestas afiladas y valles anchos, y eso se consigue moviendo cada punto también en horizontal: hacia la cresta. Es la onda de Gerstner, o de trocoide.

// Devuelve el desplazamiento completo, con las dos primeras componentes
// horizontales y la tercera vertical, en el espacio local del plano.
vec3 gerstner( vec2 p, vec2 direccion, float longitud, float pendiente, float t ) {
  float k = 6.283185307179586 / longitud;
  float c = sqrt( 9.8 / k );            // velocidad de fase en aguas profundas
  vec2 d = normalize( direccion );
  float a = pendiente / k;              // amplitud derivada de la pendiente
  float f = k * ( dot( d, p ) - c * t );

  return vec3( d.x * a * cos( f ), d.y * a * cos( f ), a * sin( f ) );
}

El parámetro pendiente sustituye a la amplitud porque es lo que de verdad controla el aspecto: con pendiente cercana a uno la cresta se vuelve un pico, y por encima de uno la onda se pliega sobre sí misma y la malla se atraviesa. Con varias componentes, la suma de las pendientes es lo que no debe pasar de uno.

uniform float uTiempo;
varying vec3 vPosicionLocal;

void main() {
  vec3 p = position;
  vec3 d = vec3( 0.0 );

  d += gerstner( position.xy, vec2(  1.0,  0.0 ), 8.0, 0.30, uTiempo );
  d += gerstner( position.xy, vec2(  0.6,  0.8 ), 4.7, 0.22, uTiempo );
  d += gerstner( position.xy, vec2( -0.7,  0.5 ), 2.3, 0.16, uTiempo );
  d += gerstner( position.xy, vec2(  0.2, -1.0 ), 1.1, 0.10, uTiempo );

  p += d;
  vPosicionLocal = p;

  gl_Position = projectionMatrix * modelViewMatrix * vec4( p, 1.0 );
}

Cuatro componentes con longitudes de onda que no son múltiplos entre sí, para que el patrón no se repita de forma visible. Las direcciones tampoco deben estar alineadas.

Lo que se rompe al deformar

El culling por frustum

Mesh.frustumCulled vale true por defecto y usa geometry.boundingSphere, calculada a partir de las posiciones del buffer. Tus vértices ya no están ahí. Un plano de 20 unidades con ondas de medio metro sobresale medio metro de su esfera, y una malla con desplazamiento fuerte puede desaparecer entera cuando su centro sale del frustum.

La solución es de una línea y hay que elegirla:

// A) Inflar la esfera envolvente. La opcion por defecto.
geometry.computeBoundingSphere();
geometry.boundingSphere.radius += amplitudMaxima;

// B) Renunciar al culling. Correcto si el objeto siempre esta en pantalla.
mesh.frustumCulled = false;

La opción A conserva el culling y solo pierde precisión; la B lo desactiva. Para un plano de agua que ocupa toda la escena, la B es lo razonable. Para cien banderas que ondean repartidas por un mapa, la A.

La sombra proyectada

Éste es el que sorprende. Cuando una luz proyecta sombra, Three.js dibuja la escena desde la luz con un material propio, MeshDepthMaterial, que no sabe nada de tu vertex shader. El resultado es que un plano ondulado proyecta la sombra de un plano liso, y un árbol que se mece proyecta la sombra de un árbol quieto.

La solución es dar a la malla un material de profundidad propio con la misma deformación:

const uniformsCompartidos = {
  uTiempo: { value: 0 },
};

const material = new THREE.ShaderMaterial( {
  uniforms: uniformsCompartidos,
  vertexShader: vertexOndas,
  fragmentShader: fragmentAgua,
} );

// El mismo desplazamiento, pero escribiendo profundidad empaquetada
mesh.customDepthMaterial = new THREE.ShaderMaterial( {
  uniforms: uniformsCompartidos,          // el MISMO objeto: se actualizan juntos
  vertexShader: vertexOndas,
  fragmentShader: /* glsl */`
    #include <packing>
    void main() {
      gl_FragColor = packDepthToRGBA( gl_FragCoord.z );
    }
  `,
} );

mesh.castShadow = true;
mesh.receiveShadow = true;

Compartir el objeto de uniforms es lo que garantiza que la sombra y la geometría vayan en fase: actualizar uniformsCompartidos.uTiempo.value afecta a los dos materiales a la vez.

Para luces puntuales hace falta además mesh.customDistanceMaterial, que escribe distancia en vez de profundidad. Y #include <packing> funciona porque Three.js resuelve las directivas de inclusión también en un ShaderMaterial.

La deformacion en el vertex shader rompe todo lo que Three.js calcula en CPU sobre esa malla

El culling y las sombras son los dos síntomas visibles, pero la causa es más general y conviene enunciarla entera: a partir del momento en que deformas en el vertex shader, la geometría que hay en geometry.attributes.position es una mentira para todo lo que no sea el propio shader. Eso incluye bastante más de lo que parece. Raycaster intersecará contra la malla sin deformar, así que un clic sobre la cresta de una ola acertará en el plano liso que hay debajo. Box3.setFromObject devolverá la caja sin ondas, con lo que cualquier lógica de encuadre automático de cámara fallará. Un motor de física acoplado leerá vértices que no están donde se ven. Las colisiones que hagas a mano compararán contra la superficie equivocada. Y geometry.computeVertexNormals() recalculará normales de la malla plana. No hay solución general: la única salida es duplicar la función de desplazamiento en JavaScript para las consultas que la necesiten. Eso suena a mantener dos verdades y lo es, con el riesgo evidente de que se separen; el mitigante práctico es escribir la función una sola vez como cadena y compartirla, o mantener las dos versiones juntas en el mismo fichero con un test que compruebe que coinciden en unos cuantos puntos. Cuando esa duplicación no compensa —y muchas veces no compensa— la alternativa honesta es aceptar que la deformación es puramente visual y no interactuar con ella: raycast contra un plano matemático, cámara encuadrada a mano, y física sobre una superficie simplificada.