Blog/Web-Engineering

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.

··8 Min. Lesezeit

  • three.js
  • WebGL
  • GLSL
  • WebSockets
  • Game development
Segelspiel mit three.js: Zwei gleich verlaufende Wellenschienen übereinander, darüber ein halb geteilter Kreis.

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.

Diesen Artikel anhören

0:00

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:

TypeScript
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.

Die Dschunke von hinten auf tiefblauer, bewegter See zu Mittag, grüne Hügel auf beiden Seiten und weiße Wolken.
Mittag: Der Rumpf reitet auf denselben sechs Wellen, die der Shader zeichnet.

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:

TypeScript
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.

Die Hafenstadt zu Mittag: Reihen beleuchteter Häuser, eine Pagode und Kiefernhügel hinter einer sonnigen Bucht.
Harbour Town zu Mittag, von vorne beleuchtet.
Dieselbe Hafenstadt bei Nacht: beleuchtete Fenster und Laternen spiegeln sich im dunklen Wasser.
Dieselbe Stelle bei Nacht: Laternen und Fenster übernehmen.

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.

Die Dschunke bei Nacht auf dunkler See, Laternen leuchten am Mast, voraus steigen Lichtsäulen aus dem Wasser.
Nacht in der Bucht: Die Mastlaternen erhellen das Wasser um den Rumpf.

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.
Die Dschunke im Morgengrauen auf ruhiger, graublauer See unter blassem Himmel.
Morgengrauen, etwa in der Mitte des Fünf-Minuten-Tages.

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

  1. GPU Gems, Chapter 1: Effective Water Simulation from Physical Models
  2. RFC 6455: The WebSocket Protocol

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.

Klingt nach dem, was Sie suchen?

Erzählen Sie mir von Ihrem Projekt oder Ihrer Stelle – ich freue mich, von Ihnen zu hören.