Im 2D-Post hat die CPU das Wasser Schritt für Schritt simuliert, jede Zelle hat ihre Nachbarn gefragt. Für eine Fläche, über die man in 3D bis zum Horizont schaut, wird das schnell zu viel. Also wird hier nichts simuliert. Die Höhe an jedem Punkt ist eine Formel, die nur von Ort und Zeit abhängt: sechs Wellen, aufsummiert, ausgewertet im Vertex-Shader.
Ziehen dreht die Kamera, das Mausrad zoomt. Die Chips stellen den Wind auf calm, breeze oder storm, die Wellen wachsen dabei weich in die neue Stärke. Mit wireframe siehst du das Gitter darunter, mit foam schaltest du den Schaum ab. Die orange Boje liegt auf dem Wasser und kippt mit der Neigung. Ihre Position rechnet die CPU, das Wasser zeichnet die GPU, und beide haben dieselbe Formel im Kopf.
Warum Gerstner statt Sinus
Eine Summe aus Sinuswellen gibt Hügel, die oben so rund sind wie unten. Echtes Wasser hat spitze Kämme und flache Täler. Eine Gerstner-Welle schiebt deshalb jeden Punkt obendrein waagerecht in Laufrichtung. Jeder Punkt behält seine Ruheposition und läuft auf einer Ellipse, bei einer Steilheit von Q = 1 ist es ein Kreis. In der Demo liegt Q zwischen 0,72 und 1. Unter dem Kamm rücken die Punkte zusammen, im Tal werden sie auseinandergezogen.
Das Gitter hat 201 mal 201 Vertices, also 80 000 Dreiecke. Es wird einmal hochgeladen und nie wieder angefasst, in den Vertexdaten steht nur die Ruheposition (x, z). Der Rest passiert pro Frame im Shader:
float theta = k * dot(d, aRest) - uWaveB[i].x; float s = sin(theta); float c = cos(theta); p += vec3(q * a * d.x * c, a * s, q * a * d.y * c);
d ist die Laufrichtung, k die Wellenzahl (2π durch Wellenlänge), a die Amplitude, q die Steilheit. Sechs Wellen von 22 bis 3 Metern, aus verschiedenen Richtungen.
Lange Wellen laufen schneller
Im tiefen Wasser hängt die Frequenz von der Wellenzahl ab: ω = √(g·k), die Phasengeschwindigkeit ist c = ω/k. Die 22-Meter-Welle braucht 3,75 Sekunden pro Periode und läuft mit 5,9 m/s, die 3-Meter-Welle schafft 2,2 m/s. Im Test steht ein handgerechneter Fall: Bei k = 1/g ist ω genau 1.
inline float dispersion(float k) { return std::sqrt(kGravity * std::max(k, 0.0F)); }
inline double timePhase(const Wave& w, double t) {
double p = std::fmod(static_cast<double>(omega(w)) * t, kTwoPi);
if (p < 0.0) p += kTwoPi;
return p;
}
Die zweite Funktion ist ein Stolperstein. Die Zeit sammelt die CPU aus den Frame-Zeiten auf, wie im Update Loop, mit einer Klemme bei 0,25 Sekunden gegen Hänger, und Pause zählt nichts dazu. Zum Shader geht ω·t, auf eine Umdrehung reduziert und in double gerechnet. Ein Float hat bei ω·t um 100 000 (mit ω = 3 sind das gut neun Stunden) nur noch 0,008 Radiant Auflösung, die Wellen würden zittern.
Normalen aus den Ableitungen
Für die Normale könnte man Differenzen aus den Nachbarpunkten bilden. Die Ableitungen nach x und z sind aber so einfach wie die Formel selbst und fallen im selben Schleifendurchlauf ab:
float ka = k * a; float sharp = q * ka; tx += vec3(-sharp * d.x * d.x * s, ka * d.x * c, -sharp * d.x * d.y * s); tz += vec3(-sharp * d.x * d.y * s, ka * d.y * c, -sharp * d.y * d.y * s); // nach der Schleife vec3 n = cross(tz, tx); vJacobian = n.y;
Das y des unnormierten Kreuzprodukts ist die Jacobi-Determinante der waagerechten Verschiebung. Auf glattem Wasser ist sie 1, unter dem Kamm fällt sie auf 1 − Q·k·A, im Tal steigt sie auf 1 + Q·k·A. Wo die Fläche zusammengedrückt wird, gehört der Schaum hin: 1.0 - smoothstep(0.30, 0.80, vJacobian), dazu Rauschen in Ruhekoordinaten, damit der Schaum mit dem Wasser mitwandert. Der Test vergleicht die analytische Normale an 900 Zufallspunkten mit zentralen Differenzen, die Abweichung bleibt unter 0,004.
Die Farbe kommt aus Fresnel nach Schlick: Wer flach auf das Wasser schaut, sieht den Himmel gespiegelt, wer steil draufschaut, das Teal darunter.
Die Steilheitsgrenze
Wählt man Q zu groß, klappt die Fläche über sich selbst. Als Kriterium dient S = Q·k·A: Solange die Summe über alle Wellen höchstens 1 ist, wird die Jacobi-Determinante nicht negativ. Der Test zeigt es an einer einzelnen Welle. Bei S = 0,9 läuft die verschobene x-Position streng aufwärts, bei S = 1,5 läuft sie stellenweise rückwärts, und die Normale zeigt am Kamm nach unten.
Die Grenze ist großzügig. Mit sechs Wellen aus verschiedenen Richtungen fällt J im Sturm (S = 0,96) in meinem Test-Scan nur bis 0,26. Bei doppelter Steilheit (S = 1,91) klappt die Fläche tatsächlich um, bei 1,53 lag J noch bei 0,002. Die Summe steht oben links in der Demo.
Eine Boje, die mitschwimmt
Die Boje ankert an einem festen Weltpunkt, die Formel arbeitet aber in Ruhekoordinaten, und die Punkte wandern waagerecht, im Sturm um bis zu 1,7 Meter. Ein einfaches displace(x, z) liefert also die Höhe an der falschen Stelle. Man muss die Verschiebung umkehren, und dafür reicht das Newton-Verfahren auf der 2×2-Abbildung. Deren Matrix steht schon da, es sind die Tangenten von eben:
for (int i = 0; i < iterations; ++i) {
const Frame f = frame(waves, r.x, r.z, t);
const float ex = f.position.x - worldX;
const float ez = f.position.z - worldZ;
if (std::fabs(ex) < 1e-6F && std::fabs(ez) < 1e-6F) break;
if (std::fabs(f.jacobian) < 1e-3F) break;
r.x -= (f.tangentZ.z * ex - f.tangentZ.x * ez) / f.jacobian;
r.z -= (f.tangentX.x * ez - f.tangentX.z * ex) / f.jacobian;
}
Am gefundenen Punkt liefert dieselbe Funktion Position und Normale, die Normale dreht die Boje. Der Test prüft an 300 Punkten und drei Windstärken, dass die verschobene Position höchstens 2 Millimeter vom Ankerplatz abweicht. Ein Schiff würde man genauso behandeln und an mehreren Punkten abtasten.
Vertex gegen Fragment
Der Shader rechnet pro Vertex sechsmal sin und cos, bei 40 401 Vertices. Im Fragment-Shader wären es bei 960×540 Pixeln gut zwölfmal so viele Auswertungen. Dafür sind die Normalen so fein wie das Gitter, also 0,5 Meter. Die 3-Meter-Welle hat sechs Vertices pro Wellenlänge, das ist die Untergrenze.
Weitere Stolperfallen
Wellen verdecken Wellen, also braucht die Szene einen Tiefenpuffer. Der Fensterhelfer der Serie fragt keinen an, hier legt die Demo das Fenster selbst an, mit 24 Bit Tiefe und vierfachem MSAA. Die Kamera hält mindestens 3 Meter Höhe, weil die Sturmkämme 2,6 Meter erreichen. Die Uniform-Arrays im Shader haben feste Größe, acht Wellen sind das Maximum.
Den Code findest du im Repo unter water-waves-3d. Alle Demos der Serie sammelt das Projekt Game Dev Snippets.