Skip to content

Eine offene Welt im Browser entwickeln, Teil 12: Ringe, Himmelsnebel und was wir wieder so machen würden

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.

Spike 24 sollte eigentlich nur „Clipmap-Ringe zum Terrain hinzufügen“. Daraus wurde ein großes Finale, das gleichzeitig Rendering, Shader, Modulinfrastruktur und visuelle Integration berührte.

Die zentrale Terrain-Aufgabe bestand darin, konzentrische Clipmap-Ringe im Vertex-Shader zu erzeugen. Jeder Ring ist ein flaches, auf die Kamera zentriertes Gitter-Mesh, dessen Vertices durch Heightmap-Samples verschoben werden. Der innerste Ring nutzt die volle Auflösung. Jeder weitere Ring verdoppelt den Vertex-Abstand und deckt eine größere Fläche ab. Der schwierige Teil ist die Grenze zwischen den Ringen: Dort, wo ein hochauflösender Ring auf einen niedrigauflösenden Ring trifft, müssen die Rand-Vertices des feineren Meshs auf den Mittelpunkt der Kante des gröberen Meshs einrasten. Wir setzten 2:1-Kantenmorphing um, indem wir Rand-Vertices erkannten – also solche, deren Gitterkoordinate entlang der Ringkante ungerade ist – und ihre Höhe auf den Mittelwert ihrer beiden geraden Nachbarn setzten: h=12(hleft+hright). Genau an dieser Stelle würde die einzelne Kante des groben Rings liegen, sodass die feine und die grobe Kante dieselbe Linie beschreiben. Dadurch entstehen wasserdichte Nähte ohne Übergangsgeometrie.

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

Dann folgte die Integration von Nebel und Himmel. Entferntes Terrain sollte in die tatsächliche Himmelsfarbe übergehen, nicht in eine einheitliche konstante Farbe. Dafür musste der Nebel-Shader wissen, welche Farbe der Himmel in der Richtung jedes Fragments haben würde. Wir luden eine equirektanguläre HDR-Skybox-Textur und sampelten sie im Fragment-Shader anhand der Blickrichtung von der Kamera zum Fragment. Diese wandelten wir über TSLs equirectUV-Node in equirektanguläre UV-Koordinaten um. Der Nebelfaktor basierte auf der Entfernung und nutzte positionView.z.negate() für die Tiefe im Kameraraum; zwischen einer nahen und einer fernen Entfernung wurde mit smoothstep überblendet.

Die Modulverdrahtung erwies sich als lästiger als die gesamte Geometrie. Wir aktualisierten auf Three.js 0.183.1, wodurch die Build-Ausgaben neu strukturiert worden waren. Der Import three/tsl musste zu three.tsl.js aufgelöst werden, und TSL importierte intern three/webgpu als Bare Specifier. Beide Zuordnungen mussten explizit in der HTML-Import-Map stehen. Fehlte auch nur eine davon, entstanden kryptische Fehler wie „does not provide an export“ oder „failed to resolve module specifier“, ohne jeden Hinweis darauf, welche Zuordnung falsch war. Sobald beide in der Import-Map standen, wurde der Shader-Graph korrekt geladen.

Außerdem hatten wir ein Problem mit der Ausrichtung der Skybox, bei dem die Textur auf dem Kopf gerendert wurde. Die Lösung war flipY = true für die equirektanguläre Textur. Das ist bei geladenen Texturen der Standardwert von Three.js, war in unserem ursprünglichen Code aber auf false gesetzt.

Die ursprüngliche Nebelimplementierung sampelte den Himmel in einer nahezu konstanten Richtung. Dadurch entstand statt eines natürlichen Verlaufs ein schmaler Streifen in Horizontfarbe. Die Lösung bestand darin, mit positionWorld.sub(cameraPosition).normalize() pro Pixel die tatsächliche Weltrichtung von der Kamera zum Fragment zu berechnen und sie für die Abfrage der Nebelfarbe an equirectUV zu übergeben. Dadurch gehen Terrain-Fragmente in die Himmelsfarbe über, die tatsächlich hinter ihnen liegt, was aus jedem Kamerawinkel korrekt aussieht.

Unter all den einzelnen Korrekturen blieb das zentrale Ergebnis bestehen. Wir verfügen nun über ein Terrain-System, das volumetrische Bearbeitung im Nahbereich – Marching Cubes mit Transvoxel-Nähten –, Heightmap-Chunks im mittleren Bereich und Clipmap-Ringe im Fernbereich kombiniert. Gesteuert wird alles von einer Policy-Schicht, die Modus, LOD und Übergangsverhalten festlegt.

Wenn ich die Muster benennen müsste, die ich im nächsten Projekt wiederholen würde, wären es diese:

Beginnt vor der Feature-Entwicklung mit Risiko-Spikes. Spike 1 beantwortete die Frage „Können wir überhaupt schnell genug rendern?“, bevor wir in Content-Pipelines investierten.

Friert vor großen Integrationssprüngen nachweislich funktionierende Ausgangsstände ein. Die Spikes 13 und 14 ersparten uns tagelanges Eingrenzen von Regressionen.

Erzwingt klare Richtlinien und Beobachtbarkeit vor langen Optimierungsmarathons. Spike 23 verwandelte rätselhafte Bugs in benannte Zustände mit definierten Auslöseregeln.

Testet in Bewegung, nicht anhand von Screenshots. Clipmap-Pops, flackernde Nähte und Streaming-Ruckler verbergen sich in Standbildern.

Messt die Frame-Zeit-Kosten pro Feature statt der durchschnittlichen FPS. Durchschnittswerte verbergen genau die Spitzen, die Nutzende tatsächlich spüren.

Und veröffentlicht auch die chaotischen Teile. Die Irrwege, die Geisterjagden nach veralteten Puffern, die zwei Tage, an denen wir der Übergangslogik die Schuld gaben, obwohl der Draw Range falsch war. Genau daraus können andere wirklich lernen.

Externer Realitätscheck: die Devlogs von Vuntra City

Nach Abschluss dieser Serie haben wir die Devlogs von @VuntraCity als externen Praxischeck für unsere eigenen Open-World-Annahmen untersucht. Es handelt sich um ein natives UE5-Projekt und nicht um einen Browser-Stack, doch die Systemmuster lassen sich gut genug übertragen, um den Vergleich sinnvoll zu machen.

Die erste Erkenntnis ist, dass die Fortbewegungsgeschwindigkeit als Streaming-Steuergröße behandelt werden muss, nicht nur als Teil des Gameplays. In Vuntra City werden schnelle Transportwege bewusst oberhalb der meisten Innenräume geführt, und die Detailreichweite skaliert mit der Bewegungsgeschwindigkeit, um ständiges Erzeugen und Entfernen von Objekten sowie Hänger zu vermeiden (Transportsystem, Performance-Techniken). Das entspricht der Ausrichtung unserer Policy-Schicht: Der Bewegungsmodus sollte den Chunk-Radius, die Aktivierung von Innenräumen und den zulässigen Arbeitsaufwand pro Frame direkt beeinflussen.

Die zweite Erkenntnis betrifft die Architektur. Ihre Karten und ihr Adresssystem erforderten eine Trennung der Welttopologie von den gerenderten Objekten, damit globale Abfragen auch für nicht geladene Regionen ausgeführt werden können (Karten und Adressen). Dieselbe Trennung benötigen wir im Browser für die Weltsuche, Quest-Routenplanung, Moderations-Scans und POI-Indexierung, ohne dafür an das Rendering gebundene Datenpfade erzwingen zu müssen.

Die dritte Erkenntnis betrifft die Abstufung der Simulation. Ihr System für eine Million NPCs hält den groben, globalen Zeitplanstatus günstig und weist nur in der Nähe der Spielfigur Budget für aufwendiges Verhalten zu (Überblick über eine Million NPCs, ausführliche Systemanalyse). Das bestärkt unser eigenes AOI-zentriertes Simulationsmodell, bei dem Detailtreue im Nahbereich und Determinismus im Fernbereich getrennte Anforderungen mit getrennten Budgets sind.

Und die vierte Erkenntnis betrifft die Designqualität, nicht die reine Größenordnung. Ihre stärksten Erkundungsmomente entstehen durch gewichtete Verteilungen, seltene Ausreißer und diegetische Navigationshinweise statt durch dauerhafte UI-Einblendungen (Anmerkungen zur prozeduralen Umgebung, Gameplay-Schleife ohne Minimap). Für uns ist das eine Erinnerung daran, technische Systeme so abzustimmen, dass sie entdeckbare Vielfalt erzeugen, statt lediglich maximalen Durchsatz zu liefern.

In diesem Kapitel behandelte Technologien

Geometrie von Clipmap-Ringen. Jeder Ring ist ein flaches, auf die Kamera zentriertes Gitter-Mesh, dessen Vertices durch Heightmap-Samples verschoben werden. Der innerste Ring nutzt die volle Auflösung. Jeder weitere Ring verdoppelt den Vertex-Abstand und deckt eine größere Fläche ab. Der schwierige Teil ist die Grenze: Dort, wo ein hochauflösender Ring auf einen niedrigauflösenden Ring trifft, rasten die Rand-Vertices des feineren Meshs auf den Mittelpunkt der Kante des gröberen Meshs ein. Die Technik stammt aus dem SIGGRAPH-Paper von Losasso und Hoppe aus dem Jahr 2004 (PDF) und wird ausführlich in GPU Gems 2, Kapitel 2 beschrieben. Siehe unseren Landschaftsleitfaden zu Geometry Clipmaps.

2:1-Kantenmorphing. An der Grenze zwischen zwei Clipmap-Ringen besitzt der feinere Ring Vertices an Positionen, die der gröbere Ring nicht teilt. Rand-Vertices, deren Gitterkoordinate entlang der Ringkante ungerade ist, werden erkannt, und ihre Höhe wird zwischen den beiden benachbarten geraden Vertices interpoliert. Dadurch entstehen wasserdichte Nähte ohne eigene Übergangsgeometrie. Die Interpolation läuft im Vertex-Shader: morphedHeight = mix(heightLeft, heightRight, 0.5) für Rand-Vertices, unter Verwendung desselben Geomorphing-Frameworks, das in unserem Leitfaden beschrieben wird.

Equirektanguläres Skybox-Mapping. Ein einzelnes 2D-Bild, das mithilfe einer Längen- und Breitengradprojektion die gesamte Kugel möglicher Himmelsrichtungen abbildet. Die horizontale Achse deckt 0 bis 360 Grad ab, die vertikale Achse 0 bis 180 Grad. Eine normalisierte Blickrichtung d^=(dx,dy,dz) wird anhand ihres Längen- und Breitengrads in UV-Koordinaten umgewandelt:

u=12+atan2(dz,dx)2π,v=12arcsin(dy)π

In Three.js aktiviert texture.mapping = EquirectangularReflectionMapping zusammen mit SRGBColorSpace diese Abbildung als Szenenhintergrund. In TSL führt equirectUV(direction) dieselbe Umwandlung durch und überführt eine dreidimensionale Blickrichtung in die zweidimensionalen UV-Koordinaten zum Sampeln der Textur.

Nebelfarbe pro Fragment aus dem Himmel. Standardnebel überblendet Fragmente zu einer einzigen konstanten Farbe. Bei einer Szene mit detaillierter Skybox sieht das falsch aus, weil sich die Himmelsfarbe je nach Richtung unterscheidet. Die Lösung besteht darin, pro Pixel die Weltrichtung von der Kamera zum Fragment zu berechnen (positionWorld.sub(cameraPosition).normalize()) und die Skybox in dieser Richtung abzufragen, um die Nebelfarbe zu bestimmen. Jedes Fragment geht in die Himmelsfarbe über, die tatsächlich hinter ihm liegt, wodurch aus jedem Kamerawinkel eine korrekte Überblendung entsteht. Der Nebelfaktor verwendet smoothstep(nearDist, farDist, viewDepth) und positionView.z.negate() für die Tiefe im Kameraraum.

Import-Maps für ES-Module. Ein browsernativer Mechanismus (<script type="importmap">), der Bare Module Specifiers wie three/tsl tatsächlichen URLs zuordnet. Als Three.js 0.183.1 seine Build-Ausgaben neu strukturierte, musste three/tsl zu three.tsl.js aufgelöst werden, während TSL intern three/webgpu als Bare Specifier importierte. Beide Zuordnungen mussten explizit in der Import-Map stehen, andernfalls erzeugte der Browser Fehler wie „does not provide an export“ oder „failed to resolve module specifier“.

Weiterführende Informationen

Eine ausführlichere Behandlung der in dieser Serie verwendeten Technologien findet ihr in unseren ergänzenden Leitfäden:

Vielen Dank, dass ihr uns auf dieser zwölfteiligen Reise begleitet habt.

Teil 1: Wir begannen mit dem Versuch, alles zum Scheitern zu bringen
Teil 2: Worker-Physik und die Angst vor Eingabeverzögerung
Teil 3: Die unspektakulären Spikes, die uns retteten
Teil 4: Streaming vor aufwendigem Terrain
Teil 5: Ein Budget für die schönen Dinge
Teil 6: Clipmaps veränderten den Handlungsverlauf
Teil 7: Marching Cubes und die ersten echten Höhlen
Teil 8: Integration, ohne unseren Ausgangsstand zu verlieren
Teil 9: Transvoxel begann mit einem Grundgerüst
Teil 10: Nahtchaos und der Bosskampf an den Ecken
Teil 11: Policy-Modus statt hartcodiertem Modus


Teil 12 von 14.
Zurück: Teil 11 – Policy-Modus statt hartcodiertem Modus
Weiter: Teil 13 – Terrain-Modellierung und das Ende der mathematischen Funktion
Serienleitfaden: /de/blog/2026-02-25-open-world-browser-series-guide