Eine offene Welt im Browser erschaffen, Teil 24: Eine Welt speichern und Wind sichtbar machen
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 23 brachte Menschen in die Welt und gab ihnen eine Stimme. In diesem Teil geht es darum, die Welt daran erinnern zu lassen, was sie mit ihr gemacht haben, und sie lebendig wirken zu lassen, wenn niemand sie berührt. Spike 47 behandelt Persistenz: Ein Creator formt Terrain und platziert Props, und diese Änderungen überstehen ein Neuladen, werden mit allen anderen Peers synchronisiert und sauber koordiniert, wenn zwei Personen gleichzeitig etwas bearbeiten. Spike 49 behandelt Wind: Wir portieren die Vegetations-Shader eines stilisierten Naturpakets, damit sich Bäume, Büsche und Gras so bewegen, wie es der Künstler vorgesehen hat. Dabei wurde der Shader-Compiler zum größeren Gegner als die Mathematik.
Eine Welt, die sich erinnert
Spike 47 in einem neuen Tab öffnen ↗ · Quellcode ansehen
Das Szenario ist eine gemeinsame Authoring-Sitzung. Spieler bewegen sich durch eine Welt, platzieren Props per Klick, entfernen sie mit einem Rechtsklick und formen den Boden, indem sie einen Pinsel ziehen. Dabei heben und senken sie die Heightmap oder schneiden volumetrische Höhlen in das SDF-Terrain, wodurch ein Chunk auf Marching Cubes umgestellt wird. Alles, was sie tun, bleibt in einem Cloudflare-WorldChunkDO erhalten, einem serverautoritativen Durable Object mit eigenem SQLite-Speicher. Clients schreiben den Zustand nicht direkt. Sie senden ihre Absicht, das DO koordiniert sie und überträgt das Ergebnis an alle. Das DO ist die einzige Quelle der Wahrheit, sodass ein neu beigetretener Nutzer einen Snapshot erhält und in exakt derselben Welt landet, die auch alle anderen sehen.
Zwei Details der Persistenz haben sich besonders bewährt. Terrain wird nicht als Ereignisprotokoll gespeichert, das beim Beitritt erneut abgespielt werden muss. Stattdessen wird es in binären Blobs pro Chunk gespeichert, die als Quelle der Wahrheit dienen und am Ende eines Pinselstrichs hochgeladen werden. So lädt ein neu beigetretener Nutzer direkt die bestätigten Bytes, anstatt Tausende von Pinsel-Samples erneut auszuführen. Diese Blobs liegen außerdem unter jeweils einem Speicherschlüssel pro Chunk und nicht gemeinsam in einer großen Zeile, denn ein einzelner SDF-Chunk ist 168 KB groß und das DO hat ein Limit von 2 MB pro Zeile. Ein Chunk-Index hält fest, welche Schlüssel vorhanden sind, damit das DO beim Aufwachen die gesamte Karte wiederherstellen kann. Die Identität wird auf dem Client über zwei Speicher geregelt: Die Spieler-ID liegt in sessionStorage, sodass zwei Tabs als zwei unterschiedliche Peers gelten und nicht als ein Peer, der sich in der Spieler-Map des DO selbst überschreibt. Der Anzeigename liegt dagegen in localStorage, sodass eine Umbenennung in einem Tab in alle anderen übernommen wird.
Gleichzeitige Bearbeitungen zur Konvergenz bringen
Der wirklich schwierige Teil des Multiplayer-Authorings ist die Frage, was passiert, wenn zwei Personen im selben Moment überlappende Bereiche des Bodens bearbeiten. Der Spike trennt Bearbeitungen nach ihren algebraischen Eigenschaften. Additive Operationen – Anheben und Absenken der Heightmap sowie Addieren und Subtrahieren beim SDF – sind kommutativ: Sie führen unabhängig von ihrer Reihenfolge zum selben Ergebnis. Deshalb nutzen sie einen optimistischen Stamp-Pfad, bei dem jeder Client die Änderung lokal anwendet und den Stamp sendet, den das DO anschließend ohne Koordination an alle überträgt. Die Reihenfolge spielt tatsächlich keine Rolle, also gibt es auch nichts zu koordinieren.
Die reihenfolgeabhängigen Operationen Glätten und Einebnen waren der interessante Fall. Der erste Entwurf gab ihnen eine Bereichssperre: Ein Client fordert beim Herunterdrücken des Zeigers eine Sperre an, das DO gewährt oder verweigert sie, der Client puffert während des Gedrückthaltens die Samples, und beim Loslassen wendet das DO den gesamten Pinselstrich atomar an. Das funktioniert, ist aber ein separates Protokoll mit eigener Sperr-TTL und einer zusätzlichen Roundtrip-Kommunikation für Gewährung oder Ablehnung. Die sauberere Lösung, die dieses Verfahren ersetzte, ist ein vorberechnetes Delta: Der ursprüngliche Client führt den Glättungs- oder Einebnungspinsel lokal aus und sendet anschließend die resultierende Liste von Deltas pro Zelle. Jeder Peer addiert diese Deltas einfach zu seinen eigenen Zellen, ohne irgendetwas neu herzuleiten. Damit wird eine reihenfolgeabhängige Operation kommutativ, indem ihr Ergebnis an der Quelle festgeschrieben wird. Das gesamte Bearbeitungssystem läuft somit über ein einheitliches kommutatives Protokoll mit identischer Konvergenz und ganz ohne Sperren. Prop-Sperren bleiben aus einem anderen Grund bestehen: Sperren pro Datensatz ersetzen das Löschen ausschließlich durch den Eigentümer. Dadurch kann jeder Peer jedes Prop löschen, sofern es nicht von jemandem gesperrt wurde, und nur der sperrende Nutzer kann die Sperre aufheben. Rückgängig und Wiederholen funktionieren, indem der Client die ID eines Props vor seiner Platzierung festlegen darf. So kennt er die ID bereits vor dem Echo des Servers und kann seine eigenen Aktionen deterministisch umkehren. WebSockets im Ruhezustand sorgen dafür, dass ein inaktiver Raum durchgehend kostenlos bleibt – dieselbe Eigenschaft, die schon das Avatar-Relay im vorherigen Teil günstig machte.
Wind originalgetreu portiert – und anschließend bekämpft
Spike 49 in einem neuen Tab öffnen ↗ · Quellcode ansehen
Spike 49 übernimmt Quaternius’ Stylized Nature MegaKit und portiert dessen Wind auf unseren Stack. Das Paket enthält seine vier Godot-Shader im Quellcode, und der richtige Ansatz war eine originalgetreue Übersetzung statt einer Neuerfindung. Die Vertex-Funktion für Rinde ist leer, sodass Baumstämme starr bleiben. Ein früherer Versuch mit einer prozeduralen Maske hatte die Stämme zum Winken gebracht, und die Lösung bestand schlicht darin, überhaupt keinen Wind auf die Rinde anzuwenden. Blätter erhalten durch einen Hash mit Dreiecksimpulsen ein chaotisches Schwanken pro Vertex. Dieses wird anhand der Höhe maskiert, sodass sich die Baumkronen bewegen, während die Basis fest verwurzelt bleibt. Einfache Vegetation erhält im World Space eine durch Rauschen modulierte Sinus-/Kosinusbewegung. Gras kombiniert einfache Vegetation mit einem Wippen entlang von Windlinien. Dafür wird eine scrollende Rauschtextur über eine Potenzkurve abgetastet, sodass nur die hellen Bänder der Textur beitragen. Dadurch entstehen die sichtbaren Wellen, die über eine Wiese ziehen. Die Auswahl folgt der Materialbenennung des Pakets: Ein Material namens Leaves_Birch wird dem Blätter-Pfad und Grass_Common dem Gras-Pfad zugeordnet – ganz ohne Raten.
Eine Überraschung beim Portieren war, dass die Blattfarbe nicht in der Textur steckt. Quaternius gestaltet das Erscheinungsbild der Blätter vollständig über einen vertikalen Verlauf und einen Fresnel-Rand: Die Albedo ist eine höhenabhängige Mischung aus einer zusätzlichen Farbe am unteren Ende der Baumkrone und der Blattfarbe an ihrem oberen Ende. Dazu kommt eine Subsurface-Scattering-Tönung, die als Emission hinzugefügt und durch einen Fresnel-Term von heightFactor anstelle des unverarbeiteten lokalen Y-Werts. Dieses wird beim Laden für jede Blattgruppe anhand des Y-Werts im World Space normalisiert. Dadurch verhalten sich sowohl der Verlauf als auch die Windmaske korrekt, unabhängig davon, wie der FBX-Import die lokalen Achsen der einzelnen Meshes gedreht hat. Die Godot-Farbkonstanten sind als sRGB markiert und werden in den linearen Farbraum umgewandelt, bevor der Shader sie sieht. Der Port führt daher dieselbe Konvertierung aus, statt die hellen sRGB-Werte als lineare Werte einzuspeisen und die Vegetation auszuwaschen.
Die Neukompilierung, die die Framerate verschlang
Dass zunächst eine FBX-Basisversion ausgeliefert und das vollständige Windmaterial in einer .bak-Datei beiseitegelegt wurde, lag an einem Fehler, der in jedem Frame eine Neukompilierung auslöste und die Szene auf etwa 1 fps einbrechen ließ. Das Hinzufügen von benutzerdefiniertem TSL zu aus FBX geladenen Materialien veranlasste Three.js, die Shader-Programme in jedem Frame neu zu erstellen, wobei needsUpdate praktisch dauerhaft aktiviert blieb. Der methodische Diagnoseansatz bestand darin, jedes Vegetationsmaterial auf einen einfachen texturierten Pass ohne benutzerdefinierte Nodes zu reduzieren und zu beobachten, ob die Neukompilierungsschleife bestehen blieb. Wenn sie aufhörte, war der benutzerdefinierte Graph der Auslöser; wenn sie weiterlief, lag die Ursache weiter oben in der FBX-Materialkonfiguration oder in Three.js selbst. Die Lösung, mit der die eigentlichen Shader zurückkehren konnten, bestand darin, alle materialspezifischen Unterschiede – Blattfarbe, SSS-Farbe, Stärke und Mischung – als Uniforms zu binden. Dadurch verwenden alle Blatt-Assets dasselbe kompilierte Programm, statt dass der Compiler für jede einzigartige Farbkombination einen neuen Shader erzeugt und die Kompilierungswarteschlange überlastet.
Zwei weitere Aspekte sind erwähnenswert. Gras wird als ein InstancedMesh pro Quelldatei gerendert, und sein Wind wird im World Space berechnet, weil die Sinusphasen von der Weltposition abhängen. Die Verschiebung muss jedoch im Local Space angewendet werden, bevor die Instanzmatrix ausgeführt wird, und WGSL stellt kein inverse() bereit. Bei einer Instanzmatrix aus Translation, einer Y-Rotation und einer einheitlichen Skalierung ist die Inverse der oberen 3×3-Matrix einfach ihre Transponierte geteilt durch das Quadrat der Skalierung. Der Shader multipliziert die Weltverschiebung daher mit der transponierten Modellmatrix und teilt sie durch die quadrierte Länge der ersten Spalte der Matrix. So wird die Skalierung ohne Quadratwurzel ermittelt. Nachdem die Vertex-Transformation die Matrix erneut angewendet hat, landet die Bewegung exakt wie vorgesehen im World Space – unabhängig von Rotation oder Skalierung des jeweiligen Grasbüschels. Außerdem führt jedes Büschel ein GPU-Frustum-Culling pro Vertex aus: Sein Instanzzentrum wird in den Clip Space projiziert. Fällt es mit einem Sicherheitsabstand aus dem Frustum, werden alle Vertices auf den lokalen Ursprung reduziert, sodass alle drei Vertices jedes Dreiecks zusammenfallen. Der Rasterizer verwirft das degenerierte Dreieck, und für Gras außerhalb des Bildes fallen weder Fragment-, Alpha-Test- noch Schattenberechnungen an. Dieses Verfahren ergänzt das grobe Culling anhand der Bounding Sphere pro Chunk, das Three.js bereits ausführt. Es basiert auf dem Gleitkommawert step statt auf booleschen Werten, sodass es direkt in die Positionsmischung multipliziert werden kann.
In diesem Kapitel verwendete Technologie
Serverautoritative Weltpersistenz. Ein WorldChunkDO Durable Object koordiniert die Platzierung von Props und Terrainbearbeitungen. Es speichert binäre Blobs pro Chunk als Quelle der Wahrheit, sodass neu beigetretene Nutzer bestätigte Bytes laden, statt ein Ereignisprotokoll erneut abzuspielen. Außerdem verwendet es einen Speicherschlüssel pro Chunk, um unter dem Limit des DO von 2 MB pro Zeile zu bleiben. Die Spieler-ID liegt in sessionStorage, damit Tabs als unterschiedliche Peers gelten; der Anzeigename liegt in localStorage, damit Umbenennungen tabübergreifend übernommen werden.
Konvergenz kommutativer Bearbeitungen. Additive Terrainoperationen (Anheben/Absenken, SDF-Addition/-Subtraktion) sind kommutativ und laufen ohne Koordination über einen optimistischen Stamp-Pfad. Reihenfolgeabhängige Operationen (Glätten, Einebnen) werden kommutativ gemacht, indem vorberechnete Deltas pro Zelle gesendet werden, statt eine Bereichssperre anzufordern. Dadurch konvergiert das gesamte System unter einem einheitlichen Protokoll ohne Sperren. Prop-Sperren pro Datensatz ersetzen das Löschen ausschließlich durch den Eigentümer, und vom Client benannte Objekt-IDs ermöglichen ein deterministisches Rückgängigmachen und Wiederholen, noch bevor das Server-Echo eintrifft. Siehe GPU-gesteuertes LOD.
Originalgetreues Portieren von Shadern aus Godot nach TSL. Die vier ursprünglichen Wind-Shader von Quaternius werden Zeile für Zeile übersetzt: starre Rinde, höhenmaskiertes Schwanken der Blätter, Vegetationsbewegung im World Space und Gras mit einem scrollenden Wippen entlang von Windlinien. Die Auswahl erfolgt anhand der Materialbenennung des Pakets. Die Blattfarbe stammt aus einem Höhenverlauf und einem Fresnel-gesteuerten SSS-Rand statt aus der Textur. Außerdem werden die in sRGB erstellten Farbkonstanten in den linearen Farbraum umgewandelt, damit das Ergebnis den Referenz-Renderings entspricht.
Neukompilierungen von Shadern in jedem Frame vermeiden. Benutzerdefiniertes TSL auf FBX-Materialien kann needsUpdate dauerhaft aktiv halten und Programme in jedem Frame neu erstellen, wodurch die Leistung auf etwa 1 fps einbricht. Werden alle materialspezifischen Unterschiede als Uniforms gebunden, können sämtliche Varianten ein einziges kompiliertes Programm gemeinsam nutzen, statt für jeden einzigartigen Parametersatz einen neuen Shader zu erzeugen. Ein einfacher texturierter Diagnose-Pass zeigt, ob der benutzerdefinierte Graph oder die vorgelagerte Konfiguration die Ursache ist.
Wind im World Space für instanziierte Vegetation. Der Wind für Gras wird im World Space berechnet und mithilfe einer von Hand hergeleiteten Inversen – Transponierte geteilt durch das Quadrat der Skalierung – zurück in den Local Space transformiert, da WGSL kein inverse() besitzt. Ein GPU-Frustum-Culling pro Vertex reduziert Büschel außerhalb des Bildes auf ein degeneriertes Dreieck, sodass weder Fragment- noch Schattenberechnungen ausgeführt werden. Dies ergänzt das Culling anhand der Bounding Sphere pro Chunk von Three.js.
Teil 24 von 29. Zurück: Teil 23 – Fünfzig Avatare und eine Stimme im Raum Weiter: Teil 25 – Ein Skelett, jedes Outfit Serienleitfaden: /de/blog/2026-02-25-open-world-browser-series-guide