Devlog · three.js

Un océano en plena tormenta con three.js: olas, espuma y un barco que las surca

Una sola función de altura, consultada en todas partes: cómo la escena de Tormenta a proa mueve su mar, hace flotar su barco sin motor de física y finge una tormenta convincente.

Actualizado en Por ShengyuStudio8 min de lectura

Tormenta a proa (Storm Ahead) es una pequeña escena 3D en tiempo real de nuestra web: un velero de madera que sortea un fuerte oleaje bajo un cielo negro, lluvia cruzando en diagonal por delante de la cámara, tiburones y una serpiente marina dando vueltas, y un ballestero en la proa al que puedes apuntar y hacer disparar con el clic izquierdo. Funciona en el navegador con three.js (r160) y sin modelos ni texturas prefabricados: todo se construye con código.

Este artículo explica cómo funcionan el océano y el movimiento del barco, con fragmentos del código real. La versión corta: una función de altura barata es la única fuente de verdad del mar, y todo lo que toca el agua —la malla, el barco, los tiburones, las salpicaduras, los virotes de ballesta— le pregunta a esa misma función dónde está la superficie.

Un velero de madera cabeceando sobre un oleaje gris de tormenta
La escena de Tormenta a proa: barco, oleaje, lluvia y niebla, todo generado en tiempo de ejecución.

Una función de altura para todo el mar

Los renderizadores de océanos realistas suelen usar espectros FFT u ondas de Gerstner. Nosotros usamos algo mucho más simple: una suma de cinco ondas sinusoidales que viajan en direcciones distintas y a velocidades distintas:

function waveH(x, z, t) {
  return Math.sin(x * 0.055 + t * 0.9) * 1.55
       + Math.sin(z * 0.042 - t * 0.7 + 1.7) * 1.25
       + Math.sin((x + z) * 0.03 + t * 0.55) * 1.85
       + Math.sin(x * 0.14 - z * 0.09 + t * 1.6) * 0.42
       + Math.sin((x * 0.75 - z) * 0.11 + t * 2.1) * 0.22;
}

Los tres primeros términos son el mar de fondo: longitudes de onda largas, amplitudes grandes (de 1,25 a 1,85 unidades) y velocidades lentas, a lo largo de x, de z y en diagonal. Los dos últimos son marejadilla corta y rápida, de amplitud pequeña, por encima. Como las direcciones son distintas, las crestas nunca se alinean en una cuadrícula repetitiva evidente, y la superficie parece irregular aunque sea completamente determinista.

¿Por qué no algo más correcto físicamente? Porque esta función se puede llamar en cualquier sitio, en cualquier momento y en la CPU, y siempre devuelve el mismo resultado para el mismo (x, z, t). Esa propiedad acaba importando mucho más para la escena que la precisión espectral: es lo que permite que el barco, los tiburones y las flechas coincidan con el agua que ves.

Mover la malla y calcular las normales

El océano es una única PlaneGeometry de 520 × 520 unidades con 112 × 112 segmentos —113 × 113 = 12.769 vértices—, girada para quedar en horizontal. En cada fotograma escribimos alturas nuevas en ella y recalculamos las normales nosotros mismos:

function updateOcean(t) {
  const p = oceanGeo.attributes.position, n = oceanGeo.attributes.normal;
  const e = 0.6;
  for (let i = 0; i < p.count; i++) {
    const x = p.getX(i), z = p.getZ(i);
    const h = waveH(x, z, t);
    p.setY(i, h);
    const hx = waveH(x + e, z, t), hz = waveH(x, z + e, t);
    let nx = -(hx - h) / e, nz = -(hz - h) / e;
    const inv = 1 / Math.hypot(nx, 1, nz);
    n.setXYZ(i, nx * inv, inv, nz * inv);
  }
  p.needsUpdate = true;
  n.needsUpdate = true;
}

La normal sale de diferencias finitas. Para un campo de alturas y = h(x, z), la normal de la superficie es proporcional a (−∂h/∂x, 1, −∂h/∂z). Estimamos cada pendiente muestreando la altura 0,6 unidades más allá en x y en z, y después normalizamos. Es más barato que computeVertexNormals() (no hace falta recorrer triángulos) y da una iluminación suave que no depende de la resolución de la malla.

¿Es un derroche actualizar unos 12.800 vértices en la CPU en cada fotograma? Un poco: son tres evaluaciones de altura por vértice. Llevar las mismas matemáticas a un vertex shader sería más rápido, pero entonces la forma del mar viviría en GLSL además de en JavaScript y habría que mantener idénticas las dos copias. Con este tamaño de malla la versión en CPU va sobrada, y tener una sola implementación nos compensaba más que los milisegundos ahorrados.

Espuma en las crestas con onBeforeCompile

El agua usa un MeshStandardMaterial estándar (verde azulado oscuro, rugosidad 0,38, un toque de metalicidad) iluminado por un mapa de entorno generado a partir del cielo, así que recibe reflejos gratis. La espuma se añade parcheando el shader integrado con onBeforeCompile en lugar de escribir un material propio. El vertex shader pasa la altura al fragment shader, que blanquea el color cerca de las crestas:

float crest = smoothstep(1.9, 3.7, vH);
float streak = 0.7 + 0.3 * sin(vWp.x * 0.35 + vWp.z * 0.5 + uTime * 0.8)
                         * sin(vWp.x * 0.13 - vWp.z * 0.21 - uTime * 0.5);
diffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.87, 0.93, 0.95), crest * streak * 0.92);

crest vale 0 por debajo de una altura de 1,9 y 1 por encima de 3,7, con una rampa suave entre ambos valores, así que solo hacen espuma las crestas más altas. streak rompe la espuma en manchas que se desplazan para que no parezca una capa blanca uniforme. Parchear #include <color_fragment> nos permite conservar toda la iluminación, las sombras, la niebla y el tone mapping de three.js.

Hacer que el barco surque las olas

El barco no está simulado físicamente. No hay cuerpo rígido, ni fuerza de flotación, ni masa. En lugar de eso, muestreamos el mar bajo el casco y orientamos el barco para que encaje. Bastan cinco muestras: una en el centro, una hacia proa y otra hacia popa (±10 a lo largo del casco) y una a babor y otra a estribor (±4 a lo ancho de la manga).

const hC = waveH(0, 0, t);
const hBow = waveH(10, 0, t), hStern = waveH(-10, 0, t);
const hPort = waveH(0, -4, t), hStar = waveH(0, 4, t);
const k = 1 - Math.exp(-dt * 2.1);
shipState.y  += (hC * 0.85 - shipState.y) * k;
shipState.rz += (Math.atan2(hBow - hStern, 20) * 0.85 - shipState.rz) * k;
shipState.rx += (Math.atan2(hPort - hStar, 8) * 0.8 - shipState.rx) * k;
  • Arfada (arriba y abajo): sigue la altura en el centro.
  • Cabeceo (proa arriba o abajo): es el ángulo de la línea que va de la muestra de popa a la de proa, atan2(diferencia de altura, 20), porque las muestras están a 20 unidades.
  • Balanceo (de lado a lado): la misma idea a lo ancho de la manga de 8 unidades.

Dos detalles venden el peso del barco. Primero, cada objetivo se reduce al 80–85 %, así que el casco nunca llega a igualar del todo la pendiente del agua: un barco pesado no sigue a la perfección cada ola. Segundo, el barco no salta a su objetivo, sino que se acerca a él con k = 1 − e−2.1·dt. Este suavizado exponencial se comporta igual a 30, 60 o 144 fotogramas por segundo (un simple lerp(a, b, 0.05) por fotograma no lo haría) e introduce un retraso natural: el barco siempre va un poco por detrás de la ola, como si tuviera inercia.

Como el casco es un grupo, los mástiles, las velas, las barandillas, los barriles, el farol y el ballestero heredan este movimiento automáticamente.

Salpicaduras de proa y otras cosas que tocan el agua

Una vez conocida la pose del barco, podemos saber cuándo la proa choca contra una ola. Estimamos la altura de la proa en el mundo a partir de la arfada y el cabeceo, la comparamos con la altura del mar justo delante y, si el agua está más de 0,35 unidades por encima, generamos un gran sprite de salpicadura, como mucho una vez cada 0,9 segundos para que no rocíe sin parar:

const bowWorldY = shipState.y + Math.sin(shipState.rz) * 12;
if (t > bowSplashCd && waveH(12, 0, t) > bowWorldY + 0.35) {
  spawnFrom(splashes, _v1.set(13.5, waveH(13.5, 0, t) + 0.5, (Math.random() - 0.5) * 3), 4.5);
  bowSplashCd = t + 0.9;
}

Las salpicaduras salen de un pequeño pool de 26 sprites que crecen, suben y se desvanecen, así que no se reserva memoria durante la partida. La misma llamada a waveH mueve todo lo demás que hay en el agua:

  • Los tiburones rodean el barco a una profundidad ligada a la altura local de la ola y, cada 8–15 segundos, cada uno salta siguiendo un arco de media sinusoide, levantando salpicaduras al salir y al volver a entrar en el agua.
  • La serpiente marina son diez esferas colocadas a lo largo de un círculo; la altura de cada segmento es la altura de la ola más una joroba que se desplaza, de modo que su lomo parece rodar entre el oleaje, y salen salpicaduras cuando la cabeza rompe la superficie.
  • Los virotes de ballesta vuelan con una gravedad simple y cuentan como impacto en el agua en cuanto su altura baja de waveH en su posición.

La puntería de la ballesta sigue la misma filosofía de «simple y predecible». Calcula el tiempo de vuelo hasta el objetivo (distance / speed), adelanta el tiro a un objetivo en movimiento un 80 % de su velocidad multiplicada por ese tiempo y eleva el punto de mira ½·g·t² para compensar la caída. No es una solución balística exacta, pero a estas distancias el virote cae donde esperas.

Un casco a partir de una caja, velas a partir de un plano

El casco empieza siendo una BoxGeometry subdividida y se curva vértice a vértice hasta tener forma de barco. A lo largo de la eslora, la anchura se estrecha siguiendo una curva de potencia: de forma brusca en la proa y más suave en la popa. Una curva de «arrufo» levanta los dos extremos para que la cubierta se eleve hacia proa y popa, y el fondo es más estrecho que la parte superior. La cubierta es una caja más fina curvada con la misma función para que encaje exactamente.

Las velas son planos subdivididos empujados hacia fuera con sin(πx)·sin(πy)0.7, un hinchado suave que vale cero en los bordes y es máximo en el centro. Un pequeño parche en el vertex shader añade dos ondas sinusoidales en el tiempo para un aleteo suave, y la bandera roja del tope del mástil usa una versión más rápida e intensa del mismo truco, cuya amplitud crece hacia el extremo libre. Todas las texturas —tablones del casco, cubierta, lona, nubes, salpicaduras, lluvia— se dibujan en canvas al arrancar.

Vender la tormenta: cielo, niebla, lluvia, relámpagos y etalonaje

El océano solo parece tormentoso por lo que lo rodea:

  • Cielo. El objeto Sky de three.js con turbidez alta y un sol bajo da una cúpula brumosa y encapotada. Un mapa de entorno PMREM generado a partir de él ilumina el agua y el barco.
  • Niebla. Una niebla exponencial en un tono gris azulado se traga el horizonte, oculta el borde del plano oceánico de 520 unidades y hace que la escena se sienta cerrada.
  • Lluvia. 800 puntos con una textura de trazo y mezcla aditiva caen a velocidades ligeramente distintas y en diagonal; sus posiciones se actualizan en un único array tipado en cada fotograma.
  • Nubes. Sprites que derivan despacio en un anillo a al menos 130 unidades del centro, para que nunca se interpongan entre la cámara y el barco.
  • Relámpagos. Una luz direccional normalmente a intensidad cero. De vez en cuando destella al máximo en una posición aleatoria del cielo, puede volver a parpadear mientras aún brilla y decae de forma exponencial (flash *= 0.03dt). La probabilidad del destello se tira en cada fotograma, así que los relámpagos son algo más frecuentes con tasas de fotogramas altas: una simplificación con la que vivimos tranquilos.
  • Un farol cálido. Una luz puntual en la popa parpadea con dos ondas sinusoidales sumadas. Es la única luz cálida de la escena y le da al ojo un sitio donde descansar.
  • Posprocesado. Tone mapping fílmico ACES con exposición baja, un bloom suave y un pase de etalonaje propio que añade una curva en S para el contraste, tiñe las sombras de azul acero y las luces de un tono ligeramente cálido, y oscurece las esquinas con una viñeta.

Mantenerlo ligero

Se ejecuta dentro de una página web normal, así que tiene que portarse bien:

  • La relación de píxeles del dispositivo se limita a 1,75: los móviles de alta densidad no necesitan renderizar a 3×.
  • El tiempo de fotograma se limita a 50 ms, para que una pestaña que se ha quedado colgada no haga dar un bandazo al barco al reanudarse.
  • Los virotes, las salpicaduras y las chispas salen de pools fijos; no se crea nada durante la partida.
  • El renderizado se detiene cuando la escena sale de la pantalla o la pestaña queda oculta, y la lenta órbita automática de la cámara se desactiva para quien prefiere movimiento reducido.

Conclusiones

  • Una suma de unos pocos senos es un océano estilizado perfectamente válido si eliges direcciones, longitudes de onda y velocidades variadas.
  • Haz de la función de altura la única fuente de verdad. Si todo lo que toca el agua muestrea la misma función, todo encaja.
  • No necesitas física para un barco que flota. Muestrea proa, popa, babor y estribor, deriva la arfada, el cabeceo y el balanceo, amortígualos y redúcelos un poco.
  • Usa 1 − e−rate·dt para un suavizado independiente de la tasa de fotogramas.
  • La atmósfera hace la mitad del trabajo: niebla, lluvia, un destello de luz y un etalonaje convierten un plano azul en una tormenta.

La escena está en marcha en Tormenta a proa: gira la cámara, apunta con el ratón y suelta unos cuantos virotes contra los tiburones. Nuestro próximo juego de supervivencia, Island Escape, también termina con una apuesta en mar abierto, por si te gustan los océanos con algo en juego.