Skip to content

Construindo um mundo aberto no navegador, parte 18: um pincel de dispersão que parece usar IA

Por Oleg Sidorkin, CTO e cofundador da Cinevva

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

A Parte 17 deu ao jogador um conjunto de animações pronto para combate e uma maneira de inserir qualquer modelo CC0 no mundo. Esta parte volta ao lado do criador. A paleta do Spike 34 posiciona um objeto por clique, o que funciona bem para compor um objeto de destaque e é inútil para uma floresta. O Spike 37 é o pincel: arraste-o pelo terreno e as árvores preenchem os lugares onde deveriam estar.

Posicionamento por IA sem uma IA

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

A pergunta que este spike responde é se um pincel puramente heurístico parece inteligente o bastante para dispensar o LLM. O teste de “posicionamento por IA” é concreto: as árvores ficam longe dos penhascos, as rochas se inclinam acompanhando a encosta e as pedrinhas da praia param na linha d'água, tudo já na primeira pincelada. Chegamos lá com predicados de inclinação e altitude, sorteios ponderados e espaçamento por família, sem uma única chamada a modelo.

O pincel trabalha sobre um mapa de altura de CPU de 257×257, com elementos ajustados manualmente para que cada predefinição tenha onde ser aplicada: montanhas ao norte para as seleções de inclinação mista, uma faixa de penhascos a leste para cascalheiras, uma planície costeira ao sul para praia e prado e uma depressão de lago no sudoeste. O terreno gera cores de vértice a partir de um classificador de biomas baseado em (altitude, slope), portanto, antes de pintar uma única árvore, você consegue ver onde uma predefinição será aplicada. Cinco predefinições são fornecidas como dados simples, cada uma com uma lista de seleções como { category, weight, slopeMin, slopeMax, altMin, altMax, minSpacing, alignToSlope }. Cliff and Scree define slopeMin: 0.3 para que as rochas sejam posicionadas apenas em encostas reais e alignToSlope: true para que o vetor vertical de cada rochedo acompanhe a normal da superfície.

A cada pincelada, o mecanismo de dispersão amostra densityPerM2 × area pontos candidatos dentro do disco do pincel, lê a altura e a inclinação de cada candidato, filtra as seleções da predefinição para manter aquelas cujos predicados são satisfeitos, sorteia uma delas com pesos e então verifica o espaçamento usando um hash espacial dentro do raio. Tudo é determinístico: um gerador de números aleatórios Mulberry32 com semente controla cada sorteio, portanto (seed, brush events) reproduz qualquer sessão com exatidão. No terreno inicial, uma pincelada de Mixed Forest em um prado plano posicionou 139 dos 158 candidatos em 5 ms, enquanto a mesma predefinição em um penhasco posicionou apenas 106 de 226, e o HUD informou que 81 deles foram rejeitados por inclinação. Esse detalhamento das rejeições é a essência da experiência do usuário: você consegue ver por que o penhasco recebeu apenas algumas árvores, em vez de precisar adivinhar.

A razão para manter as predefinições como dados simples é que a versão com LLM, quando chegar, será uma troca de JSON, não uma reescrita. paint({ preset }) não se importa se preset.picks veio de uma receita ajustada manualmente ou de um worker que transformou “floresta decídua com rochedos cobertos de musgo” em pesos. O mecanismo também nunca fixa um ID de objeto no código, portanto é possível trocar o catálogo sem alterar o mecanismo.

De 300 draw calls para 49

A primeira versão renderizava cada posicionamento como um clone(true) de um grupo com várias malhas, o que funciona bem com algumas centenas de objetos, mas encontra uma barreira no limite de 2.500, quando as draw calls chegam aos milhares. Antes de atingir esse ponto, migramos para InstancedMesh, com um bucket por (propId, partIndex). Cada bucket cresce dobrando de tamanho: alocamos um InstancedMesh maior, copiamos as matrizes ativas, trocamos o pai na cena e descartamos o atributo antigo. O apagamento usa swap-remove, portanto remover uma instância é O(1), independentemente do tamanho do bucket. O determinismo, o espaçamento e o HUD de rejeições permanecem inalterados, pois a troca acontece inteiramente abaixo do registro de posicionamento.

Um diagnóstico no pacote MegaKit resolveu uma questão real de arquitetura. Uma malha glTF com várias primitivas (tronco e folhas) pode chegar ao three.js como uma única malha com um array de materiais e geometry.groups, ou como malhas irmãs separadas, cada uma com um material. Para esse pacote, o carregador segue o segundo caminho: cada parte é uma malha com um único material e grupos vazios. Esse é o melhor formato para dispersão, porque buckets separados por primitiva permitem que o bucket do tronco cresça independentemente do bucket das folhas caso suas contagens divirjam. A quantidade de draw calls é a mesma nos dois casos, mas a divisão oferece uma organização melhor da memória. O ganho medido se confirmou: uma pincelada de floresta que gerava cerca de 300 draw calls passou a gerar 49, e uma sessão completa com várias pinceladas chegou a 3.221 instâncias a 75 FPS em 51 draw calls — um limite que a abordagem com clones nunca conseguiria alcançar antes de estourar o orçamento de frame.

LOD por distância e quatro bugs escondidos nele

O instanciamento reduziu as draw calls, mas cada instância ainda desenhava sua contagem total de triângulos, mesmo as árvores a 90 m de distância que contribuíam com apenas dois pixels de detalhes de folhas. Por isso, geramos três níveis de LOD por parte do objeto com o meshoptimizer (completo, 50%, 15%), ampliamos a chave do bucket para (propId, partIndex, lod) e adicionamos um move() que transfere um posicionamento entre buckets irmãos sem fazer alocações. As faixas de distância vão de 0 a 30 m, de 30 a 90 m e além, com ±4 m de histerese ao redor de cada limite para que uma câmera parada próxima à borda de uma faixa não fique transferindo um posicionamento de um lado para o outro e reenviando sua matriz a cada frame. A reavaliação é limitada a 4 Hz e só acontece se a câmera realmente tiver se movido, portanto uma câmera parada custa apenas uma comparação de distância ao quadrado por frame.

Foi nesse caminho de LOD que surgiram os bugs mais instrutivos. O primeiro se manifestava como posicionamentos desaparecendo ou sendo duplicados enquanto a câmera orbitava, e piorava conforme a cena ficava mais cheia. A causa era uma matriz temporária compartilhada: move() lia a transformação de um posicionamento para _tmpMat, definida no escopo do módulo, mas o swap-remove do bucket de origem usava essa mesma _tmpMat em sua reorganização interna, sobrescrevendo a matriz transportada antes que o destino pudesse gravá-la. O bug só não ocorria quando o slot movido já era o último do bucket, uma chance aproximada de 1/count, que corresponde exatamente ao “piscar raro que piora conforme a cena cresce” observado no teste. A correção foi reservar uma _carryMat exclusiva para move(). Em um teste de estresse com 1.274 movimentações acumuladas, o agrupamento permaneceu idêntico em cada pixel.

O segundo bug era mais sutil: todas as transições de LOD pareciam suaves, exceto a primeira. As árvores que passavam para o LOD1 apresentavam uma mudança visível de sombreamento, embora sua silhueta quase não mudasse, enquanto reduções maiores de triângulos mais adiante na sequência pareciam normais. O simplificador com LockBorder nunca move nem cria vértices, portanto os vértices preservados mantêm exatamente suas normais, mas mesmo assim estávamos chamando computeVertexNormals() após cada simplificação. O LOD0 retorna as normais originais criadas pelo artista sem alterações; já o LOD1 e os seguintes recebiam o recálculo genérico do three.js por média das faces. O limite entre 0 e 1 era o único ponto da sequência em que o regime de normais mudava, portanto era ali que acontecia o salto visual. Remover essa única linha defensiva corrigiu o sombreamento e, como bônus, reduziu aproximadamente pela metade o tempo de geração por objeto, pois deixamos de recalcular normais em quatro LODs por parte.

Uma auditoria do que o simplificador produzia revelou um terceiro ganho. Cada LOD era um original.clone() com um índice novo, e BufferGeometry.clone() faz uma cópia profunda de cada atributo, portanto cinco LODs mantinham cinco cópias independentes dos buffers de posição, normal, UV e cor, embora seus valores fossem idênticos bit a bit. Refatoramos o sistema para compartilhar referências de atributos e manter apenas um buffer de índices privado por LOD, reduzindo uma parte típica de árvore de 20 identidades distintas de atributos para 9 e enviando cada buffer de vértices à GPU apenas uma vez. O armazenamento compartilhado traz dois contratos: não altere os dados dos atributos por meio de nenhum LOD individual e não chame dispose() na geometria de um único LOD, pois ambas as ações afetariam todos os irmãos que compartilham o buffer.

O quarto bug não tinha nenhuma relação com a pintura. Apenas mover o cursor sobre o terreno derrubava a taxa de quadros, mesmo sem nenhum botão pressionado. O manipulador de pointermove fazia raycast contra a malha do terreno, um plano com 131.072 triângulos e sem estrutura espacial, portanto o three.js percorria o buffer de índices inteiro por evento, em uma frequência que podia chegar a 1.000 eventos por segundo. Não precisávamos da malha para essa consulta, pois o terreno é um mapa de altura paramétrico. Uma marcha de raio adaptativa contra sampleHeight — passos grandes bem acima da superfície, um mínimo de 0,4 m perto dela e depois 12 bisseções na inversão de sinal — custa aproximadamente de 8 a 30 amostras por raio, em vez de 131.072 testes de triângulos, cerca de três ordens de grandeza menos, e o simples movimento do cursor voltou a manter o limite de frames.

O custo apenas muda de lugar; garanta que ele saia do clique

Depois de migrar o spike para WebGPURenderer no three r184, nosso alvo de produção, um perfil do DevTools mostrou que a primeira pintura bloqueava por 265 ms, sendo 79% desse tempo dentro do WASM do meshoptimizer. A geração realizava trabalho de verdade, cerca de 180 chamadas de simplificação para uma predefinição fria, mas estava sendo executada dentro do manipulador de clique porque preloadProps apenas buscava e analisava as cenas, sem iniciar a geração dos LODs. A correção foi fazer com que a seleção da predefinição realizasse toda a geração em segundo plano: agora preloadProps chama o caminho de resolução das partes, armazena em cache a promise em andamento para que um clique rápido se junte a ela em vez de iniciar uma duplicata e memoriza o pré-processamento por geometria que o simplificador repetia quatro vezes por parte. O tempo da primeira pintura caiu de 209 ms para 4 ms no HUD. O tempo de WASM não desapareceu; apenas saiu do caminho crítico do usuário e passou a ocorrer enquanto ele observa o terreno e decide onde pintar.

Essa é a lição recorrente deste spike. Quase nenhuma dessas correções alterou o que o pincel faz. Elas alteraram quando o custo é pago: fora do clique, fora do movimento do cursor, fora do limite perto do qual a câmera está parada. Uma ferramenta de dispersão que parece instantânea não necessariamente realiza menos trabalho; ela realiza o trabalho onde o usuário não precisa esperar por ele.

Tecnologias mencionadas neste capítulo

Dispersão heurística por adequação. Um pincel amostra pontos candidatos em um disco, lê (height, slope) de cada ponto em um mapa de altura da CPU, filtra as seleções de uma predefinição por predicados de inclinação e altitude, sorteia uma delas com pesos e a rejeita se violar o espaçamento mínimo por família, acompanhado em um hash espacial. Seleções alinhadas à inclinação giram seu vetor vertical para acompanhar a normal da superfície. Isso produz posicionamentos que parecem intencionais — árvores longe dos penhascos, rochas inclinadas acompanhando as encostas e pedrinhas parando na linha d'água — sem pesos aprendidos, e mantém a predefinição como dados simples para que uma lista de seleções gerada por LLM possa substituí-la diretamente.

Posicionamento determinístico com carregamentos assíncronos. Um gerador de números aleatórios Mulberry32 com semente controla cada sorteio, portanto (seed, brush events) reproduz uma sessão com exatidão. Os sorteios do RNG acontecem antes de qualquer await, e as reservas de espaçamento são inseridas no índice espacial antes que o clone glTF seja resolvido, portanto candidatos concorrentes respeitam uns aos outros e o carregamento assíncrono de recursos não consegue alterar a sequência.

InstancedMesh organizado em buckets com edições O(1). Um InstancedMesh por (propId, partIndex, lod), com a capacidade duplicada sob demanda por meio da cópia das matrizes ativas para um buffer maior. O apagamento e a remoção FIFO usam swap-remove com um array de referências inversas que corrige o índice da instância movida, portanto uma remoção é O(1), independentemente do tamanho do bucket. Um diagnóstico confirmou que as partes glTF chegam como malhas de material único, tornando um bucket por primitiva o caminho ativo e fornecendo a cada primitiva um bucket que pode crescer de forma independente.

LOD por distância com histerese e buffers de atributos compartilhados. Três níveis simplificados pelo meshopt por parte, selecionados por faixas de distância com ±4 m de histerese para que uma câmera próxima a um limite não fique alternando continuamente, reavaliados em uma frequência limitada e condicionados ao movimento real da câmera. Como a simplificação com LockBorder nunca move os vértices, todos os LODs compartilham um único conjunto de buffers de posição, normal, UV e cor, diferenciando-se apenas pelo buffer de índices privado, o que reduz aproximadamente pela metade o número de buffers distintos de vértices na GPU. Ignorar um computeVertexNormals defensivo mantém as normais do artista idênticas entre os LODs e remove a única descontinuidade de sombreamento da sequência. Consulte LOD e meshoptimizer.

Raycast analítico no mapa de altura para consultas de alta frequência. Consultar o cursor na frequência de pointermove contra uma malha plana de 131 mil triângulos percorre o buffer de índices inteiro por evento. Substituir isso por uma marcha de raio adaptativa contra a função analítica de altura — passos grandes longe da superfície, um mínimo pequeno próximo a ela e bisseção na inversão de sinal de (rayyterrainy) — custa dezenas de amostras em vez de dezenas de milhares de testes de triângulos, cerca de três ordens de grandeza menos, e um vetor de saída pré-alocado mantém o caminho crítico livre de alocações.

Remova o trabalho do caminho crítico da interação. Trabalhos únicos e caros — geração de LODs pelo meshopt e compilação de pipelines WGSL — devem ser executados durante intervalos ociosos, não dentro do manipulador de clique. Pré-carregar a geração completa da predefinição ativa quando ela é selecionada, armazenar em cache a promise em andamento para que um clique rápido se junte a ela em vez de iniciar outra e memorizar o pré-processamento por geometria reduziu a latência da primeira pintura de 209 ms para 4 ms sem reduzir o trabalho total realizado.


Parte 18 de 29. Anterior: Parte 17 — Animações que não precisaram de redirecionamento e uma busca de assets em tempo real Próxima: Parte 19 — O impostor que precisa sobreviver a uma floresta Guia da série: /pt-BR/blog/2026-02-25-open-world-browser-series-guide