Skip to content

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