Skip to content

Eine offene Welt im Browser entwickeln, Teil 20: Tiefe auf einer flachen Ebene vortäuschen

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 19 nutzte ein flaches Quad, um in der Ferne einen ganzen Baum vorzutäuschen. Dieser Teil nutzt ein flaches Quad, um aus der Nähe Tiefe vorzutäuschen: Parallax Occlusion Mapping, der Trick, der eine Kopfsteinpflasterstraße so aussehen lässt, als hätte sie 5 cm tief eingelassene Fugen, ohne dafür einen einzigen zusätzlichen Vertex zu benötigen. Das Ziel bestand darin, dieses Verfahren in den Produktions-Stack (Three.js r184, WebGPU, TSL) zu integrieren, damit Detailmaterialien für Gelände dort diese Tiefenillusion erzeugen können, wo sie wichtig ist, und überall sonst nur die Kosten einer flachen Textur anfallen.

Drei Methoden zum Vortäuschen von Tiefe im direkten Vergleich

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

Der Spike platziert drei flache, jeweils 5 × 5 m große Ebenen nebeneinander. Sie verwenden alle dasselbe Materialgerüst und unterscheiden sich lediglich durch die UV-Koordinaten, die an die Sampler übergeben werden. „Flat“ liest die Textur direkt aus und dient als Referenzbasis. Die Parallaxe mit einer einzelnen Abtastung verschiebt die UV-Koordinaten einmal entlang der Blickrichtung um die Höhe an diesem Punkt. Das ist günstig und funktioniert bei geringer Amplitude ordentlich, schwimmt jedoch bei flachen Blickwinkeln. POM führt Ray Marching im Tangentenraum durch: Es schreitet entlang des Sichtstrahls voran, findet die erste Schicht, in der der Strahl unter das Höhenfeld gelangt, und verfeinert anschließend den Schnittpunkt. Der Tangentenraum bleibt einfach, weil jede Testebene achsenparallel ausgerichtet ist. Dadurch lässt sich die Blickrichtung mit einigen Vorzeichenwechseln in den Tangentenraum übertragen, statt eine vollständige TBN-Matrix pro Vertex zu benötigen. Der Textursatz wird live über die Datei-API von Polyhaven geladen, denselben Pfad, den die Modellsuche aus Teil 17 verwendet hat.

Zwei WebGPU-Hürden und ein verzweigungsfreies Ray Marching

Die klassische POM-Schleife bricht die Suche beim ersten Schnittpunkt ab. In r184 funktioniert das aus zwei verschiedenen Gründen nicht. If(...).and(...) wurde zwar fehlerfrei kompiliert, erzeugte jedoch WGSL-Code, in dem der Schleifenkörper nie ausgeführt wurde. Dadurch arbeitete die anschließende Verfeinerung mit unbrauchbaren Werten, und die Ebene wurde nahezu weiß gerendert. Außerdem war Break() als eigenständiger Node im r184-Build noch gar nicht enthalten. Selbst mit einem funktionierenden If ließ sich daher „beim ersten Schnittpunkt anhalten“ nicht ausdrücken. Beides lässt sich auf bekannte Probleme in three.js zurückführen, bei denen die TSL-Kontrollflussoptimierung in diesem Versionsbereich über If- und Loop-Grenzen hinweg zu aggressiv arbeitet.

Die Neufassung ist verzweigungsfrei. In jeder Iteration wird die Textur bedingungslos abgetastet. Dadurch bleibt der Texturzugriff im einheitlichen Kontrollfluss, wie von der WGSL-Spezifikation verlangt. Anschließend wird der neue Zustand über ein als Fließkommazahl gespeichertes done-Flag übernommen. Sobald done auf 1 wechselt, werden die mix-Aufrufe jeder Iteration effektiv zu „Zustand unverändert beibehalten“ – dem verzweigungsfreien Äquivalent eines Abbruchs. Das done-Flag wird mit einer als 0.5 + 0.5 × sign(x + ε) implementierten step-Hilfsfunktion erzeugt, weil die Umwandlung von booleschen Werten in Fließkommazahlen innerhalb der r18x-Reihe unzuverlässig war und sign() durchgängig sicher ist. Der Preis dafür ist, dass jedes Fragment unabhängig vom tatsächlichen Schnittpunkt alle 64 Iterationen durchläuft. Auf Fragmentebene ist das jedoch der richtige Kompromiss: Die Laufzeit begrenzt ohnehin über die maximale Schrittzahl, und eine echte GPU würde auch über einen „echten“ Abbruch hinaus spekulieren. Hier ist ein sauberer Fallback wichtig: Ein abschließendes mix(baseUV, refined, done) sorgt dafür, dass bei einer Amplitude von null – also am fernen Ende der Distanzüberblendung – kein Fragment einen Schnittpunkt findet, done bei 0 bleibt und das POM-Material bitgenau mit der flachen Variante übereinstimmt. Genau das ist der Sinn dieses Distanz-LOD-Tricks: Dort, wo der Effekt ohnehin kleiner als ein Pixel ist, sinken die Kosten auf die einer flachen Textur.

Der Fehler lag in mangelnder Disziplin, nicht in der Mathematik

Die verzweigungsfreie Version lief, sah aber verzerrt aus: streifige horizontale Artefakte bei mittlerer Amplitude und ein bei niedriger Amplitude subtil falsches, unscharfes Ergebnis. Die Lösung kam durch eine einzeilige Aufforderung: Lies die kanonische Referenz. Das LlamAcademy-Tutorial, das als Inspiration diente, besteht lediglich aus einem Unity-ShaderGraph-Node; die eigentliche Implementierung befindet sich daher in Unitys PerPixelDisplacement.hlsl. Beim zeilenweisen Lesen kamen drei semantische Unterschiede zum Vorschein, die ich unbewusst eingeführt hatte: ein Versatz um eins bei der Ausgangshöhe des Strahls – Unity führt vor der Schleife einen ersten Schritt aus, sodass mein Bezugsrahmen um einen ganzen Schritt phasenverschoben war und die Schnittpunkte etwa in der Hälfte der Fälle in der falschen Schicht landeten –, eine Vorzeichenkonvention für den maximalen Versatz, von der der Verfeinerungsschritt abhängt, sowie eine unterschiedliche Verwaltung von kumulativem Versatz und kumulativen UV-Koordinaten, die meine Verfeinerungsmathematik unnötig erschwerte und das Vorzeichen verkomplizierte.

Die eigentliche Ursache war kein einzelner Fehler, sondern die Vermischung zweier Referenzen. Ich hatte mich am POM-Tutorial von LearnOpenGL orientiert, das ähnliche, aber andere Vorzeichenkonventionen und eine andere Verfeinerungsformel verwendet. So entstand ein Mischwesen, bei dem zwei Drittel der Mathematik zur einen Quelle und ein Drittel zur anderen passten. Die Neufassung ist eine nahezu wortgetreue Portierung von Unitys HLSL nach TSL: dieselben Variablennamen, derselbe erste Schritt, dieselbe Verfeinerung – ergänzt um das verzweigungsfreie done-Flag. Die Lehre daraus ist allgemein gültig: Wenn du einen nachweislich funktionierenden Shader aus einem anderen Stack portierst, übertrage ihn zunächst Zeile für Zeile mit denselben Namen und passe ihn erst danach an den lokalen Stil an. Leite ihn nicht mitten in der Portierung anhand einer zweiten Referenz neu her.

Eine Referenzebene, die nicht lügen kann

Im direkten Vergleich fehlte das Offensichtliche: eine Ebene aus echter Geometrie. Ohne sie lässt sich die Aussage „POM sieht ziemlich gut aus“ nicht widerlegen. Ziemlich gut im Vergleich wozu? Deshalb erhielt der Spike eine vierte Ebene, bei der dieselbe Heightmap tatsächlich auf die Vertexpositionen angewendet wird. WebGPU bietet keine Hardware-Tessellation – sie ist schlicht nicht Teil der Spezifikation und wurde zugunsten der Metal-Kompatibilität weggelassen. Als Ersatz dient daher eine dicht unterteilte Ebene mit 256 × 256 Segmenten und 131.072 Dreiecken, deren Vertices in der Vertex-Stage verschoben werden. Dasselbe Amplituden-Uniform steuert sowohl POM als auch die Geometrieebene. So werden beide gemeinsam ausgeblendet, und der Vergleich bleibt bei jeder Entfernung fair.

Mit der tatsächlichen Geometrie als Referenz ließen sich die qualitativen Aussagen messen. Bei einer Umlaufansicht mit 16° Blickwinkel nach unten stimmen POM und die tessellierte Ebene in der internen Schattierung überein. Bei flachen Blickwinkeln weichen sie genau dort voneinander ab, wo es unvermeidlich ist: POM bleibt auf die vollkommen geraden, rechteckigen Kanten der Geometrie beschränkt, während das echte Mesh ein unebenes Horizontprofil aus tatsächlichen, vom Licht erfassten Höhen und Tälern zeigt. Damit lässt sich nun belegen, dass das „Schwimmen“ an den POM-Kanten dem Algorithmus selbst innewohnt und kein Artefakt der Textur oder Beleuchtung ist. Auch die beiden Kostenprofile werden klar: POM ist fragmentgebunden – die Kosten skalieren mit der Anzahl bedeckter Pixel –, während die tessellierte Ebene vertexgebunden ist und ihre Kosten unabhängig von der Bildschirmabdeckung mit der Mesh-Dichte skalieren. Für einen Gelände-Chunk, der bereits die Vertexkosten einer durch eine Heightmap gesteuerten Ebene trägt, ist POM die richtige Lösung für Details unterhalb der Mesh-Auflösung.

Die Referenzebene deckte außerdem einen subtilen UX-Fehler auf. Ein Nutzer bemerkte, dass die Oberfläche mit steigender Amplitude abzusinken schien. Die Ursache war Unitys Konvention, die geometrische Ebene als Oberseite des Höhenfelds zu behandeln. Dadurch bleiben die Spitzen bündig verankert, während alles andere durch die Parallaxe nach unten verschoben wird. Die durchschnittliche Oberfläche sinkt somit um (1 − mean_h) × amplitude unter die flache Ausgangsebene. Die Korrektur zentriert die Konvention neu, sodass h = 0.5 der Ebene entspricht: Spitzen ragen zur Kamera hin auf, während Täler zurückweichen. Der Algorithmus läuft exakt nach Unitys Vorgabe; der Spike verarbeitet die Ausgabe lediglich nachträglich mit einem halben Versatz, damit „Amplitude“ dem entspricht, was ein Mensch beim Ziehen eines Schiebereglers erwarten würde.

Noch eine weitere Frage wurde durch die Referenzebene geklärt. Ein „Schritte“-Regler schien keinerlei Wirkung zu haben, was wie ein Verdrahtungsfehler aussah, aber keiner war. Die dreistufige Sekantenverfeinerung nach der linearen Suche ist so gut – Tatarchuks POM-Paper von 2006 merkt an, dass eine Suche mit 4 Schritten plus 3-stufiger Sekantenverfeinerung visuell nicht von einer Suche mit 64 Schritten zu unterscheiden ist –, dass auf einer glatten Heightmap jede Schrittzahl von 4 bis 64 auf dieselben UV-Koordinaten unterhalb der Texelgröße konvergiert. Die Lösung war ein Umschalter statt einer Neuverdrahtung: Wird die Sekantenverfeinerung deaktiviert, ist der Schrittregler die einzige Steuerung für die Genauigkeit des Schnittpunkts. Eine Reduzierung auf 4 erzeugt dann sichtbare Abstufungen im Kopfsteinpflaster, während eine Erhöhung auf 64 es wieder glättet. Der Umschalter ist ein 0/1-Uniform, das jede Aktualisierung des Sekantenzustands per mix wirkungslos macht, wenn es deaktiviert ist. Dadurch wird das Material beim Umschalten nie neu erstellt, und es entstehen keine Ruckler.

In diesem Kapitel verwendete Technologien

Parallax Occlusion Mapping in TSL. POM führt Ray Marching entlang der Blickrichtung durch ein Höhenfeld im Tangentenraum aus, findet die erste Schicht, in der der Strahl unter die Oberfläche fällt, und verfeinert den Schnittpunkt. Dadurch entsteht auf einem flachen Quad ohne zusätzliche Geometrie der Tiefeneindruck eingelassener Fugen. Ein abschließendes mix(baseUV, refined, done) sorgt dafür, dass das Material bitgenau mit der flachen Variante übereinstimmt, wenn kein Fragment einen Schnittpunkt findet. So kann die entfernungsabhängige LOD-Abschwächung der Amplitude die Kosten in der Ferne auf die einer flachen Textur reduzieren. Siehe Geländematerialien.

Verzweigungsfreie Schleifen für die WebGPU-Kontrollflusssteuerung. In Three.js r184 kann TSL-Code mit If(...).and(...) zu WGSL kompiliert werden, dessen Schleifenkörper nie ausgeführt wird; außerdem ist ein eigenständiges Break() nicht verfügbar. Das portable Muster besteht aus einer bedingungslosen Texturabtastung pro Iteration – wodurch der Texturzugriff gemäß WGSL-Spezifikation im einheitlichen Kontrollfluss bleibt – und einem als Fließkommazahl gespeicherten done-Flag, das nach seiner Aktivierung jede Zustandsaktualisierung per mix wirkungslos macht. Eine aus sign(x + ε) aufgebaute step-Hilfsfunktion vermeidet die unzuverlässige Umwandlung von booleschen Werten in Fließkommazahlen. Unabhängig von einem möglichen frühzeitigen Abbruch bleibt die Anzahl der Iterationen konstant auf dem Maximalwert – auf Fragmentebene der richtige Kompromiss.

Wortgetreue Shader-Portierung. Ein nachweislich funktionierender Shader aus einer anderen Engine sollte zunächst Zeile für Zeile unter Beibehaltung der ursprünglichen Variablennamen portiert und erst danach an den lokalen Stil angepasst werden. Die Vermischung zweier Referenzen – Unitys PerPixelDisplacement.hlsl und des LearnOpenGL-Tutorials – erzeugte ein Mischwesen mit einem um eins versetzten Strahl-Ausgangswert, einem invertierten Versatzvorzeichen und einer Verfeinerungsformel, deren Begrenzung außerhalb des Wertebereichs liegende Gewichtungen als räumliche Diskontinuitäten kaschierte. Eine kanonische Referenz als verlässliche Grundlage, keine erneute Herleitung.

Durch Vertex-Verschiebung erzeugte Referenzgeometrie. Da WebGPU keine Hardware-Tessellation bietet, dient eine dicht unterteilte Ebene mit 256² Segmenten, die in der Vertex-Stage verschoben wird, als echte Geometrie zur Validierung eines in der Fragment-Stage erzeugten Trugbilds. Werden beide Varianten durch dasselbe Amplituden-Uniform gesteuert, bleibt der Vergleich über alle Entfernungen hinweg fair. POM ist fragmentgebunden und skaliert mit den bedeckten Pixeln, während die Geometrieebene vertexgebunden ist und mit der Mesh-Dichte skaliert. Daher weichen beide genau an den Silhouettenkanten voneinander ab – der Beweis, dass das Schwimmen der POM-Kanten inhärent und kein Artefakt ist.


Teil 20 von 29. Zurück: Teil 19 – Der Imposter, der einen Wald überstehen muss Weiter: Teil 21 – Ein schnellerer Renderer, der nicht schneller war Serienleitfaden: /blog/2026-02-25-open-world-browser-series-guide