Portando un xogo multixogador de C++ á web con Cheerp, WebRTC e Firebase

Introdución

a nosa empresa Tecnoloxías inclinadas ofrece solucións para portar aplicacións de escritorio tradicionais á web. O noso compilador C++ animar xera unha combinación de WebAssembly e JavaScript, que proporciona ambos interacción sinxela do navegador, e alto rendemento.

Como exemplo da súa aplicación, decidimos portar un xogo multixogador á web e escollemos Mundos teew. Teeworlds é un xogo retro multixogador en 2D cunha pequena pero activa comunidade de xogadores (incluíndo eu!). É pequeno tanto en canto a recursos descargados como en requisitos de CPU e GPU, un candidato ideal.

Portando un xogo multixogador de C++ á web con Cheerp, WebRTC e Firebase
Executando no navegador Teeworlds

Decidimos utilizar este proxecto para experimentar solucións xerais para portar código de rede á web. Isto adoita facerse das seguintes formas:

  • XMLHttpRequest/fetch, se a parte da rede consiste só en solicitudes HTTP, ou
  • sockets web.

Ambas as solucións requiren hospedar un compoñente de servidor no lado do servidor e ningunha permite o seu uso como protocolo de transporte UDP. Isto é importante para aplicacións en tempo real, como software de videoconferencia e xogos, porque garante a entrega e a orde dos paquetes de protocolo. TCP pode converterse nun obstáculo para a baixa latencia.

Hai unha terceira forma: usa a rede desde o navegador: WebRTC.

RTCDataChannel Admite transmisións fiables e pouco fiables (neste último caso tenta utilizar UDP como protocolo de transporte sempre que sexa posible), e pódese utilizar tanto cun servidor remoto como entre navegadores. Isto significa que podemos portar toda a aplicación ao navegador, incluído o compoñente do servidor.

Non obstante, isto vén cunha dificultade adicional: antes de que dous pares de WebRTC poidan comunicarse, necesitan realizar un apretón de contactos relativamente complexo para conectarse, o que require varias entidades de terceiros (un servidor de sinalización e un ou máis servidores). ABORDAR/XIRAR).

Idealmente, gustaríanos crear unha API de rede que use WebRTC internamente, pero que se aproxime o máis posible a unha interface de sockets UDP que non precisa establecer unha conexión.

Isto permitiranos aproveitar WebRTC sen ter que expoñer detalles complexos ao código da aplicación (que queriamos cambiar o menos posible no noso proxecto).

WebRTC mínimo

WebRTC é un conxunto de API dispoñibles nos navegadores que proporciona transmisión punto a punto de audio, vídeo e datos arbitrarios.

A conexión entre pares establécese (aínda que haxa NAT nun ou dous lados) mediante servidores STUN e/ou TURN mediante un mecanismo chamado ICE. Os compañeiros intercambian información ICE e parámetros da canle a través da oferta e resposta do protocolo SDP.

Vaia! Cantas abreviaturas á vez? Imos explicar brevemente o que significan estes termos:

  • Utilidades de Session Traversal para NAT (ABORDAR) — un protocolo para evitar NAT e obter un par (IP, porto) para intercambiar datos directamente co host. Se consegue completar a súa tarefa, os compañeiros poden intercambiar datos de forma independente entre eles.
  • Travesía mediante relés arredor de NAT (XIRAR) tamén se usa para o cruzamento NAT, pero implementa isto reenviando datos a través dun proxy que é visible para ambos os pares. Engade latencia e é máis caro de implementar que STUN (porque se aplica durante toda a sesión de comunicación), pero ás veces é a única opción.
  • Establecemento de conectividade interactiva (ICE) utilízase para seleccionar o mellor método posible de conectar dous pares en función da información obtida ao conectarse directamente, así como da información recibida por calquera número de servidores STUN e TURN.
  • Protocolo de descrición da sesión (RDS) é un formato para describir os parámetros da canle de conexión, por exemplo, candidatos ICE, códecs multimedia (no caso dunha canle de audio/vídeo), etc... Un dos compañeiros envía unha Oferta SDP, e o segundo responde cunha Resposta SDP. . . Despois diso, créase unha canle.

Para crear esa conexión, os compañeiros deben recoller a información que reciben dos servidores STUN e TURN e intercambiala entre eles.

O problema é que aínda non teñen a capacidade de comunicarse directamente, polo que debe existir un mecanismo fóra de banda para intercambiar estes datos: un servidor de sinalización.

Un servidor de sinalización pode ser moi sinxelo porque o seu único traballo é reenviar datos entre pares na fase de apretón de mans (como se mostra no diagrama a continuación).

Portando un xogo multixogador de C++ á web con Cheerp, WebRTC e Firebase
Diagrama de secuencia de apretón de mans WebRTC simplificado

Visión xeral do modelo de rede de Teeworlds

A arquitectura de rede de Teeworlds é moi sinxela:

  • Os compoñentes do cliente e do servidor son dous programas diferentes.
  • Os clientes entran no xogo conectándose a un dos varios servidores, cada un dos cales só alberga un xogo á vez.
  • Toda a transferencia de datos do xogo realízase a través do servidor.
  • Utilízase un servidor mestre especial para recoller unha lista de todos os servidores públicos que se amosan no cliente do xogo.

Grazas ao uso de WebRTC para o intercambio de datos, podemos transferir o compoñente servidor do xogo ao navegador onde se atopa o cliente. Isto dános unha gran oportunidade...

Desfacerse dos servidores

A falta de lóxica do servidor ten unha boa vantaxe: podemos implementar toda a aplicación como contido estático en Github Pages ou no noso propio hardware detrás de Cloudflare, garantindo así descargas rápidas e un alto tempo de actividade de balde. De feito, podemos esquecernos deles, e se temos sorte e o xogo faise popular, a infraestrutura non terá que ser modernizada.

Non obstante, para que o sistema funcione, aínda temos que usar unha arquitectura externa:

  • Un ou máis servidores STUN: temos varias opcións gratuítas para escoller.
  • Polo menos un servidor TURN: non hai opcións gratuítas aquí, polo que podemos configurar o noso propio ou pagar polo servizo. Afortunadamente, a maioría das veces a conexión pódese establecer a través dos servidores STUN (e proporcionar p2p verdadeiro), pero TURN é necesario como opción alternativa.
  • Servidor de sinalización: A diferenza dos outros dous aspectos, a sinalización non está estandarizada. O que o servidor de sinalización será realmente responsable depende algo da aplicación. No noso caso, antes de establecer unha conexión, é necesario intercambiar unha pequena cantidade de datos.
  • Teeworlds Master Server: é usado por outros servidores para anunciar a súa existencia e polos clientes para atopar servidores públicos. Aínda que non é obrigatorio (os clientes sempre poden conectarse a un servidor que coñecen manualmente), sería bo ter para que os xogadores poidan participar en xogos con persoas aleatorias.

Decidimos utilizar os servidores STUN gratuítos de Google e implantamos un servidor TURN.

Para os dous últimos puntos utilizamos Base de lume:

  • O servidor mestre de Teeworlds implícase de xeito moi sinxelo: como unha lista de obxectos que contén información (nome, IP, mapa, modo,...) de cada servidor activo. Os servidores publican e actualizan o seu propio obxecto, e os clientes toman a lista completa e móstralla ao xogador. Tamén mostramos a lista na páxina de inicio como HTML para que os xogadores poidan simplemente facer clic no servidor e ser levados directamente ao xogo.
  • A sinalización está moi relacionada coa implementación dos nosos sockets, descrita na seguinte sección.

Portando un xogo multixogador de C++ á web con Cheerp, WebRTC e Firebase
Lista de servidores dentro do xogo e na páxina de inicio

Implantación de enchufes

Queremos crear unha API que estea o máis próxima posible aos Sockets UDP de Posix para minimizar o número de cambios necesarios.

Tamén queremos implementar o mínimo necesario para o intercambio de datos máis sinxelo a través da rede.

Por exemplo, non necesitamos un enrutamento real: todos os pares están na mesma "LAN virtual" asociada a unha instancia de base de datos Firebase específica.

Polo tanto, non necesitamos enderezos IP únicos: os valores únicos das claves de Firebase (similares aos nomes de dominio) son suficientes para identificar de forma única aos pares e cada igual asigna localmente enderezos IP "falsos" a cada clave que hai que traducir. Isto elimina completamente a necesidade de asignación de enderezos IP global, que é unha tarefa non trivial.

Aquí está a API mínima que necesitamos implementar:

// 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);

A API é sinxela e similar á API de Posix Sockets, pero ten algunhas diferenzas importantes: rexistrando devolucións de chamada, asignando IPs locais e conexións perezosas.

Rexistro de devolucións de chamada

Aínda que o programa orixinal utilice E/S sen bloqueo, o código debe ser refactorizado para executalo nun navegador web.

O motivo é que o bucle de eventos no navegador está oculto do programa (xa sexa JavaScript ou WebAssembly).

No entorno nativo podemos escribir código así

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;
    ...
  }
  ...
}

Se o bucle de eventos está oculto para nós, necesitamos convertelo en algo así:

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

Asignación de IP local

Os ID dos nodos da nosa "rede" non son enderezos IP, senón claves de Firebase (son cadeas que se ven así: -LmEC50PYZLCiCP-vqde ).

Isto é conveniente porque non necesitamos un mecanismo para asignar IPs e comprobar a súa unicidade (así como eliminalos despois de que o cliente se desconecte), pero moitas veces é necesario identificar os pares por un valor numérico.

Para iso serven exactamente as funcións. resolve и reverseResolve: A aplicación recibe dalgún xeito o valor de cadea da clave (a través da entrada do usuario ou a través do servidor mestre) e pode convertelo nun enderezo IP para uso interno. O resto da API tamén recibe este valor en lugar dunha cadea para simplificar.

Isto é semellante á busca de DNS, pero realízase localmente no cliente.

É dicir, os enderezos IP non se poden compartir entre distintos clientes, e se se precisa algún tipo de identificador global, terá que xerarse doutro xeito.

Conexión preguiceira

UDP non precisa de conexión, pero como vimos, WebRTC require un longo proceso de conexión antes de que poida comezar a transferir datos entre dous pares.

Se queremos proporcionar o mesmo nivel de abstracción, (sendto/recvfrom con pares arbitrarios sen conexión previa), entón deben realizar unha conexión "perezosa" (atrasada) dentro da API.

Isto é o que ocorre durante a comunicación normal entre o "servidor" e o "cliente" cando se usa UDP, e o que debería facer a nosa biblioteca:

  • Chamadas do servidor bind()para indicarlle ao sistema operativo que quere recibir paquetes no porto especificado.

Pola contra, publicaremos un porto aberto para Firebase baixo a clave do servidor e escoitaremos eventos na súa subárbore.

  • Chamadas do servidor recvfrom(), aceptando paquetes procedentes de calquera host neste porto.

No noso caso, necesitamos comprobar a cola entrante de paquetes enviados a este porto.

Cada porto ten a súa propia cola, e engadimos os portos de orixe e destino ao comezo dos datagramas WebRTC para que saibamos a que cola reenviar cando chegue un novo paquete.

A chamada é sen bloqueo, polo que se non hai paquetes, simplemente devolvemos -1 e configuramos errno=EWOULDBLOCK.

  • O cliente recibe a IP e o porto do servidor por algún medio externo, e chama sendto(). Isto tamén fai unha chamada interna. bind(), polo tanto posterior recvfrom() recibirá a resposta sen executar de forma explícita bind.

No noso caso, o cliente recibe externamente a chave de cadea e usa a función resolve() para obter un enderezo IP.

Neste punto, iniciamos un apretón de mans WebRTC se os dous pares aínda non están conectados entre si. As conexións a diferentes portos do mesmo par usan o mesmo WebRTC DataChannel.

Tamén realizamos indirectos bind()para que o servidor poida volver conectarse no seguinte sendto() por se pechou por algún motivo.

O servidor recibe unha notificación da conexión do cliente cando o cliente escribe a súa oferta SDP baixo a información do porto do servidor en Firebase e o servidor responde coa súa resposta alí.

O diagrama a continuación mostra un exemplo de fluxo de mensaxes para un esquema de socket e a transmisión da primeira mensaxe do cliente ao servidor:

Portando un xogo multixogador de C++ á web con Cheerp, WebRTC e Firebase
Diagrama completo da fase de conexión entre cliente e servidor

Conclusión

Se liches ata aquí, probablemente esteas interesado en ver a teoría en acción. O xogo pódese xogar teeworlds.leaningtech.com, próbao!


Partido amistoso entre compañeiros

O código da biblioteca da rede está dispoñible gratuitamente en Github. Únete á conversa na nosa canle en Gitter!

Fonte: www.habr.com

Engadir un comentario