Como lidar com o netcode?

10

Estou interessado em avaliar as diferentes maneiras pelas quais o netcode pode "se conectar" a um mecanismo de jogo. Estou projetando um jogo multiplayer agora e até agora concluí que preciso (pelo menos) de um thread separado para lidar com os soquetes de rede, distinto do restante do mecanismo que lida com o loop e o script gráficos.

Eu tinha uma maneira potencial de criar um jogo em rede inteiramente de thread único, ou seja, verificar a rede depois de renderizar cada quadro, usando soquetes sem bloqueio. No entanto, isso claramente não é ideal porque o tempo necessário para renderizar um quadro é adicionado ao atraso na rede: As mensagens que chegam pela rede devem esperar até que a renderização do quadro atual (e a lógica do jogo) seja concluída. Mas, pelo menos assim, a jogabilidade ainda permaneceria suave, mais ou menos.

Ter um encadeamento separado para rede permite que o jogo seja totalmente responsivo à rede, por exemplo, pode enviar de volta um pacote ACK instantaneamente após o recebimento de uma atualização de estado do servidor. Mas estou um pouco confuso sobre a melhor maneira de me comunicar entre o código do jogo e o código da rede. O encadeamento de rede empurrará o pacote recebido para uma fila, e o encadeamento do jogo será lido na fila no momento apropriado durante o loop, portanto, não nos livramos desse atraso de um quadro.

Além disso, parece que eu gostaria que o encadeamento que lida com o envio de pacotes fosse separado daquele que está verificando pacotes descendo pelo canal, porque não seria capaz de enviar um enquanto estiver no meio de verificando se há mensagens recebidas. Estou pensando na funcionalidade selectou similar.

Acho que minha pergunta é: qual é a melhor maneira de projetar o jogo para obter a melhor capacidade de resposta da rede? Claramente, o cliente deve enviar a entrada do usuário o mais rápido possível para o servidor, para que eu possa ter o código de envio de rede imediatamente após o loop de processamento do evento, ambos dentro do loop do jogo. Isto faz algum sentido?

Steven Lu
fonte

Respostas:

13

Ignore a capacidade de resposta. Em uma LAN, o ping é insignificante. Na internet, 60-100ms de ida e volta é uma bênção. Ore para os deuses do atraso que você não recebe picos de> 3K. Seu software teria que ser executado em um número muito baixo de atualizações / s para que isso fosse um problema. Se você disparar por 25 atualizações / s, terá um tempo máximo de 40 ms entre o recebimento de um pacote e a ação. E esse é o caso de rosca única ...

Projete seu sistema para flexibilidade e correção. Aqui está minha idéia sobre como conectar um subsistema de rede ao código do jogo: Mensagens. A solução para muitos problemas pode ser "mensagens". Acho que as mensagens curaram o câncer em ratos de laboratório uma vez. As mensagens economizam US $ 200 ou mais em meu seguro de carro. Mas, falando sério, as mensagens são provavelmente a melhor maneira de conectar qualquer subsistema ao código do jogo, mantendo ainda dois subsistemas independentes.

Use o sistema de mensagens para qualquer comunicação entre o subsistema de rede e o mecanismo de jogos e, nesse caso, entre dois subsistemas. As mensagens entre subsistemas podem ser simples como um blob de dados transmitidos pelo ponteiro usando std :: list.

Basta ter uma fila de mensagens enviadas e uma referência ao mecanismo do jogo no subsistema de rede. O jogo pode despejar as mensagens que deseja enviar para a fila de saída e enviá-las automaticamente, ou talvez quando alguma função como "flushMessages ()" for chamada. Se o mecanismo de jogo tiver uma fila de mensagens grande e compartilhada, todos os subsistemas necessários para enviar mensagens (lógica, IA, física, rede etc.) poderão despejar mensagens nele, onde o loop principal do jogo poderá ler todas as mensagens e depois aja de acordo.

Eu diria que executar os soquetes em outro segmento é bom, embora não seja obrigatório. O único problema com esse design é que ele geralmente é assíncrono (você não sabe exatamente quando os pacotes estão sendo enviados) e pode dificultar a depuração e fazer com que os problemas relacionados ao tempo apareçam / desapareçam aleatoriamente. Ainda assim, se feito corretamente, nenhum deles deve ser um problema.

De um nível superior, eu diria que a rede separada do próprio mecanismo de jogo. O mecanismo de jogo não se importa com soquetes ou buffers, se preocupa com eventos. Eventos são coisas como "O jogador X disparou um tiro" "Uma explosão no jogo T aconteceu". Eles podem ser interpretados diretamente pelo mecanismo do jogo. De onde eles são gerados (um script, a ação de um cliente, um jogador de IA etc.) não importa.

Se você tratar seu subsistema de rede como um meio de enviar / receber eventos, obterá várias vantagens em chamar apenas recv () em um soquete.

Você pode otimizar a largura de banda, por exemplo, recebendo 50 pequenas mensagens (de 1 a 32 bytes) e fazer com que o subsistema de rede as empacote em um pacote grande e envie-o. Talvez pudesse comprimi-los antes de enviar, se isso era um grande problema. Por outro lado, o código pode descompactar / descompactar o pacote grande em 50 eventos discretos novamente para o mecanismo de jogo ler. Tudo isso pode acontecer de forma transparente.

Outras coisas interessantes incluem o modo de jogo para um jogador que reutiliza o código de rede, tendo um cliente puro + um servidor puro executando na mesma máquina, comunicando-se por meio de mensagens no espaço de memória compartilhado. Então, se o seu jogo para um jogador funcionar corretamente, um cliente remoto (ou seja, verdadeiro multiplayer) também funcionará. Além disso, obriga você a considerar antecipadamente quais dados são necessários para o cliente, pois seu jogo para um jogador pareceria certo ou totalmente errado. Misture e combine, execute um servidor E seja um cliente em um jogo multiplayer - tudo funciona com a mesma facilidade.

PatrickB
fonte
Você mencionou o uso de uma simples std :: list ou algo parecido para transmitir mensagens. Esse pode ser um tópico para o StackOverflow, mas é verdade que todos os threads compartilham o mesmo espaço de endereço e, desde que eu impeça vários threads de mexer com a memória pertencente à minha fila simultaneamente, eu devo ficar bem? Posso apenas alocar dados para a fila no heap como normalmente, e apenas usar alguns mutexes nele?
Steven Lu
Sim esta correto. Um mutex que protege todas as chamadas para std :: list.
PatrickB
Obrigado por responder! Eu tenho feito muitos progressos com minhas rotinas de segmentação até agora. É uma sensação tão boa ter meu próprio mecanismo de jogo!
Steven Lu
4
Esse sentimento vai desaparecer. Os grandes latões que você ganha, no entanto, ficam com você.
ChrisE
@StevenLu Um pouco [extremamente] atrasado, mas quero ressaltar que impedir que os threads estraguem a memória simultaneamente pode ser extremamente difícil, dependendo de como você tenta fazê-lo e da eficiência que deseja ser. Você estava fazendo isso hoje, eu indicaria uma das muitas excelentes implementações de filas simultâneas de código aberto, para que você não precise reinventar uma roda complicada.
Fund Monica's Lawsuit
4

Eu preciso (no mínimo) ter um thread separado para lidar com os soquetes de rede

Não, você não.

Eu tinha uma maneira potencial de criar um jogo em rede inteiramente de thread único, ou seja, verificar a rede depois de renderizar cada quadro, usando soquetes sem bloqueio. No entanto, isso claramente não é ideal porque o tempo necessário para renderizar um quadro é adicionado ao atraso da rede:

Não importa necessariamente. Quando sua lógica é atualizada? Não adianta tirar os dados da rede se você ainda não pode fazer nada. Da mesma forma, não adianta responder se você ainda não tem nada a dizer.

por exemplo, ele pode enviar de volta um pacote ACK instantaneamente após o recebimento de uma atualização de estado do servidor.

Se o seu jogo é tão rápido que esperar o renderização do próximo quadro for um atraso significativo, enviará dados suficientes para que você não precise enviar pacotes ACK separados - inclua valores ACK nos dados normais cargas úteis, se você precisar delas.

Para a maioria dos jogos em rede, é perfeitamente possível ter um loop de jogo como este:

while 1:
    read_network_messages()
    read_local_input()
    update_world()
    send_network_updates()
    render_world()

Você pode separar as atualizações da renderização, o que é altamente recomendado, mas todo o resto pode ser simples assim, a menos que você tenha uma necessidade específica. Exatamente que tipo de jogo você está fazendo?

Kylotan
fonte
7
Desacoplar a atualização da renderização não é apenas altamente recomendado, é necessário se você deseja um mecanismo que não seja de merda.
AttackingHobo
A maioria das pessoas não está fabricando motores, e provavelmente a maioria dos jogos ainda não separa os dois. A configuração para passar um valor de tempo decorrido para a função de atualização funciona de maneira aceitável na maioria dos casos.
Kylotan
2

No entanto, isso claramente não é ideal porque o tempo necessário para renderizar um quadro é adicionado ao atraso na rede: As mensagens que chegam pela rede devem esperar até que a renderização do quadro atual (e a lógica do jogo) seja concluída.

Isso definitivamente não é verdade. A mensagem passa pela rede enquanto o receptor renderiza o quadro atual. O atraso da rede está preso a um número inteiro de quadros no lado do cliente; sim, mas se o cliente tem tão poucos FPS que isso é um grande problema, eles têm problemas maiores.

DeadMG
fonte
0

As comunicações de rede devem ser em lote. Você deve se esforçar para que um único pacote envie cada tick de jogo (que geralmente ocorre quando um quadro é renderizado, mas realmente deve ser independente).

Suas entidades de jogo conversam com o subsistema de rede (NSS). O NSS agrupa mensagens, ACKs, etc. e envia alguns (espero que um) pacotes UDP de tamanho ideal (~ 1500 bytes geralmente). O NSS emula pacotes, canais, prioridades, reenvia, etc., enquanto envia apenas pacotes UDP únicos.

Leia o gaffer no tutorial dos jogos ou use o ENet, que implementa muitas das idéias de Glenn Fiedler.

Ou você pode simplesmente usar o TCP se o seu jogo não precisar de reações de contração. Todos os problemas de lote, reenvio e ACK desaparecem. Você ainda deseja que um NSS gerencie a largura de banda e os canais, no entanto.

deft_code
fonte
0

Não ignore completamente a capacidade de resposta. Há pouco a ganhar com a adição de outra latência de 40ms a pacotes já atrasados. Se você estiver adicionando alguns quadros (a 60fps), atrasará o processamento de uma posição e atualizará outros dois quadros. É melhor aceitar pacotes rapidamente e processá-los rapidamente, para melhorar a precisão da simulação.

Consegui otimizar a largura de banda pensando nas informações mínimas de estado necessárias para representar o que é visível na tela. Depois, observe cada bit de dados e escolha um modelo para ele. As informações de posição podem ser expressas como valores delta ao longo do tempo. Você pode usar seus próprios modelos estatísticos para isso e passar horas depurando-os, ou pode usar uma biblioteca para ajudá-lo. Prefiro usar o modelo de ponto flutuante da biblioteca DataBlock_Predict_Float Torna realmente fácil otimizar a largura de banda usada para o gráfico da cena do jogo.

Justin
fonte