Jogos multijogador. Parte 3: Interpolação e Compensação de Lag

> April 30, 2021 |  Categorias:  Multiplayer   Multijogador  

Nos artigos anteriores, vimos como empregar a arquitetura cliente-servidor para impedir que cheaters atrapalhem a vida de jogadores honestos. Também estudamos maneiras de lidar com as imperfeições da rede, utilizando da predição no cliente e da reconciliação com o servidor para fazer com que a movimentação do jogador seja fluída e mantenha-se sincronizada com o estado de jogo observado pelos demais participantes.

Até agora, os assuntos abordados apresentam soluções apenas para problemas que assolam a movimentação do personagem controlado diretamente pelo jogador. Neste artigo, exploraremos técnicas que permitem a visualização da intenção dos personagens controlados por outros jogadores.

Taxa de atualização

Se você joga Counter-strike ou Valorant há algum tempo e acompanha os artigos publicados pelas desenvolvedoras desses jogos, já deve ter ouvido falar do termo tick rate. O tick rate é a taxa de atualização do servidor, medida em Hertz. Ou seja, é o número de mensagens que o servidor envia a seus jogadores por segundo. O conteúdo dessas mensagens, como vimos anteriormente, é o estado de jogo contendo a posição de todos participantes de uma partida.

O cliente, por sua vez, também possui uma taxa de atualização. Nesse caso, a taxa de atualização refere-se a quantidade de vezes por segundo com que o programa colhe os comandos do jogador e os envia ao servidor.

É notável que o servidor processa um volume muito maior de informações que os clientes. O cliente apenas calcula a posição do personagem controlado por seu usuário e desenha as informações recebidas na tela do jogador. O servidor, no entanto, recebe comandos de todos os participantes, faz testes para verificar se os dados recebidos são válidos, calcula os efeitos destes comandos e envia os resultados de volta para todos. Isso se traduz em um maior gasto de tempo, energia e largura de banda por parte do servidor.

Para tornar esse processo mais eficiente e impedir a sobrecarga do servidor, é comum que a taxa de atualização do cliente e servidor sejam diferentes. Para que o jogador sinta-se imerso no jogo, seus comandos devem ser lidos pelo cliente a uma alta velocidade, criando a impressão de que sua movimentação acontece de maneira fluída, sem interrupções. Uma taxa comum para a atualização do cliente é 60 Hertz, ou 60 vezes por segundo. No servidor, as coisas são bem mais lentas – títulos como Overwatch, por exemplo, empregam um tick rate de apenas 20hz, o equivalente a uma atualização a cada 50 milissegundos, transmitindo informações a uma velocidade 3 vezes menor que a dos jogadores.

Na prática, isso significa que o servidor recebe constantemente atualizações dos participantes. Essas informações são então armazenadas, sem ser processadas, em filas organizadas por ordem de chegada. Passado algum tempo, digamos os 50 milissegundos do exemplo acima, o servidor pega todos esses dados, faz os cálculos necessários e envia os resultados aos participantes.

Isso, no entanto, cria uma situação desagradável para os jogadores. Empregando a técnica de predição explicada no artigo anterior juntamente com a alta taxa de atualização utilizada localmente, o usuário não vê problemas em sua movimentação – seus comandos possuem uma resposta rápida e fluída. Em contrapartida, no movimento dos aliados e inimigos observamos o contrário.

Na perspectiva do cliente, os demais participantes só se movem quando as atualizações do servidor chegam. Numa situação hipotética onde o tempo de viagem das mensagens entre servidor e cliente é 50 milissegundos, ainda teríamos que somar a esse tempo a taxa de atualização do servidor. Se a taxa de atualização do servidor for de 20hz, chegaríamos a um intervalo de 100 milissegundos entre as atualizações. Ou seja, nesta situação o jogador observa seus aliados e inimigos movendo-se em espasmos: uma atualização chega; os demais participantes são movidos a novas coordenadas; tudo fica parado por 100 milissegundos; uma nova atualização chega e os participantes são movidos, e assim por diante.

Interpolação de entidades

Como tornar o movimento dos demais participantes tão fluídos quanto o do jogador local? A primeira coisa que devemos fazer é dar ao cliente um tempo para respirar: ao invés de aplicar cegamente atualizações do servidor conforme elas chegam, vamos guardá-las em fila e anotar o horário em que chegaram. Feito isso, o cliente terá em seu poder uma lista de coordenadas de determinado participante, organizadas em função do tempo.

Com esse conhecimento, o cliente pode evitar os supracitados espasmos de movimento. Em vez de manter um inimigo parado no intervalo entre as atualizações do servidor, o cliente tenta “adivinhar” o que aconteceu nesse espaço de tempo. O cliente deve desenhar na tela uma posição estimada com base em duas das posições guardadas em seu histórico, criando um movimento fluído entre as coordenadas recebidas do servidor. Ou seja, o programa está preenchendo as lacunas existentes na informação e criando uma apresentação inteligível ao jogador.

Para calcular essa estimativa, utilizaremos de uma técnica, nascida na ciência da estatística, chamada interpolação linear. Em sua forma mais simples, essa técnica pode ser expressa pela seguinte fórmula:

A + (B – A) * T

onde A e B são as duas coordenadas escolhidas para realização da estimativa. A variável T é o fator de interpolação. Esse fator varia de 0 a 1. Quando seu valor é 0, essa fórmula toda vai resultar no valor A. Quando T for igual a 1, o resultado será B. Na maior parte do tempo, procuraremos por um valor intermediário, algo que possa preencher as supracitadas lacunas.

Mas como assim preencher as lacunas? Bem, a parte visual de um jogo, assim como um filme ou animação, é desenhada na tela do usuário quadro a quadro. Cada atualização do servidor pode ser considerada um quadro. Mas, como já sabemos, só temos disponíveis em nosso histórico quadros demasiadamente espaçados uns dos outros, então faremos com que o programa crie quadros intermediários para o jogador utilizando a fórmula da interpolação, substituindo os espaços vazios entre cada informação recebida.

Observe que, ao guardarmos as atualizações vindas do servidor em um histórico ao invés de as aplicarmos imediatamente, cria-se um atraso na percepção do jogador. Isso é proposital. E esse atraso deve ser sempre mantido em um intervalo fixo de tempo. O valor desse atraso varia de acordo com o tipo de jogo, ficando aberto a experimentação por parte do desenvolvedor. A título de exemplo, o famoso Counter-Strike introduz um atraso no valor de 100 milissegundos em seus sistemas.

Desta maneira, o cliente não mais aplica as atualizações imediatamente. Ele as guarda até que tenha um amplo estoque de informações. Usando a coordenada mais velha deste estoque, isto é, aquela que já está guardada a tanto tempo quanto o intervalo de atraso escolhido pelo desenvolvedor (a variável A em nossa fórmula), juntamente com a que chegou depois dessa (a variável B), o cliente sintetiza novos quadros usando a interpolação. Cada quadro vai possuir um valor diferente de T.

Num primeiro momento, T será igual a 0. O jogo desenha na tela a atualização do servidor da mesma maneira como ela chegou. No próximo quadro, a fórmula nos dá um valor um pouco maior, digamos 0,25, colocando o personagem um pouco mais próximo de B. No próximo quadro, 0,5, e assim por diante, até finalmente chegarmos em B. Quando chegamos em B, a informação contida em A é descartada. B torna-se o novo A e o ciclo se repete.

Por fim, o fator de interpolação T é calculado da seguinte maneira:

([Horário de agora - Atraso] – Hora de chegada de A) / (Horário de chegada de B – Horário de chegada de A)

Empregando está técnica o jogador não vê o ambiente em tempo real e sim no passado. Vale a pena mencionar que esse atraso nada tem a ver com a demora da chegada de informações proveniente da qualidade da rede e da taxa de atualização do servidor, trata-se de um artifício utilizado pelo programador para viabilizar a interpolação das entidades desenhadas na tela do usuário.

Como todas as outras técnicas descritas nessa série de artigos, a interpolação de entidades tem seus pontos positivos e negativos. O lado bom é que o jogador agora vê seus amigos e inimigos movimentando-se de maneira natural, não mais em espasmos ocorridos em intervalos de tempo ocasionados pelas condições de rede e processamento. O lado ruim é que esse atraso introduzido no sistema cria uma situação já conhecida por alguns jogadores de Counter-strike: você acabou de se esconder de um inimigo. Ele não consegue te ver e você não consegue vê-lo. Ainda assim, uma fração de segundo depois, você é atingido por um tiro fatal e seu personagem cai no chão morto, dando a impressão que bala fez uma curva. Isso acontece pois o cliente usado pelo oponente também emprega o supracitado atraso em seus sistemas, fazendo com que ele mire em uma versão antiga de seu personagem, posicionada a momentos antes de possuir cobertura.

Para entender como os disparos são efetuados num jogo de tiro multijogador e porque esse fenômeno ocorre, precisamos entender o conceito de latência.

Latência

Latência é o tempo que uma atualização leva para ir do servidor ao cliente, somado ao tempo que o cliente leva para avisar ao servidor que a recebeu. É o famoso ping, já conhecido entre os apreciadores de jogos multijogador.

Para medir o ping, devemos numerar cada uma das atualizações enviadas pelo servidor. Quando o cliente envia um comando, ele envia também o número da última mensagem que recebeu do servidor.

O servidor, assim como o cliente possui um histórico de comandos enviados, possui em seus domínios um histórico atualizações enviadas aos jogadores. Esse histórico deve conter a posição de todos jogadores em determinado momento, ou seja, deve conter os estados de jogo já passados. Cada um dos estados de jogo deve estar marcado com o horário em que foi enviado.

Quando o comando do jogador chega em seus sistemas, o servidor olha qual é o número da última atualização que o cliente recebeu. Ele procura em seu histórico o horário de envio da mensagem com o mesmo número contido no comando recebido. De posse do horário de envio, fica fácil calcular a latência: basta subtrair o horário atual do horário de envio:

Latência = Horário atual – horário de envio da mensagem

Conhecendo o ping os jogadores podem reclamar quando seu servidor ou a rede estiver lenta, usando a lentidão como desculpa por terem jogado mal! Você, o desenvolvedor, pode usar esse número para compensar o lag e calcular com exatidão o momento dos disparos efetuados nos clientes.

Mais sobre compensação de lag

No artigo anterior falamos de um método chamado compensação de lag, onde o programador emprega de alguns artifícios para fazer com o que usuário acredite que o jogo está se passando em tempo real. Na situação lá descrita, esse método foi utilizado apenas no cliente, mas podemos utilizar o mesmo conceito no servidor.

Com esse atraso introduzido no sistema para viabilizar a interpolação de entidades, vimos que num jogo de tiro multijogador o cliente sempre está mirando em inimigos cuja posição não é a mais atual.

Uma implementação que nada faz para compensar esse atraso, torna o jogo complexo de maneiras inesperadas. Por exemplo, imagine que você está jogando Counter-strike e realiza um disparo contra um inimigo que está se movimentando continuamente para direita. Nesta situação tempos dois problemas: o atraso introduzido pelo sistema de interpolação de entidades e o tempo de viagem de seu comando até o servidor. Somando esses fatores, observe que no momento que o comando chegar no servidor, se seu inimigo continuou movendo-se para direita, a posição dele já não mais será a mesma que o jogador visualizou em seu cliente.

Quando o comando do disparo chega no servidor, seu oponente estará longe do local onde você mirou. Seu tiro parecia perfeito, porém a próxima atualização vinda do servidor diz ao cliente que o disparo acertou uma parede qualquer. Na prática, podemos assumir que o tempo de viagem do comando até o servidor é a soma de metade da latência com o atraso introduzido pelo sistema de interpolação de entidades.

Essa implementação ingênua do sistema colocaria sobre os ombros dos jogadores o fardo de, ao realizar um disparo, sempre mirar em algum lugar a frente de onde o inimigo está. Ou seja, o jogador teria que adivinhar, levando em conta a latência, o atraso da interpolação e a velocidade de seu oponente, adivinhar onde seu alvo estará no momento em que seu comando chegar no servidor.

Poderíamos simplesmente permitir que o próprio cliente avisasse o servidor quando um oponente é atingido por seus disparos, mas essa decisão traria problemas no caso de estarmos lidando com um cheater, visto que ele poderia falsificar seus comandos e avisar ao servidor que efetuou disparou impossíveis para um jogador humano.

De modo a preservar a autoridade do servidor e evitar que hackers ardilosos estraguem a partida dos demais participantes e permitir que jogadores precisos sejam recompensados por seus disparos, adotaremos um procedimento bastante interessante.

O primeiro de tudo a se fazer é assegurar que o cliente envie juntamente com seus comandos o fator de interpolação T utilizando no momento do disparo. Desta maneira, quando o jogador avisa que deseja disparar sua arma, o servidor emprega o seguinte algoritmo:

  1. Conhecendo o tempo que as mensagens levam para chegar ao jogador, bem como o atraso empregado no cliente, calcula o horário, relativo a seu histórico de estados de jogo, em que o disparo foi efetuado. O horário se dá pela seguinte fórmula:

Horário do disparo = Horário de agora – Latência – Atraso usado na interpolação

  1. Utiliza de seu histórico de estados de jogo para reconstruir o momento do disparo. Isto é, em um ambiente separado do jogo, reverte a posição de todos os participantes para o momento em que o jogador enviou o comando de atirar, essencialmente criando uma reconstituição dos eventos.
  2. O servidor reconstrói também o estado de jogo imediatamente anterior ao disparo.
  3. Utilizando do fator de interpolação T enviado pelo cliente, simula o disparo utilizando um momento intermediário entre as duas reconstruções. Caso o alvo tenha sido atingido, envia um estado de jogo atualizado a todos jogadores informando as consequências do fato.

Ou seja, para acomodar o atraso gerado pela interpolação de entidades do cliente e o tempo de viagem dos comandos, devemos fazer o servidor voltar no tempo cada vez que os jogadores efetuam um disparo. E é justamente isso que ocasiona o efeito explicado acima, onde algumas vezes o personagem é atingido imediatamente após adquirir cobertura. O jogador já está em segurança de acordo com o que enxerga em seu cliente, mas o servidor exerce sua autoridade recompensando o oponente por um disparo ocorrido no passado.

Pode parecer exagero, mas dessa forma garantimos que os jogadores cuja mira é precisa consigam atingir seus inimigos e também evitamos que algum trapaceiro tome controle do jogo. Num jogo onde essa técnica não é empregada, o jogador seria obrigado a realizar todo esse cálculo que o servidor fez por conta própria. É como um acordo entre as partes: em troca de saber que seus disparos mais precisos atingirão o oponente, o jogador se expõem a raros efeitos indesejados.

Outros métodos

A interpolação de entidades não é o único método existente para tornar o movimento dos demais participantes fluído.

Dentre outras técnicas, podemos mencionar a navegação estimada ou, em inglês, dead reckoning, uma técnica muito utilizada na aeronáutica. Esse método emprega as equações da física cinemática para estimar a posição e velocidade de uma embarcação detectada pelos pulsos de um sonar. O servidor, ao enviar suas mensagens, comporta-se de maneira análoga a um sonar: as informações chegam ao cliente em pulsos.

De posse da localização de um objeto em ao menos dois momentos distintos, é possível utilizar as equações da física cinemática para prever onde o objeto estará antes do próximo pulso de informação chegar. Num jogo, poderíamos empregar a navegação estimada para substituir o atraso da interpolação de entidades por estas previsões.

Essa técnica funciona muito bem quando tratamos de objetos cujo movimento é facilmente previsível: aviões, barcos, carros de corrida, etc. Esse veículos são incapazes de alterar o sentido e a velocidade na qual estão se movendo de maneira instantânea, por isso as fórmulas matemáticas são capazes de “adivinhar” sua posição com bastante precisão. Ou seja, seu movimento é determinista.

Esse não é o caso quando estamos falando de personagens em um jogo: é comum os jogadores dobrarem esquinas enquanto estão saltando, descrever uma trajetória num zigue-zague impossível de se reproduzir na vida real e mudar de direção a qualquer momento, ignorando as leis da física como as conhecemos. Nestes casos, as equações nos dariam resultados errados, visto que os movimentos são indeterministas, ou seja, totalmente imprevisíveis.

Conclusão


Neste artigo vimos como tornar a movimentação e aliados e inimigos num jogo multijogador tão fluída quanto a do cliente local e também como lidar com as consequências das técnicas usadas para este fim. No próximo artigo, veremos como implementar estes conceitos em um jogo.