Construire un monde ouvert dans le navigateur, partie 23 : cinquante avatars et une voix dans la pièce
Par Oleg Sidorkin, CTO et cofondateur de Cinevva
Vous découvrez la série ? Consultez le guide de la série. Il explique ce qu'est un spike et renvoie vers toutes les parties.
La partie 22 a ajouté un ciel au-dessus du monde. Cette partie y ajoute des habitants. Un monde ouvert à la troisième personne doit pouvoir afficher plus de 50 personnages à tout moment, tout en permettant d'entendre ceux qui se trouvent à proximité. Le spike 45 concerne le rendu : envoyer autant d'avatars animés au GPU sans faire fondre le thread principal. Le spike 46 concerne l'audio : une communication vocale pair à pair dont la panoramique et l'atténuation dépendent de la position, réglée pour sonner comme un appel vidéo normal plutôt que comme une démo technique.
Un seul appel de rendu pour cinquante danseurs
Ouvrir le spike 45 dans un nouvel onglet ↗ · Voir le code source
Le fonctionnement par défaut de Three.js attribue à chaque personnage son propre SkinnedMesh, son propre AnimationMixer, son propre transfert de matrices d'os et son propre appel de rendu. Avec 50 avatars sur le Mac local, cela représentait environ 13 ms de surcharge JavaScript pure par image, avant même que le GPU ne fasse quoi que ce soit. La question à lever pour toute la partie multijoueur était de savoir si une architecture unique de skinning par lots pouvait maîtriser ce coût et évoluer linéairement avec le nombre de personnages.
La solution consiste à séparer l'endroit où l'animation est calculée de celui où elle est rendue. Trois classes de personnages partagent un même modèle FBX. Le joueur local est un Avatar normal, un clone complet du squelette avec son propre mixer, suivant le parcours standard de Three.js, puisqu'il n'y en a jamais qu'un. Chaque pair distant est un VirtualSkeleton : lui aussi est un clone complet doté de son propre mixer exécutant les mêmes clips, mais chaque nœud SkinnedMesh est supprimé immédiatement après le clonage afin de ne conserver que les os. Il n'est jamais ajouté à la scène. À chaque image, une fois le mixer mis à jour et les matrices stabilisées, il compacte (bone.matrixWorld × boneInverse) pour les 100 os dans un emplacement d'un Float32Array partagé. Le BatchSkinnedRenderer possède ensuite un InstancedMesh par élément de géométrie, tous lisant un unique StorageBufferAttribute de matrices d'os dimensionné à maxInstances × numBones × mat4, soit 60 × 100 × 64 = 384 Ko. Un MeshStandardNodeMaterial doté de positionNode et normalNode personnalisés lit directement dans ce tampon de stockage les quatre influences osseuses de chaque sommet. Le résultat est un transfert vers le tampon de stockage et un appel de rendu par élément de géométrie pour toute la foule, quel que soit le nombre de personnes qui la composent. Le skinning s'effectue dans le vertex shader, et le coût JavaScript par avatar se réduit à l'exécution d'un mixer et à la copie de 100 matrices.
Le HUD chargé de mesurer cela a également dû être reconstruit. L'ancienne version signalait un « dépassement du budget » lorsque le temps GPU total d'une image dépassait 3 ms, mais une image comprend toujours la shadow map, le sol et le mesh skinné complet du joueur local, qui représentent ensemble 3 à 5 ms sur du matériel réel, quel que soit le nombre de pairs synthétiques présents. La solution est un budget qui s'étalonne automatiquement : tant qu'aucun avatar traité par lots n'est présent, il capture le temps GPU réel comme référence au moyen d'une EMA rapide, puis fige cette référence et augmente linéairement le budget de 0,06 ms par avatar ajouté dès que les avatars synthétiques apparaissent. Il affiche PASS au repos sur toutes les machines, puis resserre proportionnellement le budget à mesure que la foule grandit.
Le bug était un visage invisible
Les premiers essais dans Chrome montraient des ombres argentées au sol, mais aucun avatar, avec une erreur d'analyse WGSL : cannot index type 'f32' sur une ligne tentant d'indexer object.nodeUniform2[i], alors que l'uniforme était déclaré comme un scalaire. Pour être honnête, la première correction était mauvaise, mais elle a tout de même fonctionné. Nous avons supposé que le parcours des matrices d'instances d'InstancedMesh générait le code erroné, et son remplacement par un StorageInstancedBufferAttribute a fait disparaître l'erreur dans Chrome. Mais elle a disparu parce que le nouveau parcours produisait un shader différent, et non parce qu'il corrigeait la cause : c'est le type de correctif le plus dangereux.
Le véritable responsable était le morphing. Le fichier 3MIKE.fbx contient des expressions faciales sous forme de blend shapes, la géométrie clonée hérite des morphAttributes, et MorphNode.setup() de Three.js déclare morphTargetInfluences comme un float scalaire avant de tenter de lui appliquer .element(i) dans une boucle générée, ce qui correspond exactement à l'indexation de scalaire rejetée par le compilateur. La correction tient en une ligne : définir geometry.morphAttributes = {} sur les géométries qui n'utilisent pas le morphing, afin que Three.js n'injecte jamais le MorphNode. Le correctif accidentel pour Chrome est resté quelque temps avant de se retourner contre nous : dans Safari, le parcours d'instanciation par stockage produisait 256 fois l'erreur Vertex buffer is not big enough, car le backend WebGPU de Safari ne le traduit pas correctement. Revenir en arrière était la bonne décision, et le simple tampon de stockage des matrices d'os, qui repose sur le cœur de WebGPU plutôt que sur un parcours d'instanciation généré, fonctionne correctement partout. La leçon à retenir est la suivante : lorsqu'un correctif fonctionne dans un navigateur sans que vous puissiez en expliquer le mécanisme, vous avez corrigé un symptôme ; il faut donc lire le WGSL réellement généré. Un shim getCompilationInfo() ajouté plus tard dans le spike a transformé le vague message « module is not valid » de Three.js en véritable erreur Tint, et s'est révélé rentable à maintes reprises.
Une technique connexe pour contourner le framework se trouve juste à côté. Three.js détecte les noms d'attributs standard skinIndex et skinWeight, puis tente d'injecter son propre SkinningNode, même dans un InstancedMesh dont le positionNode personnalisé effectue déjà le skinning. Renommer ces attributs en boneIndex et boneWeight les masque au framework, et le TSL personnalisé les lit sous leurs nouveaux noms.
Un relais qui vous oublie entre deux mots
La première version synchronisait les pairs via BroadcastChannel, un substitut limité au même navigateur utilisant le véritable format de transmission et la véritable cadence, et le commentaire du protocole promettait que le passage à un transport réel ne demanderait qu'une ligne. Tenir cette promesse a nécessité un AvatarRoomDO, un Durable Object Cloudflare de 74 lignes qui ne décode même pas la trame binaire de 36 octets. Il transmet chaque message tel quel à tous les autres pairs présents dans la salle, car l'identifiant de l'expéditeur est intégré à la trame et chaque destinataire filtre son propre écho côté client. Le relais n'a aucune connaissance des identités. Les WebSockets en hibernation rendent une salle inactive gratuite : le DO quitte la mémoire entre les messages et l'environnement d'exécution restaure les sockets étiquetées à l'arrivée du paquet suivant. À raison de 10 événements par seconde et par pair, cela représente 36 000 requêtes au DO par heure-pair, soit environ un demi-centime, avec un trafic sortant gratuit chez Cloudflare et un coût environ 6 à 10 fois inférieur à celui d'une architecture WebSocket équivalente sur AWS.
Ce remplacement a révélé un bug de machine à états qu'il vaut la peine de retenir. Un joueur distant continuait à marcher après s'être arrêté. La requête d'animation vérifiait this._state, le clip en cours de lecture, au lieu du dernier nom mis en file d'attente. Ainsi, lorsque deux messages réseau arrivaient pendant le même tick, d'abord walk, puis idle, idle était comparé à un état qui n'avait pas encore évolué et était silencieusement ignoré. Le pair restait bloqué à marcher indéfiniment, car les paquets idle suivants étaient dédupliqués en amont comme étant inchangés. La correction consiste à toujours remplacer le nom en attente et à laisser l'assistant de transition court-circuiter les véritables requêtes visant le même état, ce qu'il faisait déjà. Cette catégorie de bug est générale : une vérification de déduplication effectuée par rapport à la mauvaise valeur de référence élimine silencieusement l'entrée qui compte.
Safari a nécessité deux protections supplémentaires. Il ouvre le WebSocket plus vite que Chrome, si bien que le premier message entrant d'un pair pouvait arriver avant la fin de la construction du moteur de rendu par lots et déréférencer une valeur nulle ; ignorer les messages tant que le moteur de rendu est absent ne présente aucun risque, puisque les pairs rediffusent leur état toutes les 100 ms. De plus, 'gpu' in navigator renvoyait true alors que requestAdapter() renvoyait null. Three.js basculait donc silencieusement vers WebGL2, où la chaîne de skinning par tampon de stockage ne possède aucune traduction valide et générait une avalanche d'erreurs. Vérifier la présence d'un véritable adaptateur et confirmer que le backend est bien WebGPU transforme un rendu dégradé en un message clair sur l'écran de chargement. Il existait même un écart de dialecte WGSL : Three.js génère la forme moderne à deux arguments @interpolate(flat, either), qui n'est pas encore prise en charge par le compilateur de WebKit. Nous l'avons contourné en réécrivant le code source du shader lors de son passage dans createShaderModule afin de supprimer le second argument, sans aucun coût puisque l'interpolation flat transporte la même valeur sur chaque sommet dans tous les cas.
Une voix qui se déplace dans la pièce
Ouvrir le spike 46 dans un nouvel onglet ↗ · Voir le code source
Le spike 46 est consacré à la voix de proximité : une communication WebRTC pair à pair avec un son spatialisé HRTF, dont l'objectif explicite est d'atteindre une qualité comparable à Google Meet et Microsoft Teams dans une pièce calme à modérément bruyante. Un VoiceRoomDO gère la signalisation sous la forme d'un relais JSON : il envoie à chaque nouveau pair la liste des participants, annonce les arrivées et les départs, achemine le SDP et les informations ICE vers un pair précis grâce à l'étiquette du socket, et diffuse les mises à jour de position qui pilotent les panoramiques spatiaux. Il ajoute l'identifiant de l'expéditeur à chaque message afin d'empêcher les pairs de se faire passer les uns pour les autres, et l'audio lui-même ne transite jamais par le DO. Il existe une RTCPeerConnection par pair distant, et le pair dont l'identifiant est le plus petit dans l'ordre lexicographique émet toujours l'offre. Les deux côtés s'accordent ainsi sur l'initiateur sans nécessiter une implémentation complète du modèle perfect negotiation.
Côté réception, l'audio de chaque pair traverse un PannerNode configuré en HRTF avec une atténuation inversement proportionnelle à la distance, tandis que l'AudioListener est mis à jour à chaque image à partir de la position et de l'orientation du joueur local au moyen de forwardX = sin(facing), forwardZ = cos(facing), ce qui correspond à la convention d'orientation atan2(wx, wz) de la scène. Une particularité de Chrome nous a coûté une heure : un MediaStream consommé uniquement par Web Audio peut parfois ne pas récupérer les paquets. Chaque flux est donc également associé à un élément <audio> masqué et mis en sourdine afin de forcer la planification du décodeur. En matière de qualité, les navigateurs utilisent par défaut Opus en mono à environ 32 kbit/s. Le spike modifie donc la ligne fmtp de chaque offre et réponse pour passer à 128 kbit/s, activer la correction d'erreurs FEC intrabande et désactiver le DTX, puis appelle setParameters avec un débit maximal élevé afin de garantir que l'encodeur utilise réellement ce qu'annonce le SDP. Après l'augmentation du débit, la FEC apporte la deuxième amélioration audible la plus importante, en compensant les pertes de paquets sans renégociation.
Supprimer pour obtenir un son propre
La chaîne audio livrée est bien plus petite que celle avec laquelle j'avais commencé, et cette simplification a été la véritable leçon. La première version comportait un filtre passe-haut, un limiteur de clics réglé pour détecter le bruit du clavier, un compresseur, un noise gate et un fondu enchaîné entre le signal traité et le signal direct, le tout piloté par un panneau flottant doté de plus de douze curseurs. Lorsque l'utilisateur a signalé que les clics du clavier restaient audibles, le premier réflexe a été de renforcer le limiteur de clics et de réduire la part du signal direct : un empilement de rustines. La réponse structurelle était qu'une fois un débruiteur par ML intégré à la chaîne, le limiteur de clics, le noise gate et l'essentiel du filtre passe-haut deviennent tous redondants, car RNNoise est précisément entraîné sur les bruits de clavier, de souris et de frappe, tandis que l'écrêtage d'amplitude n'est qu'une version nettement inférieure de la même fonction. Les clients en production utilisent un débruitage par ML, une annulation d'écho, un gain automatique et un compresseur doux pour niveler le volume, rien de plus. Quatre étages ont donc été supprimés, tout comme le panneau de curseurs et les options permettant de choisir le mode de réduction du bruit, ne laissant qu'une seule chaîne fixe.
Chaque étage conservé justifie sa présence. L'annulation d'écho du navigateur reste activée, car RNNoise ne traite pas l'écho et, sans elle, la boucle entre les haut-parleurs et le microphone n'a aucune limite. La suppression du bruit du navigateur est désactivée, car la superposer à RNNoise produit des artefacts sur les consonnes fricatives : il faut donc choisir un seul débruiteur. Le gain automatique du navigateur reste activé, car sa désactivation rendait le signal trop faible pour que le compresseur puisse travailler, et le DynamicsCompressorNode de Web Audio ne possède aucun paramètre de gain de compensation ; le nivellement général du navigateur et le compresseur rapide du spike opèrent sur des échelles de temps différentes et peuvent coexister. RNNoise est mélangé à 92 % de signal traité et 8 % de signal direct, car il peut trop atténuer les consonnes non voisées comme s, ch et f, dont la probabilité vocale diminue. La petite part de signal direct les préserve au prix d'une légère réintroduction des bruits de frappe. Deux fonctionnalités viennent compléter l’ensemble. Le push-to-talk ne modifie pas track.enabled, car cela supprime tout ce qui se trouve encore dans les tampons du pipeline et coupe la dernière syllabe lorsque la touche est relâchée. À la place, un GainNode placé vers la fin de la chaîne effectue une transition avec setTargetAtTime : une attaque rapide pour préserver la première syllabe et un relâchement lent pour laisser passer la dernière consonne, tandis que la piste reste activée en permanence. Et un délai de diffusion de cinq secondes, demandé comme fonctionnalité de type radio, utilise en parallèle une branche de contournement et une branche avec DelayNode, entre lesquelles s’effectue un fondu enchaîné. Un bouton d’interruption coupe instantanément la sortie retardée et affiche un compte à rebours sur le HUD avant la reprise du son. L’intégration du débruiteur a été une petite saga en soi : le worklet RNNoise publié utilise des imports à spécificateur nu qu’aucun CDN ne sait résoudre. La solution a donc consisté à créer localement avec esbuild un bundle autonome de 1,9 Mo, avec le WASM encodé en base64 directement intégré, puis à le commiter dans le dépôt et à le référencer par une URL relative au module afin qu’il soit résolu aussi bien par le serveur de développement que par le build VitePress et le domaine personnalisé. Si le chargement du worklet échoue un jour, la chaîne continue à produire du son au moyen d’un simple filtre passe-haut et d’un compresseur, et le HUD affiche l’échec en rouge.
Technologies abordées dans ce chapitre
Skinning GPU groupé pour les foules. Les avatars distants exécutent un VirtualSkeleton sans rendu (un clone complet dont les maillages skinnés ont été retirés, mais qui conserve les os et dispose de son propre mixer) qui regroupe bone.matrixWorld × boneInverse pour chaque os dans un StorageBufferAttribute partagé. Un InstancedMesh par élément de géométrie lit ces matrices dans des positionNode/normalNode TSL personnalisés. Ainsi, toute la foule ne nécessite qu’un seul transfert vers le stockage et un seul appel de dessin par élément, le travail CPU par avatar se limitant à une mise à jour du mixer et à une copie de matrice. Voir LOD piloté par le GPU.
Lire le WGSL généré, pas le symptôme. Une erreur de compilation cannot index type 'f32' provenait du MorphNode de Three.js, qui déclarait morphTargetInfluences comme un scalaire avant de tenter de l’indexer. Le problème a été corrigé en supprimant les morphAttributes des géométries qui n’utilisent pas de morphing. Une première correction, qui ne faisait que modifier le chemin de shader généré, masquait la cause et a ensuite cassé Safari. Renommer skinIndex/skinWeight en boneIndex/boneWeight masque les attributs à l’injection automatique de SkinningNode par Three.js, afin qu’un matériau de skinning personnalisé prenne en charge les calculs.
Relais Durable Object en hibernation. Un AvatarRoomDO entièrement binaire transmet des trames de 36 octets à tous les autres pairs sans les décoder, l’identité de l’émetteur étant intégrée à la trame et l’écho vers soi-même étant filtré côté client. Les WebSockets en hibernation rendent une salle inactive gratuite, et cette architecture coûte environ un demi-centime par heure et par pair à 10 Hz, bien moins que l’équivalent avec des WebSockets managés. Un garde-fou de déduplication qui comparait l’animation avec l’état en cours de lecture plutôt qu’avec le dernier état mis en file d’attente supprimait silencieusement les messages d’arrêt et bloquait les joueurs distants dans une boucle de marche.
Voix de proximité WebRTC avec HRTF. Une RTCPeerConnection par pair, avec les rôles d’offre et de réponse déterminés par l’ordre des identifiants de pairs, un son acheminé vers un PannerNode HRTF doté d’un AudioListener mis à jour à chaque image selon l’orientation du joueur, et des paramètres Opus modifiés pour atteindre 128 kbit/s avec FEC intrabande afin d’améliorer la résilience. Un élément <audio> masqué et coupé force Chrome à récupérer les paquets d’un flux utilisé uniquement par Web Audio.
Ingénierie audio soustractive. Atteindre une qualité comparable à Meet ou Teams impliquait de retirer des étapes, pas d’en ajouter : débruitage par ML, annulation d’écho, gain automatique et compresseur doux, sans noise gate ni limiteur de clics, car un débruiteur ML entraîné sur le bruit de clavier rend inutile l’écrêtage fondé sur l’amplitude. Le push-to-talk fait varier un GainNode en fin de chaîne avec une enveloppe asymétrique au lieu de modifier l’état de la piste, afin de ne pas couper les syllabes, et le worklet du débruiteur est distribué sous la forme d’un unique bundle esbuild autonome pour contourner la résolution des imports à spécificateur nu.
Partie 23 sur 29. Précédent : Partie 22 - Des nuages que l’on peut éclairer et un culling qu’il faut alimenter Suivant : Partie 24 - Sauvegarder un monde et rendre le vent visible Guide de la série : /blog/2026-02-25-open-world-browser-series-guide