Construindo um mundo aberto no navegador, parte 23: cinquenta avatares e uma voz na sala
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 22 colocou um céu sobre o mundo. Esta parte coloca pessoas nele. Um mundo aberto em terceira pessoa precisa exibir mais de 50 personagens a qualquer momento, além de permitir que você ouça quem está ao seu lado. O Spike 45 trata da renderização: colocar tantos avatares animados na GPU sem sobrecarregar a thread principal. O Spike 46 trata do áudio: voz ponto a ponto que se desloca entre os canais e perde intensidade de acordo com a posição, ajustada para soar como uma videochamada normal, e não como uma demonstração técnica.
Uma chamada de desenho para cinquenta dançarinos
Abrir o Spike 45 em uma nova aba ↗ · Ver código-fonte
O fluxo padrão do Three.js dá a cada personagem seu próprio SkinnedMesh, seu próprio AnimationMixer, seu próprio envio de matrizes de ossos e sua própria chamada de desenho. Com 50 avatares no Mac local, isso representava cerca de 13 ms de sobrecarga de JavaScript puro por quadro, antes mesmo de a GPU fazer qualquer coisa. A questão de redução de risco para toda a parte multijogador era saber se uma única arquitetura de skinning em lote conseguiria controlar esse custo e escalar linearmente com a quantidade de personagens.
A resposta é separar onde a animação é calculada de onde ela é desenhada. Três classes de personagens compartilham um único modelo FBX. O jogador local é um Avatar normal, um clone completo do esqueleto com seu próprio mixer, seguindo o fluxo padrão do Three.js, porque só existe um deles. Cada par remoto é um VirtualSkeleton: também um clone completo com seu próprio mixer executando os mesmos clipes, mas cada nó SkinnedMesh é removido imediatamente após a clonagem, de modo que apenas os ossos permaneçam. Ele nunca entra na cena. A cada quadro, depois que o mixer é atualizado e as matrizes se estabilizam, ele empacota (bone.matrixWorld × boneInverse) para todos os 100 ossos em um slot de um Float32Array compartilhado. O BatchSkinnedRenderer então mantém um InstancedMesh por parte da geometria, todos lendo de um único StorageBufferAttribute de matrizes de ossos dimensionado como maxInstances × numBones × mat4, ou seja, 60 × 100 × 64 = 384 KB. Um MeshStandardNodeMaterial com positionNode e normalNode personalizados lê quatro influências de ossos por vértice diretamente desse buffer de armazenamento. O resultado é um envio ao buffer de armazenamento e uma chamada de desenho por parte da geometria para toda a multidão, independentemente do número de pessoas. O skinning fica no vertex shader, e o custo de JavaScript por avatar cai para a execução de um mixer e a cópia de 100 matrizes.
O HUD que mede isso também precisou ser refeito. A versão antiga indicava “acima do orçamento” quando o tempo total de GPU do quadro ultrapassava 3 ms, mas o quadro sempre inclui o mapa de sombras, o chão e a malha completa com skinning do jogador local, que juntos consomem de 3 a 5 ms em hardware real, não importa quantos pares sintéticos existam. A solução é um orçamento que se calibra sozinho: enquanto não há avatares em lote, ele captura o tempo real da GPU como referência por meio de uma EMA rápida; depois, congela essa referência e aumenta linearmente o orçamento em 0,06 ms por avatar adicionado assim que os sintéticos aparecem. Ele exibe PASS quando ocioso em qualquer máquina e fica proporcionalmente mais rigoroso à medida que a multidão cresce.
O bug era um rosto que você não conseguia ver
As primeiras execuções no Chrome exibiam sombras prateadas no chão e nenhum avatar, acompanhadas de um erro de análise de WGSL: cannot index type 'f32' em uma linha que tentava indexar object.nodeUniform2[i], embora o uniforme tivesse sido declarado como escalar. A parte honesta desta história é que a primeira correção estava errada e funcionou mesmo assim. A suposição era de que o fluxo de matrizes de instância do InstancedMesh gerava o código inválido, e substituí-lo por um StorageInstancedBufferAttribute fez o erro desaparecer no Chrome. Mas ele desapareceu porque o novo fluxo emitia um código de shader diferente, não porque corrigia a causa — o tipo mais perigoso de correção.
O verdadeiro culpado eram os morph targets. O 3MIKE.fbx inclui expressões faciais com blend shapes, a geometria clonada herda os morphAttributes, e o MorphNode.setup() do Three.js declara morphTargetInfluences como um float escalar e depois tenta chamar .element(i) dentro de um loop sintetizado, exatamente a indexação de escalar rejeitada pelo compilador. A correção é uma única linha: limpar geometry.morphAttributes = {} na geometria que não usa morphs, impedindo que o Three.js injete o MorphNode. A correção acidental para o Chrome permaneceu por algum tempo e depois voltou para nos prejudicar: no Safari, o fluxo de instâncias em armazenamento produziu Vertex buffer is not big enough 256 vezes, porque o backend WebGPU do Safari não o traduz corretamente. Revertê-lo foi a decisão certa, e o buffer de armazenamento simples para matrizes de ossos, que faz parte do núcleo da WebGPU em vez de ser um fluxo de instâncias gerado, funciona bem em todos os navegadores. A lição que vale levar adiante é esta: quando uma correção funciona em um navegador e você não consegue explicar o mecanismo, você corrigiu um sintoma; portanto, leia o WGSL realmente gerado. Um shim de getCompilationInfo() adicionado mais tarde ao spike transformou o genérico “module is not valid” do Three.js no erro real do Tint e compensou seu custo muitas vezes.
Ao lado disso há um truque relacionado para contornar o framework. O Three.js detecta os nomes de atributos padrão skinIndex e skinWeight e tenta injetar seu próprio SkinningNode, até mesmo em um InstancedMesh cujo positionNode personalizado já executa o skinning. Renomear esses atributos para boneIndex e boneWeight os oculta do framework, e o TSL personalizado passa a lê-los pelos novos nomes.
Um relay que esquece você entre uma palavra e outra
A primeira versão sincronizava os pares por BroadcastChannel, um substituto dentro do mesmo navegador que usava o formato e a cadência reais da comunicação, e o comentário do protocolo prometia que a troca pelo transporte real exigiria uma única linha. Cumprir essa promessa resultou em um AvatarRoomDO, um Cloudflare Durable Object de 74 linhas que nem sequer decodifica o quadro binário de 36 bytes. Ele encaminha cada mensagem sem alterações para todos os outros pares da sala, porque o id do remetente está incorporado ao quadro e cada destinatário filtra seu próprio eco no cliente. O relay não sabe absolutamente nada sobre identidades. WebSockets com hibernação tornam gratuita uma sala ociosa: o DO sai da memória entre as mensagens, e o runtime restaura os sockets com tags no próximo pacote. Com 10 eventos por segundo por par, são 36.000 solicitações ao DO por hora-par, cerca de meio centavo, com saída gratuita na Cloudflare e um custo aproximadamente 6 a 10 vezes menor do que uma arquitetura WebSocket equivalente na AWS.
A troca revelou um bug de máquina de estados que vale registrar. Um jogador remoto continuava andando depois de parar. A solicitação de animação verificava this._state, o clipe atualmente em reprodução, em vez do último nome enfileirado. Assim, quando duas mensagens de rede chegavam no mesmo tick, primeiro walk e depois idle, o idle era comparado com um estado que ainda não havia avançado e acabava silenciosamente descartado. O par ficava andando para sempre, porque os pacotes idle seguintes eram deduplicados antes disso como se nada tivesse mudado. A correção é sempre sobrescrever o nome pendente e deixar que o auxiliar de transição interrompa solicitações genuínas para o mesmo estado, algo que ele já fazia. Essa classe de bug é geral: uma verificação de deduplicação baseada no valor de referência errado descarta silenciosamente a entrada que realmente importa.
O Safari precisou de mais duas proteções. Ele abre o WebSocket mais rápido que o Chrome, então a primeira mensagem recebida de um par podia chegar antes que a construção do renderizador em lote terminasse, causando uma desreferência de null; descartar mensagens enquanto o renderizador não existe é seguro, pois os pares retransmitem a cada 100 ms. Além disso, 'gpu' in navigator retornava true enquanto requestAdapter() retornava null, fazendo o Three.js recorrer silenciosamente ao WebGL2, no qual a cadeia de skinning com buffer de armazenamento não tem tradução válida e gerava uma enxurrada de erros. Verificar se existe um adaptador real e confirmar que o backend é de fato WebGPU transforma uma renderização degradada em uma mensagem clara na tela de carregamento. Havia até uma incompatibilidade de dialeto WGSL: o Three.js emite o moderno @interpolate(flat, either) de dois argumentos, que o compilador do WebKit ainda não implementou. Isso foi contornado reescrevendo o código-fonte do shader no caminho até createShaderModule para remover o segundo argumento, sem custo algum, porque a interpolação flat carrega o mesmo valor em todos os vértices de qualquer maneira.
Voz que se desloca com a sala
Abrir o Spike 46 em uma nova aba ↗ · Ver código-fonte
O Spike 46 implementa voz por proximidade: WebRTC ponto a ponto com áudio espacial HRTF, com o objetivo explícito de alcançar a qualidade do Google Meet e do Microsoft Teams em uma sala silenciosa ou moderadamente ruidosa. Um VoiceRoomDO cuida da sinalização como um relay JSON, enviando a cada novo par uma lista de participantes, anunciando entradas e saídas, encaminhando SDP e ICE para um par específico por tag do socket e transmitindo atualizações de posição que controlam os panners espaciais. Ele inclui o id do remetente em cada mensagem para impedir que os pares se passem uns pelos outros, e o áudio em si nunca passa pelo DO. Há um RTCPeerConnection por par remoto, e o par com o id lexicograficamente menor sempre faz a oferta, para que os dois lados concordem sobre quem inicia sem precisar de uma implementação completa de perfect negotiation.
No lado receptor, o áudio de cada par passa por um PannerNode configurado como HRTF com atenuação de distância inversa, e o AudioListener é atualizado a cada quadro com base na posição e na direção do jogador local usando forwardX = sin(facing), forwardZ = cos(facing), o que corresponde à convenção de orientação atan2(wx, wz) da cena. Uma peculiaridade do Chrome custou uma hora: um MediaStream consumido apenas pela Web Audio às vezes não busca os pacotes, então cada stream também é conectado a um elemento <audio> oculto e silenciado para forçar o decodificador a ser agendado. Quanto à qualidade, os navegadores usam por padrão Opus mono a cerca de 32 kbps. Por isso, o spike modifica a linha fmtp de todas as ofertas e respostas para elevá-la a 128 kbps, com FEC em banda habilitado e DTX desabilitado, e depois chama setParameters com uma taxa de bits máxima alta para garantir que o codificador realmente use o que o SDP anuncia. O FEC é a segunda maior melhoria audível depois do aumento da taxa de bits, recuperando perdas de pacotes sem renegociação.
Removendo componentes até obter um áudio limpo
A cadeia de áudio entregue é muito menor do que aquela com que comecei, e reduzi-la foi a verdadeira lição. A primeira versão tinha um filtro passa-altas, um limitador de cliques ajustado para capturar ruído de teclado, um compressor, um noise gate e um crossfade entre sinal processado e original, tudo controlado por um painel flutuante com mais de doze sliders. Quando o usuário relatou cliques audíveis do teclado, o instinto foi ajustar o limitador com mais agressividade e reduzir a mistura do sinal original, empilhando remendos. A resposta estrutural era que, depois que um removedor de ruído por ML entra na cadeia, o limitador de cliques, o gate e a maior parte do passa-altas tornam-se redundantes, porque o RNNoise é treinado especificamente com ruídos de teclado, mouse e digitação, e o recorte de amplitude é uma versão estritamente pior da mesma solução. Clientes de produção oferecem redução de ruído por ML, cancelamento de eco, ganho automático e um compressor suave para nivelamento — nada além disso. Portanto, quatro estágios foram removidos, assim como o painel de sliders e as opções de “escolha sua redução de ruído”, restando um único pipeline fixo.
Cada estágio restante justifica sua presença. O cancelamento de eco do navegador permanece ativado porque o RNNoise não trata eco e, sem ele, o retorno do alto-falante para o microfone não tem limites. A supressão de ruído do navegador fica desativada porque combiná-la com o RNNoise produz artefatos em fricativas; é preciso escolher apenas um removedor de ruído. O ganho automático do navegador permanece ativado, porque desativá-lo deixou o sinal baixo demais para o compressor processar, e o DynamicsCompressorNode da Web Audio não tem um parâmetro de ganho de compensação; o nivelamento amplo do navegador e o compressor rápido do spike operam em escalas de tempo diferentes e coexistem. O RNNoise funciona com 92% de sinal processado misturado a 8% de sinal original, porque pode suprimir em excesso consoantes não vozeadas como s, ch e f, cuja probabilidade de voz cai, e a pequena parcela de sinal original as preserva ao custo de um leve vazamento dos sons das teclas. Duas funcionalidades completam o conjunto. O apertar para falar não alterna track.enabled, porque isso descarta tudo o que ainda está nos buffers do pipeline e corta a última sílaba ao soltar a tecla. Em vez disso, um GainNode próximo ao fim da cadeia faz uma rampa com setTargetAtTime: ataque rápido para preservar a primeira sílaba e liberação lenta para deixar a última consoante terminar, mantendo a faixa permanentemente habilitada. E um atraso de transmissão de cinco segundos, solicitado como um recurso no estilo de rádio, usa em paralelo um caminho de bypass e outro com DelayNode, com crossfade entre eles, além de um botão de corte que silencia imediatamente a saída atrasada e exibe uma contagem regressiva no HUD antes de o áudio voltar. Empacotar o removedor de ruído foi uma pequena saga à parte: o worklet publicado do RNNoise usa importações com especificadores simples que nenhuma CDN consegue resolver, então a solução foi criar um bundle local com esbuild, gerando um único arquivo autocontido de 1,9 MB com o WASM embutido em base64, versionado no repositório e referenciado por uma URL relativa ao módulo, para funcionar igualmente no servidor de desenvolvimento, no build do VitePress e no domínio personalizado. Se o worklet não carregar, a cadeia ainda produz áudio por meio de um filtro passa-altas simples e um compressor, e o HUD exibe a falha em vermelho.
Tecnologias abordadas neste capítulo
Skinning em lote na GPU para multidões. Avatares remotos executam um VirtualSkeleton sem renderização (um clone completo com as malhas com skinning removidas, os ossos preservados e seu próprio mixer) que compacta bone.matrixWorld × boneInverse para cada osso em um StorageBufferAttribute compartilhado. Um InstancedMesh por peça de geometria lê essas matrizes em um positionNode/normalNode personalizado do TSL, de modo que toda a multidão custa um único upload para o buffer de armazenamento e uma única draw call por peça, com o trabalho de CPU por avatar limitado à atualização do mixer e à cópia de uma matriz. Consulte LOD controlado pela GPU.
Ler o WGSL gerado, não o sintoma. Um erro de compilação cannot index type 'f32' foi rastreado até o MorphNode do Three.js, que declarava morphTargetInfluences como escalar e tentava acessá-lo por índice; a correção foi limpar morphAttributes nas geometrias que não usam morphs. Uma primeira correção que apenas alterava qual caminho de shader era gerado mascarou a causa e depois quebrou no Safari. Renomear skinIndex/skinWeight para boneIndex/boneWeight oculta os atributos da injeção automática de SkinningNode do Three.js, permitindo que um material de skinning personalizado seja o único responsável pelos cálculos.
Relays com Durable Objects em hibernação. Um AvatarRoomDO puramente binário encaminha frames de 36 bytes a todos os outros pares sem decodificá-los, com a identidade do remetente embutida no frame e o eco para o próprio remetente filtrado no cliente. WebSockets em hibernação tornam gratuita uma sala ociosa, e essa configuração custa cerca de meio centavo por par-hora a 10 Hz, muito abaixo do preço equivalente de WebSockets gerenciados. Uma proteção contra duplicação que comparava com o estado da animação em reprodução, em vez da última animação enfileirada, descartava silenciosamente mensagens de parada e deixava jogadores remotos presos em um loop de caminhada.
Voz por proximidade via WebRTC com HRTF. Uma RTCPeerConnection por par, com os papéis de oferta e resposta decididos pela ordenação dos IDs dos pares, áudio encaminhado por um PannerNode com HRTF e um AudioListener atualizado a cada frame de acordo com a direção para a qual o jogador está olhando, além do Opus ajustado para 128 kbps com FEC em banda para maior resiliência. Um elemento <audio> oculto e silenciado força o Chrome a receber pacotes de um stream usado apenas pela Web Audio.
Engenharia de áudio subtrativa. Alcançar uma qualidade comparável à do Meet e do Teams exigiu remover etapas, não adicioná-las: redução de ruído por ML, cancelamento de eco, ganho automático e um compressor suave, sem gate e sem limitador de cliques, porque um removedor de ruído por ML treinado com ruído de teclado torna redundante o corte por amplitude. O apertar para falar aplica uma rampa a um GainNode no fim da cadeia com um envelope assimétrico, em vez de alternar a faixa, para não cortar sílabas; e o worklet do removedor de ruído é distribuído como um único bundle autocontido do esbuild para evitar problemas de resolução de importações com especificadores simples.
Parte 23 de 29. Anterior: Parte 22 — Nuvens que você pode iluminar e um culling que precisa ser alimentado Próxima: Parte 24 — Salvando um mundo e um vento que você pode ver Guia da série: /blog/2026-02-25-open-world-browser-series-guide