- Em um ambiente de FPS rápido, informações de estado que chegam tarde têm pouco valor, então Quake 3 opta por um design centrado em UDP/IP para reduzir a latência
- O NetChannel abstrai a comunicação sobre UDP, que pode ter perda de pacotes, e o servidor recalcula apenas as diferenças de estado necessárias com registro de snapshots por cliente
- O servidor usa juntos o Master Gamestate, os 32 gamestates mais recentes e um dummy gamestate para criar atualizações completas e atualizações delta com o mesmo procedimento
- Se não houver ACK do cliente, o servidor compara o último snapshot confirmado com o estado atual e coloca mudanças perdidas e mudanças novas na mesma mensagem
- Mesmo sem introspecção embutida em C, ele encontra diferenças entre campos com
netField_t e macros, e o NetChannel usa segmentação prévia em 1400 bytes para evitar fragmentação em roteadores
Modelo de rede baseado em UDP/IP
- O modelo de rede de Quake 3 é considerado uma das partes mais elegantes da engine e, em nível mais baixo, abstrai a comunicação com o módulo NetChannel, que apareceu pela primeira vez em Quake World
- Em jogos rápidos, a informação perdida na primeira transmissão logo se torna informação desatualizada, então costuma ser melhor enviar o estado mais recente do que reenviar o antigo
- Por isso, a engine não tem nenhum vestígio de TCP/IP, pois considera difícil aceitar a latência criada pela transmissão confiável
- Duas camadas mutuamente exclusivas foram adicionadas à pilha de rede
- Criptografia com chave pré-compartilhada
- Compressão com chave Huffman pré-calculada
- O servidor reduz o tamanho dos datagramas UDP e, ao mesmo tempo, compensa a falta de confiabilidade
- Gera pacotes delta com registro de snapshots
- Encontra e envia apenas os campos alterados com uma abordagem de introspecção de memória
Papéis do servidor e do cliente
- O fluxo no lado do cliente é simples
- Envia comandos ao servidor a cada frame
- Recebe atualizações de gamestate do servidor
- O servidor precisa propagar o Master Gamestate para cada cliente, levando em conta até mesmo pacotes UDP perdidos
- O mecanismo central é composto por três elementos
- Master Gamestate: o estado geral do jogo considerado verdadeiro; os comandos do cliente entram pelo NetChannel, são convertidos em
event_t e então modificam o estado do jogo no servidor
- 32 gamestates recentes por cliente: os estados enviados pela rede são armazenados em um buffer circular e chamados de snapshots
- dummy gamestate: um estado com todos os campos em 0, usado como base para gerar deltas quando não existe estado anterior
- Com esses três elementos, o servidor monta a mensagem de atualização que será passada ao NetChannel
- Como é preciso manter muitos gamestates por cliente, o uso de memória cresce bastante
- Como referência de medição, usa-se 8 MB para 4 jogadores
Como snapshots criam atualizações completas e parciais
- O exemplo considera o envio de uma atualização para o Client1, usando um estado do Client2 composto por quatro campos:
pos[X], pos[Y], pos[Z] e health
- A comunicação ocorre via UDP/IP e, na internet, mensagens podem se perder com frequência
-
Primeiro frame do servidor
- O servidor aplica ao Master Gamestate as atualizações recebidas de todos os clientes e então propaga o estado para o Client1
- O módulo de rede segue sempre o mesmo procedimento
- Copia o Master Gamestate para o próximo slot no histórico do cliente
- Compara o snapshot copiado com outro snapshot
- Na primeira atualização, não há snapshot válido no histórico do Client1, então ele compara com o dummy snapshot
- Como todos os campos do dummy snapshot são 0, o resultado é uma atualização completa
- Antes de cada campo é colocado um marcador de bit indicando se houve mudança
- No exemplo, a atualização completa usa 132 bits
- O formato é
[1 A_on32bits 1 B_on32bits 1 B_on32bits 1 C_on32bits]
-
Segundo frame do servidor
- No frame seguinte, o Client2 se move no eixo Y e o valor de
pos[1] passa a ser E
- O Client1 confirmou o recebimento da atualização anterior com ACK, então o Snapshot1 fica em estado ACK
- O servidor copia o Master Gamestate para o próximo slot do histórico, criando o Snapshot2, e o compara com o Snapshot1 válido
- Como resultado, apenas a mudança
pos[1] = E é transmitida pela rede
- Como cada campo recebe um marcador de bit, essa atualização parcial usa 36 bits
- O formato é
[0 1 32bitsNewValue 0 0]
-
Terceiro frame do servidor
- No frame seguinte, o Client2 perde vida e
health = H
- O Client1 não envia ACK da última atualização
- O pacote UDP do servidor pode ter se perdido, ou o ACK do cliente pode ter se perdido
- Em qualquer caso, esse snapshot não pode ser usado
- O servidor copia o Master Gamestate para o próximo slot, criando o Snapshot3, e o compara com o último Snapshot1 confirmado por ACK
- A mensagem transmitida é uma atualização parcial, mas inclui tanto a mudança anterior
pos[1] = E quanto a nova mudança health = H
- Se o Snapshot1 estiver velho demais para ser usado, a engine volta a usar o dummy snapshot como base e envia uma atualização completa
Como o mesmo procedimento compensa perdas
- A simplicidade do sistema de snapshots está em o mesmo algoritmo lidar automaticamente com duas tarefas
- Gerar atualização completa ou parcial
- Reenviar em uma só mensagem informações anteriores não recebidas e informações novas
- Em vez de tratar a perda de pacotes UDP com um fluxo separado e complexo, ele compensa isso calculando a diferença entre o último snapshot confirmado por ACK e o Master Gamestate atual
- Quando não há estado anterior ou ele não pode mais ser usado, a recuperação acontece enviando o estado completo com base no dummy snapshot
Como encontrar diferenças entre campos em C
- Quake 3 não tem introspecção na linguagem C, mas a posição de cada campo é previamente definida com um array
netField_t e diretivas do pré-processador
netField_t contém o nome do campo, o offset e o número de bits
- A macro
NETF(x) usa o operador de stringização e o cálculo de offset sobre entityState_t para permitir escrever as informações dos campos de forma curta
- A estrutura de exemplo é a seguinte
typedef struct { char *name; int offset; int bits; } netField_t;
// using the stringizing operator to save typing...
#define NETF(x) #x,(int)&((entityState_t*)0)->x
netField_t entityStateFields[] = {
{ NETF(pos.trTime), 32 },
{ NETF(pos.trBase[0]), 0 },
{ NETF(pos.trBase[1]), 0 },
...
}
- A implementação completa está em parte de MSG_WriteDeltaEntity
- Quake 3 não interpreta o significado do que está sendo comparado; ele apenas percorre o índice, o offset e o tamanho definidos em
entityStateFields e transmite as diferenças pela rede
Por que dividir antecipadamente em 1400 bytes
- O módulo NetChannel divide mensagens em blocos de 1400 bytes, embora o tamanho máximo de um datagrama UDP seja 65507 bytes
- O código relacionado está em Netchan_Transmit
- Como a maioria das redes usa MTU de 1500 bytes, a divisão em 1400 bytes foi escolhida para evitar que roteadores precisem fragmentar pacotes ao longo do caminho na internet
- Há dois motivos para evitar fragmentação em roteadores
- Ao entrar na rede, o roteador precisa segurar o pacote enquanto o fragmenta
- Ao sair da rede, é preciso esperar todos os fragmentos do datagrama e então fazer uma remontagem custosa
Mensagens que precisam chegar obrigatoriamente
- O sistema de snapshots compensa datagramas UDP perdidos na rede, mas algumas mensagens e comandos precisam necessariamente ser entregues
- Isso inclui casos como o jogador sair do jogo ou o servidor exigir que o cliente carregue um novo nível
- Essa garantia é abstraída pelo NetChannel
Leituras relacionadas
Ainda não há comentários.