Skip to content

Eine offene Welt im Browser bauen, Teil 16: Struktur für eine Welt, die immer weiter wächst

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.

Jeder Spike seit Teil 13 folgte demselben Rezept: den vorherigen Monolithen kopieren und eine Funktion hinzufügen. Am Ende von Spike 32 bestand dieser Monolith aus 6.285 Zeilen index.html in einem einzigen <script type="module">. Die Codesuche lieferte zu viel Rauschen, die richtige Stelle für eine neue Funktion zu finden dauerte länger als ihre Implementierung, und jede architektonische Änderung betraf eine Datei, die zu groß war, um den Diff gedanklich zu erfassen. Bevor wir eine weitere Funktion hinzufügten, räumten wir die Struktur auf.

Den Monolithen ohne Verhaltensänderung zerlegen

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

Die Vorgabe war strikt: Jede Aufteilung musste ein reines Refactoring sein, keine Neugestaltung. Aus dem Monolithen wurden 19 .mjs-Dateien sowie eine Host-Shell mit 151 Zeilen. Dazu kamen Top-Level-Module für Szene, Wasser, Gras, Charakter, Physik, Multiplayer und Benutzeroberfläche, eine wgsl.mjs, die als zentrale GPU-Quelle alle WGSL-Quelltexte enthält, sowie ein terrain/-Unterbaum für Heightmap, SDF, Chunks, GPU-Buffer-Wrapper, Pinsel, LOD und Persistenz. Insgesamt umfasste der Code 6.555 Zeilen – im Wesentlichen der Monolith plus Import-Boilerplate. Das Volumen blieb nahezu unverändert, die Navigierbarkeit verbesserte sich erheblich.

Dann lud die Seite mit einem schwarzen Bildschirm. Zwei Fehlermeldungen, zwei voneinander unabhängige Ursachen. Die erste war eine WebGPU-Beschwerde über die Bindung eines Buffers mit null Byte. Im Monolithen wurde der SDF-Pinselbuffer erst dann verzögert angelegt, wenn der erste Marching-Cubes-Chunk erschien, und die Bind-Group-Factory lief zufällig später, nachdem der Buffer bereits existierte. Durch die Trennung von terrain/gpu.mjs und terrain/brush.mjs änderte sich die Auswertungsreihenfolge der Module. Dadurch lief die Factory nun zuerst und versuchte, einen null-Platzhalter zu binden. Die Lösung bestand darin, die Erstellung der Bind Group mit einem getOrCreateBindGroup(chunk)-Helper bis zum ersten Dispatch aufzuschieben. Das Muster „alles im Voraus erstellen“ war ein Überbleibsel des einzigen Initialisierungspfads im Monolithen.

Die zweite Meldung war eine beängstigend wirkende FBX-Skelettwarnung, die sich als falsche Fährte herausstellte. Sie wurde bereits seit Spike 25 ausgegeben und war harmlos. Der Charakter fehlte nur, weil der erste Fehler eine Kaskade auslöste: Jeder Compute-Dispatch schlug fehl, die Heightmap wurde nie geschrieben, Höhenabfragen lieferten 0 zurück, und der Charakter erschien am Ursprung und fiel durch die Welt. Nach der Reparatur des Buffers wurde der Charakter trotz der Warnung einwandfrei animiert.

Das ist die eigentliche Lehre aus dem Refactoring. Der Monolith verbarg jede Beziehung der Art „X muss existieren, bevor Y erstellt wird“ in der Ausführungsreihenfolge des Skripts von oben nach unten. Die Modularisierung mischte diese Reihenfolge neu und deckte drei weitere latente Reihenfolgefehler auf: Das Gras wurde verteilt, bevor die Heightmap hochgeladen war, die Wasserfläche wurde hinzugefügt, bevor die Environment Map fertig dekodiert war, und der Persistenz-Ladevorgang wurde erst nach dem ersten Frame abgeschlossen. Alle drei ließen sich mit jeweils einer Zeile beheben, und ohne die Aufteilung wäre keiner davon entdeckt worden.

Hundert Objekte, ohne irgendetwas aufzublähen

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

Spike 34 war der Test, ob sich die neue Struktur auszahlte. Das Ziel war eine First-Person-Palette, mit der sich Bäume, Felsen, Büsche, Pilze und Wege aus einem CC0-Modellpaket platzieren ließen – mit geländeabhängiger Ausrichtung, Persistenz, Multiplayer-Synchronisierung und Physik-Collidern, ohne die Steuerung verlassen zu müssen. Jede neue Codezeile landete in fünf neuen Dateien unter src/props/, und kein bestehendes Modul wuchs um mehr als zehn Zeilen für die Anbindung.

Der Weg zu den Assets nahm einen erwähnenswerten Umweg, denn solche Dinge können leicht einen ganzen Tag verschlingen. Wir begannen mit Quaternius' Ultimate Nature Pack, einer FBX-Bibliothek ohne eingebettete Texturen. Die FBX-Materialien wurden als MeshPhong ohne Map ausgeliefert. Deshalb erstellten wir manuell eine Zuordnungstabelle von Materialnamen zu PNG-Dateien, konvertierten Phong zu Standard und legten die Farbräume von Hand fest. Für etwa 30 % der Materialien gab es keine passende PNG-Datei, und mehrere Namen waren bei ähnlichen Bäumen mehrdeutig. Ein zweites FBX-Paket hatte dieselbe Lücke. Die Lösung waren nicht noch mehr Zuordnungstabellen, sondern ein besser aufbereitetes Paket: Quaternius' Stylized Nature MegaKit enthält 116 vollständige glTFs mit eingebetteten PBR-Materialien und gebackenen Normalen. Durch den Austausch von FBXLoader gegen GLTFLoader entfielen die Skalierung von Zentimetern zu Metern, die Texturtabelle und die Phong-Konvertierung, wodurch library.mjs um etwa 80 Zeilen schrumpfte. Die Erkenntnis: glTF mit PBR ist die richtige Pipeline für CC0-Pakete, die dieses Format anbieten. FBX mit manueller Texturzuordnung bedeutete dagegen doppelt so viel Code für die halbe Qualität.

Der glTF-Pfad brachte einige Stolpersteine mit sich. Die Palette rendert 116 Vorschaubilder, und WebGPUs canvas.toDataURL() liefert bei einer GPUCanvasContext-Oberfläche ein leeres Ergebnis. Deshalb werden die Vorschaubilder in ein RenderTarget gerendert, mit readRenderTargetPixelsAsync zurückgelesen und in eine 2D-Canvas kopiert, wobei WebGPUs Zeilenausrichtung auf 256 Byte berücksichtigt werden muss. Geistervorschauen klonen jedes Material, um es grün einzufärben. Das funktionierte bei Meshes nicht, deren material ein Array ist, und wurde mit einem Array.isArray-Zweig behoben. Außerdem wurden die mitgelieferten 16-Bit-Normal-Maps, die zusammen etwa 200 MB belegten, einmalig mit mogrify -depth 8 auf rund 32 MB reduziert. Das Ergebnis ist visuell identisch, da Browser die Daten beim Upload ohnehin herunterrechnen.

Wenn die gerenderte Geometrie nur auf der GPU existiert

Der lehrreichste Fehler war, dass die Geistervorschau beim Bewegen des Cursors in Schritten von 1 bis 2 Metern einrastete. Die Terrain-Meshes speichern ihre Vertexpositionen in einem StorageBufferAttribute, weil die Compute-Pipeline sie direkt auf der GPU schreibt. Deshalb kann der CPU-basierte Raycaster von three.js sie nicht sehen und liefert kein Ergebnis. Als Rückfalllösung diente ein grobes Ray Marching in Schritten von 1,5 m gegen die analytische Heightmap – und genau dieses feste Intervall wurde als Raster sichtbar. Wir ersetzten es durch adaptives Marching: eine Schrittweite von 2,5 m weit oberhalb der Oberfläche, reduziert auf 0,4 m innerhalb von 5 m Abstand, gefolgt von 14 Bisektionsschritten, sobald das Vorzeichen von (rayyterrainy) wechselt. Damit wird bei ungefähr 30 großen Schritten plus 14 Bisektionen pro Strahl eine Genauigkeit im Submillimeterbereich erreicht. Wenn die gerenderte Geometrie nur auf der GPU existiert, sollte man nicht gegen den Raycaster ankämpfen, sondern die analytische Quelle abtasten.

Collider, die auch nach Bearbeitungen korrekt bleiben

Wir entschieden uns für primitive Näherungsformen statt konvexer Hüllen oder Mesh-Collider. Die Objekte von Quaternius sind rundlich und Low-Poly, ohne relevante konkave Bereiche. Hüllen hätten daher für dasselbe Spielerlebnis ungefähr 50-mal so viel Code und zehnmal so hohe Laufzeitkosten verursacht. Jedes Objekt wird auf eine aus seiner Bounding Box abgeleitete Form reduziert: Bäume und Kakteen auf eine vertikale Kapsel, Felsen auf eine Kugel, Baumstämme auf eine horizontale Kapsel entlang ihrer Längsachse und dekorative Büsche und Blumen auf gar nichts. Felsen und Baumstämme sind begehbar – mit ausschließlich vertikalem Schub, sodass man auf ihnen stehen kann. Bäume und Kakteen blockieren dagegen mit vollem 3D-Schub, sodass man nicht an einem Stamm hochklettern kann. Ein Spatial Hash mit 8-m-Zellen beschränkt die Tests pro Frame auf die 3×3-Nachbarschaft des Spielers, typischerweise null bis sechs Objekte.

Zwei Designentscheidungen hielten das System konsistent. Die Geländeausrichtung ist ein Manifest-Flag und kein im Code fest verdrahtetes Kategorie-Enum. Dadurch lesen sowohl die Geistervorschau als auch die endgültige Platzierung denselben Wert placement.alignToTerrain und können nicht voneinander abweichen. Außerdem reagieren platzierte Objekte über einen einzigen Helper auf Geländebearbeitungen: Nach einem Pinselstrich – lokal oder von einem Peer wiedergegeben – tastet refreshPlacementsInRadius den Boden unter jedem Objekt im betroffenen Kreis erneut ab, wendet die Ausrichtung erneut an und berechnet die Endpunkte des Colliders neu. Formt man unter einem Baum einen Hügel, bewegt sich der Baum mit nach oben. Persistenz und Multiplayer verwenden exakt das Muster aus Spike 31 wieder. Dabei wird eine flache Liste aus {uid, propId, x, y, z, rotY, scale} gespeichert, und Ereignisse zum Platzieren, Entfernen und Verschieben werden über BroadcastChannel gespiegelt.

In diesem Kapitel verwendete Technologien

Zerlegung in ES-Module unter WebGPU. Das Aufteilen eines monolithischen <script type="module"> in .mjs-Imports mit direkten Pfaden erfordert keinen Bundler, wenn die Module als statische Assets ausgeliefert werden. Auch three.js TSL funktioniert problemlos über Modulgrenzen hinweg. Die verborgenen Kosten liegen in der Initialisierungsreihenfolge: Ein Monolith kodiert „X vor Y erstellen“ durch die Skriptreihenfolge von oben nach unten. Module werden dagegen in Importreihenfolge ausgewertet, wodurch eine GPU-Bind-Group-Factory ausgeführt werden kann, bevor ihr Buffer existiert. Das passende Lösungsmuster ist verzögerte Initialisierung (getOrCreate... bei der ersten Verwendung) und das Abwarten des richtigen Promise, statt sich auf die Deklarationsreihenfolge zu verlassen.

glTF mit eingebettetem PBR gegenüber FBX mit manueller Zuordnung. glTF verwendet Meter, referenziert seine eigenen Texturen und liefert direkt MeshStandardMaterial. Dadurch lässt sich ein als glTF erstelltes CC0-Paket unmittelbar in eine PBR-Pipeline integrieren. FBX-Pakete ohne Metadaten für die Texturbindung benötigen eine manuell gepflegte Zuordnungstabelle von Materialnamen zu PNG-Dateien, die bei jeder Paketaktualisierung auseinanderläuft. Hinzu kommen die Konvertierung von Phong zu Standard und die manuelle Kennzeichnung der Farbräume. Als Sicherheitsnetz für Vegetation werden transparent-Materialien ohne alphaTest zu Cutout-Flächen mit alphaTest: 0.5 hochgestuft, damit sie hinter undurchsichtiger Geometrie korrekt sortiert werden.

Offscreen-Vorschaubilder mit WebGPU. canvas.toDataURL() liefert bei einer von GPUCanvasContext gestützten Canvas ein leeres Ergebnis, weil kein Pfad von einer Präsentationsoberfläche zurück zu einem 2D-Kontext existiert. Das Rendern in ein RenderTarget, das Auslesen der Pixel mit readRenderTargetPixelsAsync und das Kopieren in eine 2D-Canvas funktioniert, solange beim Kopieren die auf 256 Byte ausgerichtete Rückleseschrittweite von WebGPU berücksichtigt wird. Die Ergebnisse werden in localStorage unter einem Schlüssel mit Versionsnummer zwischengespeichert, sodass Änderungen am Paket veraltete Renderings ungültig machen.

Adaptives Ray Marching gegen eine analytische Heightmap. Wenn Terrain-Vertices in einem GPU-StorageBufferAttribute liegen, kann der CPU-Raycaster sie nicht sehen. Das Abtasten der analytischen Höhenfunktion mit großer Schrittweite fern von der Oberfläche, kleiner Schrittweite in ihrer Nähe und einer binären Verfeinerung beim Vorzeichenwechsel von (rayyterrainy) ermöglicht eine Cursorpräzision im Submillimeterbereich bei einer begrenzten Anzahl von Abfragen. Dasselbe Verfahren treibt den Pinselcursor und die Geistervorschau der Objekte an.

Primitive Kapsel-Collider mit Spatial Hash. Jedes Objekt wird anhand seiner Bounding Box auf eine von der Kategorie abgeleitete Kapsel oder Kugel reduziert, als {kind, walkable, radius, p1, p2} gespeichert und in jeder überlappten 8-m-Hash-Zelle registriert. Pro Frame testet der Spieler nur die Objekte in der 3×3-Nachbarschaft seiner Zelle, jeweils mit einer Kapsel-gegen-Kapsel-Auflösung. Begehbare Näherungsformen wie Felsen und Baumstämme erhalten ausschließlich vertikalen Schub, blockierende Näherungsformen wie Bäume dagegen den vollen 3D-Schub. Die zugrunde liegende Kapselmathematik wird unter SDF-Geländekollisionen erläutert.


Teil 16 von 29. Zurück: Teil 15 – Die Ausgangsbasis ersetzen und anschließend synchronisieren Weiter: Teil 17 – Animationen, die kein Retargeting benötigten, und eine Live-Asset-Suche Serienleitfaden: /de/blog/2026-02-25-open-world-browser-series-guide