1 pontos por GN⁺ 2024-11-24 | Ainda não há comentários. | Compartilhar no WhatsApp
  • 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.

Ainda não há comentários.