Skip to content

Eine offene Welt im Browser entwickeln, Teil 26: Drei Wege, Wasser zu erzeugen

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 25 hat die Avatare eingekleidet. In diesem Teil geht es um Wasser, und zwar in drei Spikes, denn Wasser ist die Oberfläche, bei der eine billige Abkürzung und die richtige Lösung auf einem Screenshot identisch, in Bewegung jedoch völlig unterschiedlich aussehen. Spike 51 erzeugt Reflexionen mit dem verlockenden Screen-Space-Verfahren und stößt direkt an dessen inhärente Grenze. Spike 52 wechselt zu der Methode, die tatsächlich jedes veröffentlichte Spiel verwendet. Spike 53 integriert eine fertige Wasserbibliothek, um zu zeigen, wie weit wir noch von „fertig“ entfernt sind. Alle drei verwenden dieselbe Refraktionsebene, sodass sich zwischen den ersten beiden nur die Berechnung der Reflexion ändert.

Reflexion aus dem bereits vorhandenen Bild

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

Screen-Space-Reflexionen verwenden das bereits gerenderte Bild erneut. Für jedes Wasserpixel spiegelst du den Sichtstrahl an der Oberfläche, lässt diesen reflektierten Strahl durch den Tiefenpuffer laufen und hast, sobald der Strahl hinter einer erfassten Oberfläche verläuft, das gefunden, was das Wasser reflektiert – direkt aus dem Farbpuffer abgetastet. Die Portierung folgt Zeile für Zeile dem SSRNode von three.js, der wiederum auf lettiers SSR-Einführung basiert. Der Strahlverlauf ist eine DDA-Abtastung im Screen Space: Anfang und Ende des Strahls werden in Pixelkoordinaten projiziert, dann wird entlang der längeren Achse mit einer Abtastung pro Pixel vorangeschritten. Die Tiefe des reflektierten Strahls benötigt bei jedem Schritt eine perspektivisch korrekte Interpolation, 11/z0+s(1/z11/z0), denn eine lineare Interpolation von View-Z ist schlicht falsch und erzeugt Treffer an den falschen Stellen.

Zwei Dinge machen das Verfahren brauchbar statt zu einer Diashow. Der grobe Strahlverlauf ist auf 64 Schritte begrenzt, weil ein langer Strahl, der in der Projektion tausend Pixel überquert, sonst Hunderte Iterationen pro Fragment benötigen würde – und eine Wasserfläche mit einer Million Fragmenten, multipliziert mit Hunderten Iterationen und mehreren Texturabtastungen, ergibt eine Szene mit 30 fps. Die Qualität steuert innerhalb dieses Limits die effektive Schrittweite statt der Iterationszahl. Weil ein begrenzter grober Strahlverlauf sichtbare stufenartige Bänder hinterlässt, halbiert eine binäre Verfeinerung mit sechs Iterationen das Intervall zwischen dem letzten Fehlschlag und dem Treffer. Das ergibt eine 64-fache Genauigkeit unterhalb der Schrittweite und reicht aus, damit benachbarte Wasserfragmente nicht mehr dieselbe grobe Trefferposition erfassen. Eine abschließende Punkt-zu-Linie-Distanzprüfung bestätigt, dass der Kandidat tatsächlich auf dem Reflexionsstrahl liegt und nicht nur dieselbe Tiefe besitzt. Dabei kommt eine Dickentoleranz zum Einsatz, die sich automatisch an die Breite eines Pixels im View Space in der jeweiligen Tiefe anpasst: im Nahbereich enger, in der Ferne großzügiger.

Die ehrliche Erkenntnis dieses Spikes steht direkt in seinen Kommentaren: SSR kann nicht reflektieren, was die Hauptkamera nie erfasst hat. Die Unterseite eines Baums, alles außerhalb des Bildausschnitts und alles Verdeckte existiert in den Puffern nicht und kann deshalb auch nicht in der Reflexion erscheinen. Das ist der „Informationsverlust auf der falschen Seite“, den keine noch so hohe Qualität des Strahlverlaufs beheben kann – und genau deshalb gibt es den nächsten Spike.

Der Spiegel, der nicht lügen kann

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

Bei einer planaren Reflexion wird die Szene ein zweites Mal mit einer an der Wasserfläche gespiegelten Kamera in ein Offscreen-Renderziel gerendert, das anschließend vom Wasser-Shader abgetastet wird. Dies ist das Standardmuster, das UE5 Water, der three.js-eigene WaterMesh, ABZÛ und Sea of Thieves verwenden, weil dabei jedes Pixel der Szene als Quelle zur Verfügung steht – einschließlich der Geometrie, die SSR niemals sehen kann. In TSL ist das beinahe unspektakulär: reflector() erzeugt die gespiegelte Hilfskamera und ihr Renderziel, du fügst dessen Ziel dem Mesh hinzu, damit es bei jedem Frame aktualisiert wird, und tastest seine Farbe ab. Wenn später Wellen hinzukommen, wird die Reflexion durch einen Verzerrungsversatz am UV-Knoten des Reflektors bewegt. Das ist exakt die Zeile, die auch WaterMesh verwendet.

Der erwähnenswerte Fehler lag nicht im Spiegel, sondern im Sicherheitsnetz. Eine frühere Version mischte die Ausgabe des Reflektors als Ausweichlösung mit einem prozeduralen Himmel, gewichtet nach dem Betrag der reflektierten Farbe. Dahinter stand die Annahme, dass eine nahezu schwarze Reflexion bedeute, das Ziel enthalte an dieser Stelle nichts. Doch auch ein dunkler Schatten unter einem Blätterdach hat einen kleinen Betrag. Deshalb erreichte die Begrenzung nie ihre volle Stärke, und diese tatsächlich dunklen Pixel wurden mit dem hellen Himmel vermischt. Das vom Nutzer beobachtete Symptom war eindeutig: Im Debug-Modus zeigte das rohe Spiegelziel perfekte dunkle Bäume, während die zusammengesetzte Darstellung ausgewaschene Reflexionen enthielt. Die Diagnose lautete, dass der Shader „dunkle Farben verschwinden lässt“. Die Lösung war, den Code zu löschen. Das Reflektorziel ist nach dem ersten Frame zuverlässig, daher ist überhaupt keine Ausweichlösung erforderlich. Unter beiden Spikes liegt dieselbe Refraktion: Die Szene hinter der Oberfläche wird abgetastet, für jedes Pixel wird rekonstruiert, wie weit es unterhalb der Wasserlinie liegt, und anschließend wird pro Farbkanal die Beer-Lambert-Extinktion angewendet. Dadurch verschwindet Rot innerhalb weniger Meter, während Blau erhalten bleibt. Eine Himmelsmaske verhindert zugleich, dass der Hintergrund an der Far Plane von Nebel überlagert wird. Der Schlick-Fresnel-Effekt mischt Refraktion beim senkrechten Blick ins Wasser mit Reflexion beim flachen Blick über die Oberfläche.

So sieht ein fertiges Ergebnis aus

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

Spike 53 ist der Vergleich zwischen Eigenentwicklung und Zukauf. Er integriert threejs-water-pro unverändert im ausgelieferten Zustand, einschließlich des tropischen Presets, des standardmäßigen Ozean-Clipmaps, der Kameraverfolgung und des Rayleigh-Himmels, und lädt dasselbe Insel-glTF wie die eigene Demo der Bibliothek. Ziel ist es, den Abstand zwischen einem selbst entwickelten Shader für flaches Wasser und einem vollständigen Ozeansystem sichtbar zu machen – und dieser Abstand ist groß: Dieses System verfügt über eine Auftriebssimulation, die die Wellenhöhe an mehreren Punkten unter dem Schiffsrumpf abtastet, sodass sich das Schiff neigt und rollt, statt nur auf und ab zu wippen. Hinzu kommen ein Generator für Kielwasser, Schaum an Küste und Oberfläche sowie ein Masken-Pass, der das Rendern von Wasser innerhalb des Schiffsrumpfs unterdrückt, damit keine Wellen durch das Deck dringen.

Bei der Integration kamen Details ans Licht, die man nur durch die Verwendung einer Bibliothek lernt, nicht durch das Lesen ihrer README. Schaumtexturen werden im Preset über Dateinamen referenziert, müssen aber vom Nutzer der Bibliothek selbst geladen werden. Ohne sie wirkt die Küstenlinie wie eine harte Wasserkante statt wie brechende Brandung. Die Insel ist so positioniert, dass ihre Unterwassergeometrie unter den Meeresboden reicht. Dadurch verdeckt das Boden-Mesh der Bibliothek den äußeren Ring des Modells, ohne dass eine sichtbare Kante der Ebene entsteht. Das ist das von der Bibliothek vorgesehene Muster: ein 3D-Modell mitbringen, statt Terrain zu synthetisieren. Außerdem ist Antialiasing im Renderer bewusst deaktiviert und wird durch einen FXAA-Post-Processing-Pass ersetzt. MSAA mischt Kantenfragmente bereits mit dem Hintergrund, bevor der tiefenabhängige atmosphärische Nebel berechnet wird. Dadurch entsteht überall dort ein schmaler dunkler Saum, wo Geometrie auf Nebel trifft. Wird das Aliasing erst nach statt vor dem Nebel aufgelöst, verschwindet dieser Saum. Damit wird vor allem die Entscheidung selbst abgesichert: Ein produktionsreifer Ozean ist ein großes, spezialisiertes System. Wo wir ein solches System benötigen, ist die Übernahme einer gepflegten Bibliothek besser, als Kielwasser, Auftrieb und Schaum von Grund auf neu zu entwickeln. Für kleinere Binnengewässer, die Kreative in ihrer eigenen Welt platzieren, bleibt dagegen der Shader mit planarem Spiegel aus Spike 52 die richtige Lösung.

In diesem Kapitel behandelte Technologien

Wasser mit Screen-Space-Reflexionen. Ein reflektierter Sichtstrahl durchläuft den Tiefenpuffer mittels DDA im Screen Space. Dabei kommen eine perspektivisch korrekte 1/z-Interpolation, ein festes Schrittlimit zur Begrenzung der Kosten pro Fragment und ein binärer Verfeinerungsdurchlauf zum Entfernen der durch den begrenzten Strahlverlauf entstehenden Bänder zum Einsatz. Eine Punkt-zu-Linie-Prüfung mit tiefenabhängiger Dicke verwirft falsche Treffer. Die unvermeidbare Grenze des Verfahrens besteht darin, dass es nur Geometrie reflektieren kann, die bereits von der Hauptkamera erfasst wurde. Oberflächen außerhalb des Bildes oder auf der nicht sichtbaren Seite erscheinen daher nie. Siehe Terrain-Materialien.

Planare Spiegelreflexion. Eine an der Wasserfläche gespiegelte Hilfskamera rendert die Szene in ein Offscreen-Ziel, das vom Wasser-Shader abgetastet wird. Dadurch entstehen pixelgenaue Reflexionen einschließlich der Geometrie, die SSR nicht sehen kann. Dieses Muster wird von UE5 Water und dem three.js-WaterMesh verwendet; die Wellenverzerrung wird dabei als Versatz auf den UV-Knoten des Reflektors angewendet. Eine anhand des Farbbetrags gewichtete Himmels-Ausweichlösung löschte fälschlicherweise dunkle Reflexionspixel. Da das Reflektorziel nach dem ersten Frame zuverlässig ist, bestand die Lösung darin, diese Ausweichlogik zu entfernen.

Tiefenabhängig getönte Refraktion nach Beer-Lambert. Beide Shader tasten die Szene hinter der Oberfläche ab, rekonstruieren für jedes Hintergrundpixel die Tiefe unterhalb der Wasserlinie und wenden eine exponentielle Extinktion pro Farbkanal an – Rot verschwindet innerhalb weniger Meter, Blau bleibt erhalten. Das Ergebnis wird in Richtung einer Wassernebelfarbe zusammengesetzt, wobei eine Himmelsmaske verhindert, dass die Far Plane vernebelt wird. Der Schlick-Fresnel-Effekt mischt Refraktion bei senkrechtem Einfall mit Reflexion bei streifenden Winkeln.

Übernahme einer produktionsreifen Wasserbibliothek. threejs-water-pro enthält ein Ozean-Clipmap, einen Rayleigh-Himmel, Mehrpunkt-Auftrieb für das Stampfen und Rollen von Schiffen, Kielwasser, Schaum und einen Masken-Pass für den Schiffsrumpf. Details auf Verbraucherseite sind wichtig: Schaumtexturen müssen ausdrücklich geladen werden, die Unterwassergeometrie einer Insel sollte unter den Meeresboden reichen, damit der Boden ihre Kanten verdeckt, und Antialiasing sollte als FXAA-Post-Processing-Pass statt per MSAA ausgeführt werden, um einen dunklen Nebelsaum an Geometriekanten zu vermeiden.


Teil 26 von 29. Zurück: Teil 25 – Ein Skelett, jedes Outfit Weiter: Teil 27 – Eine Insel aus Rauschen und Boden, der wie Boden aussieht Serienleitfaden: /de/blog/2026-02-25-open-world-browser-series-guide