Construindo um mundo aberto no navegador, parte 24: Salvando um mundo e tornando o vento visível
Por Oleg Sidorkin, CTO e cofundador da Cinevva
Chegou agora? Consulte o guia da série. Ele explica o que é um spike e reúne links para todas as partes.
A Parte 23 colocou pessoas no mundo e deu voz a elas. Esta parte trata de fazer o mundo lembrar o que fizeram nele e de fazê-lo parecer vivo quando ninguém está interagindo com ele. O Spike 47 aborda persistência: um criador esculpe o terreno e posiciona objetos, e essas edições sobrevivem a um recarregamento, são sincronizadas com todos os outros pares e passam por uma arbitragem limpa quando duas pessoas editam ao mesmo tempo. O Spike 49 aborda vento: portar os shaders de vegetação de um pacote de natureza estilizada para que árvores, arbustos e grama se movam como o artista planejou, o que acabou virando uma batalha mais contra o compilador de shaders do que contra a matemática.
Um mundo que se lembra
Abrir o Spike 47 em uma nova aba ↗ · Ver código-fonte
O cenário é uma sessão compartilhada de criação. Os jogadores percorrem um mundo, posicionam objetos com um clique, removem-nos com um clique do botão direito e esculpem o solo arrastando um pincel, elevando e rebaixando o mapa de altura ou escavando cavernas volumétricas no terreno SDF, o que promove um chunk para marching cubes. Tudo o que fazem persiste em um WorldChunkDO da Cloudflare, um Durable Object com autoridade do servidor e armazenamento SQLite próprio. Os clientes não gravam o estado diretamente. Eles enviam intenções, o DO arbitra e transmite o resultado, e o DO é a única fonte da verdade. Assim, quem entra pela primeira vez recebe um snapshot e chega exatamente ao mesmo mundo que todos os demais estão vendo.
Dois detalhes de persistência provaram seu valor. O terreno não é armazenado como um log de eventos que precisa ser reproduzido quando alguém entra; ele é armazenado como blobs binários por chunk que constituem a fonte da verdade e são enviados ao fim de cada pincelada. Assim, quem entra carrega diretamente os bytes confirmados em vez de executar novamente milhares de amostras do pincel. E esses blobs usam uma chave de armazenamento por chunk, em vez de uma única linha grande, porque um só chunk SDF ocupa 168 KB e o DO tem um limite de 2 MB por linha. Um índice de chunks registra quais chaves existem para que o DO possa reidratar todo o mapa ao despertar. A identidade é tratada com dois tipos de armazenamento no cliente: o id do jogador fica no sessionStorage, para que duas abas sejam dois pares distintos em vez de um par que sobrescreve a si mesmo no mapa de jogadores do DO, enquanto o nome de exibição fica no localStorage, para que uma alteração feita em uma aba seja refletida em todas as outras.
Fazendo edições simultâneas convergirem
A parte realmente difícil da criação multiplayer é o que acontece quando duas pessoas editam áreas sobrepostas do terreno no mesmo instante. O spike divide as edições de acordo com sua álgebra. Operações aditivas — elevar e rebaixar o mapa de altura, além de adicionar e subtrair no SDF — são comutativas: aplicá-las em qualquer ordem leva ao mesmo resultado. Por isso, elas seguem um fluxo otimista de carimbos, no qual cada cliente aplica a edição localmente e envia o carimbo, e o DO o transmite a todos sem coordenação. A ordem realmente não importa, então não há nada a coordenar.
As operações dependentes da ordem — suavizar e nivelar — foram o caso interessante. O primeiro projeto atribuía a elas um bloqueio de região: um cliente solicita o bloqueio ao pressionar o ponteiro, o DO o concede ou nega, o cliente armazena as amostras durante o pressionamento e, quando o ponteiro é solto, o DO aplica a pincelada inteira atomicamente. Funciona, mas é um protocolo separado, com seu próprio TTL de bloqueio e uma viagem de ida e volta para concessão ou negação. A solução mais limpa que o substituiu é um delta pré-calculado: o cliente de origem executa localmente o pincel de suavização ou nivelamento, envia a lista resultante de deltas por célula e cada par apenas soma esses deltas às próprias células, sem recalcular nada. Isso transforma uma operação dependente da ordem em uma operação comutativa ao congelar seu resultado na origem. Assim, todo o sistema de edição funciona com um único protocolo comutativo e uniforme, com convergência idêntica e sem bloqueio algum. Os bloqueios de objetos permanecem por outro motivo: o bloqueio por registro substituiu a exclusão restrita ao proprietário, de modo que qualquer par pode excluir qualquer objeto, a menos que alguém o tenha bloqueado, e somente quem detém o bloqueio pode removê-lo. Desfazer e refazer funcionam permitindo que o cliente defina o id de um objeto antes de posicioná-lo. Assim, ele conhece o id antes do eco do servidor e pode reverter suas próprias ações de modo determinístico. WebSockets em hibernação mantêm uma sala ociosa sem custo o tempo todo, a mesma característica que barateou o relay de avatares na parte anterior.
O vento, portado fielmente e depois enfrentado
Abrir o Spike 49 em uma nova aba ↗ · Ver código-fonte
O Spike 49 usa o Stylized Nature MegaKit da Quaternius e porta o vento do pacote para nossa stack. O pacote inclui quatro shaders-fonte para Godot, e a decisão certa foi fazer uma tradução fiel em vez de reinventá-los. O shader da casca tem uma função de vértice vazia, portanto os troncos são rígidos; uma tentativa anterior com máscara procedural fazia os troncos balançarem, e a correção foi simplesmente parar de aplicar vento à casca. As folhas recebem uma oscilação caótica por vértice produzida por um hash de pulso triangular, mascarada pela altura para que as copas se movam enquanto a base permanece fixa. A folhagem básica recebe, no espaço de mundo, uma oscilação de seno e cosseno modulada por ruído. A grama combina a folhagem básica com uma ondulação de linhas de vento, amostrando uma textura de ruído em movimento por meio de uma curva de potência para que somente as faixas claras da textura contribuam, o que produz as ondulações visíveis que atravessam um campo. O despacho segue a própria convenção de nomes de materiais do pacote. Assim, um material chamado Leaves_Birch é encaminhado para o caminho das folhas e Grass_Common, para o caminho da grama, sem adivinhação.
Uma surpresa do port é que a cor das folhas não está na textura. A Quaternius define a aparência das folhas inteiramente com um gradiente vertical e uma borda de Fresnel: o albedo é uma mistura entre uma cor extra na parte inferior da copa e a cor da folha no topo, determinada pela altura, com uma tonalidade de espalhamento subsuperficial adicionada como emissão e dimensionada por um termo de Fresnel heightFactor pré-calculado em vez do Y local bruto, normalizado por grupo de folhas durante o carregamento a partir do Y no espaço de mundo. Assim, tanto o gradiente quanto a máscara de vento se comportam corretamente, não importa como a importação do FBX tenha girado os eixos locais de cada malha. As constantes de cor do Godot são marcadas como sRGB e convertidas para linear antes de chegarem ao shader, portanto o port faz a mesma conversão em vez de fornecer os valores sRGB claros como se fossem lineares e deixar a folhagem desbotada.
A recompilação que devorou a taxa de frames
O motivo pelo qual isso foi lançado primeiro com um FBX de referência, deixando o conjunto completo de materiais de vento de lado em um arquivo .bak, foi um bug de recompilação a cada frame que derrubava a cena para cerca de 1 fps. Sobrepor TSL personalizado aos materiais carregados do FBX fazia o Three.js reconstruir os programas de shader a cada frame, com needsUpdate efetivamente travado como ativo. A abordagem de diagnóstico consistiu em reduzir cada material de folhagem a uma passagem simples com textura, sem nós personalizados, e observar se o ciclo de recompilação persistia. Se parasse, o problema estava no grafo personalizado; se continuasse, a causa estava antes dele, na configuração de materiais do FBX ou no próprio Three.js. A correção que permitiu restaurar os shaders reais foi vincular cada diferença específica de material — cor das folhas, cor de SSS, intensidade e mistura — como uniforms. Assim, todos os assets de folhas compartilham um único programa compilado, em vez de o compilador emitir um shader novo para cada combinação exclusiva de cores e sobrecarregar a fila de compilação.
Vale a pena preservar mais dois elementos. A grama é renderizada como um InstancedMesh por arquivo de origem, e seu vento é calculado no espaço de mundo porque as fases dos senos dependem da posição no mundo. Mas o deslocamento precisa ser aplicado no espaço local antes da execução da matriz de instância, e o WGSL não tem inverse() para chamar. Para uma matriz de instância composta por translação, rotação em Y e escala uniforme, a inversa da parte superior 3×3 é simplesmente sua transposta dividida pela escala ao quadrado. Portanto, o shader multiplica o deslocamento no mundo pela matriz de modelo transposta e o divide pelo comprimento ao quadrado da primeira coluna da matriz, recuperando a escala sem uma raiz quadrada. Depois que a transformação de vértice reaplica a matriz, o movimento chega ao espaço de mundo exatamente como foi criado, independentemente da rotação ou da escala de cada tufo. Além disso, cada tufo executa um culling de frustum por vértice na GPU: ele projeta o centro da instância no espaço de clipe e, se cair fora do frustum com uma margem, recolhe todos os vértices para a origem local, fazendo os três vértices de cada triângulo coincidirem. O rasterizador descarta o triângulo degenerado e nenhum trabalho de fragmento, teste alfa ou sombra é executado para a grama fora da tela. Isso se soma ao culling grosseiro por esfera delimitadora de cada chunk que o Three.js já realiza, construído com step de ponto flutuante em vez de booleanos para que possa ser multiplicado diretamente na mistura de posições.
Tecnologias mencionadas neste capítulo
Persistência de mundo com autoridade do servidor. Um Durable Object WorldChunkDO arbitra o posicionamento de objetos e as edições do terreno, persiste blobs binários por chunk como fonte da verdade — para que quem entra carregue os bytes confirmados em vez de reproduzir um log de eventos — e armazena uma chave por chunk para permanecer abaixo do limite de 2 MB por linha do DO. O id do jogador fica no sessionStorage, para que as abas sejam pares distintos; o nome de exibição fica no localStorage, para que as alterações sejam propagadas entre as abas.
Convergência comutativa de edições. As operações aditivas de terreno — elevar/rebaixar e adicionar/subtrair no SDF — são comutativas e seguem um fluxo otimista de carimbos sem coordenação. Operações dependentes da ordem — suavizar e nivelar — tornam-se comutativas pelo envio de deltas pré-calculados por célula, em vez da aquisição de um bloqueio de região. Assim, todo o sistema converge sob um protocolo único e uniforme, sem bloqueios. Bloqueios de objetos por registro substituem a exclusão restrita ao proprietário, e ids de objetos definidos pelo cliente permitem desfazer/refazer de maneira determinística antes da chegada do eco do servidor. Consulte LOD controlado pela GPU.
Port fiel de shaders do Godot para TSL. Os quatro shaders-fonte de vento da Quaternius são traduzidos linha por linha: casca rígida, oscilação de folhas mascarada pela altura, oscilação da folhagem no espaço de mundo e grama com uma ondulação de linhas de vento em movimento, despachados de acordo com a convenção de nomes de materiais do pacote. A cor das folhas vem de um gradiente de altura combinado com uma borda de SSS controlada por Fresnel, e não da textura. Além disso, as constantes de autoria em sRGB são convertidas para linear para que o visual corresponda às renderizações de referência.
Como evitar recompilações de shaders a cada frame. Sobrepor TSL personalizado aos materiais FBX pode manter needsUpdate ativo e reconstruir os programas a cada frame, derrubando o desempenho para cerca de 1 fps. Vincular cada diferença específica de material como uniform permite que todas as variantes compartilhem um único programa compilado, em vez de emitir um shader novo para cada conjunto exclusivo de parâmetros. Uma passagem de diagnóstico com textura simples isola se a causa está no grafo personalizado ou na configuração anterior a ele.
Vento no espaço de mundo em folhagem instanciada. O vento da grama é calculado no espaço de mundo e transformado de volta para o espaço local com uma inversa derivada manualmente — transposta sobre a escala ao quadrado — porque o WGSL não tem inverse(). Um culling de frustum por vértice na GPU recolhe os tufos fora da tela em um triângulo degenerado, evitando qualquer trabalho de fragmento ou sombra. Isso se soma ao culling por esfera delimitadora de cada chunk do Three.js.
Parte 24 de 29. Anterior: Parte 23 — Cinquenta avatares e uma voz na sala Próxima: Parte 25 — Um esqueleto, todos os trajes Guia da série: /pt-BR/blog/2026-02-25-open-world-browser-series-guide