Skip to content

Eine offene Welt im Browser entwickeln, Teil 15: Die Basis ersetzen und dann synchronisieren

Von Oleg Sidorkin, CTO und Mitgründer von Cinevva

Neu hier? Nutzen Sie den Serienleitfaden. Dort wird erklärt, was ein Spike ist, und alle Teile sind verlinkt.

Die ersten vierzehn Teile behandelten die Spikes 1 bis 30. Am Ende dieser Reihe stand ein Geländesystem, das sich in Echtzeit formen ließ, sowie eine Figur, die darauf laufen, gleiten und fallen konnte. Dieser Teil setzt die Serie bei Spike 31 fort, und die erste Entscheidung, die wir treffen mussten, war keine technische. Es ging darum, was wir mit dem gesamten Spike-Code machen sollten.

Die Entscheidung: „Ersetzen statt zurückportieren“

Wir hatten 30 eigenständige HTML-Dateien, von denen jede ein isoliertes Konzept belegte, und keinerlei Integration. Das produktive world/client/ verwendete noch immer den alten Stack: WebGL, eine einfache Heightmap, einen 75 Zeilen langen Charakter-Controller und ein MessagePack-Protokoll mit neun Nachrichtentypen. Keine Bearbeitung, kein WebGPU, keine Materialien, keine Vegetation.

Der naheliegende Plan bestand darin, die Ergebnisse der Spikes einzeln in diese produktive Codebasis zurückzuportieren. Wir verwarfen ihn. Spike 30 verfügte bereits über besseres Gelände, bessere Physik, bessere Materialien, bessere Vegetation und eine bessere Kamera, als world/client/ sie je hatte. Eine Rückportierung in den alten WebGL-Code hätte bedeutet, ständig gegen ihn anzukämpfen. Also entschieden wir uns, die Implementierung der Welt durch den erfolgreichsten Spike zu ersetzen und darauf weiter aufzubauen. Spike 30 wurde zur neuen Basis, und world/client/ wurde zu totem Code.

Dadurch erschien die verbleibende Arbeit in einem neuen Licht. Um aus einer „großartigen Einzelspieler-Techdemo“ ein „Produkt“ zu machen, brauchten wir Multiplayer, Persistenz, Streaming einer unendlichen Welt und die Platzierung von Objekten. Die Multiplayer-Synchronisierung des Geländes kam zuerst, weil sie die Architektur vorgibt. Die dabei zu beantwortende Frage ist leicht gestellt, aber eine falsche Antwort kann teuer werden: Was wird tatsächlich über die Leitung geschickt, wenn Spieler A das Gelände formt?

Wiedergabe von Pinselparametern statt Pixel-Synchronisierung

Spike 31 in einem neuen Tab öffnen ↗ · Quellcode ansehen

Bevor wir eine einzige Zeile Netzwerkcode schrieben, verfolgten wir genau, was ein Pinselstrich bewirkt. Der Heightmap-Pinsel durchläuft in einem CPU-Float32Array einen Radius um den Cursor, wendet einen Smoothstep-Abfall an und erhöht, senkt, glättet oder ebnet das Gelände. Der SDF-Pinsel macht dasselbe in 3D über eine Kugel aus Voxeln. Beide Pfade bestehen aus reiner CPU-Array-Mathematik. Keine GPU-Berechnung in der Schleife, kein Zufall, keine nichtdeterministische Gleitkommaarithmetik. Dasselbe Eingabe-Array plus dieselben Parameter ergibt auf jedem Rechner dieselbe Ausgabe.

Das ist der ganze Trick. Wir senden nicht das bearbeitete Gelände. Wir senden die Pinselparameter – 56 Byte pro Pinselstrich-Tick – und jeder Client führt dieselbe deterministische Funktion erneut aus. Das Synchronisierungsprotokoll umfasst vier Nachrichtentypen: Peer-Erkennung, die Pinselnachricht {op, wx, wy, wz, radius, strength, flattenTarget} und eine Spielerpositionsnachricht mit 20 Hz.

Für den Spike verzichteten wir vollständig auf den Server und verwendeten BroadcastChannel, die Browser-API für die Kommunikation zwischen Tabs desselben Ursprungs. Zwei Tabs öffnen, und schon kommunizieren sie miteinander – ganz ohne Infrastruktur. Dadurch wird die Synchronisierungsfrage von Latenz, Authentifizierung und der Anbindung eines Durable Objects isoliert. Wenn die Parameterwiedergabe über mehrere Tabs hinweg konvergiert, wird sie das auch über einen WebSocket tun.

An einer Stelle kann die Wiedergabe auseinanderlaufen: bei reihenfolgeabhängigen Operationen. Erhöhen und Senken sind kommutativ, daher führt val + strength * falloff unabhängig davon zum selben Ergebnis, wer die Operation zuerst angewendet hat. Glätten und Einebnen lesen benachbarte Werte. Wenn also zwei Clients im exakt selben Moment dieselbe Stelle glätten, können sie pro Tick um Bruchteile eines Millimeters auseinanderdriften. In der Praxis tritt das praktisch nie auf, und die Lösung für die Produktion liegt bereits auf der Hand: Bearbeitungen durch das DO leiten, ihm eine monoton steigende Sequenznummer zuweisen lassen, die Änderungen optimistisch auf dem Client anwenden und die Reihenfolge korrigieren, falls sie von der autoritativen Sequenz abweicht. Klassische optimistische Nebenläufigkeit – und das DO ist ohnehin ein natürlicher Serialisierungspunkt.

Die Peer-Kapsel, die immer wieder verschwand

Die Bearbeitungen wurden gleich beim ersten Versuch synchronisiert. Die Kapsel des entfernten Spielers hingegen nicht. Sie flackerte im anderen Tab ständig ein und aus, und wir mussten drei separate Fehler beheben, damit sie dauerhaft sichtbar blieb.

Die Kapsel erschien am Weltursprung, der unter dem Gelände begraben liegt, weil die join-Nachricht vor den Positionsdaten eintrifft. Lösung: zunächst ausblenden und beim ersten Positionsupdate sichtbar machen. Die Positionsübertragung befand sich innerhalb der Render-Schleife, und Chrome drosselt requestAnimationFrame in nicht fokussierten Tabs. Deshalb entfernte die Veraltungsprüfung des anderen Tabs den Peer, woraufhin die nächste Nachricht ihn neu erstellte. Lösung: Die Übertragung in ein setInterval verschieben, das bei sichtbaren Tabs nicht gedrosselt wird. Außerdem war das Zeitlimit für veraltete Daten mit 5 Sekunden zu aggressiv und wurde durch jede GC-Pause ausgelöst. Lösung: auf 30 Sekunden erhöhen und bei regulär geschlossenen Tabs auf die saubere leave-Nachricht vertrauen.

Persistenz und später Beitritt im selben Format

Wir integrierten die Persistenz in denselben Spike, anstatt dafür einen neuen aufzusetzen, denn das Serialisierungsformat ist identisch – unabhängig davon, ob das Ziel IndexedDB oder ein anderer Tab ist. Ein Snapshot besteht aus der vollständigen Heightmap (ein 129×129 großes Float32Array, etwa 66 KB), ausschließlich den bearbeiteten SDF-Chunks (jeweils 653, etwa 1,1 MB) und der Liste der Chunk-IDs, die auf den Marching-Cubes-Modus festgelegt wurden. Ein entprellter Speichervorgang schreibt zwei Sekunden nach der letzten Bearbeitung in IndexedDB. Beim Laden wird das prozedurale Gelände synchron generiert und anschließend vor dem ersten relevanten Frame durch den gespeicherten Zustand überschrieben. Für später beitretende Spieler werden exakt dieselben Bytes wiederverwendet: Tritt ein neuer Tab bei, serialisiert ein vorhandener Tab mit Bearbeitungen seinen Zustand und sendet ihn gezielt an den Neuankömmling. Daraufhin erscheint der Berg, den man vor fünf Minuten geformt hat, auf dessen Bildschirm.

Der erste Persistenztest deckte einen interessanten Fehler bei der Reihenfolge auf. Das Gras wird bei der Initialisierung synchron anhand der prozeduralen Höhen verteilt. Die Wiederherstellung aus IndexedDB erfolgt jedoch asynchron und überschreibt die Heightmap anschließend, wodurch jeder Grashalm schwebt oder im Boden versinkt. Die Lösung ist ein refreshAllGrass()-Durchlauf, der die Höhe unter jeder Instanz erneut abtastet und alle Halme ausblendet, die sich nun an einem ungeeigneten Hang oder auf einer ungeeigneten Höhe befinden. Dieselbe Funktion dient sowohl dem Laden als auch dem späteren Beitritt.

Die Saga der Hangphysik

Geformtes Gelände ist unebener als die glatte prozedurale Basis und deckte drei Physikfehler auf, die das alte Gelände niemals hätte sichtbar machen können. Beim geraden Bergaufgehen rutschte die Kapsel seitwärts. Ursache war eine Geschwindigkeitsprojektion, die die Bewegung tangential zum Boden halten sollte, aber ausschließlich anhand der horizontalen Komponenten der Normalen berechnet wurde. Auf einem diagonalen Hang mit der Normalen (0.3,0.9,0.3) erzeugte eine Bewegung nach Norden aus dem Nichts eine seitliche Geschwindigkeit. Da das Einrasten am Boden den Spieler bereits auf der Oberfläche hält, löschten wir die Projektion einfach.

Das Abdriften blieb aufgrund einer zweiten Ursache bestehen. Die SDF-Kollisionsproben schieben den Körper entlang des Gradienten um die Eindringtiefe nach außen. An jedem Hang hat der Gradient horizontale Komponenten. Eine Eindringtiefe von 0,1 m auf einem 15°-Hang verschiebt den Körper daher pro Schritt um etwa 0,026 m zur Seite, was bei 120 Hz einem unsichtbaren Abdriften von ungefähr 3 m/s entspricht. Lösung: Die Reaktion nach Hangneigung aufteilen. Auf begehbarem Untergrund (grady oberhalb des Schwellenwerts für begehbare Flächen) erfolgt der Schub ausschließlich vertikal. An Wänden und Klippen bleibt der vollständige 3D-Schub erhalten, denn genau dort ist eine Ablenkung erwünscht. Außerdem erhöhten wir die Grenze für begehbare Flächen von cos45° auf cos60°, damit sich mit dem Pinsel erstellte Hügel ähnlich gut erklimmen lassen wie in Breath of the Wild.

Der dritte Fehler ließ die Kapsel an Chunk-Grenzen einfrieren, weil die Kollisionsproben die SDF eines einzelnen Chunks abtasteten und den Sentinel-Wert für „tief in der Luft“ erhielten, sobald eine Probe in den benachbarten Chunk reichte. Die Lösung waren sdfSampleWorld(wx, wy, wz) und sdfGradientWorld(...). Sie finden für jede Weltposition den richtigen Chunk und greifen auf eine anhand der Heightmap geschätzte Distanz zurück, wenn keine SDF vorhanden ist. Der Kollisionsübergang von der SDF zur Heightmap ist nun kontinuierlich.

Wasser vervollständigt die Welt

Spike 32 in einem neuen Tab öffnen ↗ · Quellcode ansehen

Bis hierhin lautete die Devise jedes Spikes: „Land über Wasser“. Spike 32 fügte einen Ozean und damit eine neue Fortbewegungsart hinzu. Wir legten den Wasserspiegel in einem Gelände, dessen Höhe ungefähr von 8 bis 58 reicht, auf 22 fest. Dadurch werden die niedrigen Täler geflutet, an der Küstenlinie entstehen Strände und es bleibt reichlich trockenes Land zum Spielen.

Die Oberfläche besteht aus einem in TSL erstellten MeshStandardNodeMaterial, also demselben Node-Ansatz wie beim Gelände. Drei überlagerte Sinuswellen mit unterschiedlichen Frequenzen verschieben die Vertices. Die Oberflächennormale wird aus den analytischen Kosinus-Ableitungen dieser Wellen gewonnen statt aus Mesh-Normalen. Die Farbe wechselt anhand einer auf [0,1] begrenzten Tiefenschätzung (levely+wave)×0.12 von türkisfarbenem Flachwasser zu dunklem Blaugrün in der Tiefe. Gischt entsteht dort, wo sich diese Schätzung an der Küstenlinie null nähert. Der Alphawert folgt der Tiefe, sodass flaches Wasser transparent und tiefes Wasser nahezu undurchsichtig wirkt.

Das Schwimmen basiert auf einer Auftriebsfeder. Der Spieler wechselt in den Schwimmmodus, wenn seine Füße unter den Wasserspiegel sinken und sich die Körpermitte höchstens eine halbe Kapselhöhe von der Oberfläche entfernt befindet. Eine Feder zieht den Körper zu einem Ziel knapp unterhalb der Oberfläche. Mit einer Auftriebskonstante von 12 und einer Wasserdämpfung von 4 treibt der Spieler stabil mit dem Kopf über Wasser, ohne zu schwingen. Die Schwimmgeschwindigkeit ist geringer als die Gehgeschwindigkeit und kombiniert eine schwebende Beschleunigung mit Widerstand. Springt man nahe der Oberfläche, wird man mit 60 % der normalen Sprunggeschwindigkeit aus dem Wasser katapultiert. Beim Eintauchen wird die Abwärtsgeschwindigkeit auf -5 m/s begrenzt, damit man nicht zu tief hinabstürzt. Die Geländekollision bleibt unter Wasser aktiv, sodass man auf dem Seegrund gehen kann, wo dieser über das Schwimmziel hinausragt. Das Schwimm-Flag wird zusammen mit der Positionsübertragung gesendet, damit andere Spieler die Schwimmbewegung sehen. Ein HTML-Verlaufs-Overlay färbt außerdem die Ansicht ein, sobald die Kamera unter die Oberfläche taucht.

In diesem Kapitel erwähnte Technologien

Deterministische Wiedergabe von Pinselparametern. Anstatt bearbeitetes Gelände zu streamen, sendet jeder Client nur die Pinselparameter und führt dieselbe CPU-Funktion erneut aus. Das funktioniert, weil sowohl die Heightmap- als auch die SDF-Pinsel reine Float32Array-Mathematik ohne Zufall oder GPU-Nichtdeterminismus verwenden. Identische Eingaben erzeugen daher überall bitgenau identische Ausgaben. Die Nutzlast beträgt 56 Byte pro Pinselstrich-Tick. Kommutative Operationen (Erhöhen, Senken) konvergieren unabhängig von der Reihenfolge. Operationen, die benachbarte Werte lesen (Glätten, Einebnen), benötigen hingegen einen Serialisierungspunkt, um die Konvergenz zu garantieren. Diesen stellt das produktive Durable Object über monoton steigende Sequenznummern bereit.

BroadcastChannel als WebSocket-Ersatz. Eine Browser-API für die Kommunikation zwischen Tabs desselben Ursprungs ohne Server. Sie wurde hier verwendet, um das Synchronisierungsprotokoll unabhängig von Netzwerklatenz und Authentifizierung zu testen. Das Serialisierungsformat – rohe Float32Array-Heightmap plus bearbeitete SDF-Chunks plus IDs der auf MC festgelegten Chunks – verwendet dieselben Bytes für die IndexedDB-Persistenz und die Zustandsübertragung an später beitretende Spieler. Damit deckt ein Format drei Aufgaben ab.

Nach Hangneigung aufgeteilte SDF-Kollisionsreaktion. Dringt eine Kapselprobe in volumetrisches Gelände ein, schiebt die naive Lösung den Körper entlang des SDF-Gradienten um die Eindringtiefe nach außen. An Hängen enthält dieser Gradient horizontale Komponenten, die eine seitliche Drift verursachen. Teilt man die Reaktion so auf, dass begehbare Oberflächen (grady oberhalb des Schwellenwerts) ausschließlich einen vertikalen Schub erhalten, während steile Oberflächen den vollständigen 3D-Schub samt Geschwindigkeitsprojektion beibehalten, verschwindet die Drift, ohne dass die Wandkollision verloren geht. Siehe SDF-Geländekollisionen.

TSL-Wasser mit analytischen Wellennormalen. Der Ozean ist ein Node-Material, dessen Vertices durch drei summierte Sinuswellen verschoben werden. Anstatt die Mesh-Normalen nach der Verschiebung neu zu berechnen, wird die Oberflächennormale analytisch aus den Kosinus-Ableitungen der Wellenfunktionen hergeleitet. Das ist effizienter und vermeidet die Artefakte von Normalen, die auf einem groben Raster durch finite Differenzen berechnet werden. Tiefenabhängige Farbe, Gischt an der Küste und tiefenabhängige Transparenz basieren alle auf derselben Tiefenschätzung.

Schwimmen mit Auftriebsfeder. Die Schwimmphysik modelliert den Körper als gedämpfte Feder, die ihn zu einem Ziel knapp unterhalb der Oberfläche zieht. Mit einer Auftriebskonstante von 12 und einer Dämpfung von 4 stabilisiert sich der Spieler ohne Schwingung an der Oberfläche. Eigene Bewegungskonstanten – geringere Geschwindigkeit, schwebende Beschleunigung und starker Widerstand – geben dem Schwimmen ein anderes Gefühl als dem Gehen. Die vorhandene Kollision zwischen Kapsel und Gelände funktioniert dabei auch unter Wasser weiter.


Teil 15 von 29. Zurück: Teil 14 – Die Welt wird lebendig Weiter: Teil 16 – Struktur für eine stetig wachsende Welt Serienleitfaden: /de/blog/2026-02-25-open-world-browser-series-guide