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