Ein 3D-Segelspiel mit Multiplayer in reinem three.js
Segelspiel mit Multiplayer in reinem three.js: Gerstner-Wellen auf GPU und CPU gleich berechnet, Tag-Nacht-Wechsel in fünf Minuten, WebSocket-Relay.
Balázs Csorba··8 Min. Lesezeit
- three.js
- WebGL
- GLSL
- WebSockets
- Game development

Das Wichtigste in Kürze
- Der Ozean besteht aus sechs Gerstner-Wellen; dieselben Parameter gehen an den Vertex-Shader und an eine CPU-Funktion, damit Schiffe auf der sichtbaren Welle schwimmen.
- Himmel, Wasserspiegelung und Nebel rufen dieselbe GLSL-Funktion skyColor(dir) auf – dadurch war der Tag-Nacht-Wechsel fast gratis.
- Zwei Kurven aus der Sonnenhöhe (dayAmount, duskAmount) mischen jede Farbe, jedes Licht und den Nebel, in JS wie in GLSL.
- Der Multiplayer läuft über ein abhängigkeitsfreies WebSocket-Relay mit rund 280 Zeilen; der am längsten verbundene Browser ist Host und rechnet die Spiellogik.
- Adaptive Pixel-Ratio, instanzierte Meshes und Pausieren außerhalb des Bildschirms halten es auf normalen Laptops flüssig.
In meinem Portfolio steckt ein Spiel. Dragon Voyage ist ein kleines Segelspiel: Du steuerst eine Dschunke mit Drachenbug durch einen laternenbeleuchteten Hafen, direkt auf der Seite. Es gibt fünf Quests (Laternen entzünden, durch die Tore rasen, Schiffbrüchige retten, eine Piratenflotte schlagen, das Flaggschiff versenken), Breitseiten-Gefechte, Handel zwischen zwei Häfen und einen Tag-Nacht-Wechsel in fünf Minuten. Alle, die die Seite gerade offen haben, segeln im selben Hafen.
Eine Game-Engine gibt es nicht: nur three.js und rund 9.700 Zeilen TypeScript, GLSL und Vue. Das sind die Teile, die mir beim Bauen am meisten Spaß gemacht haben.
Wellen, auf denen das Schiff wirklich schwimmt
Die meisten Ozean-Demos in three.js bewegen das Wasser im Vertex-Shader, und das war's. Ein Spiel braucht mehr: Das Schiff muss auf der Welle liegen, die man sieht, mit ihr stampfen und ins Wellental rollen.
Der Ozean ist die Summe aus sechs Gerstner-Wellen. Jede ist durch Wellenlänge, Steilheit und eine Richtungsabweichung vom Wind definiert, ihre Geschwindigkeit ergibt sich aus der Tiefwasser-Dispersion (c = √(g/k)). Dieses Array ist die einzige Quelle der Wahrheit:
const SPEC: [number, number, number][] = [
// wavelength (m), steepness, direction offset (rad)
[46, 0.085, 0.0],
[27, 0.1, 0.38],
[15.5, 0.12, -0.52],
// …three shorter waves
]Dieselben Zahlen gehen als Uniforms an den Vertex-Shader und an eine CPU-Funktion, die Höhe und Normale an jedem Punkt liefert. Das Schiff fragt sie jeden Frame unter dem Rumpf ab. Genauso die Piratenschiffe, die Boote der anderen Spieler, die Flöße der Schiffbrüchigen und die Einschläge der Kanonenkugeln.
Ein Haken: Gerstner-Wellen verschieben Punkte auch seitlich, der Oberflächenpunkt über (x, z) kommt also nicht von (x, z). Ein paar Fixpunkt-Iterationen gleichen diese Verschiebung aus, bevor die Höhe gelesen wird. Das kostet fast nichts und verhindert, dass das Schiff sichtbar über die Wellen rutscht.

Eine Himmelsfunktion für alles
Die Himmelskuppel, die Spiegelung im Wasser und der Nebel auf jedem Mesh rufen dieselbe GLSL-Funktion auf, skyColor(dir). Sie wird als String-Baustein in jeden Shader eingefügt, beim Nebel über onBeforeCompile auch in die eingebauten Materialien von three.js.
Das ist die nützlichste Entscheidung im ganzen Renderer:
- Spiegelungen passen immer zum Himmel darüber. Es gibt keine Cube-Map, die synchron gehalten werden muss.
- Ferne Berge verblassen in der Himmelsfarbe dahinter, nicht in einem flachen Nebelgrau.
- Ändert sich der Himmel, folgt alles mit – deshalb war der Tag-Nacht-Wechsel fast geschenkt.
Ein Tag in fünf Minuten
Der Hafen war um eine einzige Abenddämmerung herum gebaut. Daraus einen vollen Zyklus zu machen hieß: Die Sonne muss sich bewegen, und alles, was „Dämmerung“ voraussetzte, muss stattdessen die Sonne lesen.
Sonne und Mond drehen sich gemeinsam um eine Achse, die 35° gegen den Horizont geneigt ist. Die Mittagssonne steht deshalb hoch im Westen, vor der Stadt statt dahinter. Die erste Version hat den Hafen den ganzen Tag im Gegenlicht gezeigt, und die Stadt sah aus wie Pappe.
Zwei Werte, abgeleitet aus der Sonnenhöhe, steuern alles:
export const dayAmount = (sunY: number) => smoothstep(-0.06, 0.3, sunY)
export const duskAmount = (sunY: number) => Math.exp(-(((sunY + 0.02) / 0.13) ** 2))Jede Farbe hat drei Werte (Nacht, Tag und Dämmerung) und mischt sich über dayAmount von Nacht zu Tag, dann über duskAmount Richtung Dämmerung. Dieselben Kurven gibt es in GLSL. So stimmen Himmel, Wasser, Hemisphärenlicht, Tone-Mapping-Belichtung, Nebeldichte und sogar die Tönung der Dunst-Sprites überein.


Knifflig war das bildbasierte Licht. Die Environment-Map für die PBR-Materialien wird mit PMREMGenerator aus dem Himmel gebacken. Früher einmal beim Start, mit wandernder Sonne jetzt alle paar Sekunden Himmelszeit neu. Das vorherige Render-Target wird jedes Mal freigegeben, sonst wächst der GPU-Speicher stetig.
Multiplayer mit einem Relay von rund 280 Zeilen
Der Multiplayer sollte billig zu hosten und langweilig im Betrieb sein. Der Server ist ein WebSocket-Relay ohne Abhängigkeiten: RFC-6455-Framing auf node:http, hinter nginx. Vom Spiel weiß er fast nichts. Er:
- führt eine Liste von bis zu 24 Spielern;
- wählt einen Host: den Spieler, der am längsten verbunden ist;
- leitet Schiffszustände und Kanonenschüsse zwischen den Spielern weiter;
- reicht Spielaktionen an den Host durch;
- speichert den letzten Welt-Snapshot des Hosts, damit Neuankömmlinge – oder ein neu gewählter Host – bei der laufenden Quest einsteigen können.
Der Browser des Hosts führt die maßgebliche Spiellogik aus: Quests, Piraten, wer welchen Schiffbrüchigen gerettet hat. Geht der Host, übernimmt der Nächste aus dem gespeicherten Snapshot. Das ist kein kompetitiver Shooter, einem Client zu vertrauen ist also in Ordnung – und dafür kostet der Server fast nichts.

60 fps auf dem Laptop
Das Spiel läuft auf einem Portfolio, also muss schon der erste Besuch flüssig sein, egal auf welchem Gerät:
- Adaptive Auflösung: Der Renderer beobachtet die durchschnittliche Frame-Zeit. Liegt sie über etwa 21 ms, sinkt das Pixel-Verhältnis um eine Stufe, unter etwa 14 ms steigt es wieder. Die meisten Laptops pendeln sich auf ein scharfes Bild ein, ohne dass jemand eine Einstellung anfasst.
- Instanced Meshes für Wiederholtes: Kisten und Pfosten am Markt und jede fliegende Kanonenkugel.
- Gerendert wird erst, wenn das Spiel sichtbar ist, und die Schleife pausiert, wenn der Tab verborgen ist.
Die Kleinigkeiten, die es fertig wirken lassen
- Eingabe: Tastatur, Touch-Steuerung am Handy und Gamepads.
- Reduzierte Bewegung wird respektiert: kein Kamerawackeln, kein Vibrieren von Controller oder Handy.
- Automatisches Speichern: Der Fortschritt landet alle paar Sekunden und beim Verlassen der Seite im
localStorage. Nach einem Neuladen heißt es „Fahrt fortsetzen – Quest 3 von 5“. - Sprachen: Englisch, Deutsch und Ungarisch, wie die restliche Website.

Ausprobieren
Leinen los auf balazscsorba.com/de/game. Ist gerade jemand anderes auf der Seite, siehst du sein Schiff. Und wer wissen will, woran ich sonst arbeite: Ich habe auch über die Skills geschrieben, mit denen meine Coding-Agenten vom Bug-Report bis zum Pull Request kommen. Für die praktische Seite gibt es außerdem eine Home-Assistant-App, die das E-Auto in die günstigsten Stunden lädt.
Quellen
Häufige Fragen
Wie lässt man ein Schiff auf einem three.js-Ozean-Shader schwimmen?
Die Wellenparameter in einem Array halten und zweimal verwenden: als Shader-Uniforms für die sichtbare Oberfläche und in einer CPU-Funktion, die Höhe und Normale an jedem Punkt liefert. Diese Funktion wird jeden Frame unter dem Rumpf abgefragt. Bei Gerstner-Wellen vorher mit ein paar Iterationen die seitliche Verschiebung ausgleichen.
Was sind Gerstner-Wellen?
Gerstner-Wellen (Trochoidenwellen) bewegen Oberflächenpunkte auf Kreisbahnen statt nur auf und ab. Das ergibt spitze Kämme und flache Täler. Eine Summe weniger Wellen mit verschiedenen Wellenlängen, Steilheiten und Richtungen ist ein günstiger, überzeugender Ozean für Echtzeitgrafik.
Braucht ein kleines Multiplayer-Browserspiel einen Spieleserver?
Keinen vollständigen. Ein kleines WebSocket-Relay kann die Spielerliste führen, einen Browser zum Host bestimmen, Nachrichten weiterleiten und den letzten Weltzustand des Hosts zwischenspeichern. Der Host rechnet die Spiellogik – gut für ein entspanntes Koop-Spiel, nicht für ein kompetitives, bei dem man Clients nicht trauen darf.
