Ein Stein fällt ins Wasser, und ein Ring läuft nach außen, prallt am Rand ab und überlagert sich mit den anderen. Das lässt sich mit erstaunlich wenig Code nachbauen. Zwei Arrays, eine Schleife und ein Shader, der aus den Zahlen Wasser macht.
Klick und Ziehen setzen Tropfen, der Regen wirft von allein welche rein. Die drei Chips stellen die Dämpfung auf 0,97, 0,99 oder 0,999. Bei 0,97 sind die Ringe nach einem Wimpernschlag weg, bei 0,999 laufen sie noch lange über den Rand und zurück.
Drei Zeilen
Die Oberfläche ist ein Gitter aus Höhenwerten, hier 240 mal 135 Zellen. Dazu gibt es zwei Puffer: die aktuelle Höhe cur und die vom Schritt davor, prev. Die neue Höhe einer Zelle ist der Mittelwert ihrer vier Nachbarn, verdoppelt, minus die Höhe, die sie vorher hatte. Danach kommt die Dämpfung drauf:
next = (left + right + up + down) / 2 - prev; next *= damping;
Das ist die Wellengleichung in einer Form, die man sich merken kann. Ein Punkt, der höher ist als seine Nachbarn, wird nach unten gezogen und schießt über die Ruhelage hinaus, genau das macht ein Pendel auch. Im Repo steht die ausgeschriebene Variante mit einem Parameter courant2, der auf 0,5 steht, dann ist es dieselbe Formel:
const float laplace = up[x] + down[x] + mid[x - 1] + mid[x + 1] - 4.0F * mid[x]; old[x] = (2.0F * mid[x] - old[x] + courant2 * laplace) * damping;
Das Ergebnis landet direkt in prev, das braucht ohnehin niemand mehr, danach werden die beiden Puffer getauscht. Der Rand wird nie geschrieben und bleibt 0. Deshalb prallen die Wellen dort zurück.
Ein Tropfen addiert eine Cosinus-Beule auf cur. prev bleibt unberührt, das Wasser bekommt also einen Stoß statt einer sanft aufgebauten Delle, und daraus entsteht der Ring.
Simulation auf der CPU, Wasser auf der GPU
Die Simulation läuft in festen Schritten, 120 pro Sekunde, mit dem Accumulator aus Update Loop und der Klemme gegen Hänger. Warum feste Schritte, steht in Fester Zeitschritt. Für die Welle gibt es einen zweiten Grund, dazu unten mehr.
32 400 Zellen sind für die CPU nichts, und die Simulation lässt sich so ohne Grafikkarte testen. Das Bild selbst hat dagegen 518 400 Pixel mit je einem Haufen Rechnerei, das ist Shader-Arbeit. Also lädt die Demo jeden Frame das Höhenfeld als Textur hoch:
heights = Texture::create(240, 135, GL_R32F, GL_RED, GL_FLOAT, GL_NEAREST, ...); // jeden Frame heights.update(field.heights().data());
Der Fragment-Shader liest daraus die Steigung. Aus ihr werden zwei Dinge: Wo die Oberfläche geneigt ist, wird der Boden darunter an einer anderen Stelle abgetastet (Brechung), und wo sie zum Licht zeigt, glänzt sie.
vec2 slope = 0.5 * vec2(height(vUv + vec2(e.x, 0.0)) - height(vUv - vec2(e.x, 0.0)),
height(vUv + vec2(0.0, e.y)) - height(vUv - vec2(0.0, e.y)));
vec3 n = normalize(vec3(-slope * 5.0, 1.0));
vec3 color = floorColor(p + slope * 150.0);
float spec = pow(max(dot(n, normalize(light + vec3(0.0, 0.0, 1.0))), 0.0), 120.0);
Der Boden ist ein Fliesenmuster, das der Shader selbst ausrechnet, eine Bilddatei gibt es nicht. Die Fugen verraten die Wellen am besten, sie biegen sich mit.
Wo es explodiert
Die 0,5 ist die Obergrenze, bis zu der das Verfahren stabil bleibt: Eine Welle darf pro Schritt nicht weiter laufen, als das Gitter sie auflösen kann. Im Test steht das schwarz auf weiß. Mit courant2 = 0.5 bleibt die Höhe nach 80 Schritten unter 2, mit 0,6 steht sie über 1000. Der Fehler wächst mit jedem Schritt, aus einem Tropfen werden riesige Zahlen.
Daran hängen die zwei Dinge aus der Überschrift. Die Wellengeschwindigkeit steckt in der Schrittweite, bei 0,5 läuft eine Welle rund 0,7 Zellen pro Schritt. Würde man die Schrittweite mit der Frame-Zeit mitwachsen lassen, liefe das Wasser mal schnell und mal langsam, und ein langer Frame risse die Grenze von 0,5. Feste Schritte lösen beides. Die Dämpfung hat ihren eigenen Grund. Ohne sie (1,0) bleibt die Energie über 300 Schritte auf 0,1 Prozent genau erhalten, das Becken beruhigt sich also nie mehr. Mit 0,99 pro Schritt ist nach 200 Schritten gut ein Achtel der Energie übrig.
Stolperfallen
Float-Texturen linear zu filtern braucht in WebGL2 die Erweiterung OES_texture_float_linear, und die gibt es nicht überall. R32F mit GL_NEAREST geht ohne Erweiterung, also macht der Shader die bilineare Interpolation selbst, vier Abrufe pro Höhe, 16 pro Pixel.
Die Ringe sind leicht eckig. Das liegt am Gitter, das sich in den Diagonalen anders verhält als entlang der Achsen. Wer es genauer will, braucht mehr Nachbarn.
Und die Maus: Die Seite skaliert den Canvas per CSS, die Demo rechnet jedes Ereignis über das Verhältnis von Fenster- zu Pixelgröße auf 960 mal 540 zurück. Sonst liegt der Tropfen neben dem Klick.
Den Code findest du im Repo unter water-ripples-2d. Alle Demos der Serie sammelt das Projekt Game Dev Snippets.