Construindo um mundo aberto no navegador, parte 15: Substitua a base e depois sincronize
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.
As primeiras catorze partes abordaram os spikes 1 a 30. Essa sequência terminou com um sistema de terreno que podia ser esculpido em tempo real, além de um personagem capaz de caminhar, planar e cair sobre ele. Esta parte retoma a série no spike 31, e a primeira decisão que tivemos que tomar não foi técnica. Foi o que fazer com todo aquele código dos spikes.
A decisão de “substituir, não fazer backport”
Tínhamos 30 arquivos HTML independentes, cada um comprovando um conceito isolado, e nenhuma integração. O world/client/ de produção ainda era a stack antiga: WebGL, um mapa de altura simples, um controlador de personagem com 75 linhas e um protocolo MessagePack com nove tipos de mensagem. Sem edição, sem WebGPU, sem materiais, sem vegetação.
O plano óbvio era fazer o backport dos resultados dos spikes para aquela base de código de produção, um por um. Descartamos esse plano. O spike 30 já tinha terreno, física, materiais, vegetação e câmera melhores do que o world/client/ jamais teve. Fazer backport para o código WebGL antigo significaria lutar contra ele o tempo todo. Então decidimos substituir a implementação do mundo pelo spike mais bem-sucedido e seguir construindo a partir dele. O spike 30 se tornou a nova base, e o world/client/ virou código morto.
Isso reformulou o trabalho restante. Para passar de uma “ótima demonstração técnica para um jogador” a um “produto”, precisávamos de multiplayer, persistência, streaming de mundo infinito e posicionamento de objetos. A sincronização do terreno multiplayer veio primeiro, porque é ela que força as decisões de arquitetura. A pergunta que ela responde é simples de fazer e cara de responder errado: quando o Jogador A esculpe, o que realmente trafega pela rede?
Repetição dos parâmetros do pincel, não sincronização de pixels
Abrir o spike 31 em uma nova aba ↗ · Ver código-fonte
Antes de escrever uma única linha de código de rede, rastreamos exatamente o que uma pincelada faz. O pincel do mapa de altura percorre um raio ao redor do cursor em um Float32Array na CPU, aplica uma atenuação smoothstep e então adiciona, subtrai, suaviza ou achata. O pincel SDF faz a mesma coisa em 3D sobre uma esfera de voxels. Ambos os caminhos são pura matemática de arrays na CPU. Não há computação na GPU nesse ciclo, aleatoriedade nem ponto flutuante não determinístico. O mesmo array de entrada mais os mesmos parâmetros resulta na mesma saída em todas as máquinas.
Esse é o truque todo. Não enviamos o terreno editado. Enviamos os parâmetros do pincel, 56 bytes por tick da pincelada, e cada cliente repete a mesma função determinística. O protocolo de sincronização tem quatro tipos de mensagem: descoberta de pares, a mensagem do pincel {op, wx, wy, wz, radius, strength, flattenTarget} e uma mensagem de posição do jogador a 20 Hz.
No spike, ignoramos completamente o servidor e usamos BroadcastChannel, a API do navegador para troca de mensagens entre abas da mesma origem. Abra duas abas e elas se comunicam, sem nenhuma infraestrutura. Isso isola a questão da sincronização da latência, autenticação e integração com Durable Objects. Se a repetição dos parâmetros converge entre abas, também convergirá por WebSocket.
O único ponto em que a repetição pode divergir são as operações que dependem da ordem. Elevar e rebaixar são comutativas, então val + strength * falloff chega ao mesmo resultado independentemente de quem aplicou primeiro. Suavizar e achatar leem valores vizinhos, portanto dois clientes suavizando exatamente o mesmo ponto no mesmo instante podem divergir em frações de milímetro por tick. Na prática, isso nunca acontece, e a correção para produção já é óbvia: encaminhar as edições pelo DO, deixar que ele atribua um número de sequência monotônico, aplicar de forma otimista no cliente e corrigir a ordem caso a sequência autoritativa seja diferente. É a clássica concorrência otimista, e o DO já é um ponto natural de serialização.
A cápsula do outro jogador que vivia desaparecendo
As edições foram sincronizadas na primeira tentativa. A cápsula do jogador remoto, não. Ela aparecia e desaparecia na outra aba, e foram necessários três bugs diferentes para fazê-la permanecer sólida.
A cápsula surgia na origem do mundo, que fica enterrada sob o terreno, porque a mensagem join chega antes de qualquer dado de posição. Correção: iniciá-la oculta e revelá-la na primeira atualização de posição. A transmissão da posição ficava dentro do loop de renderização, e o Chrome limita o requestAnimationFrame em abas sem foco, então a verificação de inatividade da outra aba removia o par e a mensagem seguinte o recriava. Correção: mover a transmissão para um setInterval, que não é limitado em abas visíveis. E o tempo limite de inatividade de 5 segundos era agressivo demais, sendo acionado por qualquer pausa do GC. Correção: aumentá-lo para 30 segundos e contar com a mensagem leave limpa nos fechamentos normais.
Persistência e entrada tardia, mesmo formato
Incorporamos a persistência ao mesmo spike em vez de criar outro, porque o formato de serialização é idêntico, seja o destino o IndexedDB ou outra aba. Um snapshot contém o mapa de altura completo (um Float32Array de 129×129, cerca de 66 KB), somente os chunks SDF editados (cada um com
O primeiro teste de persistência revelou um belo bug de ordenação. A grama é distribuída de forma síncrona durante a inicialização usando as alturas procedurais, mas a restauração do IndexedDB é assíncrona e sobrescreve o mapa de altura depois, deixando cada folha flutuando ou afundada. A correção é uma passagem de refreshAllGrass() que obtém novamente a altura sob cada instância e oculta qualquer folha que agora esteja em uma inclinação ou altitude inadequada. A mesma função atende tanto ao carregamento quanto à entrada tardia.
A saga das inclinações
O terreno esculpido é mais irregular do que a base procedural suave, e expôs três bugs de física que o terreno antigo jamais conseguiria revelar. Caminhar diretamente morro acima fazia a cápsula deslizar para o lado. A causa era uma projeção de velocidade feita para manter o movimento tangente ao chão, mas implementada usando apenas os componentes horizontais da normal. Em uma inclinação diagonal com normal
O desvio persistiu por causa de uma segunda fonte. As sondas de colisão SDF empurram o corpo para fora ao longo do gradiente, de acordo com a profundidade de penetração. Em qualquer inclinação, o gradiente tem componentes horizontais, então uma penetração de 0,1 m em uma inclinação de 15° empurra cerca de 0,026 m para o lado por passo e, a 120 Hz, isso equivale a aproximadamente 3 m/s de desvio invisível. Correção: dividir a resposta de acordo com a inclinação. Em terreno caminhável (
O terceiro bug congelava a cápsula nos limites dos chunks, porque as sondas de colisão amostravam o SDF de um único chunk e recebiam o sentinela de “muito fundo no ar” quando uma sonda atravessava para o vizinho. A correção foi sdfSampleWorld(wx, wy, wz) e sdfGradientWorld(...), que encontram o chunk correto para qualquer posição no mundo e usam como fallback uma estimativa de distância do mapa de altura onde não existe SDF. Agora, a transição da colisão de SDF para mapa de altura é contínua.
A água completa o mundo
Abrir o spike 32 em uma nova aba ↗ · Ver código-fonte
Até aqui, todos os spikes eram “terra acima da água”. O spike 32 adicionou um oceano e, com ele, uma nova ação de movimento. Definimos o nível da água como 22 em um terreno que varia aproximadamente de 8 a 58, inundando os vales baixos, criando praias na linha costeira e mantendo bastante terra seca para jogar.
A superfície é um MeshStandardNodeMaterial construído em TSL, com a mesma abordagem baseada em nós usada no terreno. Três ondas senoidais sobrepostas em frequências diferentes deslocam os vértices, e a normal da superfície vem das derivadas analíticas de cosseno dessas ondas, em vez das normais da malha. A cor varia de turquesa nas águas rasas a verde-petróleo escuro nas profundezas, usando uma estimativa de profundidade
A natação usa uma mola de flutuabilidade. O jogador entra no modo de natação quando os pés ficam abaixo do nível da água e o centro do corpo está a até meia altura de cápsula da superfície. Uma mola puxa o corpo em direção a um alvo logo abaixo da superfície e, com uma constante de flutuabilidade de 12 contra um amortecimento da água de 4, o jogador boia de forma estável, com a cabeça para fora e sem oscilar. A velocidade de natação é menor do que a de caminhada, com aceleração flutuante e arrasto; saltar perto da superfície lança o jogador para fora a 60% da velocidade normal de salto; e a entrada na água limita a velocidade descendente a -5 m/s para evitar um mergulho profundo. A colisão com o terreno continua funcionando debaixo d'água, permitindo caminhar pelo leito do lago onde ele se eleva acima do alvo de natação. O indicador de natação acompanha a transmissão de posição para que os outros jogadores vejam você nadar, e uma sobreposição HTML com gradiente colore a visão quando a câmera mergulha abaixo da superfície.
Tecnologias mencionadas neste capítulo
Repetição determinística dos parâmetros do pincel. Em vez de transmitir o terreno editado, cada cliente envia apenas os parâmetros do pincel e repete a mesma função na CPU. Isso funciona porque tanto os pincéis do mapa de altura quanto os de SDF usam pura matemática de Float32Array, sem aleatoriedade nem não determinismo da GPU, portanto entradas idênticas produzem saídas idênticas bit a bit em todos os lugares. O payload é de 56 bytes por tick da pincelada. Operações comutativas (elevar, rebaixar) convergem independentemente da ordem, enquanto operações que leem os vizinhos (suavizar, achatar) precisam de um ponto de serialização para garantir a convergência, fornecido pelo Durable Object de produção por meio de números de sequência monotônicos.
BroadcastChannel como substituto do WebSocket. Uma API do navegador para troca de mensagens entre abas da mesma origem, sem servidor. Foi usada aqui para testar o protocolo de sincronização isoladamente da latência da rede e da autenticação. O formato de serialização (mapa de altura Float32Array bruto, mais chunks SDF editados, mais IDs dos chunks bloqueados em MC) usa os mesmos bytes empregados na persistência com IndexedDB e na transferência de estado para entradas tardias, de modo que um formato atende a três funções.
Resposta de colisão SDF dividida por inclinação. Quando uma sonda de cápsula penetra o terreno volumétrico, a correção ingênua empurra o corpo para fora ao longo do gradiente do SDF, de acordo com a profundidade de penetração. Em inclinações, esse gradiente tem componentes horizontais, introduzindo desvio lateral. Dividir a resposta para que superfícies caminháveis (
Água em TSL com normais analíticas das ondas. O oceano é um material de nós cujos vértices são deslocados pela soma de três ondas senoidais. Em vez de recalcular as normais da malha após o deslocamento, a normal da superfície é derivada analiticamente das derivadas de cosseno das funções de onda, o que é mais barato e evita os artefatos das normais por diferenças finitas em uma grade de baixa resolução. A cor baseada em profundidade, a espuma na linha costeira e a transparência baseada em profundidade usam uma única estimativa de profundidade.
Natação com mola de flutuabilidade. A física de natação modela o corpo como uma mola amortecida puxada em direção a um alvo logo abaixo da superfície. Com uma constante de flutuabilidade de 12 e amortecimento de 4, o jogador se estabiliza na superfície sem oscilar. Constantes de movimento distintas — velocidade menor, aceleração flutuante e arrasto intenso — fazem a natação ter uma sensação diferente da caminhada, e a colisão existente entre cápsula e terreno continua funcionando debaixo d'água.
Parte 15 de 29. Anterior: Parte 14 — O mundo ganha vida Próxima: Parte 16 — Estrutura para um mundo que não para de crescer Guia da série: /pt-BR/blog/2026-02-25-open-world-browser-series-guide