Portovanie hry pre viacerých hráčov z C++ na web pomocou Cheerp, WebRTC a Firebase

Úvod

naša spoločnosť Leaning Technologies poskytuje riešenia na prenos tradičných desktopových aplikácií na web. Náš kompilátor C++ veselý generuje kombináciu WebAssembly a JavaScript, ktorá poskytuje oboje jednoduchá interakcia s prehliadačoma vysoký výkon.

Ako príklad jej aplikácie sme sa rozhodli preniesť hru pre viacerých hráčov na web a vybrali sme si Teeworlds. Teeworlds je multiplayerová 2D retro hra s malou, ale aktívnou komunitou hráčov (vrátane mňa!). Je malý ako z hľadiska sťahovaných zdrojov, tak aj nárokov na CPU a GPU – ideálny kandidát.

Portovanie hry pre viacerých hráčov z C++ na web pomocou Cheerp, WebRTC a Firebase
Beží v prehliadači Teeworlds

Tento projekt sme sa rozhodli využiť na experimentovanie všeobecné riešenia pre prenos sieťového kódu na web. Zvyčajne sa to robí nasledujúcimi spôsobmi:

  • XMLHttpRequest/fetch, ak sieťovú časť tvoria iba HTTP požiadavky, príp
  • WebSockets.

Obe riešenia vyžadujú hosťovanie serverového komponentu na strane servera a ani jedno neumožňuje použitie ako transportný protokol UDP. To je dôležité pre aplikácie v reálnom čase, ako je softvér pre videokonferencie a hry, pretože to zaručuje doručenie a poradie protokolových paketov. TCP môže byť prekážkou nízkej latencie.

Existuje tretí spôsob - použite sieť z prehliadača: WebRTC.

RTCDataChannel Podporuje spoľahlivý aj nespoľahlivý prenos (v druhom prípade sa snaží použiť UDP ako transportný protokol vždy, keď je to možné), a dá sa použiť so vzdialeným serverom aj medzi prehliadačmi. To znamená, že môžeme preniesť celú aplikáciu do prehliadača, vrátane serverového komponentu!

To však prichádza s ďalšou ťažkosťou: predtým, ako môžu dvaja partneri WebRTC komunikovať, musia vykonať relatívne komplexný handshake na pripojenie, čo si vyžaduje niekoľko entít tretích strán (signalizačný server a jeden alebo viac serverov STUN/OTOČTE).

V ideálnom prípade by sme chceli vytvoriť sieťové API, ktoré interne používa WebRTC, ale je čo najbližšie k rozhraniu UDP Sockets, ktoré nepotrebuje nadviazať spojenie.

To nám umožní využívať výhody WebRTC bez toho, aby sme kódu aplikácie museli vystavovať zložité detaily (ktoré sme chceli v našom projekte meniť čo najmenej).

Minimálne WebRTC

WebRTC je sada rozhraní API dostupných v prehliadačoch, ktorá poskytuje peer-to-peer prenos zvuku, videa a ľubovoľných údajov.

Spojenie medzi partnermi je vytvorené (aj keď je NAT na jednej alebo oboch stranách) pomocou serverov STUN a/alebo TURN prostredníctvom mechanizmu nazývaného ICE. Partneri si vymieňajú informácie ICE a parametre kanálov prostredníctvom ponuky a odpovede protokolu SDP.

Wow! Koľko skratiek naraz? Stručne vysvetlíme, čo tieto pojmy znamenajú:

  • Session Traversal Utilities pre NAT (STUN) — protokol na obídenie NAT a získanie páru (IP, port) na výmenu dát priamo s hostiteľom. Ak sa mu podarí splniť svoju úlohu, rovesníci si môžu navzájom nezávisle vymieňať údaje.
  • Prechádzanie pomocou relé okolo NAT (OTOČTE) sa tiež používa na prechádzanie NAT, ale implementuje to preposielaním údajov cez proxy, ktorý je viditeľný pre oboch partnerov. Zvyšuje latenciu a je nákladnejšia na implementáciu ako STUN (pretože sa používa počas celej komunikačnej relácie), ale niekedy je to jediná možnosť.
  • Zriadenie interaktívnej konektivity (ICE) používa sa na výber najlepšej možnej metódy spojenia dvoch rovesníkov na základe informácií získaných priamym spojením rovesníkov, ako aj informácií získaných ľubovoľným počtom serverov STUN a TURN.
  • Protokol popisu relácie (SDP) je formát na popis parametrov kanála pripojenia, napríklad kandidáti ICE, multimediálne kodeky (v prípade audio/video kanála) atď... Jeden z partnerov odošle ponuku SDP a druhý odpovie odpoveďou SDP .. Potom sa vytvorí kanál.

Na vytvorenie takéhoto spojenia musia partneri zhromaždiť informácie, ktoré dostanú zo serverov STUN a TURN, a vymieňať si ich medzi sebou.

Problém je v tom, že ešte nemajú možnosť priamej komunikácie, takže na výmenu týchto údajov musí existovať mechanizmus mimo pásma: signalizačný server.

Signalizačný server môže byť veľmi jednoduchý, pretože jeho jedinou úlohou je posielať údaje medzi rovesníkmi vo fáze handshake (ako je znázornené na obrázku nižšie).

Portovanie hry pre viacerých hráčov z C++ na web pomocou Cheerp, WebRTC a Firebase
Zjednodušený sekvenčný diagram handshake pre WebRTC

Prehľad modelu siete Teeworlds

Architektúra siete Teeworlds je veľmi jednoduchá:

  • Komponenty klienta a servera sú dva rôzne programy.
  • Klienti vstupujú do hry pripojením k jednému z niekoľkých serverov, z ktorých každý hosťuje iba jednu hru naraz.
  • Všetky prenosy údajov v hre sa vykonávajú prostredníctvom servera.
  • Špeciálny hlavný server sa používa na zhromažďovanie zoznamu všetkých verejných serverov, ktoré sú zobrazené v hernom klientovi.

Vďaka použitiu WebRTC na výmenu dát dokážeme preniesť serverový komponent hry do prehliadača, kde sa nachádza klient. To nám dáva skvelú príležitosť...

Zbavte sa serverov

Nedostatok serverovej logiky má peknú výhodu: celú aplikáciu môžeme nasadiť ako statický obsah na Github Pages alebo na našom vlastnom hardvéri za Cloudflare, čím sa zabezpečí rýchle sťahovanie a vysoká dostupnosť zadarmo. V podstate na ne môžeme zabudnúť a ak budeme mať šťastie a hra sa stane populárnou, tak infraštruktúra nebude musieť byť modernizovaná.

Aby však systém fungoval, stále musíme použiť externú architektúru:

  • Jeden alebo viac serverov STUN: Na výber máme niekoľko bezplatných možností.
  • Aspoň jeden server TURN: tu nie sú žiadne bezplatné možnosti, takže si môžeme buď zriadiť vlastný alebo zaplatiť za službu. Našťastie je väčšinou možné spojenie nadviazať prostredníctvom serverov STUN (a poskytnúť skutočné p2p), ale ako záložná možnosť je potrebný TURN.
  • Signalizačný server: Na rozdiel od ostatných dvoch aspektov signalizácia nie je štandardizovaná. To, za čo bude signalizačný server skutočne zodpovedný, závisí do určitej miery od aplikácie. V našom prípade je potrebné pred nadviazaním spojenia vymeniť malé množstvo dát.
  • Teeworlds Master Server: Používajú ho iné servery na propagáciu svojej existencie a klienti na vyhľadávanie verejných serverov. Aj keď to nie je potrebné (klienti sa môžu vždy manuálne pripojiť k serveru, o ktorom vedia), bolo by pekné, aby sa hráči mohli zúčastniť hier s náhodnými ľuďmi.

Rozhodli sme sa použiť bezplatné servery STUN od spoločnosti Google a sami sme nasadili jeden server TURN.

Na posledné dva body sme využili Firebase:

  • Hlavný server Teeworlds je implementovaný veľmi jednoducho: ako zoznam objektov obsahujúcich informácie (názov, IP, mapa, režim, ...) každého aktívneho servera. Servery zverejňujú a aktualizujú svoj vlastný objekt a klienti zoberú celý zoznam a zobrazia ho prehrávaču. Zoznam zobrazujeme aj na domovskej stránke ako HTML, takže hráči môžu jednoducho kliknúť na server a dostať sa priamo do hry.
  • Signalizácia úzko súvisí s implementáciou našich zásuviek, ktorá je popísaná v ďalšej časti.

Portovanie hry pre viacerých hráčov z C++ na web pomocou Cheerp, WebRTC a Firebase
Zoznam serverov v hre a na domovskej stránke

Implementácia zásuviek

Chceme vytvoriť API, ktoré je čo najbližšie k Posix UDP Sockets, aby sa minimalizoval počet potrebných zmien.

Chceme tiež implementovať nevyhnutné minimum potrebné pre čo najjednoduchšiu výmenu dát cez sieť.

Napríklad nepotrebujeme skutočné smerovanie: všetci partneri sú na rovnakej „virtuálnej sieti LAN“ priradenej ku konkrétnej inštancii databázy Firebase.

Preto nepotrebujeme jedinečné adresy IP: jedinečné hodnoty kľúča Firebase ​​(podobne ako názvy domén) sú dostatočné na jedinečnú identifikáciu partnerov a každý partner lokálne priraďuje „falošné“ adresy IP ku každému kľúču, ktorý je potrebné preložiť. To úplne eliminuje potrebu globálneho prideľovania IP adries, čo je netriviálna úloha.

Tu je minimálne API, ktoré musíme implementovať:

// Create and destroy a socket
int socket();
int close(int fd);
// Bind a socket to a port, and publish it on Firebase
int bind(int fd, AddrInfo* addr);
// Send a packet. This lazily create a WebRTC connection to the 
// peer when necessary
int sendto(int fd, uint8_t* buf, int len, const AddrInfo* addr);
// Receive the packets destined to this socket
int recvfrom(int fd, uint8_t* buf, int len, AddrInfo* addr);
// Be notified when new packets arrived
int recvCallback(Callback cb);
// Obtain a local ip address for this peer key
uint32_t resolve(client::String* key);
// Get the peer key for this ip
String* reverseResolve(uint32_t addr);
// Get the local peer key
String* local_key();
// Initialize the library with the given Firebase database and 
// WebRTc connection options
void init(client::FirebaseConfig* fb, client::RTCConfiguration* ice);

API je jednoduché a podobné Posix Sockets API, má však niekoľko dôležitých rozdielov: zaznamenávanie spätných volaní, prideľovanie lokálnych IP adries a lenivé pripojenia.

Registrácia spätných volaní

Aj keď pôvodný program používa neblokujúce I/O, kód musí byť prerobený, aby sa dal spustiť vo webovom prehliadači.

Dôvodom je, že slučka udalostí v prehliadači je pred programom skrytá (či už je to JavaScript alebo WebAssembly).

V natívnom prostredí môžeme písať kód takto

while(running) {
  select(...); // wait for I/O events
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
}

Ak je pre nás slučka udalostí skrytá, musíme ju zmeniť na niečo takéto:

auto cb = []() { // this will be called when new data is available
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
};
recvCallback(cb); // register the callback

Lokálne pridelenie IP adresy

ID uzlov v našej „sieti“ nie sú adresy IP, ale kľúče Firebase (sú to reťazce, ktoré vyzerajú takto: -LmEC50PYZLCiCP-vqde ).

Je to pohodlné, pretože nepotrebujeme mechanizmus na prideľovanie IP adries a kontrolu ich jedinečnosti (rovnako ako ich likvidáciu po odpojení klienta), ale často je potrebné identifikovať peerov podľa číselnej hodnoty.

Presne na to slúžia funkcie. resolve и reverseResolve: Aplikácia nejakým spôsobom prijme hodnotu reťazca kľúča (prostredníctvom používateľského vstupu alebo cez hlavný server) a môže ju previesť na IP adresu pre interné použitie. Zvyšok API tiež dostáva túto hodnotu namiesto reťazca kvôli jednoduchosti.

Je to podobné ako vyhľadávanie DNS, ale vykonáva sa lokálne na klientovi.

To znamená, že IP adresy nemožno zdieľať medzi rôznymi klientmi a ak je potrebný nejaký globálny identifikátor, bude potrebné ho vygenerovať iným spôsobom.

Lenivé spojenie

UDP nepotrebuje pripojenie, ale ako sme videli, WebRTC vyžaduje zdĺhavý proces pripojenia predtým, ako môže začať prenášať údaje medzi dvoma partnermi.

Ak chceme poskytnúť rovnakú úroveň abstrakcie, (sendto/recvfrom s ľubovoľnými partnermi bez predchádzajúceho pripojenia), potom musia vykonať „lenivé“ (oneskorené) pripojenie vo vnútri API.

Toto sa deje počas bežnej komunikácie medzi „serverom“ a „klientom“ pri používaní UDP a čo by mala naša knižnica robiť:

  • Hovory servera bind()povedať operačnému systému, že chce prijímať pakety na zadanom porte.

Namiesto toho zverejníme otvorený port na Firebase pod kľúčom servera a budeme počúvať udalosti v jeho podstrome.

  • Hovory servera recvfrom(), prijíma pakety prichádzajúce z akéhokoľvek hostiteľa na tomto porte.

V našom prípade musíme skontrolovať prichádzajúcu frontu paketov odoslaných na tento port.

Každý port má svoj vlastný front a na začiatok datagramov WebRTC pridávame zdrojový a cieľový port, aby sme vedeli, do ktorého frontu sa má preposlať, keď príde nový paket.

Hovor je neblokovaný, takže ak nie sú žiadne pakety, jednoducho vrátime -1 a nastavíme errno=EWOULDBLOCK.

  • Klient prijíma IP a port servera nejakými externými prostriedkami a volaniami sendto(). Tým sa uskutoční aj interný hovor. bind(), teda následné recvfrom() dostane odpoveď bez explicitného vykonania bind.

V našom prípade klient externe dostane reťazcový kľúč a použije funkciu resolve() na získanie IP adresy.

V tomto bode spustíme nadviazanie spojenia WebRTC, ak dvaja partneri ešte nie sú navzájom prepojení. Pripojenia k rôznym portom toho istého partnera používajú rovnaký WebRTC DataChannel.

Vykonávame aj nepriame bind()aby sa server mohol znova pripojiť sendto() v prípade, že sa z nejakého dôvodu zatvorila.

Server je upozornený na pripojenie klienta, keď klient zapíše svoju ponuku SDP pod informácie o porte servera vo Firebase a server odpovie svojou odpoveďou tam.

Nižšie uvedený diagram zobrazuje príklad toku správ pre schému soketu a prenos prvej správy z klienta na server:

Portovanie hry pre viacerých hráčov z C++ na web pomocou Cheerp, WebRTC a Firebase
Kompletný diagram fázy pripojenia medzi klientom a serverom

Záver

Ak ste sa dočítali až sem, pravdepodobne vás bude zaujímať teória v praxi. Hru je možné hrať ďalej teeworlds.leaningtech.com, Skús to!


Priateľský zápas medzi kolegami

Kód sieťovej knižnice je voľne dostupný na GitHub. Zapojte sa do diskusie na našom kanáli na Gitter!

Zdroj: hab.com

Pridať komentár