Devlog · three.js

Um oceano em tempestade no three.js: ondas, espuma e um navio que navega nelas

Uma única função de altura, consultada em todo lugar: como a cena Tempestade à proa movimenta o mar, faz o navio flutuar sem motor de física e simula uma tempestade convincente.

Atualizado em Por ShengyuStudioLeitura de 8 min

Tempestade à proa (Storm Ahead) é uma pequena cena 3D em tempo real aqui do site: um veleiro de madeira enfrentando um mar pesado sob um céu negro, chuva cortando a câmera na diagonal, tubarões e uma serpente marinha rondando e um besteiro na proa que você mira e dispara com o clique esquerdo. Ela roda no navegador com three.js (r160) e sem nenhum modelo ou textura pronta — tudo é construído em código.

Este post mostra como funcionam o oceano e o movimento do navio, com trechos do código-fonte real. A versão curta: uma função de altura barata é a única fonte da verdade sobre o mar, e tudo o que toca a água — a malha, o navio, os tubarões, os respingos, os virotes da besta — pergunta a essa mesma função onde fica a superfície.

Um veleiro de madeira arfando num mar cinzento de tempestade
A cena Tempestade à proa: navio, ondulação, chuva e névoa, tudo gerado em tempo de execução.

Uma função de altura para o mar inteiro

Renderizadores de oceano de verdade costumam usar espectros FFT ou ondas de Gerstner. Nós usamos algo bem mais simples — uma soma de cinco ondas senoidais viajando em direções e velocidades diferentes:

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;
}

Os três primeiros termos são a ondulação: comprimentos de onda longos, amplitudes grandes (1,25–1,85 unidade) e velocidades baixas, correndo ao longo de x, ao longo de z e na diagonal. Os dois últimos são uma marola curta e rápida, com amplitudes pequenas, por cima. Como as direções são diferentes, os picos nunca se alinham numa grade repetitiva óbvia, e a superfície parece irregular, mesmo sendo totalmente determinística.

Por que não algo fisicamente mais correto? Porque essa função pode ser chamada em qualquer lugar, a qualquer momento, na CPU, e sempre devolve a mesma resposta para o mesmo (x, z, t). Essa propriedade acaba importando muito mais para a cena do que a precisão espectral: é ela que faz o navio, os tubarões e as flechas concordarem com a água que você vê.

Movendo a malha e calculando as normais

O oceano é uma única PlaneGeometry de 520 × 520 unidades com 112 × 112 segmentos — 113 × 113 = 12.769 vértices —, girada para ficar na horizontal. A cada quadro escrevemos novas alturas nela e recalculamos as normais nós mesmos:

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;
}

A normal vem de diferenças finitas. Para um campo de alturas y = h(x, z), a normal da superfície é proporcional a (−∂h/∂x, 1, −∂h/∂z). Estimamos cada inclinação amostrando a altura 0,6 unidade adiante em x e em z, e depois normalizamos. Isso sai mais barato que computeVertexNormals() (não é preciso percorrer triângulos) e dá uma iluminação suave que não depende da resolução da malha.

Atualizar ~12,8 mil vértices na CPU a cada quadro é desperdício? Um pouco — são três avaliações de altura por vértice. Levar a mesma matemática para um vertex shader seria mais rápido, mas aí o formato do mar viveria em GLSL e em JavaScript, e as duas cópias teriam de ser mantidas idênticas. Com essa malha, a versão na CPU é folgadamente rápida, e ter uma única implementação valeu mais para nós do que os milissegundos economizados.

Espuma nas cristas com onBeforeCompile

A água usa um MeshStandardMaterial padrão (verde-azulado escuro, rugosidade 0,38, um pouco de metalicidade) iluminado por um environment map gerado a partir do céu, então ganha reflexos de graça. A espuma é adicionada remendando o shader embutido com onBeforeCompile, em vez de escrever um material próprio. O vertex shader repassa a altura para o fragment shader, que clareia a cor perto das cristas:

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 abaixo da altura 1,9 e 1 acima de 3,7, com uma rampa suave entre os dois, então só os picos mais altos espumam. streak quebra a espuma em manchas que derivam, para ela não parecer uma capa branca uniforme. Remendar #include <color_fragment> significa que mantemos toda a iluminação, as sombras, a névoa e o tone mapping do three.js.

Fazendo o navio acompanhar as ondas

O navio não é simulado fisicamente. Não há corpo rígido, nem força de empuxo, nem massa. Em vez disso, amostramos o mar debaixo do casco e orientamos o navio para acompanhá-lo. Cinco amostras bastam: uma no centro, uma na proa e uma na popa (±10 ao longo do comprimento do casco) e uma a bombordo e outra a estibordo (±4 na largura).

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;
  • Arfagem vertical (sobe e desce) segue a altura no centro.
  • Caturro (proa para cima ou para baixo) é o ângulo da linha entre a amostra da popa e a da proa: atan2(diferença de altura, 20), porque as amostras estão a 20 unidades uma da outra.
  • Balanço (de um lado para o outro) é a mesma ideia, atravessando a largura de 8 unidades.

Dois detalhes vendem o peso do navio. Primeiro, cada alvo é reduzido para 80–85%, então o casco nunca acompanha totalmente a inclinação da água — um navio pesado não segue cada onda à perfeição. Segundo, o navio não pula para o alvo; ele se aproxima dele com k = 1 − e−2.1·dt. Essa suavização exponencial se comporta igual a 30, 60 ou 144 quadros por segundo (um simples lerp(a, b, 0.05) por quadro não se comportaria) e introduz um atraso natural: o navio está sempre um pouco atrás da onda, como se tivesse inércia.

Como o casco é um grupo, mastros, velas, amuradas, barris, lanterna e besteiro herdam esse movimento automaticamente.

Respingos na proa e outras coisas que tocam a água

Conhecendo a pose do navio, dá para saber quando a proa bate numa onda. Estimamos a altura da proa no mundo a partir da arfagem e do caturro, comparamos com a altura do mar logo à frente e, se a água estiver mais de 0,35 unidade acima, criamos um sprite de respingo grande — no máximo uma vez a cada 0,9 segundo, para não virar um chafariz contínuo:

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;
}

Os respingos saem de um pequeno pool de 26 sprites que crescem, sobem e somem, então nada é alocado durante o jogo. A mesma chamada a waveH comanda todo o resto na água:

  • Os tubarões circulam o navio numa profundidade ligada à altura local da onda e, a cada 8–15 segundos, cada um salta num arco de meia senoide, criando respingos ao sair e ao voltar para a água.
  • A serpente marinha são dez esferas dispostas num círculo; a altura de cada segmento é a altura da onda mais uma corcova que percorre o corpo, então o dorso parece rolar pela ondulação, e os respingos disparam quando a cabeça rompe a superfície.
  • Os virotes da besta voam sob uma gravidade simples e contam como atingindo a água no momento em que a altura deles cai abaixo de waveH na sua posição.

A mira da besta segue a mesma filosofia de "simples e previsível". Ela calcula o tempo de voo até o alvo (distância / velocidade), adianta a mira num alvo em movimento em 80% da velocidade dele vezes esse tempo e sobe o ponto de mira em ½·g·t² para compensar a queda. Não é uma solução balística exata, mas nessas distâncias acerta onde você espera.

Um casco a partir de uma caixa, velas a partir de um plano

O casco começa como uma BoxGeometry subdividida e é dobrado no formato de navio, vértice por vértice. Ao longo do comprimento, a largura afina segundo uma curva de potência — de forma acentuada na proa e mais suave na popa. Uma curva de "tosamento" levanta as duas pontas, para o convés subir em direção à proa e à popa, e o fundo é mais estreito que o topo. O convés é uma caixa mais fina, dobrada pela mesma função para encaixar com exatidão.

As velas são planos subdivididos empurrados para fora por sin(πx)·sin(πy)0.7, um enfunado suave que vale zero nas bordas e é máximo no meio. Um pequeno remendo no vertex shader soma duas ondas senoidais ao longo do tempo para uma ondulação suave, e a bandeira vermelha no topo do mastro usa uma versão mais rápida e mais forte do mesmo truque, cuja amplitude cresce em direção à ponta solta. Todas as texturas — tábuas do casco, convés, lona, nuvens, respingos, chuva — são desenhadas em canvases na inicialização.

Vendendo a tempestade: céu, névoa, chuva, raios e gradação de cor

O oceano só parece tempestuoso por causa do que o cerca:

  • Céu. O objeto Sky do three.js, com turbidez alta e sol baixo, cria uma cúpula enevoada e encoberta. Um environment map PMREM gerado a partir dele ilumina a água e o navio.
  • Névoa. Uma névoa exponencial num tom cinza-azulado engole o horizonte, esconde a borda do plano de 520 unidades do oceano e faz a cena parecer fechada.
  • Chuva. 800 pontos com uma textura de risco e blending aditivo caem em velocidades um pouco diferentes e inclinados para o lado — as posições são atualizadas num único typed array a cada quadro.
  • Nuvens. Sprites derivam devagar num anel a pelo menos 130 unidades do centro, então nunca ficam entre a câmera e o navio.
  • Raios. Uma luz direcional normalmente com intensidade zero. De vez em quando ela pisca com força total numa posição aleatória do céu, pode tremular de novo enquanto ainda está forte e decai exponencialmente (flash *= 0.03dt). A chance do clarão é sorteada por quadro, então os raios ficam um pouco mais frequentes com taxas de quadros altas — uma simplificação com a qual estamos tranquilos.
  • Uma lanterna quente. Uma luz pontual na popa tremula com a soma de duas ondas senoidais. É a única luz quente da cena e dá ao olho um lugar para descansar.
  • Pós-processamento. Tone mapping ACES filmic com exposição baixa, um bloom suave e um passe de gradação de cor próprio que aplica uma curva em S para contraste, puxa as sombras para o azul-aço e os realces para um tom levemente quente, e escurece os cantos com uma vinheta.

Mantendo tudo leve

A cena roda dentro de uma página web normal, então precisa se comportar:

  • O device pixel ratio é limitado a 1,75 — celulares com tela de alta densidade não precisam renderizar em 3×.
  • O tempo de quadro é limitado a 50 ms, para que uma aba travada não faça o navio dar um tranco quando voltar.
  • Virotes, respingos e faíscas saem de pools fixos; nada é criado durante o jogo.
  • A renderização para quando a cena sai da tela ou a aba fica oculta, e a órbita automática lenta da câmera é desligada para visitantes que preferem menos movimento.

O que levar daqui

  • Uma soma de poucos senos é um oceano estilizado perfeitamente bom, desde que você varie direções, comprimentos de onda e velocidades.
  • Faça da função de altura a única fonte da verdade. Se tudo o que toca a água consulta a mesma função, tudo concorda.
  • Você não precisa de física para um navio flutuar. Amostre proa, popa, bombordo e estibordo, derive arfagem, caturro e balanço, amorteça e reduza um pouco.
  • Use 1 − e−rate·dt para uma suavização independente da taxa de quadros.
  • A atmosfera faz metade do trabalho: névoa, chuva, um clarão e uma gradação de cor transformam um plano azul numa tempestade.

A cena está no ar em Tempestade à proa — gire a câmera, mire com o mouse e dispare alguns virotes nos tubarões. O nosso próximo jogo de sobrevivência, Island Escape, também termina com uma aposta em mar aberto, se você gosta de oceanos com algo em jogo.