Previsão de física do lado do cliente no Unity

imagem

TL; DR


Criei uma demonstração mostrando como implementar a previsão do lado do cliente do movimento físico de um jogador no Unity - GitHub .

1. Introdução


No início de 2012, escrevi um post sobre como implementar previsões no lado do cliente do movimento físico de um jogador no Unity. Graças a Physics.Simulate (), essa solução desajeitada que eu descrevi não é mais necessária. O post antigo ainda é um dos mais populares no meu blog, mas para o Unity moderno essas informações já estão incorretas. Portanto, estou lançando a versão 2018.

O que há no lado do cliente?


Em jogos multiplayer competitivos, a trapaça deve ser evitada sempre que possível. Geralmente, isso significa que um modelo de rede com um servidor autoritário é usado: os clientes enviam as informações inseridas ao servidor e o servidor transforma essas informações no movimento de um jogador e, em seguida, envia uma captura instantânea do status do jogador para o cliente. Nesse caso, há um atraso entre pressionar a tecla e exibir o resultado, o que é inaceitável para jogos ativos. A previsão no lado do cliente é uma técnica muito popular que oculta o atraso, prevendo qual será o movimento resultante e mostrando imediatamente ao jogador. Quando o cliente recebe os resultados do servidor, ele os compara com o que o cliente previu e, se eles diferirem, a previsão foi incorreta e precisa ser corrigida.

Os instantâneos recebidos do servidor sempre vêm do passado com relação ao estado previsto do cliente (por exemplo, se a transferência de dados do cliente para o servidor e o retorno demorar 150 ms, cada instantâneo será atrasado em pelo menos 150 ms). Como resultado disso, quando o cliente precisa corrigir a previsão incorreta, ele deve reverter para esse ponto no passado e reproduzir todas as informações inseridas na lacuna para retornar ao local em que está. Se o movimento do jogador no jogo for baseado em física, então Physics.Simulate () será necessário para simular vários ciclos em um quadro. Se apenas Controladores de Personagens (ou cápsula lançada, etc.) forem usados ​​ao mover o jogador, você poderá ficar sem o Physics.Simulate () - e presumo que o desempenho será melhor.

Usarei o Unity para recriar uma demonstração de rede chamada "Zen of Networked Physics de Glenn Fiedler ", da qual desfruto há muito tempo. O jogador tem um cubo físico no qual ele pode exercer força, empurrando-o para dentro da cena. A demonstração simula várias condições de rede, incluindo atraso e perda de pacotes.

Começando a trabalhar


A primeira coisa a fazer é desativar a simulação automática de física. Embora Physics.Simulate () nos permita dizer ao sistema físico quando iniciar a simulação, por padrão, ele executa a simulação automaticamente com base em um delta de tempo fixo do projeto. Portanto, vamos desativá-lo em Editar-> Configurações do projeto-> Física , desmarcando a caixa " Simulação automática ".

Para começar, criaremos uma implementação simples de usuário único. A entrada é amostrada (w, a, s, d para movimento e espaço para pular), e tudo se resume às forças simples aplicadas ao Rigidbody usando AddForce ().

public class Logic : MonoBehaviour { public GameObject player; private float timer; private void Start() { this.timer = 0.0f; } private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs; inputs.up = Input.GetKey(KeyCode.W); inputs.down = Input.GetKey(KeyCode.S); inputs.left = Input.GetKey(KeyCode.A); inputs.right = Input.GetKey(KeyCode.D); inputs.jump = Input.GetKey(KeyCode.Space); this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); } } } 


O jogador se move enquanto a rede não está em uso

Enviando entrada para o servidor


Agora precisamos enviar a entrada para o servidor, que também executará esse código de movimento, fará uma captura instantânea do estado do cubo e a enviará de volta ao cliente.

 // client private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.inputs = inputs; input_msg.tick_number = this.tick_number; this.SendToServer(input_msg); this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); ++this.tick_number; } } 

Nada de especial aqui até agora, a única coisa que quero prestar atenção é adicionar a variável tick_number. É necessário que, quando o servidor enviar instantâneos do estado do cubo de volta ao cliente, possamos descobrir qual tato do cliente corresponde a esse estado, para que possamos comparar esse estado com o cliente previsto (que adicionaremos mais adiante).

 // server private void Update() { while (this.HasAvailableInputMessages()) { InputMessage input_msg = this.GetInputMessage(); Rigidbody rigidbody = player.GetComponent<Rigidbody>(); this.AddForcesToPlayer(rigidbody, input_msg.inputs); Physics.Simulate(Time.fixedDeltaTime); StateMessage state_msg; state_msg.position = rigidbody.position; state_msg.rotation = rigidbody.rotation; state_msg.velocity = rigidbody.velocity; state_msg.angular_velocity = rigidbody.angularVelocity; state_msg.tick_number = input_msg.tick_number + 1; this.SendToClient(state_msg); } } 

Tudo é simples - o servidor aguarda as mensagens de entrada, quando recebe, simula um ciclo de relógio. Em seguida, ele tira uma captura instantânea do estado resultante do cubo e o envia de volta ao cliente. Você pode observar que tick_number na mensagem de status é um número maior que tick_number na mensagem de entrada. Isso é feito porque é pessoalmente intuitivamente mais conveniente para mim pensar no "estado do jogador no tato 100" como o "estado do jogador no início do tato 100". Portanto, o estado do jogador na medida 100 em combinação com a entrada do jogador na medida 100 cria um novo estado para o jogador na medida 101.

Estado n + Entrada n = Estado n + 1


Não estou dizendo que você deve seguir da mesma maneira, o principal é a constância da abordagem.

Também deve ser dito que eu não envio essas mensagens através de um soquete real, mas imitá-las gravando-as na fila, simulando o atraso e a perda de pacotes. A cena contém dois cubos físicos - um para o cliente e outro para o servidor. Ao atualizar o cubo do cliente, desabilito o GameObject do cubo do servidor e vice-versa.

No entanto, não simulo a devolução da rede e a entrega de pacotes na ordem errada, e é por isso que assumo que cada mensagem de entrada recebida é mais recente que a anterior. Essa imitação é necessária para simplesmente executar o "cliente" e o "servidor" em uma instância do Unity, para que possamos combinar cubos de servidor e cliente em uma cena.

Você também pode observar que, se a mensagem de entrada for descartada e não chegar ao servidor, o servidor simula menos ciclos de relógio que o cliente e, portanto, criará um estado diferente. Isso é verdade, mas mesmo se simulássemos essas omissões, a entrada ainda poderia estar incorreta, o que também levaria a um estado diferente. Lidaremos com esse problema mais tarde.

Também deve ser adicionado que neste exemplo há apenas um cliente, o que simplifica o trabalho. Se tivéssemos vários clientes, precisaríamos de a) ao ligar para Physics.Simulate () para verificar se apenas o cubo de um jogador está ativado no servidor ou b) se o servidor recebeu entrada de vários cubos, simule-os todos juntos.


Atraso 75 ms (150 ms ida e volta)
0% de pacotes perdidos
Cubo amarelo - jogador servidor
Cubo azul - o último instantâneo recebido pelo cliente

Tudo parece bom até agora, mas fui um pouco seletivo com o que gravei no vídeo para esconder um problema bastante sério.

Falha na determinação


Dê uma olhada agora no seguinte:


Ai ...

Este vídeo foi gravado sem perder pacotes, no entanto, as simulações ainda variam com a mesma entrada exata. Não entendo muito bem por que isso acontece - o PhysX deve ser bastante determinístico, por isso acho impressionante que as simulações frequentemente divergam. Isso pode ser devido ao fato de eu ativar e desativar constantemente os cubos GameObject, ou seja, é possível que o problema diminua ao usar duas instâncias diferentes do Unity. Pode ser um erro, se você o vir no código do GitHub, informe-me.

Seja como for, previsões incorretas são um fato essencial na previsão do lado do cliente, então vamos lidar com elas.

Posso retroceder?


O processo é bastante simples - quando o cliente prevê movimento, ele salva um buffer de status (posição e rotação) e entrada. Depois de receber uma mensagem de status do servidor, ele compara o estado recebido com o estado previsto do buffer. Se eles diferirem por um valor muito grande, redefinimos o estado do cubo do cliente no passado e simulamos novamente todas as medidas intermediárias.

 // client private ClientState[] client_state_buffer = new ClientState[1024]; private Inputs[] client_input_buffer = new Inputs[1024]; private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.inputs = inputs; input_msg.tick_number = this.tick_number; this.SendToServer(input_msg); uint buffer_slot = this.tick_number % 1024; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = rigidbody.position; this.client_state_buffer[buffer_slot].rotation = rigidbody.rotation; this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); ++this.tick_number; } while (this.HasAvailableStateMessage()) { StateMessage state_msg = this.GetStateMessage(); uint buffer_slot = state_msg.tick_number % c_client_buffer_size; Vector3 position_error = state_msg.position - this.client_state_buffer[buffer_slot].position; if (position_error.sqrMagnitude > 0.0000001f) { // rewind & replay Rigidbody player_rigidbody = player.GetComponent<Rigidbody>(); player_rigidbody.position = state_msg.position; player_rigidbody.rotation = state_msg.rotation; player_rigidbody.velocity = state_msg.velocity; player_rigidbody.angularVelocity = state_msg.angular_velocity; uint rewind_tick_number = state_msg.tick_number; while (rewind_tick_number < this.tick_number) { buffer_slot = rewind_tick_number % c_client_buffer_size; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = player_rigidbody.position; this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation; this.AddForcesToPlayer(player_rigidbody, inputs); Physics.Simulate(Time.fixedDeltaTime); ++rewind_tick_number; } } } } 

Os dados de entrada e status do buffer são armazenados em um buffer circular muito simples, onde o identificador da medida é usado como um índice. E escolhi o valor de 64 Hz para a frequência de clock da física, ou seja, um buffer de 1024 elementos nos dá espaço por 16 segundos, e isso é muito mais do que precisamos.


A correção está ativada!

Transferência de entrada redundante


As mensagens de entrada geralmente são muito pequenas - os botões pressionados podem ser combinados em um campo de bits que leva apenas alguns bytes. Ainda existe um número de medida em nossa mensagem, ocupando 4 bytes, mas podemos compactá-los facilmente usando um valor de 8 bits com um carry (talvez o intervalo de 0 a 255 seja muito pequeno, podemos estar seguros e aumentá-lo para 9 ou 10 bits). Seja como for, essas mensagens são bem pequenas e isso significa que podemos enviar muitos dados de entrada em cada mensagem (caso os dados de entrada anteriores tenham sido perdidos). A que distância devemos voltar? Bem, o cliente sabe o número da medida da última mensagem de status que recebeu do servidor, portanto, não faz sentido voltar mais além dessa medida. Também precisamos impor um limite à quantidade de dados de entrada redundantes enviados pelo cliente. Não fiz isso na minha demonstração, mas ela deve ser implementada no código finalizado.

 while (this.HasAvailableStateMessage()) { StateMessage state_msg = this.GetStateMessage(); this.client_last_received_state_tick = state_msg.tick_number; 

Essa é uma alteração simples, o cliente simplesmente grava o número da medida da última mensagem de status recebida.

 Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.start_tick_number = this.client_last_received_state_tick; input_msg.inputs = new List<Inputs>(); for (uint tick = this.client_last_received_state_tick; tick <= this.tick_number; ++tick) { input_msg.inputs.Add(this.client_input_buffer[tick % 1024]); } this.SendToServer(input_msg); 

A mensagem de entrada enviada pelo cliente agora contém uma lista de dados de entrada, não apenas um item. A peça com o número da medida obtém um novo valor - agora esse é o número da medida da primeira entrada nesta lista.

 while (this.HasAvailableInputMessages()) { InputMessage input_msg = this.GetInputMessage(); // message contains an array of inputs, calculate what tick the final one is uint max_tick = input_msg.start_tick_number + (uint)input_msg.inputs.Count - 1; // if that tick is greater than or equal to the current tick we're on, then it // has inputs which are new if (max_tick >= server_tick_number) { // there may be some inputs in the array that we've already had, // so figure out where to start uint start_i = server_tick_number > input_msg.start_tick_number ? (server_tick_number - input_msg.start_tick_number) : 0; // run through all relevant inputs, and step player forward Rigidbody rigidbody = player.GetComponent<Rigidbody>(); for (int i = (int)start_i; i < input_msg.inputs.Count; ++i) { this.AddForcesToPlayer(rigidbody, input_msg.inputs[i]); Physics.Simulate(Time.fixedDeltaTime); } server_tick_number = max_tick + 1; } } 

Quando o servidor recebe uma mensagem de entrada, ele sabe o número da medida da primeira entrada e a quantidade de dados de entrada na mensagem. Portanto, ele pode calcular a medida da última entrada na mensagem. Se essa última medida for maior ou igual ao número da medida do servidor, ele saberá que a mensagem contém pelo menos uma entrada que o servidor ainda não viu. Nesse caso, simula todos os novos dados de entrada.

Você deve ter notado que, se limitarmos a quantidade de dados de entrada redundantes na mensagem de entrada, com um número suficientemente grande de mensagens de entrada perdidas, teremos uma lacuna de simulação entre o servidor e o cliente. Ou seja, o servidor pode simular a medida 100, enviar uma mensagem de status para iniciar a medida 101 e receber uma mensagem de entrada começando na medida 105. No código acima, o servidor passará para 105, não tentará simular medidas intermediárias com base nos dados de entrada conhecidos mais recentes. Se você precisa, depende da sua decisão e qual deve ser o jogo. Pessoalmente, não forçaria o servidor a especular e mover o player no mapa devido ao mau estado da rede. Eu acredito que é melhor deixar o player no lugar até que a conexão seja restaurada.

Na demonstração “Zen of Physics em Rede”, há uma função para enviar “movimentos importantes” pelo cliente, ou seja, ele envia dados de entrada redundantes apenas quando diferem da entrada transmitida anteriormente. Isso pode ser chamado de compactação delta de entrada e, com ela, você pode reduzir ainda mais o tamanho das mensagens de entrada. Mas até agora não o fiz, porque nesta demonstração não há otimização do carregamento da rede.


Antes de enviar dados de entrada redundantes: quando 25% dos pacotes são perdidos, o movimento do cubo é lento e se contrai, e continua sendo jogado de volta.


Após o envio de dados de entrada redundantes: com uma perda de 25% dos pacotes, ainda há uma correção de espasmos, mas os cubos se movem a uma velocidade aceitável.

Frequência Variável de Instantâneo


Nesta demonstração, a frequência com que o servidor envia capturas instantâneas para o cliente varia. Com uma frequência reduzida, o cliente precisará de mais tempo para receber a correção do servidor. Portanto, quando o cliente está errado na previsão, antes de receber uma mensagem de status, ele pode se desviar ainda mais, o que levará a uma correção mais perceptível. Com uma alta frequência de instantâneos, a perda de pacotes é muito menos importante, portanto, o cliente não precisa esperar muito tempo para o próximo instantâneo ser recebido.


Frequência de instantâneo 64 Hz


Frequência de instantâneo 16 Hz


Frequência de instantâneo 2 Hz

Obviamente, quanto maior a frequência de snapshots, melhor, então você deve enviá-los o mais rápido possível. Mas isso também depende da quantidade de tráfego adicional, seu custo, a disponibilidade de servidores dedicados, os custos de computação dos servidores e assim por diante.

Correção de suavização


Criamos previsões incorretas e obtemos correções bruscas com mais frequência do que gostaríamos. Sem acesso adequado à integração do Unity / PhysX, dificilmente posso depurar essas previsões incorretas. Eu já disse isso antes, mas repito mais uma vez - se você encontrar algo relacionado à física, no qual eu esteja errado, me avise.

Eu contornei a solução para esse problema, encobrindo as rachaduras com uma boa e velha suavização! Quando ocorre uma correção, o cliente simplesmente suaviza a posição e a rotação do player na direção do estado correto para vários quadros. O próprio cubo físico é corrigido instantaneamente (é invisível), mas temos um segundo cubo apenas para exibição, que permite suavização.

 Vector3 position_error = state_msg.position - predicted_state.position; float rotation_error = 1.f - Quaternion.Dot(state_msg.rotation, predicted_state.rotation); if (position_error.sqrMagnitude > 0.0000001f || rotation_error > 0.00001f) { Rigidbody player_rigidbody = player.GetComponent<Rigidbody>(); // capture the current predicted pos for smoothing Vector3 prev_pos = player_rigidbody.position + this.client_pos_error; Quaternion prev_rot = player_rigidbody.rotation * this.client_rot_error; // rewind & replay player_rigidbody.position = state_msg.position; player_rigidbody.rotation = state_msg.rotation; player_rigidbody.velocity = state_msg.velocity; player_rigidbody.angularVelocity = state_msg.angular_velocity; uint rewind_tick_number = state_msg.tick_number; while (rewind_tick_number < this.tick_number) { buffer_slot = rewind_tick_number % c_client_buffer_size; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = player_rigidbody.position; this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation; this.AddForcesToPlayer(player_rigidbody, inputs); Physics.Simulate(Time.fixedDeltaTime); ++rewind_tick_number; } // if more than 2ms apart, just snap if ((prev_pos - player_rigidbody.position).sqrMagnitude >= 4.0f) { this.client_pos_error = Vector3.zero; this.client_rot_error = Quaternion.identity; } else { this.client_pos_error = prev_pos - player_rigidbody.position; this.client_rot_error = Quaternion.Inverse(player_rigidbody.rotation) * prev_rot; } } 

Quando ocorre uma previsão incorreta, o cliente rastreia a diferença de posição / rotação após a correção. Se a distância total da correção da posição for superior a 2 metros, o cubo simplesmente se moverá rapidamente - a suavização ainda pareceria ruim, portanto, pelo menos, retorne ao estado correto o mais rápido possível.

 this.client_pos_error *= 0.9f; this.client_rot_error = Quaternion.Slerp(this.client_rot_error, Quaternion.identity, 0.1f); this.smoothed_client_player.transform.position = player_rigidbody.position + this.client_pos_error; this.smoothed_client_player.transform.rotation = player_rigidbody.rotation * this.client_rot_error; 

Em cada quadro, o cliente executa lerp / slerp em 10% para a posição / rotação correta. Essa é uma abordagem padrão da lei de potência para calcular a média de movimento. Depende da taxa de quadros, mas para os fins de nossa demonstração, isso é suficiente.


Atraso de 250 ms
Perdeu 10% dos pacotes
Sem suavização, a correção é muito perceptível


Atraso de 250 ms
Perdeu 10% dos pacotes
Com a suavização, a correção é muito mais difícil de perceber.

O resultado final funciona muito bem, quero criar uma versão que realmente envie pacotes, em vez de imitá-los. Mas pelo menos essa é uma prova de conceito para um sistema de previsão do lado do cliente com objetos físicos reais no Unity sem a necessidade de plug-ins físicos e similares.

Source: https://habr.com/ru/post/pt432190/


All Articles