Eine offene Welt im Browser bauen, Teil 13: Terrain-Modellierung und das Ende der mathematischen Funktion
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.
Zwölf Teile lang haben wir eine Terrain-Engine gebaut, die man ansehen konnte. Darüber hinwegfliegen. Bewundern, dass die Nähte nicht aufreißen. Diesmal wollten wir sie berühren.
Das Ziel klang einfach: Spieler sollten das Terrain in Echtzeit mit einem Pinsel modellieren können, ohne eines der Systeme zu beschädigen, die wir über 24 Spikes hinweg aufgebaut hatten. Dafür brauchten wir drei Anläufe, stießen auf zwei Fehler, die wie Rendering-Probleme aussahen, tatsächlich aber Fehler im Datenmodell waren, und mussten grundlegend überdenken, wie Terraindaten funktionieren sollten.
Drei Fehlstarts
Spike 25 sollte eigentlich der einfache sein. Die Produktions-Codebasis verfügte bereits über einen Raycaster, der Terrain-Meshes trifft. Das Platzierungswerkzeug nutzt ihn, um Objekte abzusetzen. Ein Pinselwerkzeug folgt demselben Prinzip, nur dass es Heightmap-Werte verändert, statt ein Prefab zu erzeugen. Einfach.
Erster Versuch: Ich baute es direkt in den produktiven TypeScript-Code unter world/client/ ein. Eine neue terrain-brush.ts, Änderungen an chunk.ts, Protokolländerungen, Aktualisierungen an Vue-Komponenten. Innerhalb einer Stunde hatte ich einen Pinsel, der mehr oder weniger funktionierte, doch an den Chunk-Grenzen waren sichtbare Diskontinuitäten bei den Normalen zu erkennen. Ich konnte nicht feststellen, ob der Fehler in meinem Pinselcode, im bestehenden Chunk-Stitching oder in irgendeiner Wechselwirkung mit der vollständigen Render-Schleife lag. Genau solche Situationen soll die Spike-Methodik verhindern. Ich hatte die Regel ignoriert und sofort den Preis dafür bezahlt. Also machte ich alles rückgängig.
Zweiter Versuch: ein eigenständiger Spike, aber ich griff zu Three.js 0.170.0 und WebGL. Der Produktionscode verwendet WebGL, also fühlte sich das naheliegend an. Doch die Spikes 13–24 waren alle zu WebGPU übergegangen. Ein WebGL-Pinsel-Spike würde beweisen, dass es mit dem alten Renderer funktioniert, nicht mit dem, zu dem wir migrieren. Falsche Richtung. Also noch einmal von vorn.
Dritter Versuch: WebGPU, WebGPURenderer, ein Compute-Shader zur Vertex-Erzeugung und derselbe Stack wie in Spike 22. Diesmal stimmte die Architektur. Fünf funktionierende Pinseloperationen: anheben, absenken, glätten, einebnen, Rauschen hinzufügen. P95 eines Pinselzyklus auf dem M1 unter 4 ms.
Und der Nahtfehler war immer noch da.
Spike 25 in einem neuen Tab öffnen ↗ · Quellcode ansehen
Der Nahtfehler, der einfach nicht verschwinden wollte
Die Standardlösung für die Berechnung von Normalen über Chunk-Grenzen hinweg ist eine Randüberlappung: Jeder Chunk speichert einen zusätzlichen Datenring seiner Nachbarn, damit die Normalenberechnung an der Grenze Werte von beiden Seiten abtasten kann. Ich implementierte das. Ich kopierte die Randdaten der Nachbarn in einen erweiterten Puffer. Die Nähte rissen weiterhin auf.
Ich vertiefte mich in die Mathematik. An der Grenze zwischen Chunk A (cx=-1) und Chunk B (cx=0) müssen beide Chunks am gemeinsamen Vertex dieselbe Normale berechnen. Der Shader von Chunk A tastete mix(own_col31, own_col32, 0.85) ab. Chunk B tastete mix(neighbor_edge, own_col0, 0.85) ab. Das sind unterschiedliche bilineare Interpolationspfade durch unterschiedliche Daten. Selbst mit korrekten Randdaten berechnen die beiden Chunks für denselben Punkt unterschiedliche Normalen.
In diesem Moment wurde mir klar, dass das Kopieren des Rands nicht der eigentliche Fehler war. Der eigentliche Fehler lag im Datenmodell.
Jeder Spike von 1 bis 24 verwendete eine prozedurale mathematische Funktion namens height_at(). Man übergibt ihr Weltkoordinaten und erhält eine Höhe. Sauber, global, zustandslos. Der Pinsel konnte eine mathematische Funktion nicht verändern, also hatte ich darüber einen displacement-Puffer gelegt. Das Terrain war nun height_at(x,z) + displacement[i]. Der GPU-Shader enthielt 30 Zeilen Rauschfunktionen für das Basisterrain sowie Code für die bilineare Interpolation der Verschiebungsebene. Der Einebnungspinsel musste height_at() subtrahieren, um herauszufinden, welcher Verschiebungswert die Zielhöhe ergeben würde. Zwei übereinandergelegte Systeme, die mit unterschiedlichen Abtaststrategien unterschiedliche Dinge berechneten.
So funktioniert kein echtes Spiel. In der Produktion besteht gestaltetes Terrain aus abgetasteten Daten, die in Puffern gespeichert sind. Die prozedurale Funktion war ein praktischer Platzhalter aus den frühen Spikes. Sie hatte ihren Zweck erfüllt. Jetzt verursachte sie aktiv Fehler.
Ich entfernte sie.
Jeder Chunk besitzt nun eine heightmap-Float32Array mit tatsächlichen Höhenwerten. Bei der Erstellung wird sie mit prozeduralem Rauschen gefüllt. Danach wird die Rauschfunktion nie wieder aufgerufen. Der Pinsel verändert die gespeicherten Höhen direkt. Der GPU-Shader liest über eine einzige Funktion aus einem einzigen Puffer: hm_at(i,j). Die Normalen verwenden gitterausgerichtete zentrale Differenzen auf denselben Daten. Keine Mehrdeutigkeit durch bilineare Interpolation. Keine Diskrepanz zwischen zwei Systemen. Der Shader schrumpfte von 90 auf 40 Zeilen.
Die Nähte reparierten sich von selbst. Beide Chunks lesen an einer gemeinsamen Kante nun dieselben diskreten Höhenwerte aus ihren jeweiligen Puffern, wobei die Randüberlappung die korrekten inneren Punkte des Nachbarn enthält. Gleiche Eingabedaten, gleiche Normalen als Ergebnis.
Das war keine Lektion über Pinsel. Es war eine Lektion über Datenarchitektur, die der Pinsel ans Licht gebracht hatte.
Das explodierende Mesh
Spike 26 war das volumetrische Gegenstück. Ein 64³ großes SDF-Volumen mit einem Pinsel verändern und anschließend mit Marching Cubes neu vermeshen. Dieselbe Frage wie bei Spike 25, nur in 3D.
Als ich es zum ersten Mal ausführte, explodierte das Mesh. Lange Spitzen schossen in alle Richtungen, wie ein Seeigel an einem besonders schlechten Tag.
Spike 26 in einem neuen Tab öffnen ↗ · Quellcode ansehen
Die von mir generierte MC-Falltabelle hatte 3840 statt 4096 Einträge. Die vollständige Tabelle umfasst
in
Die Lösung war lächerlich einfach: die bewährte Tabelle aus Spike 12 Byte für Byte kopieren. Lektion gelernt. Eine Lookup-Tabelle niemals neu generieren, wenn bereits eine bewährte Kopie existiert.
Der zweite Fehler war subtiler. Der Glättungspinsel sollte Terrainmerkmale weicher machen. Stattdessen erzeugte er scharfe Falten. Das Problem: Ich zog jeden SDF-Wert in Richtung null, also zur Isofläche. Das klingt, als sollte es alles glätten, doch tatsächlich kollabiert dadurch das Distanzfeld. Voxel oberhalb und unterhalb der Oberfläche bewegen sich beide schnell auf null zu, wodurch im Pinseldurchmesser alles abgeflacht wird. An der Grenze treffen geglättete Voxel mit einer harten Stufe auf ungeglättete. Der „Glättungs“-Pinsel war in Wirklichkeit ein Faltengenerator.
Die Lösung war eine korrekte Laplace-Glättung. Statt jeden Wert in Richtung null zu ziehen, wird er zum Mittelwert seiner sechs direkten Nachbarn gezogen:
Der Ausdruck in Klammern ist ein diskreter Laplace-Operator, und
Alles auf einmal
Spike 27 war das Integrationstor. Wir nahmen die vollständige Pipeline aus Spike 24 – Heightmap-Patches, MC-Chunks, Transvoxel-Nähte und Geomorph-LOD – und kombinierten sie mit dem abgetasteten Datenmodell aus Spike 25 sowie beiden Pinseltypen.
Spike 27 in einem neuen Tab öffnen ↗ · Quellcode ansehen
Als Erstes riss ich height_at() aus jedem Shader heraus. Alle drei Compute-Shader – SDF-Befüllung, Heightmap-Patch und Transvoxel-Naht – binden nun denselben 129x129 großen Heightmap-GPU-Puffer und verwenden über einen gemeinsamen WGSL-Präambelblock dieselbe bilineare Interpolationsfunktion hm_sample(). Eine Datenquelle, mehrere Verbraucher. Die prozeduralen Rauschfunktionen, die seit Spike 1 in jedem Shader steckten, waren verschwunden.
Dann begannen die interessanten Probleme.
Wenn ein SDF-Pinsel einen Chunk im MC-Modus sperrt, muss die Transvoxel-Naht zwischen diesem Chunk und seinem Heightmap-Nachbarn aus dem SDF-Volumen statt aus der Heightmap abtasten. Ich erweiterte den Naht-Shader um zusätzliche Storage-Buffer-Bindings und MC-Flags pro Chunk. Vier Grenzkombinationen mussten behandelt werden: HM-HM, HM-MC, MC-HM, MC-MC.
LOD war ein weiteres Rätsel. In früheren Spikes bedeutete der Wechsel eines MC-Chunks zu einem niedrigeren LOD, dass das SDF mit einer gröberen Auflösung neu gefüllt werden musste. Ich ersetzte das durch schrittweitenbasierte Abtastung: Die SDF-Daten bleiben in voller Auflösung mit 65 Gitterpunkten erhalten. Der MC-Shader berechnet eine Schrittweite aus dem Verhältnis von Gittergröße zu Zellenanzahl. Bei LOD0 beträgt die Schrittweite 1. Bei LOD1 beträgt sie 2, sodass nur jedes zweite Voxel abgetastet wird. Chunks können ihren LOD frei ändern, ohne ihre SDF-Daten anzutasten.
Am befriedigendsten war die Lösung für das dynamische Erzeugen vertikaler Chunks. Modelliert man über die Oberkante eines Chunks hinaus nach oben, erscheint darüber ein neuer reiner MC-Chunk, dessen SDF anhand der Grenzfläche des darunterliegenden Chunks initialisiert wird. Modelliert man nach unten, passiert dasselbe. Die Welt wächst passend zu den Bearbeitungen mit.
Die letzte Stolperfalle war, dass der Heightmap-Pinsel bei MC-gesperrten Chunks stillschweigend nichts bewirkte. Der HM-Pinsel verändert heightmapCPU und lädt die Daten erneut hoch. MC-Chunks lesen die Heightmap nicht mehr, weil ihr SDF daraus befüllt wurde und sich anschließend davon getrennt weiterentwickelt hat. Ich fügte syncHeightmapToSdf() hinzu: Nachdem sich die Heightmap geändert hat, werden die SDF-Spalten aller MC-Chunks im Pinseldurchmesser neu abgeleitet und die neuen Werte hochgeladen. Beide Pinseltypen funktionieren nun mit beiden Chunk-Typen.
Was wir tatsächlich gelernt haben
Die Pinsel-Spikes sollten eine Leistungsfrage beantworten: Kann die Modellierung innerhalb des Frame-Budgets ausgeführt werden? Ja, das kann sie. Das war der einfache Teil.
Der schwierige Teil war die Erkenntnis, dass 24 Spikes, in denen height_at() als Terrainwahrheit diente, eine unsichtbare Abhängigkeit geschaffen hatten, die in dem Moment zerbrach, als wir etwas bearbeiten wollten. Die prozedurale Funktion war sauber, global und zustandslos – bis sie nicht mehr das Terrain war.
Diese Regeln haben wir aufgeschrieben und werden sie nicht vergessen:
- Die Terrainhöhe stammt aus abgetasteten Daten. Chunks besitzen ihre Puffer.
- Prozedurale Generierung füllt die Ausgangsdaten. Sie ist nicht die Laufzeitwahrheit.
- Der Pinsel verändert Chunk-Daten direkt. Keine Verschiebungsebenen.
- Normalen werden aus denselben Daten über gitterausgerichtete zentrale Differenzen berechnet.
- Eine Randüberlappung – eine Zelle aus dem Inneren des Nachbarn – ermöglicht die Berechnung von Normalen über Chunk-Grenzen hinweg.
- Eine Lookup-Tabelle niemals neu generieren, wenn bereits eine bewährte Kopie existiert.
In Teil 14 hören wir auf, Debug-Geometrie zu modellieren, und beginnen damit, die Welt wie einen echten Ort aussehen und wirken zu lassen.
In diesem Kapitel erwähnte Technologien
Architektur mit abgetasteten Heightmaps. Das Terrain wird als eigene Daten pro Chunk gespeichert, statt zur Laufzeit durch eine prozedurale Funktion ausgewertet zu werden. Jeder Chunk enthält eine Float32Array mit tatsächlichen Höhenwerten. Prozedurales Rauschen füllt bei der Erstellung die Ausgangsdaten; danach wird die Funktion nie wieder aufgerufen. Dadurch entfällt die Diskrepanz zwischen dem mathematisch erzeugten Terrain und den darübergelegten Bearbeitungen. Pinseloperationen werden einfacher, weil sie gespeicherte Werte direkt verändern, und der GPU-Shader wird ausgesprochen simpel: aus dem Puffer lesen und Normalen über gitterausgerichtete zentrale Differenzen berechnen. Für offene Welten mit Streaming ist der Besitz der Daten pro Chunk mit einer Randüberlappung von einer Zelle aus den Nachbarn der Standardansatz. Siehe unseren Leitfaden zur Landschaftsgenerierung.
SDF-Pinseloperationen. Veränderung eines vorzeichenbehafteten Distanzfelds zur Modellierung des Terrains. Hinzufügen beziehungsweise Aufblähen verwendet einen Smoothstep-Abfall um eine Kugel. Subtrahieren beziehungsweise Aushöhlen verwendet dieselbe Form mit umgekehrtem Vorzeichen. Glätten verwendet Laplace-Mittelung: die sechs direkten Nachbarn lesen, ihren Mittelwert berechnen und den Wert in Richtung dieses Mittelwerts ziehen. Der naive Ansatz, Werte in Richtung null zu ziehen, lässt das Distanzfeld kollabieren und erzeugt scharfe Kanten. Die Laplace-Glättung erhält den Gradienten des Felds und macht Merkmale zugleich weicher. Siehe SDF-Terraindarstellung.
Transvoxel mit gemischten Datenquellen. Übergangszellen an der Grenze zwischen einem MC-Chunk und einem Heightmap-Chunk müssen auf jeder Seite unterschiedliche Daten abtasten. Der Naht-Shader verwendet Flags und Buffer-Bindings pro Chunk, um alle vier Kombinationen zu behandeln: HM-HM, HM-MC, MC-HM und MC-MC. Ist eine Seite im MC-Modus gesperrt, interpoliert der Shader den SDF-Puffer trilinear, statt die Heightmap abzutasten. Schrittweitenbasiertes LOD für Marching Cubes. Die SDF-Daten werden unabhängig von der aktuellen LOD-Stufe des Chunks in voller Auflösung gespeichert. Der MC-Shader berechnet die Abtastschrittweite aus dem Verhältnis der SDF-Gitterpunkte zu den MC-Zellen. Bei voller Auflösung beträgt die Schrittweite 1, bei halber Auflösung 2. Dadurch werden die SDF-Daten von LOD-Änderungen entkoppelt, sodass Chunks ihre LOD-Stufe frei wechseln können, ohne die SDF neu zu erstellen.
Teil 13 von 14. Zurück: Teil 12 – Ringe, Himmelsnebel und was wir wieder so machen würden Weiter: Teil 14 – Die Welt erwacht zum Leben Serienübersicht: /de/blog/2026-02-25-open-world-browser-series-guide