Blog

Többjátékos 3D vitorlázós játék tiszta three.js-szel

Gerstner-hullámok, amelyeket a GPU és a CPU ugyanúgy számol, egyetlen égbolt-függvény az égre, a vízre és a ködre, ötperces nap-éj ciklus és egy apró WebSocket-relé – a portfólióm játéka mögötti technika.

Balázs Csorba··8 perc olvasás

  • three.js
  • WebGL
  • GLSL
  • WebSockets
  • Game development
Sárkányorrú dzsunka világító lampionokkal vitorlázik egy hullámzó öbölben alkonyatkor, két oldalon erdős dombok.

A portfóliómban van egy játék. A Dragon Voyage egy kis vitorlázós játék: egy sárkányorrú dzsunkát kormányzol egy lampionokkal kivilágított kikötőben, közvetlenül az oldalon. Öt küldetés van (lampionok meggyújtása, verseny a kapukon át, hajótöröttek mentése, egy kalózflotta legyőzése, a zászlóshajó elsüllyesztése), oldalsortüzes csaták, kereskedelem két kikötő között és egy ötperces nap-éj ciklus. Aki épp nyitva tartja az oldalt, mind ugyanabban a kikötőben vitorlázik.

Játékmotor nincs: csak three.js és nagyjából 9700 sor TypeScript, GLSL és Vue. Ezeket a részeket volt a legérdekesebb megépíteni.

Hullámok, amelyeken a hajó tényleg úszik

A legtöbb three.js-es óceándemó a vertex shaderben mozgatja a vizet, és ennyi. Egy játéknak ennél több kell: a hajónak azon a hullámon kell ülnie, amit látsz, bólintania kell vele, és be kell dőlnie a hullámvölgybe.

Az óceán hat Gerstner-hullám összege. Mindegyiket egy hullámhossz, egy meredekség és a széliránytól való eltérés határozza meg, a sebessége pedig a mélyvízi diszperzióból adódik (c = √(g/k)). Ez a tömb az egyetlen igazságforrás:

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
]

Ugyanezek a számok uniformként a vertex shaderbe és egy CPU-s függvénybe is bekerülnek, amely bármely pontban megadja a magasságot és a normálist. A hajó minden képkockában lekérdezi a hajótest alatt. Ugyanígy a kalózhajók, a többi játékos hajója, a hajótöröttek tutajai és az ágyúgolyók csobbanásai.

Egy csapda: a Gerstner-hullámok oldalirányban is elmozdítják a pontokat, így a (x, z) feletti felszíni pont nem a (x, z)-ből indult. Néhány fixpontos iteráció kiegyenlíti ezt az elcsúszást, mielőtt kiolvassuk a magasságot. Ez olcsó, és ettől nem csúszkál láthatóan a hajó a hullámokon.

A dzsunka hátulról, mélykék, hullámzó tengeren délben, két oldalon zöld dombok és fehér felhők.
Dél: a hajótest ugyanazon a hat hullámon lovagol, amit a shader rajzol.

Egyetlen égbolt-függvény mindenhez

Az égbolt kupolája, a víz tükröződése és a köd minden mesh-en ugyanazt a GLSL-függvényt hívja: skyColor(dir). Szövegdarabként kerül be minden shaderbe, a ködnél onBeforeCompile segítségével a three.js beépített anyagaiba is.

Ez a legtöbbet érő döntés az egész rendererben:

  • A tükröződés mindig illik a fölötte lévő éghez. Nincs cube map, amit szinkronban kellene tartani.
  • A távoli hegyek a mögöttük lévő ég színébe olvadnak, nem egy lapos ködszürkébe.
  • Ha az ég változik, minden követi, ezért lett a nap-éj ciklus szinte ingyen.

Egy ötperces nap

A kikötő egyetlen alkonyat köré épült. A teljes ciklushoz a napnak mozognia kellett, és mindennek, ami „alkonyatra” épített, a nap állását kellett figyelnie helyette.

A nap és a hold együtt forog egy tengely körül, amely 35°-kal dől a horizonthoz képest, így a déli nap magasan a nyugati égen áll: a város előtt, nem mögötte. Az első változatban a kikötő egész nap ellenfényben volt, és a város úgy nézett ki, mint egy kartondíszlet.

A nap magasságából számolt két érték vezérel mindent:

export const dayAmount = (sunY: number) => smoothstep(-0.06, 0.3, sunY)
export const duskAmount = (sunY: number) => Math.exp(-(((sunY + 0.02) / 0.13) ** 2))

Minden színnek három értéke van (éjszaka, nappal és alkonyat): a dayAmount szerint keveredik éjszakából nappalba, aztán a duskAmount szerint az alkonyat felé. Ugyanezek a görbék GLSL-ben is megvannak, így az ég, a víz, a félgömb fény, a tone mapping expozíciója, a köd sűrűsége és még a párafelhők árnyalata is egyezik.

A kikötőváros délben: kivilágított házsorok, egy pagoda és fenyves dombok egy napsütötte öböl mögött.
Harbour Town délben, szemből megvilágítva.
Ugyanez a kikötőváros éjjel: kivilágított ablakok és lampionok tükröződnek a sötét vízen.
Ugyanaz a hely éjjel: a lampionok és az ablakok veszik át a fényt.

A képalapú megvilágítás volt a trükkös rész. A PBR-anyagok környezeti térképe PMREMGenerator-ral sül ki az égből. Ez korábban egyszer, induláskor történt, a mozgó nappal viszont most az égi időben mérve pár másodpercenként újra. Az előző render targetet minden alkalommal fel kell szabadítani, különben folyamatosan nő a GPU-memória.

Többjátékos mód egy kb. 280 soros relével

A többjátékos módnak olcsón hosztolhatónak és unalmasan üzemeltethetőnek kellett lennie. A szerver egy függőségek nélküli WebSocket-relé: RFC 6455 keretezés a node:http fölött, nginx mögött. A játékról szinte semmit sem tud. Amit csinál:

  • nyilvántart legfeljebb 24 játékost;
  • hostot választ: azt a játékost, aki a legrégebben csatlakozott;
  • továbbítja a hajók állapotát és az ágyúlövéseket a játékosok között;
  • a játékműveleteket a hosthoz küldi;
  • eltárolja a host legutóbbi világ-pillanatképét, hogy az újonnan érkezők – vagy egy újonnan választott host – bekapcsolódhassanak a futó küldetésbe.

A host böngészője futtatja a mérvadó játéklogikát: küldetések, kalózok, ki melyik hajótöröttet mentette ki. Ha a host kilép, a következő leghosszabb ideje csatlakozott játékos veszi át a tárolt pillanatképből. Ez nem egy kompetitív lövöldözős játék, úgyhogy rendben van megbízni egy kliensben – cserébe a szerver szinte semmibe sem kerül.

A dzsunka éjjel, sötét tengeren, lampionok világítanak az árbocon, előttük fényoszlopok emelkednek a vízből.
Éjszaka az öbölben: az árboc lampionjai megvilágítják a vizet a hajótest körül.

60 fps egy laptopon

Egy portfólión fut, ezért már az első látogatásnak is gördülékenynek kell lennie, bármin nézi a látogató:

  • Adaptív felbontás: a renderer figyeli az átlagos képkockaidőt. Ha kb. 21 ms fölé megy, a pixelarány egy lépcsőt csökken, kb. 14 ms alatt visszanő. A legtöbb laptop magától beáll egy éles képre, senkinek sem kell beállításokhoz nyúlnia.
  • Instanced meshek az ismétlődő dolgokhoz: a piac ládái és cölöpei, és minden repülő ágyúgolyó.
  • Semmi sem renderelődik, amíg a játék nem látszik, és a ciklus szünetel, ha a fül a háttérben van.

Az apróságok, amelyektől késznek érződik

  • Irányítás: billentyűzet, érintéses vezérlés telefonon és gamepad.
  • Csökkentett mozgás: a beállítást tiszteletben tartja: nincs kamerarázás, sem kontroller- vagy telefonrezgés.
  • Automatikus mentés: a haladás pár másodpercenként és az oldal elhagyásakor a localStorage-ba kerül. Újratöltés után az „Út folytatása – 3. küldetés az 5-ből” gomb vár.
  • Nyelvek: angol, német és magyar, mint az oldal többi része.
A dzsunka hajnalban, nyugodt, szürkéskék tengeren, halvány ég alatt.
Hajnal, nagyjából az ötperces nap felénél.

Próbáld ki

Vitorlát bonts a balazscsorba.com/hu/game oldalon. Ha más is épp az oldalon van, látni fogod a hajóját. Ha pedig kíváncsi vagy a munkám másik felére, írtam arról is, milyen skillekkel jutnak el a kódoló ügynökeim a hibabejelentéstől a pull requestig.

Pont erre van szükséged?

Írj a projektedről vagy a pozícióról – szívesen hallok felőled.