Sturm voraus ist eine kleine Echtzeit-3D-Szene auf unserer Website: ein hölzernes Segelschiff in schwerer Dünung unter schwarzem Himmel, Regen, der schräg vor der Kamera vorbeipeitscht, Haie und eine Seeschlange, die kreisen, und ein Armbrustschütze am Bug, mit dem du per Linksklick zielen und schießen kannst. Es läuft im Browser mit three.js (r160) und ohne vorgefertigte Modelle oder Texturen – alles entsteht im Code.
Dieser Artikel erklärt, wie der Ozean und die Bewegung des Schiffs funktionieren, mit Ausschnitten aus dem echten Quellcode. Kurz gesagt: Eine einzige, billige Höhenfunktion ist die einzige Quelle der Wahrheit für das Meer, und alles, was das Wasser berührt – das Mesh, das Schiff, die Haie, die Spritzer, die Armbrustbolzen –, fragt genau diese Funktion, wo die Oberfläche ist.
Eine Höhenfunktion für das ganze Meer
Echte Ozean-Renderer nutzen oft FFT-Spektren oder Gerstner-Wellen. Wir verwenden etwas viel Einfacheres – eine Summe aus fünf Sinuswellen, die in verschiedene Richtungen und mit unterschiedlicher Geschwindigkeit laufen:
function waveH(x, z, t) {
return Math.sin(x * 0.055 + t * 0.9) * 1.55
+ Math.sin(z * 0.042 - t * 0.7 + 1.7) * 1.25
+ Math.sin((x + z) * 0.03 + t * 0.55) * 1.85
+ Math.sin(x * 0.14 - z * 0.09 + t * 1.6) * 0.42
+ Math.sin((x * 0.75 - z) * 0.11 + t * 2.1) * 0.22;
}
Die ersten drei Terme sind die Dünung: lange Wellenlängen, große Amplituden (1,25–1,85 Einheiten) und langsame Geschwindigkeiten, entlang x, entlang z und diagonal. Die letzten beiden sind kurzer, schneller Kabbelsee mit kleinen Amplituden obendrauf. Weil die Richtungen verschieden sind, fügen sich die Spitzen nie zu einem offensichtlich sich wiederholenden Raster, und die Oberfläche wirkt unregelmäßig, obwohl sie vollständig deterministisch ist.
Warum nichts physikalisch Korrekteres? Weil diese Funktion überall und jederzeit auf der CPU aufgerufen werden kann und für dasselbe (x, z, t) immer dieselbe Antwort liefert. Diese Eigenschaft zählt für die Szene am Ende weit mehr als spektrale Genauigkeit: Nur so stimmen Schiff, Haie und Pfeile mit dem Wasser überein, das du siehst.
Das Mesh bewegen und Normalen berechnen
Der Ozean ist eine einzige PlaneGeometry von 520 × 520 Einheiten mit 112 × 112 Segmenten – 113 × 113 = 12.769 Vertices –, flach hingelegt. In jedem Frame schreiben wir neue Höhen hinein und berechnen die Normalen selbst:
function updateOcean(t) {
const p = oceanGeo.attributes.position, n = oceanGeo.attributes.normal;
const e = 0.6;
for (let i = 0; i < p.count; i++) {
const x = p.getX(i), z = p.getZ(i);
const h = waveH(x, z, t);
p.setY(i, h);
const hx = waveH(x + e, z, t), hz = waveH(x, z + e, t);
let nx = -(hx - h) / e, nz = -(hz - h) / e;
const inv = 1 / Math.hypot(nx, 1, nz);
n.setXYZ(i, nx * inv, inv, nz * inv);
}
p.needsUpdate = true;
n.needsUpdate = true;
}
Die Normale entsteht aus finiten Differenzen. Für ein Höhenfeld y = h(x, z) ist die Flächennormale proportional zu (−∂h/∂x, 1, −∂h/∂z). Wir schätzen jede Steigung, indem wir die Höhe 0,6 Einheiten weiter entlang x und entlang z abtasten, und normieren dann. Das ist billiger als computeVertexNormals() (kein Durchlaufen von Dreiecken nötig) und ergibt eine glatte Beleuchtung, die nicht von der Mesh-Auflösung abhängt.
Ist es Verschwendung, ~12.800 Vertices in jedem Frame auf der CPU zu aktualisieren? Ein bisschen – es sind drei Höhenauswertungen pro Vertex. Dieselbe Mathematik in einem Vertex-Shader wäre schneller, aber dann würde die Form des Meeres sowohl in GLSL als auch in JavaScript leben, und beide Kopien müssten identisch gehalten werden. Bei dieser Mesh-Größe ist die CPU-Version locker schnell genug, und eine einzige Implementierung war uns mehr wert als die gesparten Millisekunden.
Gischt auf den Wellenkämmen mit onBeforeCompile
Das Wasser verwendet ein normales MeshStandardMaterial (dunkles Petrol, Rauheit 0,38, ein wenig Metalness), beleuchtet von einer aus dem Himmel erzeugten Environment-Map, sodass es Spiegelungen gratis bekommt. Die Gischt kommt hinzu, indem wir den eingebauten Shader mit onBeforeCompile patchen, statt ein eigenes Material zu schreiben. Der Vertex-Shader reicht die Höhe an den Fragment-Shader weiter, der die Farbe nahe der Wellenkämme aufhellt:
float crest = smoothstep(1.9, 3.7, vH);
float streak = 0.7 + 0.3 * sin(vWp.x * 0.35 + vWp.z * 0.5 + uTime * 0.8)
* sin(vWp.x * 0.13 - vWp.z * 0.21 - uTime * 0.5);
diffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.87, 0.93, 0.95), crest * streak * 0.92);
crest ist 0 unterhalb einer Höhe von 1,9 und 1 oberhalb von 3,7, mit einer weichen Rampe dazwischen – nur die höchsten Spitzen schäumen also. streak bricht die Gischt in treibende Flecken auf, damit sie nicht wie eine gleichmäßige weiße Kappe aussieht. Weil wir #include <color_fragment> patchen, behalten wir die komplette Beleuchtung, Schatten, Nebel und das Tone Mapping von three.js.
Das Schiff auf den Wellen reiten lassen
Das Schiff wird nicht physikalisch simuliert. Es gibt keinen Starrkörper, keine Auftriebskraft, keine Masse. Stattdessen tasten wir das Meer unter dem Rumpf ab und richten das Schiff danach aus. Fünf Abtastpunkte genügen: einer in der Mitte, je einer Richtung Bug und Heck (±10 entlang des Rumpfs) und je einer nach Backbord und Steuerbord (±4 quer zur Breite).
const hC = waveH(0, 0, t);
const hBow = waveH(10, 0, t), hStern = waveH(-10, 0, t);
const hPort = waveH(0, -4, t), hStar = waveH(0, 4, t);
const k = 1 - Math.exp(-dt * 2.1);
shipState.y += (hC * 0.85 - shipState.y) * k;
shipState.rz += (Math.atan2(hBow - hStern, 20) * 0.85 - shipState.rz) * k;
shipState.rx += (Math.atan2(hPort - hStar, 8) * 0.8 - shipState.rx) * k;
- Heben (auf und ab) folgt der Höhe in der Mitte.
- Stampfen (Bug hoch oder runter) ist der Winkel der Linie vom Heck- zum Bugpunkt:
atan2(Höhenunterschied, 20), weil die Punkte 20 Einheiten auseinanderliegen. - Rollen (seitlich) ist dieselbe Idee quer über die 8 Einheiten Breite.
Zwei Details verkaufen das Gewicht des Schiffs. Erstens wird jedes Ziel auf 80–85 % herunterskaliert, sodass der Rumpf nie ganz der vollen Neigung des Wassers folgt – ein schweres Schiff macht nicht jede Welle perfekt mit. Zweitens springt das Schiff nicht zu seinem Ziel, sondern nähert sich ihm mit k = 1 − e−2.1·dt an. Diese exponentielle Glättung verhält sich bei 30, 60 oder 144 Bildern pro Sekunde gleich (ein simples lerp(a, b, 0.05) pro Frame täte das nicht) und erzeugt eine natürliche Verzögerung: Das Schiff ist der Welle immer ein wenig hinterher, als hätte es Trägheit.
Weil der Rumpf eine Gruppe ist, erben Masten, Segel, Reling, Fässer, Laterne und Armbrustschütze diese Bewegung automatisch.
Bugspritzer und alles andere, was das Wasser berührt
Sobald die Lage des Schiffs bekannt ist, können wir erkennen, wann der Bug in eine Welle kracht. Wir schätzen die Welthöhe des Bugs aus Heben und Stampfen, vergleichen sie mit der Meereshöhe direkt davor, und liegt das Wasser mehr als 0,35 Einheiten höher, erzeugen wir ein großes Spritzer-Sprite – höchstens einmal alle 0,9 Sekunden, damit es nicht ununterbrochen sprüht:
const bowWorldY = shipState.y + Math.sin(shipState.rz) * 12;
if (t > bowSplashCd && waveH(12, 0, t) > bowWorldY + 0.35) {
spawnFrom(splashes, _v1.set(13.5, waveH(13.5, 0, t) + 0.5, (Math.random() - 0.5) * 3), 4.5);
bowSplashCd = t + 0.9;
}
Die Spritzer stammen aus einem kleinen Objektpool von 26 Sprites, die wachsen, aufsteigen und verblassen, sodass während des Spiels nichts neu alloziert wird. Derselbe Aufruf von waveH steuert alles andere im Wasser:
- Haie umkreisen das Schiff in einer Tiefe, die an die lokale Wellenhöhe gekoppelt ist, und alle 8–15 Sekunden springt jeder von ihnen entlang eines Halbsinusbogens – mit Spritzern beim Verlassen und Wiedereintauchen.
- Die Seeschlange besteht aus zehn Kugeln entlang eines Kreises; die Höhe jedes Segments ist die Wellenhöhe plus ein wandernder Buckel, sodass ihr Rücken durch die Dünung zu rollen scheint, und Spritzer entstehen, wenn der Kopf die Oberfläche durchbricht.
- Armbrustbolzen fliegen unter einfacher Schwerkraft und gelten in dem Moment als im Wasser, in dem ihre Höhe unter
waveHan ihrer Position fällt.
Das Zielen der Armbrust folgt derselben Philosophie „einfach und vorhersehbar“. Sie berechnet die Flugzeit zum Ziel (Entfernung / Geschwindigkeit), hält bei einem bewegten Ziel um 80 % seiner Geschwindigkeit mal dieser Zeit vor und hebt den Zielpunkt um ½·g·t² an, um den Fall auszugleichen. Das ist keine exakte ballistische Lösung, aber auf diese Entfernungen trifft es dort, wo du es erwartest.
Ein Rumpf aus einer Box, Segel aus einer Ebene
Der Rumpf beginnt als unterteilte BoxGeometry und wird Vertex für Vertex in Schiffsform gebogen. Über die Länge verjüngt sich die Breite mit einer Potenzkurve – stark am Bug, sanfter am Heck. Eine „Sprung“-Kurve hebt beide Enden an, sodass das Deck zu Bug und Heck hin nach oben schwingt, und der Boden ist schmaler als die Oberseite. Das Deck ist eine dünnere Box, die von derselben Funktion gebogen wird, damit sie genau passt.
Die Segel sind unterteilte Ebenen, die mit sin(πx)·sin(πy)0.7 nach außen gedrückt werden – eine weiche Wölbung, die an den Rändern null und in der Mitte am vollsten ist. Ein winziger Vertex-Shader-Patch addiert zwei Sinuswellen über die Zeit für ein sanftes Flattern, und die rote Flagge am Masttopp nutzt eine schnellere, stärkere Version desselben Tricks, deren Amplitude zum freien Ende der Flagge hin wächst. Alle Texturen – Rumpfplanken, Deck, Segeltuch, Wolken, Spritzer, Regen – werden beim Start in Canvases gezeichnet.
Den Sturm verkaufen: Himmel, Nebel, Regen, Blitze, Grading
Der Ozean wirkt nur wegen seiner Umgebung stürmisch:
- Himmel. Das
Sky-Objekt von three.js mit hoher Trübung und tiefer Sonne ergibt eine diesige, bedeckte Kuppel. Eine daraus erzeugte PMREM-Environment-Map beleuchtet Wasser und Schiff. - Nebel. Exponentieller Nebel in Graublau verschluckt den Horizont, versteckt den Rand der 520 Einheiten großen Ozeanebene und lässt die Szene abgeschlossen wirken.
- Regen. 800 Punkte mit Schlierentextur und additivem Blending fallen mit leicht unterschiedlichen Geschwindigkeiten und schräg zur Seite – die Positionen werden in jedem Frame in einem einzigen typisierten Array aktualisiert.
- Wolken. Sprites treiben langsam auf einem Ring mindestens 130 Einheiten vom Zentrum entfernt, damit sie nie zwischen Kamera und Schiff geraten.
- Blitze. Ein gerichtetes Licht, normalerweise mit Intensität null. Gelegentlich blitzt es an einer zufälligen Position am Himmel voll auf, kann noch einmal aufflackern, solange es hell ist, und klingt exponentiell ab (
flash *= 0.03dt). Die Blitzwahrscheinlichkeit wird pro Frame gewürfelt, sodass Blitze bei hohen Bildraten etwas häufiger sind – eine Vereinfachung, mit der wir gut leben können. - Eine warme Laterne. Ein Punktlicht am Heck flackert mit zwei summierten Sinuswellen. Es ist das einzige warme Licht in der Szene und gibt dem Auge einen Ruhepunkt.
- Post-Processing. ACES-Filmic-Tone-Mapping bei niedriger Belichtung, ein dezenter Bloom und ein eigener Grading-Pass, der eine S-Kurve für Kontrast hinzufügt, Schatten stahlblau und Lichter leicht warm tönt und die Ecken mit einer Vignette abdunkelt.
Leichtgewichtig bleiben
Die Szene läuft in einer normalen Webseite, also muss sie sich benehmen:
- Das Device-Pixel-Ratio ist auf 1,75 begrenzt – hochauflösende Handys müssen nicht mit 3× rendern.
- Die Frame-Zeit ist auf 50 ms begrenzt, damit ein hängender Tab das Schiff beim Fortsetzen nicht ruckartig springen lässt.
- Bolzen, Spritzer und Funken stammen aus festen Pools; während des Spiels wird nichts erzeugt.
- Das Rendern stoppt, wenn die Szene aus dem Bild scrollt oder der Tab verborgen ist, und der langsame automatische Kameraschwenk ist für Besucher ausgeschaltet, die reduzierte Bewegung bevorzugen.
Das Wichtigste in Kürze
- Eine Summe aus wenigen Sinuswellen ist ein völlig brauchbarer stilisierter Ozean, wenn du Richtungen, Wellenlängen und Geschwindigkeiten abwechslungsreich wählst.
- Mach die Höhenfunktion zur einzigen Quelle der Wahrheit. Wenn alles, was das Wasser berührt, dieselbe Funktion abtastet, stimmt alles überein.
- Für ein schwimmendes Schiff brauchst du keine Physik. Taste Bug, Heck, Backbord und Steuerbord ab, leite Heben, Stampfen und Rollen ab, dämpfe sie und skaliere sie leicht herunter.
- Nutze
1 − e−rate·dtfür bildratenunabhängige Glättung. - Atmosphäre erledigt die halbe Arbeit: Nebel, Regen, ein Lichtblitz und ein Color Grading machen aus einer blauen Ebene einen Sturm.
Die Szene läuft live unter Sturm voraus – dreh die Kamera, ziel mit der Maus und schick ein paar Bolzen auf die Haie. Unser kommendes Survival-Spiel Island Escape endet ebenfalls mit einem Wagnis auf offener See, falls du deine Ozeane gern mit etwas auf dem Spiel magst.