Construyendo un mundo abierto en el navegador, parte 23: Cincuenta avatares y una voz en la sala
Por Oleg Sidorkin, CTO y cofundador de Cinevva
¿Acabas de llegar? Consulta la guía de la serie. Explica qué es un spike y enlaza todas las partes.
La parte 22 puso un cielo sobre el mundo. Esta parte pone personas en él. Un mundo abierto en tercera persona necesita más de 50 personajes visibles en todo momento y oír a quienes están a tu lado. El spike 45 aborda el renderizado: llevar esa cantidad de avatares animados a la GPU sin colapsar el hilo principal. El spike 46 aborda el audio: voz entre pares que se panoramiza y atenúa según la posición, ajustada para sonar como una videollamada normal en lugar de como una demostración técnica.
Una llamada de dibujo para cincuenta bailarines
Abrir el spike 45 en una pestaña nueva ↗ · Ver el código fuente
La ruta predeterminada de Three.js asigna a cada personaje su propio SkinnedMesh, su propio AnimationMixer, su propia carga de matrices de huesos y su propia llamada de dibujo. Con 50 avatares en el Mac local, eso suponía unos 13 ms de sobrecarga de JavaScript puro por fotograma antes de que la GPU hiciera una sola cosa. La pregunta de reducción de riesgos para toda la línea de trabajo multijugador era si una arquitectura única de skinning por lotes podía controlar ese coste y escalar linealmente con la cantidad de personajes.
La respuesta consiste en separar dónde se calcula la animación de dónde se dibuja. Tres clases de personajes comparten una plantilla FBX. El jugador local es un Avatar normal, un clon completo del esqueleto con su propio mezclador y la ruta estándar de Three.js, porque nunca hay más de uno. Cada par remoto es un VirtualSkeleton: también un clon completo con su propio mezclador ejecutando los mismos clips, pero todos los nodos SkinnedMesh se eliminan inmediatamente después de la clonación para conservar solo los huesos. Nunca se añade a la escena. En cada fotograma, después de que el mezclador se actualice y las matrices se estabilicen, empaqueta (bone.matrixWorld × boneInverse) para los 100 huesos en una posición de un Float32Array compartido. A continuación, BatchSkinnedRenderer posee un InstancedMesh por cada pieza de geometría; todos leen de un único StorageBufferAttribute de matrices de huesos con un tamaño de maxInstances × numBones × mat4, es decir, 60 × 100 × 64 = 384 KB. Un MeshStandardNodeMaterial con positionNode y normalNode personalizados lee cuatro influencias de huesos por vértice directamente desde ese búfer de almacenamiento. El resultado es una carga al almacenamiento y una llamada de dibujo por pieza de geometría para toda la multitud, independientemente de cuántas personas haya. El skinning se ejecuta en el shader de vértices y el coste de JavaScript por avatar se reduce a ejecutar un mezclador y copiar 100 matrices.
El HUD que mide esto también tuvo que rehacerse. La versión anterior marcaba «presupuesto excedido» cuando el tiempo total de GPU del fotograma superaba los 3 ms, pero el fotograma siempre incluye el mapa de sombras, el suelo y la malla completa con skinning del jugador local, que juntos consumen entre 3 y 5 ms en hardware real sin importar cuántos pares sintéticos existan. La solución es un presupuesto que se calibra por sí solo: mientras no haya avatares procesados por lotes, captura el tiempo de GPU en vivo como referencia mediante una EMA rápida; después congela esa referencia y aumenta el presupuesto linealmente en 0,06 ms por cada avatar añadido cuando aparecen los sintéticos. En reposo muestra PASS en todas las máquinas y se ajusta proporcionalmente a medida que crece la multitud.
El fallo era una cara que no podías ver
Las primeras ejecuciones en Chrome mostraron sombras plateadas sobre el suelo y ningún avatar, junto con un error de análisis de WGSL: cannot index type 'f32' en una línea que intentaba indexar object.nodeUniform2[i] cuando el uniforme estaba declarado como un escalar. La parte sincera de esta historia es que la primera solución era incorrecta y aun así funcionó. La suposición fue que la ruta de matrices de instancias de InstancedMesh estaba generando el código erróneo, y sustituirla por un StorageInstancedBufferAttribute hizo que el error desapareciera en Chrome. Pero desapareció porque la nueva ruta emitía un código de shader diferente, no porque solucionara la causa, que es el tipo de arreglo más peligroso.
El verdadero culpable eran los morph targets. 3MIKE.fbx incluye expresiones faciales mediante blend shapes, la geometría clonada hereda los morphAttributes y MorphNode.setup() de Three.js declara morphTargetInfluences como un float escalar y después intenta aplicarle .element(i) dentro de un bucle sintetizado, que es exactamente la indexación de un escalar que el compilador rechazó. La solución es una sola línea: borrar geometry.morphAttributes = {} en la geometría que no utiliza morphs, para que Three.js nunca inyecte el MorphNode. La solución accidental para Chrome se mantuvo durante un tiempo y después se volvió en contra: en Safari, la ruta de instancias en almacenamiento produjo Vertex buffer is not big enough 256 veces, porque el backend WebGPU de Safari no la traduce correctamente. Revertirla fue la decisión adecuada, y el búfer de almacenamiento simple de matrices de huesos, que forma parte del núcleo de WebGPU en vez de ser una ruta de instancias generada, funciona bien en todas partes. La lección que merece conservarse es esta: cuando una solución funciona en un navegador y no puedes explicar el mecanismo, has parcheado un síntoma, así que lee el WGSL generado. Un shim de getCompilationInfo() añadido más adelante en el spike convirtió el genérico «module is not valid» de Three.js en el error real de Tint y amortizó con creces el tiempo invertido.
Junto a esto hay otro truco para esquivar el framework. Three.js detecta los nombres estándar de atributos skinIndex y skinWeight e intenta inyectar su propio SkinningNode, incluso en un InstancedMesh cuyo positionNode personalizado ya realiza el skinning. Renombrar esos atributos como boneIndex y boneWeight los oculta al framework, y el TSL personalizado los lee con sus nuevos nombres.
Un relé que te olvida entre palabras
La primera versión sincronizaba los pares mediante BroadcastChannel, un sustituto dentro del mismo navegador con el formato y la cadencia reales de la comunicación, y el comentario del protocolo prometía que cambiarlo por un transporte real requeriría una sola línea. Cumplir esa promesa dio lugar a AvatarRoomDO, un Durable Object de Cloudflare de 74 líneas que ni siquiera decodifica la trama binaria de 36 bytes. Reenvía cada mensaje tal cual a todos los demás pares de la sala, porque el id del remitente está integrado en la trama y cada receptor filtra su propio eco en el cliente. El relé no sabe nada sobre identidades. Los WebSockets en hibernación hacen que una sala inactiva sea gratuita: el DO desaparece de la memoria entre mensajes y el entorno de ejecución restaura los sockets etiquetados cuando llega el siguiente paquete. Con 10 eventos por segundo y por par, eso supone 36 000 solicitudes al DO por hora de conexión de cada par, aproximadamente medio centavo, con salida de datos gratuita en Cloudflare y un coste entre 6 y 10 veces menor que una configuración WebSocket equivalente en AWS.
El cambio sacó a la luz un fallo de la máquina de estados que merece recordarse. Un jugador remoto seguía caminando después de haberse detenido. La solicitud de animación comprobaba this._state, el clip que se estaba reproduciendo, en lugar del último nombre puesto en cola. Por eso, cuando dos mensajes de red llegaban en el mismo tick, primero walk y después idle, idle se comparaba con un estado que todavía no había avanzado y se descartaba silenciosamente. El par quedaba caminando para siempre, porque los futuros paquetes idle se eliminaban antes por deduplicación al no haber cambiado. La solución es sobrescribir siempre el nombre pendiente y dejar que el asistente de transición descarte las solicitudes genuinas del mismo estado, algo que ya hacía. Esta clase de fallo es general: una comprobación de deduplicación contra el valor de referencia equivocado se traga silenciosamente la entrada que importa.
Safari necesitó dos protecciones adicionales. Abre el WebSocket más rápido que Chrome, por lo que el primer mensaje entrante de un par podía llegar antes de que terminara de construirse el renderizador por lotes y desreferenciar un valor nulo; descartar mensajes mientras no exista el renderizador es seguro porque los pares vuelven a emitir cada 100 ms. Además, 'gpu' in navigator devolvía true mientras requestAdapter() devolvía null, de modo que Three.js recurría silenciosamente a WebGL2, donde la cadena de skinning mediante búfer de almacenamiento no tiene una traducción válida y generaba errores sin parar. Comprobar que existe un adaptador real y verificar que el backend sea efectivamente WebGPU convierte un renderizado degradado en un mensaje claro en la pantalla de carga. Incluso había una diferencia de dialecto WGSL: Three.js emite la forma moderna de dos argumentos @interpolate(flat, either), que el compilador de WebKit aún no admite. Se solucionó reescribiendo el código fuente del shader al entrar en createShaderModule para eliminar el segundo argumento, lo cual no tiene coste porque la interpolación plana transporta el mismo valor en cada vértice independientemente de él.
Una voz que se panoramiza con la sala
Abrir el spike 46 en una pestaña nueva ↗ · Ver el código fuente
El spike 46 implementa voz de proximidad: WebRTC entre pares con audio espacial HRTF, cuyo alcance se definió expresamente para igualar la calidad de Google Meet y Microsoft Teams en una sala silenciosa o moderadamente ruidosa. Un VoiceRoomDO gestiona la señalización como un relé JSON: envía a cada par nuevo una lista de participantes, anuncia entradas y salidas, enruta SDP e ICE a un par específico mediante la etiqueta del socket y difunde actualizaciones de posición que controlan los panoramizadores espaciales. Añade el id del remitente a cada mensaje para que los pares no puedan suplantarse entre sí, y el audio nunca pasa por el DO. Hay una RTCPeerConnection por cada par remoto, y el id de par lexicográficamente menor siempre crea la oferta para que ambos lados coincidan en quién inicia la conexión sin tener que implementar por completo el patrón de negociación perfecta.
En el lado receptor, el audio de cada par pasa por un PannerNode configurado como HRTF con atenuación de distancia inversa, y el AudioListener se actualiza en cada fotograma a partir de la posición y la orientación del jugador local mediante forwardX = sin(facing), forwardZ = cos(facing), lo que coincide con la convención de orientación atan2(wx, wz) de la escena. Una peculiaridad de Chrome costó una hora: un MediaStream consumido únicamente por Web Audio a veces no recibe paquetes, por lo que cada flujo también se conecta a un elemento <audio> oculto y silenciado para obligar al decodificador a programar el procesamiento. En cuanto a la calidad, los navegadores usan de forma predeterminada Opus mono a unos 32 kbps, así que el spike modifica la línea fmtp de cada oferta y respuesta para elevarla a 128 kbps, con FEC en banda activado y DTX desactivado, y después llama a setParameters con una tasa de bits máxima elevada para garantizar que el codificador utilice realmente lo que anuncia el SDP. FEC es la segunda mejora audible más importante después del aumento de la tasa de bits, ya que permite recuperarse de la pérdida de paquetes sin renegociar.
Eliminar hasta conseguir un audio limpio
La cadena de audio publicada es mucho más pequeña que aquella con la que empecé, y reducirla fue la verdadera lección. La primera versión tenía un filtro de paso alto, un limitador de chasquidos ajustado para capturar el ruido del teclado, un compresor, una puerta de ruido y una mezcla con fundido entre señal procesada y seca, todo ello controlado desde un panel flotante con más de doce deslizadores. Cuando el usuario informó de que se oían las pulsaciones del teclado, el primer impulso fue ajustar el limitador de forma más agresiva y reducir la mezcla seca: una pila de parches. La respuesta estructural era que, una vez que hay un eliminador de ruido basado en aprendizaje automático en la cadena, el limitador de chasquidos, la puerta y la mayor parte del filtro de paso alto resultan redundantes, porque RNNoise está entrenado precisamente con ruido de teclado, ratón y escritura, y el recorte de amplitud es una versión claramente peor de la misma tarea. Los clientes de producción incluyen eliminación de ruido mediante aprendizaje automático, cancelación de eco, ganancia automática y un compresor suave para nivelar, y nada más. Así que se eliminaron cuatro etapas, el panel de deslizadores y los controles para «elegir la reducción de ruido», dejando una única cadena fija.
Cada etapa que sobrevivió justifica su presencia. La cancelación de eco del navegador se mantiene porque RNNoise no elimina el eco, y sin ella la realimentación del altavoz al micrófono no tiene límite. La supresión de ruido del navegador se desactiva porque combinarla con RNNoise produce artefactos en las fricativas, así que hay que elegir un solo eliminador de ruido. La ganancia automática del navegador se mantiene porque desactivarla hacía que la señal fuera demasiado baja para que el compresor pudiera trabajar, y el DynamicsCompressorNode de Web Audio no tiene ningún parámetro de ganancia de compensación; la nivelación amplia del navegador y el compresor rápido del spike operan en escalas temporales diferentes y pueden coexistir. RNNoise funciona con un 92 por ciento de señal procesada mezclada con un 8 por ciento de señal seca, porque puede suprimir en exceso consonantes sordas como s, sh y f cuando disminuye su probabilidad de voz, y la pequeña ruta seca las conserva a cambio de dejar pasar ligeramente el ruido de las teclas. Dos funciones completan el sistema. Pulsar para hablar no cambia track.enabled, porque eso descarta todo lo que aún queda en los búferes de la canalización y corta la última sílaba al soltar la tecla. En su lugar, un GainNode situado cerca del final aplica una rampa con setTargetAtTime: un ataque rápido para conservar la primera sílaba y una liberación lenta para dejar salir la última consonante, mientras la pista permanece habilitada en todo momento. Además, un retardo de emisión de cinco segundos, solicitado como función al estilo de la radio, utiliza una ruta de derivación y otra con un DelayNode, mezcladas mediante fundido cruzado, junto con un botón de corte que silencia de inmediato la salida retardada y muestra una cuenta atrás en el HUD antes de reanudar el audio. Empaquetar el eliminador de ruido fue una pequeña odisea: el worklet de RNNoise publicado utiliza importaciones con especificadores simples que ningún CDN puede resolver, así que la solución fue crear un paquete local con esbuild que produce un único archivo autocontenido de 1,9 MB, con el WASM incrustado en base64, guardado en el repositorio y referenciado mediante una URL relativa al módulo para que funcione por igual en el servidor de desarrollo, la compilación de VitePress y el dominio personalizado. Si el worklet no llega a cargarse, la cadena sigue generando audio mediante un filtro paso alto básico y un compresor, y el HUD muestra el fallo en rojo.
Tecnología mencionada en este capítulo
Skinning por lotes en la GPU para multitudes. Los avatares remotos ejecutan un VirtualSkeleton sin renderizado (un clon completo al que se le han retirado las mallas con skinning, conservando los huesos y su propio mezclador) que empaqueta bone.matrixWorld × boneInverse para cada hueso en un StorageBufferAttribute compartido. Un InstancedMesh por cada pieza geométrica lee esas matrices en un positionNode/normalNode de TSL personalizado, de modo que toda la multitud solo requiere una transferencia al almacenamiento y una llamada de dibujo por pieza, mientras que el trabajo de CPU por avatar se limita a actualizar el mezclador y copiar una matriz. Consulta LOD controlado por la GPU.
Leer el WGSL generado, no el síntoma. Un error de compilación cannot index type 'f32' se rastreó hasta el MorphNode de Three.js, que declaraba morphTargetInfluences como escalar y después intentaba indexarlo; se corrigió vaciando morphAttributes en las geometrías que no utilizan morphs. Una primera solución que solo cambiaba la ruta de shaders generada ocultó la causa y más tarde provocó fallos en Safari. Cambiar el nombre de skinIndex/skinWeight a boneIndex/boneWeight oculta los atributos frente a la inyección automática de SkinningNode de Three.js, de modo que un material de skinning personalizado controle los cálculos.
Relés de Durable Objects en hibernación. Un AvatarRoomDO exclusivamente binario reenvía tramas de 36 bytes a todos los demás pares sin decodificarlas, con la identidad del remitente incrustada en la trama y el eco propio filtrado en el cliente. Los WebSockets en hibernación hacen que una sala inactiva no tenga coste, y esta configuración cuesta aproximadamente medio centavo por hora y por par a 10 Hz, muy por debajo del precio equivalente de WebSockets gestionados. Una protección contra duplicados que comparaba con el estado de la animación en reproducción, en lugar de con el último estado puesto en cola, descartaba silenciosamente los mensajes de detención y dejaba a los jugadores remotos atrapados en un bucle de caminata.
Voz por proximidad mediante WebRTC con HRTF. Una RTCPeerConnection por cada par, con los roles de oferta y respuesta decididos según el orden de los identificadores de los pares; el audio se enruta a través de un PannerNode HRTF con un AudioListener que se actualiza en cada fotograma según la orientación del jugador, y Opus se modifica a 128 kbps con FEC integrada para mejorar la resiliencia. Un elemento <audio> oculto y silenciado obliga a Chrome a extraer paquetes de un flujo utilizado exclusivamente por Web Audio.
Ingeniería de audio sustractiva. Igualar la calidad de Meet y Teams exigió eliminar etapas, no añadirlas: eliminación de ruido mediante ML, más cancelación de eco, ganancia automática y un compresor suave, sin puerta de ruido ni limitador de clics, porque un eliminador de ruido basado en ML y entrenado con ruido de teclado vuelve redundante el recorte de amplitud. Pulsar para hablar aplica una rampa a un GainNode situado al final mediante una envolvente asimétrica, en vez de activar y desactivar la pista, para no cortar las sílabas; además, el worklet del eliminador de ruido se distribuye como un único paquete autocontenido de esbuild para evitar los problemas de resolución de importaciones con especificadores simples.
Parte 23 de 29. Anterior: Parte 22 - Nubes que puedes iluminar y un descarte que necesita datos Siguiente: Parte 24 - Guardar un mundo y un viento que puedes ver Guía de la serie: /es/blog/2026-02-25-open-world-browser-series-guide