Skip to content

Construindo um mundo aberto no navegador, parte 13: Escultura de terreno e a morte da função matemática

Por Oleg Sidorkin, CTO e cofundador da Cinevva

Chegou agora? Consulte o guia da série. Ele explica o que é um spike e traz links para todas as partes.

Durante doze partes, construímos um motor de terreno que você podia observar. Sobrevoar. Admirar as emendas sem rachaduras. Desta vez, queríamos tocá-lo.

O objetivo parecia simples: permitir que o jogador esculpisse o terreno com um pincel, em tempo real, sem quebrar nenhum dos sistemas que levamos 24 spikes para construir. Foram necessárias três tentativas, dois bugs que pareciam falhas de renderização, mas eram falhas do modelo de dados, e uma reformulação fundamental de como os dados do terreno deveriam funcionar.

Três tentativas fracassadas

O Spike 25 deveria ser o mais fácil. A base de código de produção já tinha um raycaster que detecta colisões com as malhas do terreno. A ferramenta de posicionamento o utiliza para inserir objetos. Uma ferramenta de pincel segue o mesmo padrão, apenas modifica os valores do mapa de altura em vez de instanciar um prefab. Simples.

Primeira tentativa: construí tudo diretamente no código TypeScript de produção em world/client/. Novo terrain-brush.ts, alterações em chunk.ts, mudanças de protocolo e atualizações em componentes Vue. Em menos de uma hora, eu tinha um pincel que mais ou menos funcionava, mas os limites dos chunks exibiam descontinuidades visíveis nas normais. Não conseguia saber se o bug estava no código do pincel, na costura existente entre chunks ou em alguma interação com o loop de renderização completo. Essa é exatamente a situação que a metodologia de spikes existe para evitar. Ignorei a regra e paguei por isso imediatamente. Reverti tudo.

Segunda tentativa: um spike independente, mas recorri ao Three.js 0.170.0 e ao WebGL. O código de produção usa WebGL, então parecia natural. Mas todos os Spikes 13 a 24 já tinham migrado para WebGPU. Criar um spike de pincel em WebGL provaria que ele funciona no renderizador legado, não naquele para o qual estamos migrando. Direção errada. Comecei de novo.

Terceira tentativa: WebGPU, WebGPURenderer, shader de computação para geração de vértices, seguindo a mesma stack do Spike 22. Desta vez, a arquitetura estava certa. Cinco operações de pincel funcionando: elevar, rebaixar, suavizar, achatar e adicionar ruído. P95 do ciclo do pincel abaixo de 4 ms no M1.

E o bug nas emendas continuava lá.

Abrir o Spike 25 em uma nova aba ↗ · Ver código-fonte

O bug nas emendas que não queria morrer

A solução padrão para calcular normais entre chunks é a sobreposição das bordas: cada chunk armazena um anel adicional de dados de seus vizinhos, para que o cálculo da normal no limite possa amostrar os dois lados. Implementei isso. Copiei os dados das bordas vizinhas para um buffer expandido. As emendas continuavam rachando.

Investiguei a matemática. No limite entre o chunk A (cx=-1) e o chunk B (cx=0), ambos precisam calcular a mesma normal no vértice compartilhado. O shader do chunk A amostrava mix(own_col31, own_col32, 0.85). O chunk B amostrava mix(neighbor_edge, own_col0, 0.85). Esses são caminhos diferentes de interpolação bilinear por dados diferentes. Mesmo com dados de borda corretos, os dois chunks calculam normais diferentes para o mesmo ponto.

Foi nesse momento que percebi que a cópia da borda não era o verdadeiro bug. O verdadeiro bug era o modelo de dados.

Todos os spikes do 1 ao 24 usavam uma função matemática procedural chamada height_at(). Você fornecia coordenadas do mundo e recebia uma altura. Limpa, global e sem estado. O pincel não podia modificar uma função matemática, então eu havia adicionado um buffer de displacement por cima. O terreno agora era height_at(x,z) + displacement[i]. O shader da GPU incorporava 30 linhas de funções de ruído para o terreno-base, além de código de interpolação bilinear para a camada de deslocamento. O pincel de achatamento precisava subtrair height_at() para descobrir qual valor de deslocamento produziria a altura-alvo. Dois sistemas sobrepostos, calculando coisas diferentes com estratégias de amostragem diferentes.

Nada disso corresponde à forma como um jogo de verdade funciona. Em produção, terrenos autorais são dados amostrados e armazenados em buffers. A função procedural era um substituto conveniente desde os primeiros spikes. Ela havia cumprido seu papel. Agora, estava causando bugs ativamente.

Eu a eliminei.

Agora, cada chunk possui um heightmap Float32Array com valores reais de altura. Na criação, o ruído procedural o preenche. Depois disso, a função de ruído nunca mais é chamada. O pincel modifica diretamente as alturas armazenadas. O shader da GPU lê de um único buffer usando uma única função: hm_at(i,j). As normais usam diferenças centrais alinhadas à grade sobre os mesmos dados. Sem ambiguidade de interpolação bilinear. Sem incompatibilidade entre dois sistemas. O shader caiu de 90 linhas para 40.

As emendas se corrigiram sozinhas. Agora, os dois chunks em uma borda compartilhada leem os mesmos valores discretos de altura de seus respectivos buffers, com os pontos internos corretos do vizinho na sobreposição da borda. Mesmos dados de entrada, mesmas normais de saída.

Essa não foi uma lição sobre pincéis. Foi uma lição de arquitetura de dados que o pincel revelou.

A malha que explodiu

O Spike 26 era o equivalente volumétrico. Modificar com um pincel um volume SDF de 64 ao cubo e, em seguida, gerar novamente sua malha com marching cubes. A mesma pergunta do Spike 25, mas em 3D.

Na primeira vez que executei, a malha explodiu. Espinhos longos disparavam em todas as direções, como um ouriço-do-mar em um dia ruim.

Abrir o Spike 26 em uma nova aba ↗ · Ver código-fonte

A tabela de casos de MC que eu havia gerado tinha 3.840 entradas em vez de 4.096. A tabela completa tem 256 casos×16 posições=4096, mas eu tinha 256×15=3840, com dezesseis linhas ausentes a partir do caso 112. Todas as consultas depois desse índice estavam deslocadas, então o número do caso não correspondia mais aos dados de triangulação. Quando o marching cubes lê a entrada errada, ele cria arestas cujas duas extremidades estão do mesmo lado da superfície. Em uma aresta com cruzamento real, o parâmetro de interpolação

t=vavbva

fica em [0,1] porque va e vb têm sinais opostos. Em uma aresta falsa, eles têm o mesmo sinal, então vbva fica próximo de zero ou troca de sinal, e t dispara para fora de [0,1], posicionando o vértice muito além do volume. Multiplique isso por algumas centenas de células erradas e você terá um ouriço.

A correção foi ridiculamente simples: copiar, byte por byte, a tabela comprovada do Spike 12. Lição aprendida. Nunca gere novamente uma tabela de consulta quando já houver uma cópia comprovada.

O segundo bug era mais sutil. O pincel de suavização deveria suavizar as formas do terreno. Em vez disso, criava vincos acentuados. O problema: eu estava puxando cada valor SDF em direção a zero, a isosuperfície. Parece que isso deveria suavizar as coisas, mas na verdade colapsa o campo de distância. Os voxels acima e abaixo da superfície avançam em direção a zero, achatando tudo dentro do raio do pincel. No limite, voxels suavizados encontram voxels não suavizados com um degrau abrupto. O pincel de "suavização" era um gerador de vincos.

A solução foi aplicar uma suavização laplaciana adequada. Em vez de puxar cada valor em direção a zero, puxe-o em direção à média de seus 6 vizinhos diretos:

ϕiϕi+λ(16jN(i)ϕjϕi)

O termo entre parênteses é um laplaciano discreto, e λ(0,1] é a intensidade da suavização. Isso calcula a média da geometria próxima, suavizando a forma da superfície e preservando o gradiente do campo de distância em vez de colapsá-lo.

Tudo ao mesmo tempo

O Spike 27 foi a etapa de validação da integração. Pegar o pipeline completo do Spike 24 — patches de mapa de altura, chunks de MC, emendas Transvoxel e LOD com geomorph — e combiná-lo com o modelo de dados amostrados do Spike 25 e com os dois tipos de pincel.

Abrir o Spike 27 em uma nova aba ↗ · Ver código-fonte

A primeira coisa que fiz foi remover height_at() de todos os shaders. Agora, os três shaders de computação — preenchimento do SDF, patch do mapa de altura e emenda Transvoxel — vinculam o mesmo buffer de GPU do mapa de altura de 129x129 e usam a mesma função de interpolação bilinear hm_sample() por meio de um preâmbulo WGSL compartilhado. Uma fonte de dados, vários consumidores. As funções de ruído procedural que existiam em todos os shaders desde o Spike 1 desapareceram.

Então começaram os problemas interessantes.

Quando um pincel SDF bloqueia um chunk no modo MC, a emenda Transvoxel entre esse chunk e seu vizinho de mapa de altura precisa amostrar o volume SDF, não o mapa de altura. Ampliei o shader de emenda com bindings adicionais de buffers de armazenamento e flags de MC por chunk. Quatro combinações de limites a tratar: HM-HM, HM-MC, MC-HM e MC-MC.

O LOD foi outro quebra-cabeça. Nos spikes anteriores, mudar um chunk de MC para um LOD mais baixo significava preencher novamente o SDF em uma resolução mais grosseira. Substituí isso por amostragem baseada em stride: os dados SDF permanecem na resolução máxima, com 65 pontos de grade. O shader de MC calcula um stride a partir da proporção entre o tamanho da grade e o número de células. No LOD0, o stride é 1. No LOD1, é 2, amostrando um voxel a cada dois. Os chunks podem mudar livremente de LOD sem alterar seus dados SDF.

A correção mais satisfatória foi a criação dinâmica de chunks verticais. Esculpa para cima além do topo de um chunk, e um novo chunk exclusivo de MC aparece acima dele, com o SDF inicializado a partir da face limítrofe do chunk inferior. Esculpa para baixo, e acontece o mesmo. O mundo cresce para acomodar as edições.

A última armadilha era o pincel de mapa de altura não fazer nada, silenciosamente, nos chunks bloqueados em MC. O pincel HM modifica heightmapCPU e o envia novamente. Os chunks de MC não leem mais o mapa de altura porque seu SDF foi preenchido a partir dele e, depois, divergiu. Adicionei syncHeightmapToSdf(): depois que o mapa de altura é alterado, as colunas SDF são recalculadas para todos os chunks de MC dentro do raio do pincel, e os novos valores são enviados. Agora, os dois tipos de pincel funcionam nos dois tipos de chunk.

O que realmente aprendemos

Os spikes de pincel deveriam responder a uma pergunta de desempenho: a escultura pode ser executada dentro do orçamento de frame? Pode. Essa foi a parte fácil.

A parte difícil foi descobrir que 24 spikes usando height_at() como a fonte da verdade do terreno haviam criado uma dependência invisível que quebrou no momento em que tentamos editar qualquer coisa. A função procedural era limpa, global e sem estado — até deixar de representar o terreno.

Regras que anotamos e não esqueceremos:

  1. A altura do terreno vem de dados amostrados. Os chunks são proprietários de seus buffers.
  2. A geração procedural preenche os dados iniciais. Ela não é a fonte da verdade em tempo de execução.
  3. O pincel modifica diretamente os dados dos chunks. Nada de camadas de deslocamento.
  4. As normais vêm dos mesmos dados, por meio de diferenças centrais alinhadas à grade.
  5. A sobreposição da borda, com 1 célula do interior do vizinho, resolve as normais entre chunks.
  6. Nunca gere novamente uma tabela de consulta quando já houver uma cópia comprovada.

Na parte 14, paramos de esculpir geometria de depuração e começamos a fazer com que ela pareça e transmita a sensação de um lugar de verdade.

Tecnologia mencionada neste capítulo

Arquitetura de mapa de altura amostrado. O terreno é armazenado como dados pertencentes a cada chunk, em vez de ser avaliado por uma função procedural em tempo de execução. Cada chunk mantém um Float32Array com valores reais de altura. O ruído procedural preenche os dados iniciais durante a criação; depois disso, a função nunca mais é chamada. Isso elimina a incompatibilidade entre o terreno baseado em funções matemáticas e as camadas de edição, simplifica as operações do pincel — que edita diretamente os valores armazenados — e torna o shader da GPU extremamente simples: lê do buffer e calcula as normais por meio de diferenças centrais alinhadas à grade. Para mundos abertos com streaming, o padrão é a propriedade por chunk, com sobreposição de 1 célula das bordas dos vizinhos. Consulte nosso guia de geração de paisagens.

Operações de pincel SDF. Modificação de um campo de distância com sinal para esculpir o terreno. Adicionar, ou inflar, usa uma atenuação smooth-step ao redor de uma esfera. Subtrair, ou escavar, usa a mesma forma com sinal invertido. Suavizar usa uma média laplaciana: lê os 6 vizinhos diretos, calcula a média deles e aproxima o valor dessa média. A abordagem ingênua de puxar os valores em direção a zero colapsa o campo de distância e cria arestas acentuadas. A suavização laplaciana preserva o gradiente do campo enquanto suaviza as formas. Consulte representação de terreno com SDF.

Transvoxel com fontes de dados mistas. As células de transição no limite entre um chunk de MC e um chunk de mapa de altura precisam amostrar dados diferentes em cada lado. O shader de emenda usa flags por chunk e bindings de buffers para tratar as quatro combinações: HM-HM, HM-MC, MC-HM e MC-MC. Quando um dos lados está bloqueado em MC, o shader interpola trilineramente o buffer SDF em vez de amostrar o mapa de altura. LOD baseado em stride para marching cubes. Os dados de SDF são armazenados em resolução máxima, independentemente do nível de LOD atual do chunk. O shader de MC calcula um stride de amostragem com base na proporção entre os pontos da grade de SDF e as células de MC. Na resolução máxima, o stride é 1; na metade da resolução, o stride é 2. Isso desacopla os dados de SDF das mudanças de LOD, permitindo que os chunks alternem livremente entre níveis de LOD sem reconstruir o SDF.


Parte 13 de 14. Anterior: Parte 12 — Anéis, névoa do céu e o que faríamos novamente Próxima: Parte 14 — O mundo ganha vida Guia da série: /pt-BR/blog/2026-02-25-open-world-browser-series-guide