Skip to content

Construindo um mundo aberto no navegador, parte 19: O impostor que precisa sobreviver a uma floresta

Por Oleg Sidorkin, CTO e cofundador da Cinevva

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

A Parte 18 deu aos criadores um pincel que preenche uma encosta com árvores. O problema é o custo dessas árvores quando há dezenas de milhares delas na tela. Uma árvore distante não precisa de 2.000 triângulos para contribuir com quatro pixels. Esta parte trata do LOD mais profundo: o impostor, um quad plano que exibe uma imagem da árvore, e do caminho entre “uma árvore que parece correta” e um milhão delas na GPU.

Uma árvore são duas texturas em um billboard

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

Um impostor pré-renderiza um objeto a partir de uma grade de ângulos de visão em dois atlas de texturas, um para cor e outro para normais no espaço do mundo. Em tempo de execução, ele mostra um único quad voltado para a câmera, amostrando o tile correspondente à visão atual. O bake usa duas passagens por tile: diffuse com um material sem iluminação, para que nenhuma luz seja incorporada à textura, e normais codificadas como normalWorld × 0.5 + 0.5, com o alfa encaminhado da origem para que a silhueta corresponda pixel a pixel. O material em tempo de execução é um MeshStandardNodeMaterial completo, então o impostor continua recebendo o sol e a IBL da cena como qualquer outra superfície. O ganho está na geometria: um quad em vez de milhares de triângulos, com os detalhes armazenados em uma textura de 1 MB.

Separar isso em seu próprio spike foi, por si só, uma lição. O impostor começou como o LOD mais profundo dentro do sistema de dispersão do Spike 37, e cada iteração do bake precisava ser testada por todo o pipeline de dispersão, com a correção do bake misturada à migração das matrizes das instâncias e às trocas de LOD. Isolá-lo em um objeto e um quad, lado a lado com o original, reduziu o tempo de iteração de minutos para segundos.

Quando a resposta dos livros é a resposta errada

A primeira implementação usava codificação octaédrica, o mapeamento clássico para compactar direções de uma esfera em um quadrado. Ela passava nos testes numéricos de ida e volta, mas o usuário continuava enviando capturas de tela em que o impostor saltava para um tile que parecia mostrar a árvore vista ligeiramente de cima, em vez de diretamente de frente. Seguiram-se seis rodadas de correções — um uniforme de direção de visão por quad, bake com proporção quadrada, materiais de depuração e billboarding estático —, e todas eram realmente necessárias, mas nenhuma corrigia o bug de fato. A solução só apareceu com a decisão de “repensar tudo do zero, KISS, sem remendos”.

A reescrita descartou a dobra octaédrica em favor de azimute por elevação simples: az = atan2(dir.x, dir.z), el = asin(dir.y), uv = (az/2π, el/π + ½). Essa é toda a codificação, sem normalização L1 nem casos extremos envolvendo o sinal do zero. Ela não é melhor aqui por ser mais precisa — na verdade, amostra a esfera de modo menos uniforme —, mas porque o seletor de células na CPU e o shader na GPU usam as mesmas primitivas. Assim, eles não podem discordar em uma direção de fronteira como o par octaédrico fazia silenciosamente. O atlas parece uma folha de contato: a coluna representa o ângulo ao redor do objeto e a linha representa a elevação, algo imediatamente evidente no overlay.

Mesmo depois disso, a reclamação de que a árvore “parece vista ligeiramente de cima” persistiu, e a causa era uma escolha de quantização, não a codificação. Em uma grade 4×4, os centros das linhas ficam em ±22,5° e ±67,5°, então não há nenhuma linha exatamente em 0° de elevação. Um observador olhando horizontalmente — de longe, o caso mais comum — sempre acaba em uma linha renderizada com inclinação. A solução é usar N ímpar: uma grade 5×5 coloca os centros das linhas em 0°, ±36° e ±72°, então o observador horizontal recebe um tile renderizado exatamente na horizontal. A mesma família de erro de “meia célula de diferença” aparece novamente no trabalho de paralaxe da próxima parte, e a solução vem da mesma pergunta: meu ponto de amostragem discreto realmente cai onde acho que cai para a entrada canônica?

Mais dois elementos foram importantes. O billboard precisa ser estático por intervalos, e não ficar continuamente voltado para a câmera. O impostor é uma imagem plana capturada a partir de uma direção específica de bake, então o plano da imagem do quad em tempo de execução precisa corresponder ao plano dessa câmera de bake. Isso significa que ele mantém sua orientação ao longo do arco em que a mesma célula permanece selecionada e salta apenas na fronteira. Além disso, o orçamento deve acompanhar as direções das quais os jogadores realmente observam: uma etapa posterior eliminou completamente as linhas inclinadas vistas de cima, favorecendo 24 posições em um anel horizontal, separadas por 15°, mais um tile visto diretamente de cima, porque as árvores são observadas da altura dos olhos praticamente o tempo todo.

O salto visual e como o blending o eliminou

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

O billboard estático por intervalos é imperceptível à distância e salta visualmente de perto, o que não é um problema até você começar a orbitar. O Spike 42 colocou quatro variantes lado a lado — o objeto original, a base az/el 5×5 e duas grades hemi-octaédricas — para isolar a cintilação e eliminá-la. Dois artefatos causam o salto. A troca de célula acontece porque o fragment shader quantiza a direção de visão em uma de 25 células; assim, cruzar uma fronteira troca o tile amostrado e reorienta o quad no mesmo frame. A degeneração no polo acontece na visão de cima para baixo, em que todos os azimutes colapsam em um único ponto e a transição entre o anel e o tile superior é a pior do atlas.

O mapeamento hemi-octaédrico corrige os dois problemas. Ele mapeia continuamente o hemisfério superior no quadrado unitário, então direções 3D adjacentes chegam a UVs adjacentes, sem singularidade no polo nem necessidade de um tile especial visto de cima. A solução para a cintilação é o blending bilinear entre células: em vez de saltar para o tile mais próximo, encontra-se o grupo 2×2 de tiles que delimita a direção codificada e mesclam-se os quatro, totalizando 8 amostragens de textura — 4 diffuse e 4 normal. Agora, vistas adjacentes fazem uma transição gradual em vez de saltar. O blending de dois vetores normais unitários não produz por si só um vetor unitário, então o resultado é renormalizado, comportando-se como um slerp para os pequenos ângulos entre tiles vizinhos. O custo é real — um atlas 12×12 ocupa cerca de 9 MB, contra 1,6 MB do az/el, e o bake demora aproximadamente cinco vezes mais, com 288 passagens de render target —, mas o bake acontece uma única vez durante o carregamento, e o blending produz um resultado sem saltos, que é o que torna os impostores utilizáveis enquanto a câmera está realmente em movimento.

Um milhão de árvores, uma cópia da posição da câmera por frame

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

O runtime do Spike 38 executa um lookAt na CPU por quad a cada frame, o que funciona para uma árvore e é fatal para uma floresta. Com um milhão de árvores, as atualizações de matriz e o upload do buffer de instâncias a cada frame dominariam todo o processamento. O Spike 41 transfere todo o pipeline por frame para a GPU. O centro, o yaw e a escala de cada instância são enviados uma única vez durante a construção como atributos instanciados e nunca mudam. O vertex shader constrói a base do billboard a partir da direção de visão no espaço do mundo, camPos − center, e expande um quad unitário compartilhado no espaço do mundo. O fragment shader faz a codificação hemi-octaédrica e o blending bilinear por pixel. O único trabalho da CPU por frame para toda a floresta é um Vector3.copy para atualizar o uniforme da posição da câmera, cujo custo não cresce com a quantidade de árvores.

Um aspecto elegante da matemática é que o yaw de cada instância se anula na decodificação das normais. O bake armazena as normais no sistema de referência da câmera de bake e, como uma rotação yaw ao redor do eixo vertical do mundo preserva +Y e o produto vetorial é equivariante a rotações, a base em tempo de execução construída com uma referência vertical do mundo já equivale à base de bake rotacionada. Assim, o shader decodifica as normais diretamente pelos varyings da base em tempo de execução sem nunca aplicar o yaw da instância. O posicionamento usa uma grade com jitter em vez de uma dispersão puramente aleatória: a área é dividida em células, e uma árvore é colocada em cada célula, no centro mais um deslocamento limitado. Isso garante um espaçamento mínimo — sem duas árvores uma sobre a outra —, mas ainda parece uma floresta natural. Um detalhe fácil de ignorar é a esfera delimitadora. O template da geometria é apenas um quad unitário, então o three.js faria o frustum culling da floresta inteira assim que a câmera deixasse de olhar para a origem. Definir explicitamente uma esfera delimitadora que cubra toda a área, mais a margem de um quad, evita que as árvores nos cantos sejam cortadas em ângulos rasantes.

Tecnologias mencionadas neste capítulo

Atlas de impostores octaédricos e de azimute-elevação. Um impostor renderiza um objeto a partir de uma grade de direções de visão em um atlas diffuse e um atlas de normais no espaço do mundo. Depois, renderiza um único billboard que amostra o tile correspondente, substituindo milhares de triângulos por duas texturas. O mapeamento octaédrico clássico oferece cobertura uniforme da esfera, mas é propenso a divergências entre CPU e GPU nas fronteiras das dobras; uma grade simples de azimute por elevação amostra a esfera de maneira menos uniforme, mas garante por construção que o seletor de células e o shader concordem. Use N ímpar para que uma linha caia exatamente em 0° de elevação e concentre o orçamento de tiles no anel horizontal, já que os objetos são vistos principalmente da altura dos olhos.

Orientação de billboard estática por intervalos. Um impostor é uma imagem capturada de uma direção específica de bake, então o plano da imagem do quad em tempo de execução precisa corresponder ao plano da câmera de bake, e não ficar continuamente voltado para a câmera em tempo de execução. O quad mantém sua orientação ao longo do arco em que uma célula permanece selecionada e salta na fronteira, algo imperceptível à distância de uso do impostor e visível apenas de perto, onde impostores não são utilizados.

Atlas hemi-octaédrico com blending bilinear entre células. Mapear continuamente o hemisfério superior no quadrado unitário elimina a singularidade no polo e o tile especial visto de cima. O salto é eliminado amostrando o grupo 2×2 de tiles que delimita a direção de visão codificada e aplicando blending bilinear aos quatro tiles — 8 amostragens —, para que vistas adjacentes façam uma transição gradual. As normais mescladas são renormalizadas, aproximando um slerp para o pequeno ângulo entre tiles. O custo é um atlas maior — cerca de 9 MB em 12×12 — e um bake único mais demorado, em troca de sombreamento sem saltos durante o movimento da câmera.

Impostores instanciados controlados pela GPU. O centro, o yaw e a escala de cada instância são enviados uma única vez como atributos instanciados; o vertex shader constrói a base do billboard e expande um quad unitário compartilhado, enquanto o fragment shader faz a codificação e o blending por pixel. O custo da CPU por frame para toda a floresta é uma única cópia do uniforme da posição da câmera, independentemente da quantidade de instâncias. O yaw de cada instância se anula na decodificação das normais porque a construção da base pelo produto vetorial preserva rotações yaw em torno do eixo vertical do mundo. Uma esfera delimitadora explícita para toda a floresta impede que o three.js faça o frustum culling de toda a malha instanciada quando a câmera deixa de olhar para a origem do template do quad unitário. Consulte LOD controlado pela GPU.


Parte 19 de 29. Anterior: Parte 18 — Um pincel de dispersão que parece guiado por IA Próxima: Parte 20 — Simulando profundidade em um plano Guia da série: /pt-BR/blog/2026-02-25-open-world-browser-series-guide