Eine offene Welt im Browser bauen, Teil 23: Fünfzig Avatare und eine Stimme im Raum
Von Oleg Sidorkin, CTO und Mitgründer von Cinevva
Neu hier? Nutze den Serienleitfaden. Er erklärt, was ein Spike ist, und verlinkt alle Teile.
Teil 22 spannte einen Himmel über die Welt. Dieser Teil bringt Menschen hinein. Eine offene Welt aus der Third-Person-Perspektive soll jederzeit mehr als 50 Figuren anzeigen und diejenigen hörbar machen, die direkt neben dir stehen. Spike 45 behandelt die Rendering-Seite: so viele animierte Avatare auf die GPU zu bekommen, ohne den Main Thread zu überlasten. Spike 46 behandelt die Audio-Seite: Peer-to-Peer-Sprachkommunikation, die abhängig von der Position im Stereobild verschoben und gedämpft wird und wie ein normaler Videoanruf statt wie eine Technikdemo klingen soll.
Ein Draw Call für fünfzig Tänzer
Spike 45 in einem neuen Tab öffnen ↗ · Quellcode ansehen
Der Standardpfad von Three.js gibt jeder Figur ihr eigenes SkinnedMesh, ihren eigenen AnimationMixer, ihren eigenen Upload der Knochenmatrizen und ihren eigenen Draw Call. Bei 50 Avataren auf dem lokalen Mac verursachte das etwa 13 ms reinen JavaScript-Aufwand pro Frame, bevor die GPU überhaupt etwas tat. Die entscheidende Frage zur Risikominimierung für den gesamten Multiplayer-Bereich war, ob eine einzige Architektur für gebündeltes Skinning diesen Aufwand in den Griff bekommen und linear mit der Figurenanzahl skalieren könnte.
Die Antwort besteht in einer Trennung zwischen dem Ort, an dem die Animation berechnet wird, und dem Ort, an dem sie gezeichnet wird. Drei Figurenklassen teilen sich eine FBX-Vorlage. Der lokale Spieler ist ein normaler Avatar, also ein vollständiger Skelettklon mit eigenem Mixer und dem Standardpfad von Three.js, weil es davon immer nur einen gibt. Jeder entfernte Peer ist ein VirtualSkeleton: ebenfalls ein vollständiger Klon mit eigenem Mixer, auf dem dieselben Clips laufen, doch unmittelbar nach dem Klonen wird jeder SkinnedMesh-Knoten entfernt, sodass nur die Knochen übrig bleiben. Er wird nie in die Szene eingefügt. In jedem Frame, nachdem der Mixer aktualisiert wurde und die Matrizen feststehen, schreibt er (bone.matrixWorld × boneInverse) für alle 100 Knochen in einen Abschnitt eines gemeinsam genutzten Float32Array. Der BatchSkinnedRenderer besitzt dann pro Geometrieteil ein InstancedMesh, die alle aus einem einzigen StorageBufferAttribute mit Knochenmatrizen der Größe maxInstances × numBones × mat4 lesen, also 60 × 100 × 64 = 384 KB. Ein MeshStandardNodeMaterial mit benutzerdefiniertem positionNode und normalNode liest vier Knocheneinflüsse pro Vertex direkt aus diesem Speicherpuffer. Das Ergebnis sind ein Speicher-Upload und ein Draw Call pro Geometrieteil für die gesamte Menge, unabhängig davon, wie viele Personen sie umfasst. Das Skinning findet im Vertex-Shader statt, und der JavaScript-Aufwand pro Avatar sinkt auf die Ausführung eines Mixers und das Kopieren von 100 Matrizen.
Auch das HUD, das dies misst, musste neu gebaut werden. Die alte Version meldete „Budget überschritten“, wenn die GPU-Zeit des gesamten Frames 3 ms überschritt. Doch der Frame enthält immer auch die Shadow Map, den Boden und das vollständige Skinned Mesh des lokalen Spielers, die auf echter Hardware zusammen 3 bis 5 ms benötigen, unabhängig davon, wie viele synthetische Peers existieren. Die Lösung ist ein Budget, das sich selbst kalibriert: Solange keine gebündelten Avatare vorhanden sind, erfasst es die aktuelle GPU-Zeit mittels eines schnellen EMA als Basiswert. Sobald synthetische Avatare erscheinen, wird dieser Basiswert eingefroren und das Budget wächst linear um 0,06 ms pro hinzugefügtem Avatar. Im Leerlauf zeigt es auf jedem Gerät BESTANDEN an und wird proportional strenger, wenn die Menge wächst.
Der Fehler war ein Gesicht, das man nicht sehen konnte
Die ersten Durchläufe in Chrome zeigten silberne Schatten auf dem Boden, aber überhaupt keine Avatare, zusammen mit einem WGSL-Parsing-Fehler: cannot index type 'f32' in einer Zeile, die versuchte, object.nodeUniform2[i] zu indizieren, obwohl das Uniform als Skalar deklariert war. Der ehrliche Teil dieser Geschichte ist, dass die erste Korrektur falsch war und trotzdem funktionierte. Die Vermutung lautete, dass der Instanzmatrizenpfad von InstancedMesh den fehlerhaften Code erzeugte, und nach dem Wechsel zu einem StorageInstancedBufferAttribute verschwand der Fehler in Chrome. Er verschwand jedoch nur, weil der neue Pfad anderen Shader-Code erzeugte, nicht weil er die Ursache behob – die gefährlichste Art von Korrektur.
Der wahre Schuldige waren Morph-Targets. Die Datei 3MIKE.fbx enthält Blend-Shape-Gesichtsausdrücke, die geklonte Geometrie übernimmt die morphAttributes, und MorphNode.setup() von Three.js deklariert morphTargetInfluences als skalaren float und versucht dann, innerhalb einer synthetisierten Schleife .element(i) darauf anzuwenden. Genau diese Indizierung eines Skalars lehnte der Compiler ab. Die Korrektur besteht aus einer einzigen Zeile: geometry.morphAttributes = {} wird für Geometrie geleert, die keine Morphs verwendet, sodass Three.js den MorphNode gar nicht erst einfügt. Die versehentliche Chrome-Korrektur blieb eine Weile bestehen und schlug dann zurück: In Safari erzeugte der Storage-Instancing-Pfad 256-mal Vertex buffer is not big enough, weil Safaris WebGPU-Backend ihn nicht sauber übersetzt. Ihn zurückzunehmen war die richtige Entscheidung, und der einfache Speicherpuffer für Knochenmatrizen, der zum WebGPU-Kern gehört und kein generierter Instanzpfad ist, funktioniert überall problemlos. Die wichtige Erkenntnis lautet: Wenn eine Korrektur in einem Browser funktioniert und du den Mechanismus nicht erklären kannst, hast du ein Symptom behandelt. Lies deshalb den tatsächlich generierten WGSL-Code. Ein später im Spike ergänzter getCompilationInfo()-Shim verwandelte die allgemeine Three.js-Meldung „module is not valid“ in den tatsächlichen Tint-Fehler und machte sich vielfach bezahlt.
Direkt daneben befindet sich ein verwandter Trick, um dem Framework auszuweichen. Three.js erkennt die Standardattributnamen skinIndex und skinWeight und versucht, seinen eigenen SkinningNode einzufügen – selbst bei einem InstancedMesh, dessen benutzerdefinierter positionNode das Skinning bereits übernimmt. Die Umbenennung dieser Attribute in boneIndex und boneWeight verbirgt sie vor dem Framework, und das benutzerdefinierte TSL liest sie unter ihren neuen Namen.
Ein Relay, das dich zwischen zwei Wörtern vergisst
Die erste Version synchronisierte Peers über BroadcastChannel, einen Ersatz für denselben Browser mit echtem Wire-Format und echter Taktung. Der Protokollkommentar versprach, dass der Wechsel zum echten Transport nur eine Zeile erfordern würde. Dieses Versprechen einzulösen bedeutete ein AvatarRoomDO: ein 74 Zeilen langes Cloudflare Durable Object, das den 36 Byte großen Binär-Frame nicht einmal dekodiert. Es leitet jede Nachricht unverändert an alle anderen Peers im Raum weiter, weil die ID des Absenders im Frame eingebettet ist und jeder Empfänger sein eigenes Echo clientseitig herausfiltert. Das Relay kennt keinerlei Identitäten. Durch WebSockets mit Ruhezustand ist ein inaktiver Raum kostenlos: Das DO verschwindet zwischen Nachrichten aus dem Speicher, und die Laufzeit stellt die markierten Sockets beim nächsten Paket wieder her. Bei 10 Ereignissen pro Sekunde und Peer sind das 36.000 DO-Anfragen pro Peer-Stunde, also ungefähr ein halber Cent, bei kostenlosem Egress auf Cloudflare und etwa 6- bis 10-mal günstiger als eine entsprechende AWS-WebSocket-Architektur.
Der Wechsel brachte einen Zustandsautomatenfehler ans Licht, den man sich merken sollte. Ein entfernter Spieler lief weiter, nachdem er angehalten hatte. Die Animationsanfrage prüfte this._state, also den aktuell abgespielten Clip, statt des zuletzt vorgemerkten Namens. Wenn daher zwei Netzwerknachrichten im selben Tick eintrafen – zuerst walk, dann idle –, wurde idle mit einem noch nicht aktualisierten Zustand verglichen und stillschweigend verworfen. Der Peer lief für immer weiter, weil nachfolgende idle-Pakete bereits vorgelagert als unverändert dedupliziert wurden. Die Lösung besteht darin, den vorgemerkten Namen immer zu überschreiben und echte Anfragen für denselben Zustand durch die Übergangshilfe abfangen zu lassen, was diese ohnehin bereits tat. Diese Fehlerklasse ist allgemeingültig: Eine Deduplizierungsprüfung gegen den falschen Referenzwert verschluckt unbemerkt genau die Eingabe, auf die es ankommt.
Safari benötigte zwei weitere Schutzmaßnahmen. Es öffnet den WebSocket schneller als Chrome, sodass die erste eingehende Peer-Nachricht eintreffen konnte, bevor der Batch-Renderer vollständig aufgebaut war, was zu einer Null-Dereferenzierung führte. Nachrichten zu verwerfen, solange der Renderer fehlt, ist unproblematisch, weil Peers alle 100 ms erneut senden. Außerdem lieferte 'gpu' in navigator den Wert „true“, während requestAdapter() null zurückgab. Three.js fiel dadurch stillschweigend auf WebGL2 zurück, wo die Skinning-Kette mit Speicherpuffer nicht gültig übersetzt werden kann und massenhaft Fehler ausgab. Die Prüfung auf einen echten Adapter und die Sicherstellung, dass das Backend tatsächlich WebGPU ist, verwandelt ein beeinträchtigtes Rendering in eine klare Meldung auf dem Ladebildschirm. Es gab sogar eine Lücke zwischen WGSL-Dialekten: Three.js erzeugt das moderne @interpolate(flat, either) mit zwei Argumenten, das WebKits Compiler noch nicht unterstützt. Das wurde behoben, indem der Shader-Quellcode auf dem Weg zu createShaderModule so umgeschrieben wird, dass das zweite Argument entfällt. Das ist ohne Nachteile möglich, weil eine flache Interpolation unabhängig davon auf jedem Vertex denselben Wert transportiert.
Sprachkommunikation, die sich mit dem Raum bewegt
Spike 46 in einem neuen Tab öffnen ↗ · Quellcode ansehen
Spike 46 behandelt räumlich begrenzte Sprachkommunikation: Peer-to-Peer-WebRTC mit räumlichem HRTF-Audio, ausdrücklich mit dem Ziel, in einem ruhigen bis mäßig lauten Raum die Qualität von Google Meet und Microsoft Teams zu erreichen. Ein VoiceRoomDO übernimmt die Signalisierung als JSON-Relay. Es sendet jedem neuen Peer eine Teilnehmerliste, meldet Beitritte und Abgänge, leitet SDP und ICE anhand des Socket-Tags an einen bestimmten Peer weiter und überträgt Positionsaktualisierungen, die die räumlichen Panner steuern. Es versieht jede Nachricht mit der ID des Absenders, damit Peers einander nicht imitieren können, während das Audio selbst das DO niemals berührt. Für jeden entfernten Peer gibt es eine RTCPeerConnection. Dabei erstellt stets der Peer mit der lexikografisch kleineren ID das Angebot, sodass beide Seiten ohne eine vollständige Perfect-Negotiation-Implementierung darin übereinstimmen, wer die Verbindung initiiert.
Auf der Empfängerseite läuft das Audio jedes Peers durch einen auf HRTF mit inverser Distanzabschwächung eingestellten PannerNode. Der AudioListener wird in jedem Frame anhand der Position und Blickrichtung des lokalen Spielers mit forwardX = sin(facing) und forwardZ = cos(facing) aktualisiert, was der Blickrichtungskonvention atan2(wx, wz) der Szene entspricht. Eine Eigenheit von Chrome kostete eine Stunde: Ein MediaStream, der nur von Web Audio verwendet wird, ruft manchmal keine Pakete ab. Deshalb wird jeder Stream zusätzlich mit einem versteckten, stummgeschalteten <audio>-Element verbunden, um die Planung des Decoders zu erzwingen. Bei der Qualität verwenden Browser standardmäßig ungefähr 32 kbit/s Mono-Opus. Daher verändert der Spike die fmtp-Zeile jedes Angebots und jeder Antwort, um die Bitrate auf 128 kbit/s anzuheben, In-Band-FEC zu aktivieren und DTX zu deaktivieren. Anschließend ruft er setParameters mit einer hohen maximalen Bitrate auf, damit der Encoder tatsächlich verwendet, was im SDP angegeben ist. FEC bringt nach der Bitratenerhöhung die zweitgrößte hörbare Verbesserung, weil es Paketverluste ohne erneute Aushandlung kompensiert.
Durch Löschen zu sauberem Audio
Die ausgelieferte Audiokette ist wesentlich kleiner als meine ursprüngliche Version, und diese Verkleinerung war die eigentliche Erkenntnis. Die erste Version hatte einen Hochpassfilter, einen auf Tastaturgeräusche abgestimmten Klickbegrenzer, einen Kompressor, ein Noise Gate und eine Wet-Dry-Überblendung. Davor saß ein schwebendes Bedienfeld mit mehr als zwölf Reglern. Als der Nutzer hörbare Tastaturanschläge meldete, bestand der erste Impuls darin, den Klickbegrenzer aggressiver einzustellen und den Dry-Anteil zu senken – ein Stapel von Notlösungen. Die strukturelle Antwort lautete: Sobald ein ML-Entrauscher Teil der Kette ist, sind der Klickbegrenzer, das Gate und der größte Teil des Hochpasses überflüssig. RNNoise wurde genau auf Tastatur-, Maus- und Tippgeräusche trainiert, und Amplitudenbegrenzung ist lediglich eine deutlich schlechtere Variante derselben Aufgabe. Produktiv eingesetzte Clients bieten ML-Entrauschung, Echounterdrückung, automatische Verstärkungsregelung und einen sanften Kompressor für den Pegel – und sonst nichts. Also wurden vier Stufen, das Reglerfeld und die Umschalter für die Auswahl der Rauschunterdrückung entfernt. Übrig blieb eine einzige feste Pipeline.
Jede verbliebene Stufe rechtfertigt ihren Platz. Die Echounterdrückung des Browsers bleibt aktiviert, weil RNNoise keine Echos entfernt und Rückkopplungen vom Lautsprecher ins Mikrofon andernfalls unbegrenzt sind. Die Rauschunterdrückung des Browsers wird deaktiviert, weil ihre Kombination mit RNNoise Artefakte bei Frikativen erzeugt; man entscheidet sich also für einen einzigen Entrauscher. Die automatische Verstärkungsregelung des Browsers bleibt aktiviert, weil das Signal ohne sie zu leise war, damit der Kompressor damit arbeiten konnte, und der DynamicsCompressorNode von Web Audio keinen Parameter für Makeup-Gain besitzt, um dies auszugleichen. Die breit wirkende Pegelregelung des Browsers und der schnelle Kompressor des Spikes arbeiten auf unterschiedlichen Zeitskalen und können nebeneinander bestehen. RNNoise läuft mit 92 Prozent Wet-Anteil, gemischt mit 8 Prozent Dry-Anteil, weil es stimmlose Konsonanten wie s, sch und f, deren Stimmwahrscheinlichkeit absinkt, zu stark unterdrücken kann. Der kleine Dry-Pfad erhält sie auf Kosten einer geringfügigen Durchlässigkeit für Tastaturgeräusche. Zwei Funktionen runden das Ganze ab. Push-to-Talk schaltet nicht track.enabled um, da dadurch alles verworfen wird, was sich noch in den Pipeline-Puffern befindet, und beim Loslassen der Taste die letzte Silbe abgeschnitten wird. Stattdessen wird ein GainNode am Ende der Kette mit setTargetAtTime geregelt: schnelles Einschwingen, damit die erste Silbe erhalten bleibt, und langsames Abklingen, damit der letzte Konsonant noch ausgegeben wird, während der Track dauerhaft aktiviert bleibt. Eine als Funkfunktion gewünschte fünfsekündige Sendeverzögerung führt außerdem einen Bypass-Zweig und einen DelayNode-Zweig zusammen, zwischen denen übergeblendet wird. Eine Abbruchtaste schaltet die verzögerte Ausgabe sofort stumm und zeigt im HUD einen Countdown an, bevor die Audioausgabe fortgesetzt wird. Das Bündeln des Denoisers war eine kleine Geschichte für sich: Das veröffentlichte RNNoise-Worklet verwendet Bare-Specifier-Imports, die von keinem CDN aufgelöst werden. Die Lösung war daher ein lokales esbuild-Bundle, das eine einzige eigenständige, 1,9 MB große Datei mit Base64-eingebettetem WASM erzeugt. Diese wird ins Repository eingecheckt und über eine zum Modul relative URL referenziert, sodass sie unter dem Entwicklungsserver, im VitePress-Build und auf der benutzerdefinierten Domain gleichermaßen aufgelöst wird. Sollte das Worklet jemals nicht geladen werden können, erzeugt die Kette über einen einfachen Hochpass und Kompressor weiterhin Audio, und das HUD zeigt den Fehler rot an.
In diesem Kapitel behandelte Technologien
Gebündeltes GPU-Skinning für Menschenmengen. Entfernte Avatare verwenden ein kopfloses VirtualSkeleton (einen vollständigen Klon, bei dem die Skinned Meshes entfernt, die Knochen beibehalten und ein eigener Mixer verwendet werden), das für jeden Knochen bone.matrixWorld × boneInverse in ein gemeinsames StorageBufferAttribute packt. Ein InstancedMesh pro Geometrieteil liest diese Matrizen in einem benutzerdefinierten TSL-positionNode/normalNode. Dadurch benötigt die gesamte Menschenmenge nur einen Storage-Upload und einen Draw Call pro Teil, während die CPU-Arbeit pro Avatar auf ein Mixer-Update und das Kopieren einer Matrix beschränkt bleibt. Siehe GPU-gesteuertes LOD.
Das erzeugte WGSL lesen, nicht das Symptom. Ein Kompilierungsfehler vom Typ cannot index type 'f32' ließ sich auf den MorphNode von Three.js zurückführen, der morphTargetInfluences als Skalar deklarierte und anschließend per Index darauf zugriff. Behoben wurde dies, indem morphAttributes bei Geometrie ohne Morphs geleert wurde. Ein erster Lösungsversuch, der lediglich änderte, welcher Shader-Pfad erzeugt wurde, kaschierte die Ursache und führte später zu Fehlern in Safari. Das Umbenennen von skinIndex/skinWeight in boneIndex/boneWeight verbirgt die Attribute vor der automatischen SkinningNode-Einbindung von Three.js, sodass ein benutzerdefiniertes Skinning-Material die Berechnungen vollständig übernimmt.
Durable-Object-Relays mit Ruhezustand. Ein rein binäres AvatarRoomDO leitet 36-Byte-Frames ohne Dekodierung an alle anderen Peers weiter. Die Identität des Absenders ist im Frame eingebettet, und das eigene Echo wird clientseitig herausgefiltert. Hibernating WebSockets sorgen dafür, dass ein inaktiver Raum kostenlos bleibt, und diese Architektur kostet bei 10 Hz ungefähr einen halben Cent pro Peer-Stunde – weit weniger als eine entsprechende verwaltete WebSocket-Lösung. Eine Deduplizierungsprüfung, die mit dem gerade abgespielten Animationszustand statt mit dem zuletzt eingereihten verglich, verwarf unbemerkt Stoppnachrichten und ließ entfernte Spieler in einer Endlosschleife der Laufanimation hängen.
WebRTC-Näherungschat mit HRTF. Pro Peer wird ein RTCPeerConnection verwendet, wobei die Rollen für Offer und Answer anhand der Reihenfolge der Peer-IDs festgelegt werden. Das Audio wird durch einen HRTF-PannerNode geleitet, dessen AudioListener in jedem Frame entsprechend der Blickrichtung des Spielers aktualisiert wird. Opus wird auf 128 kbit/s mit bandinterner FEC für höhere Ausfallsicherheit angepasst. Ein stummgeschaltetes, verborgenes <audio>-Element zwingt Chrome dazu, Pakete aus einem ausschließlich über Web Audio laufenden Stream abzurufen.
Subtraktives Audio-Engineering. Um die Qualität von Meet und Teams zu erreichen, mussten Verarbeitungsstufen entfernt statt hinzugefügt werden: ML-Denoising plus Echounterdrückung plus automatische Verstärkungsregelung plus ein sanfter Kompressor, ohne Gate und ohne Klick-Limiter, da ein auf Tastaturgeräusche trainierter ML-Denoiser amplitudenbasiertes Clipping überflüssig macht. Push-to-Talk regelt einen nachgeschalteten GainNode mit einer asymmetrischen Hüllkurve, statt den Track umzuschalten, damit keine Silben abgeschnitten werden. Das Denoiser-Worklet wird als einzelnes, eigenständiges esbuild-Bundle ausgeliefert, um Probleme bei der Auflösung von Bare-Specifier-Imports zu vermeiden.
Teil 23 von 29. Zurück: Teil 22 – Beleuchtbare Wolken und Culling, das mit Daten versorgt werden muss Weiter: Teil 24 – Eine Welt speichern und Wind, den man sehen kann Serienleitfaden: /de/blog/2026-02-25-open-world-browser-series-guide