Skip to content

Construindo um mundo aberto no navegador, parte 12: Anéis, névoa do céu e o que faríamos novamente

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.

O spike 24 deveria ser apenas “adicionar anéis de clipmap ao terreno”. Acabou se tornando um grande final que envolveu renderização, shaders, infraestrutura de módulos e integração visual, tudo ao mesmo tempo.

A principal tarefa relacionada ao terreno era gerar anéis concêntricos de clipmap no vertex shader. Cada anel é uma malha de grade plana centralizada na câmera, com os vértices deslocados por amostras do mapa de altura. O anel interno usa a resolução máxima. Cada anel subsequente dobra o espaçamento entre vértices e cobre uma área maior. A parte complicada é o limite entre os anéis: onde um anel de alta resolução encontra um de baixa resolução, os vértices da borda da malha mais detalhada precisam se ajustar ao ponto médio da borda da malha mais grosseira. Fizemos um morphing de borda 2:1 detectando os vértices de limite — aqueles cuja coordenada na grade é ímpar ao longo da borda do anel — e ajustando sua altura ao ponto médio dos dois vizinhos pares, h=12(hleft+hright). Esse é exatamente o ponto em que ficaria a única aresta do anel mais grosseiro, de modo que as bordas detalhada e grosseira descrevem a mesma linha. Isso produz emendas estanques sem geometria de transição.

Abrir o spike 24 em uma nova aba ↗ · Ver código-fonte

Depois veio a integração da névoa com o céu. Queríamos que o terreno distante se fundisse com a cor real do céu, e não com uma constante uniforme. Isso significava que o shader de névoa precisava saber qual seria a cor do céu na direção de cada fragmento. Carregamos uma textura HDR equiretangular de skybox e a amostramos no fragment shader usando a direção de visão da câmera até o fragmento, convertida em coordenadas UV equiretangulares por meio do nó equirectUV do TSL. O fator de névoa era baseado na distância, usando positionView.z.negate() para a profundidade no espaço da câmera, com uma interpolação via smoothstep entre uma distância próxima e outra distante.

A conexão dos módulos acabou sendo mais irritante do que qualquer parte da geometria. Atualizamos para o Three.js 0.183.1, que reorganizou as saídas do build. O import three/tsl precisava ser resolvido para three.tsl.js, e o TSL importava internamente three/webgpu como um especificador simples. Ambos os mapeamentos precisavam estar explícitos no mapa de importação do HTML. A ausência de qualquer um deles gerava erros obscuros como “does not provide an export” ou “failed to resolve module specifier”, sem indicar qual mapeamento estava errado. Assim que ambos foram adicionados ao mapa de importação, o grafo de shaders carregou corretamente.

Também tivemos um problema de orientação do skybox, em que a textura era renderizada de cabeça para baixo. A correção foi definir flipY = true na textura equiretangular, que é o padrão do Three.js para texturas carregadas, mas estava definido como false em nosso código inicial.

A implementação original da névoa amostrava o céu em uma direção quase constante, produzindo uma faixa estreita da cor do horizonte em vez de um gradiente natural. A correção foi calcular, para cada pixel, a direção real no espaço do mundo entre a câmera e o fragmento usando positionWorld.sub(cameraPosition).normalize() e passá-la para equirectUV na consulta da cor da névoa. Isso fez com que os fragmentos do terreno se fundissem com a cor do céu que realmente está atrás deles, o que parece correto de qualquer ângulo da câmera.

Por baixo de todas as correções individuais, o resultado principal se manteve. Agora temos um sistema de terreno que combina edição volumétrica de curta distância — marching cubes com emendas Transvoxel —, chunks de mapa de altura a média distância e anéis de clipmap a longa distância, tudo governado por uma camada de políticas que decide o modo, o LOD e o comportamento das transições.

Se eu tivesse que citar os padrões que repetiria no próximo projeto, seriam estes:

Comece com spikes de risco antes de desenvolver funcionalidades. O spike 1 respondeu à pergunta “será que conseguimos renderizar rápido o suficiente?” antes de investirmos em pipelines de conteúdo.

Congele baselines estáveis antes de grandes saltos de integração. Os spikes 13 e 14 nos pouparam dias procurando regressões por bisseção.

Imponha políticas e observabilidade antes de maratonas de otimização. O spike 23 transformou bugs misteriosos em condições nomeadas com regras de acionamento.

Teste em movimento, não com capturas de tela. Estalos de clipmap, cintilação nas emendas e travamentos de streaming ficam ocultos em quadros estáticos.

Meça o custo de tempo de quadro por funcionalidade, não o FPS médio. As médias escondem os picos que os usuários realmente sentem.

E publique as partes bagunçadas. Os caminhos errados, as caçadas a fantasmas em buffers desatualizados, os dois dias culpando a lógica de transição quando o problema estava no intervalo de desenho. Essas são as partes com as quais as pessoas realmente podem aprender.

Verificação com o mundo real: devlogs de Vuntra City

Depois de concluir esta série, analisamos os devlogs de @VuntraCity como uma verificação externa da implementação em relação às nossas próprias premissas sobre mundos abertos. É um projeto nativo em UE5, não uma stack de navegador, mas os padrões de sistema são semelhantes o bastante para tornar a comparação útil.

O primeiro sinal é que a velocidade de deslocamento precisa ser tratada como um controle de streaming, e não apenas como parte da jogabilidade. Em Vuntra City, o transporte de alta velocidade é deliberadamente direcionado para acima da maioria dos interiores, e o alcance dos detalhes varia de acordo com a velocidade de movimento para evitar recriações constantes e interrupções (sistema de transporte, técnicas de desempenho). Isso corresponde à direção da nossa camada de políticas: o modo de movimento deve influenciar diretamente o raio dos chunks, a ativação de interiores e a quantidade de trabalho permitida por quadro.

O segundo sinal é a arquitetura. Os mapas e o sistema de endereços deles exigiram separar a topologia do mundo dos objetos renderizados para que consultas globais pudessem ser executadas em regiões descarregadas (mapas e endereços). É a mesma separação de que precisamos no navegador para busca no mundo, roteamento de missões, verificações de moderação e indexação de pontos de interesse, sem forçar caminhos de dados vinculados à renderização.

O terceiro sinal é a divisão da simulação em níveis. O projeto para um milhão de NPCs mantém o estado geral dos cronogramas barato e global, reservando o custoso orçamento de comportamento apenas para as proximidades do jogador (visão geral de um milhão de NPCs, análise detalhada do sistema). Isso reforça nosso próprio modelo de simulação centrado em AOI, no qual a fidelidade do campo próximo e o determinismo do campo distante são aspectos separados, com orçamentos distintos.

E o quarto sinal é a qualidade do design, não a escala bruta. Os melhores momentos de exploração surgem de distribuições ponderadas, exceções raras e pistas diegéticas de navegação, em vez de sobreposições constantes da interface (observações sobre ambientes procedurais, ciclo sem minimapa). Para nós, isso serve de lembrete de que os sistemas técnicos devem ser ajustados para produzir variações que possam ser descobertas, e não apenas a maior vazão possível.

Tecnologias mencionadas neste capítulo

Geometria de anéis de clipmap. Cada anel é uma malha de grade plana centralizada na câmera, com os vértices deslocados por amostras do mapa de altura. O anel interno usa a resolução máxima. Cada anel subsequente dobra o espaçamento entre vértices e cobre uma área maior. A parte complicada é o limite: onde um anel de alta resolução encontra um de baixa resolução, os vértices da borda da malha mais detalhada se ajustam ao ponto médio da borda da malha mais grosseira. A técnica tem origem no artigo de Losasso e Hoppe para a SIGGRAPH 2004 (PDF) e é detalhada em GPU Gems 2, capítulo 2. Consulte nosso guia de paisagens sobre clipmaps de geometria.

Morphing de borda 2:1. No limite entre dois anéis de clipmap, o anel mais detalhado tem vértices em posições que não existem no anel mais grosseiro. Os vértices de limite cuja coordenada na grade é ímpar ao longo da borda do anel são detectados, e sua altura é interpolada entre os dois vértices pares vizinhos. Isso produz emendas estanques sem uma geometria de transição dedicada. A interpolação é executada no vertex shader: morphedHeight = mix(heightLeft, heightRight, 0.5) para os vértices de limite, usando a mesma estrutura de geomorphing descrita em nosso guia.

Mapeamento de skybox equiretangular. Uma única imagem 2D que mapeia a esfera completa das direções do céu usando uma projeção de longitude e latitude. O eixo horizontal cobre de 0 a 360 graus, e o eixo vertical cobre de 0 a 180 graus. Uma direção de visão normalizada d^=(dx,dy,dz) é convertida em coordenadas UV a partir de sua longitude e latitude:

u=12+atan2(dz,dx)2π,v=12arcsin(dy)π

No Three.js, definir texture.mapping = EquirectangularReflectionMapping com SRGBColorSpace habilita esse recurso como plano de fundo da cena. No TSL, equirectUV(direction) aplica a mesma conversão, transformando uma direção de visão 3D nas coordenadas UV 2D usadas para amostrar a textura.

Cor da névoa por fragmento obtida do céu. A névoa padrão mistura os fragmentos com uma única cor constante. Em uma cena com um skybox detalhado, isso parece errado porque a cor do céu varia conforme a direção. A solução é calcular, para cada pixel, a direção no espaço do mundo entre a câmera e o fragmento (positionWorld.sub(cameraPosition).normalize()) e amostrar o skybox nessa direção para obter a cor da névoa. Cada fragmento se funde com a cor do céu que realmente está atrás dele, produzindo uma mistura correta de qualquer ângulo da câmera. O fator de névoa usa smoothstep(nearDist, farDist, viewDepth) com positionView.z.negate() para a profundidade no espaço da câmera.

Mapas de importação para módulos ES. Um mecanismo nativo do navegador (<script type="importmap">) que mapeia especificadores simples de módulos — como three/tsl — para URLs reais. Quando o Three.js 0.183.1 reorganizou as saídas do build, three/tsl precisou ser resolvido para three.tsl.js, enquanto o TSL importava internamente three/webgpu como um especificador simples. Ambos os mapeamentos precisavam estar explícitos no mapa de importação, ou o navegador gerava erros como “does not provide an export” ou “failed to resolve module specifier”.

Leitura complementar

Para uma cobertura mais aprofundada das tecnologias usadas ao longo desta série, consulte nossos guias complementares:

Obrigado por acompanhar esta jornada em doze partes.

Parte 1: Começamos tentando quebrar tudo
Parte 2: Física em worker e o medo do atraso de entrada
Parte 3: Os spikes pouco chamativos que nos salvaram
Parte 4: Streaming antes de terrenos sofisticados
Parte 5: Definindo o orçamento dos efeitos visuais
Parte 6: Os clipmaps mudaram o rumo da história
Parte 7: Marching cubes e as primeiras cavernas de verdade
Parte 8: Integração sem perder nosso baseline
Parte 9: O Transvoxel começou com uma estrutura básica
Parte 10: O caos das emendas e a batalha contra o chefão dos cantos
Parte 11: Modo definido por políticas, não fixo no código


Parte 12 de 14.
Anterior: Parte 11 — Modo definido por políticas, não fixo no código
Próxima: Parte 13 — Escultura de terreno e a morte da função matemática
Guia da série: /pt-BR/blog/2026-02-25-open-world-browser-series-guide