Construindo um mundo aberto no navegador, parte 16: Estrutura para um mundo que não para de crescer
Por Oleg Sidorkin, CTO e cofundador da Cinevva
Chegando agora? Consulte o guia da série. Ele explica o que é um spike e reúne links para todas as partes.
Todos os spikes desde a parte 13 seguiram a mesma receita: copiar o monólito anterior e adicionar um recurso. Ao final do spike 32, esse monólito tinha 6.285 linhas de index.html dentro de um único <script type="module">. As buscas no código geravam muito ruído, descobrir onde adicionar um recurso levava mais tempo do que implementá-lo, e qualquer mudança arquitetural afetava um arquivo grande demais para visualizar mentalmente o diff. Antes de adicionar outro recurso, resolvemos essa dívida estrutural.
Dividindo o monólito sem alterar o comportamento
Abrir o spike 33 em uma nova aba ↗ · Ver código-fonte
A restrição era rigorosa: cada divisão precisava ser uma refatoração pura, não uma reformulação. O monólito se transformou em 19 arquivos .mjs mais um shell hospedeiro de 151 linhas. Módulos de nível superior para cena, água, grama, personagem, física, multijogador e interface; um wgsl.mjs que concentra todas as strings de código-fonte WGSL como fonte única da verdade para a GPU; e uma subárvore terrain/ para mapa de altura, SDF, chunks, wrappers de buffers da GPU, pincel, LOD e persistência. O código total ficou em 6.555 linhas, basicamente o monólito acrescido do boilerplate de imports. Nenhuma mudança líquida significativa no volume, mas uma grande melhoria na navegabilidade.
Então a página carregou com uma tela preta. Duas mensagens de erro, com duas causas-raiz sem relação entre si. A primeira era uma reclamação do WebGPU sobre o vínculo de um buffer de zero bytes. No monólito, o buffer do pincel SDF era alocado de forma preguiçosa quando o primeiro chunk de marching cubes aparecia, e a factory de bind groups por acaso era executada mais tarde, depois que o buffer já existia. Separar terrain/gpu.mjs de terrain/brush.mjs reordenou a avaliação dos módulos, fazendo a factory ser executada primeiro e tentar vincular um placeholder null. A correção foi adiar a criação do bind group até o primeiro dispatch, usando um helper getOrCreateBindGroup(chunk). O padrão de "criar tudo antecipadamente" era um artefato do caminho único de inicialização do monólito.
A segunda mensagem era um aviso assustador sobre o esqueleto FBX, mas acabou sendo uma pista falsa. Ela aparecia desde o spike 25 e era inofensiva. O personagem só estava ausente porque o primeiro bug causava um efeito em cascata: todos os dispatches de computação lançavam erros, o mapa de altura nunca era gravado, as amostras de altura retornavam 0, e o personagem surgia na origem e caía através do mundo. Corrigido o buffer, o personagem passou a ser animado normalmente, aviso e tudo mais.
Essa é a verdadeira lição da refatoração. O monólito ocultava todas as relações do tipo "X precisa existir antes que Y seja construído" dentro da ordem do script, de cima para baixo. A modularização embaralhou essa ordem e revelou mais três bugs latentes de ordenação: a distribuição da grama antes do upload do mapa de altura, a adição do plano de água antes da conclusão da decodificação do mapa de ambiente e o carregamento da persistência concluído depois do primeiro frame. Os três foram corrigidos com uma linha cada, e nenhum deles teria sido detectado sem a divisão.
Cem objetos sem aumentar nada
Abrir o spike 34 em uma nova aba ↗ · Ver código-fonte
O spike 34 foi o teste para saber se a estrutura valeria a pena. O objetivo era criar uma paleta em primeira pessoa para posicionar árvores, pedras, arbustos, cogumelos e caminhos de um pacote de modelos CC0, com alinhamento ao terreno, persistência, sincronização multijogador e colisores físicos, tudo sem sair dos controles. Cada linha de código nova foi adicionada em cinco arquivos novos dentro de src/props/, e nenhum módulo existente cresceu mais do que dez linhas de integração.
A trajetória dos assets teve um desvio que vale registrar, porque é o tipo de coisa que consome um dia inteiro. Começamos com o pacote Ultimate Nature, da Quaternius, uma biblioteca FBX sem texturas incorporadas. Os materiais FBX vinham como MeshPhong sem mapa, então criamos manualmente uma tabela que associava nomes de materiais a PNGs, convertemos Phong para Standard e definimos os espaços de cor à mão. Cerca de 30% dos materiais não tinham um PNG correspondente, e vários nomes eram ambíguos entre árvores semelhantes. Um segundo pacote FBX tinha a mesma lacuna. A solução não era criar mais tabelas de mapeamento, e sim usar um pacote mais bem preparado: o Stylized Nature MegaKit, da Quaternius, traz 116 glTFs completos com materiais PBR incorporados e normais pré-calculadas. Trocar FBXLoader por GLTFLoader eliminou a conversão de centímetros para metros, a tabela de texturas e a conversão de Phong, além de reduzir library.mjs em cerca de 80 linhas. A conclusão: glTF com PBR é o pipeline certo para pacotes CC0 que oferecem esse formato, enquanto FBX com mapeamento manual de texturas exigiu o dobro de código para entregar metade da qualidade.
O caminho do glTF também trouxe algumas arestas. A paleta renderiza 116 miniaturas, e canvas.toDataURL() do WebGPU retorna uma imagem vazia para uma superfície GPUCanvasContext. Por isso, as miniaturas são renderizadas em um RenderTarget, lidas com readRenderTargetPixelsAsync e copiadas para um canvas 2D, respeitando o alinhamento de 256 bytes por linha do WebGPU. As prévias fantasma clonam cada material para tingi-lo de verde, o que falhava em malhas cujo material era um array; corrigimos isso com uma ramificação Array.isArray. E os mapas de normais de 16 bits, que ocupavam cerca de 200 MB, passaram uma vez por mogrify -depth 8 e caíram para aproximadamente 32 MB, sem diferença visual, já que os navegadores reduzem a profundidade durante o upload de qualquer forma.
Quando a geometria renderizada só existe na GPU
O bug mais instrutivo fazia a prévia fantasma saltar em incrementos de 1 a 2 metros conforme o cursor se movia. As malhas do terreno armazenam as posições dos vértices em um StorageBufferAttribute, pois o pipeline de computação as grava diretamente na GPU. Por isso, o Raycaster da CPU do three.js não consegue enxergá-las e não retorna nada. O fallback era um ray marching grosseiro, com passos de 1,5 m sobre o mapa de altura analítico, e esse intervalo fixo era a grade que o usuário enxergava. Substituímos isso por um marching adaptativo: passos de 2,5 m quando o raio está muito acima da superfície, reduzidos para 0,4 m quando está a menos de 5 m dela, seguidos por 14 bisseções quando o sinal de
Colisores que permanecem corretos após edições
Escolhemos proxies primitivos em vez de envoltórias convexas ou colisores de malha. Os objetos da Quaternius são arredondados, low-poly e sem concavidades significativas, então as envoltórias exigiriam aproximadamente 50 vezes mais código e teriam 10 vezes mais custo em tempo de execução para oferecer a mesma jogabilidade. Cada objeto é reduzido a uma forma derivada de sua caixa delimitadora: árvores e cactos viram uma cápsula vertical, pedras viram uma esfera, troncos viram uma cápsula horizontal ao longo do eixo maior, e arbustos e flores decorativos não recebem nenhuma forma. Pedras e troncos são caminháveis — aplicam um empurrão apenas vertical para que seja possível ficar em cima deles — enquanto árvores e cactos bloqueiam o jogador — aplicam um empurrão 3D completo para impedir que ele escale o tronco. Um hash espacial de 8 m limita o teste por frame à vizinhança 3×3 do jogador, normalmente de zero a seis objetos.
Duas decisões de design mantiveram o sistema coerente. O alinhamento ao terreno é uma flag no manifesto, não um enum de categoria fixado no código. Assim, a prévia fantasma e o posicionamento confirmado leem o mesmo valor placement.alignToTerrain e não podem divergir. Além disso, os objetos posicionados reagem às edições do terreno por meio de um único helper: depois de uma pincelada — local ou reproduzida a partir de um peer —, refreshPlacementsInRadius reamostra o solo sob cada objeto no disco afetado, reaplica o alinhamento e recalcula as extremidades do colisor. Esculpa uma colina sob uma árvore e ela sobe junto. A persistência e o multijogador reutilizam exatamente o padrão do spike 31, armazenando uma lista plana de {uid, propId, x, y, z, rotY, scale} e espelhando eventos de posicionamento, remoção e ajuste pelo BroadcastChannel.
Tecnologias mencionadas neste capítulo
Decomposição em módulos ES com WebGPU. Dividir um <script type="module"> monolítico em imports .mjs com caminhos diretos não exige um bundler quando os módulos são servidos como assets estáticos, e o TSL do three.js funciona bem entre os limites dos módulos. O custo oculto é a ordem de inicialização: um monólito codifica "construa X antes de Y" na ordem do script, de cima para baixo, enquanto os módulos são avaliados na ordem dos imports, o que pode executar uma factory de bind groups da GPU antes que o buffer exista. O padrão de correção é a inicialização preguiçosa (getOrCreate... no primeiro uso) e aguardar a promise correta, em vez de depender da ordem das declarações.
glTF com PBR incorporado versus FBX com mapeamento manual. O glTF usa metros, referencia suas próprias texturas e fornece MeshStandardMaterial diretamente, portanto um pacote CC0 criado como glTF entra direto em um pipeline PBR. Pacotes FBX sem metadados de associação de texturas exigem uma tabela de nomes de materiais para PNGs mantida manualmente, que fica desatualizada a cada atualização do pacote, além de uma conversão de Phong para Standard e da marcação manual do espaço de cor. Como proteção para folhagens, materiais transparent sem alphaTest são promovidos a cartões recortados com alphaTest: 0.5, para que sejam ordenados corretamente atrás da geometria opaca.
Miniaturas offscreen com WebGPU. canvas.toDataURL() retorna uma imagem vazia em um canvas baseado em GPUCanvasContext, pois não há um caminho de uma superfície de apresentação de volta para um contexto 2D. Renderizar em um RenderTarget, ler os pixels com readRenderTargetPixelsAsync e copiá-los para um canvas 2D funciona, desde que a cópia avance de acordo com o stride de leitura alinhado a 256 bytes do WebGPU. Os resultados ficam em cache no localStorage sob uma chave cuja versão é incrementada, para que mudanças no pacote invalidem renderizações antigas.
Ray marching adaptativo sobre um mapa de altura analítico. Quando os vértices do terreno ficam em um StorageBufferAttribute da GPU, o raycaster da CPU não consegue enxergá-los. Percorrer a função analítica de altura com passos grandes longe da superfície, passos pequenos perto dela e um refinamento por busca binária quando o sinal de
Colisores de cápsula primitivos com hash espacial. Cada objeto é reduzido a uma cápsula ou esfera derivada de sua categoria e de sua caixa delimitadora, registrada como {kind, walkable, radius, p1, p2} em cada célula de 8 m do hash que ela sobrepõe. A cada frame, o jogador testa apenas os objetos em sua vizinhança de células 3×3, com uma resolução cápsula contra cápsula para cada um. Proxies caminháveis — pedras e troncos — recebem um empurrão apenas vertical; proxies bloqueadores — árvores — recebem o empurrão 3D completo. Consulte colisões em terreno SDF para ver a matemática de cápsulas na qual isso se baseia.
Parte 16 de 29. Anterior: Parte 15 — Substitua a base e depois sincronize-a Próxima: Parte 17 — Animações que não precisaram de retargeting e uma busca de assets em tempo real Guia da série: /pt-BR/blog/2026-02-25-open-world-browser-series-guide