Prédiction physique côté client dans Unity

image

TL; DR


J'ai créé une démo montrant comment implémenter la prédiction côté client du mouvement physique d'un joueur dans Unity - GitHub .

Présentation


Début 2012, j'ai écrit un article sur la façon d'implémenter les prévisions du côté client du mouvement physique d'un joueur dans Unity. Grâce à Physics.Simulate (), cette solution de contournement maladroite que j'ai décrite n'est plus nécessaire. L'ancien article est toujours l'un des plus populaires sur mon blog, mais pour Unity moderne, ces informations sont déjà incorrectes. Par conséquent, je publie la version 2018.

Qu'y a-t-il du côté client?


Dans les jeux multijoueurs compétitifs, la triche doit être évitée autant que possible. Cela signifie généralement qu'un modèle de réseau avec un serveur autoritaire est utilisé: les clients envoient les informations saisies au serveur, et le serveur transforme ces informations en mouvement d'un joueur, puis envoie un instantané de l'état du joueur au client. Dans ce cas, il y a un délai entre l'appui sur la touche et l'affichage du résultat, ce qui est inacceptable pour tous les jeux actifs. La prédiction du côté client est une technique très populaire qui masque le retard, prédisant quel sera le mouvement résultant et le montrant immédiatement au joueur. Lorsque le client reçoit les résultats du serveur, il les compare avec ce que le client a prédit, et s'ils diffèrent, la prévision était erronée et doit être corrigée.

Les instantanés reçus du serveur proviennent toujours du passé par rapport à l'état prédit du client (par exemple, si le transfert de données du client vers le serveur et vice-versa prend 150 ms, chaque instantané sera retardé d'au moins 150 ms). Par conséquent, lorsque le client a besoin de corriger la mauvaise prévision, il doit revenir à ce point dans le passé, puis reproduire toutes les informations saisies dans l'écart afin de revenir là où il se trouve. Si le mouvement du joueur dans le jeu est basé sur la physique, Physics.Simulate () est nécessaire pour simuler plusieurs cycles dans une image. Si seuls les contrôleurs de personnage (ou le casting de capsule, etc.) sont utilisés lors du déplacement du lecteur, vous pouvez vous passer de Physics.Simulate () - et je suppose que les performances seront meilleures.

J'utiliserai Unity pour recréer une démo de réseau appelée « Glenn Fiedler 's Zen of Networked Physics», que j'apprécie depuis longtemps. Le joueur possède un cube physique sur lequel il peut exercer une force, le poussant dans la scène. La démo simule diverses conditions de réseau, y compris le retard et la perte de paquets.

Se rendre au travail


La première chose à faire est de désactiver la simulation physique automatique. Bien que Physics.Simulate () nous permette de dire au système physique quand démarrer la simulation, par défaut, il exécute la simulation automatiquement sur la base d'un delta de temps de projet fixe. Par conséquent, nous le désactiverons dans Edition-> Paramètres du projet- > Physique en décochant la case " Simulation automatique ".

Pour commencer, nous allons créer une implémentation simple pour un seul utilisateur. L'entrée est échantillonnée (w, a, s, d pour se déplacer et l'espace pour sauter), et tout se résume aux forces simples appliquées au Rigidbody en utilisant 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); } } } 


Le joueur se déplace alors que le réseau n'est pas utilisé

Envoi d'entrée au serveur


Nous devons maintenant envoyer l'entrée au serveur, qui exécutera également ce code de mouvement, fera un instantané de l'état du cube et le renverra au client.

 // 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; } } 

Rien de spécial jusqu'ici, la seule chose à laquelle je veux faire attention est d'ajouter la variable tick_number. Il est nécessaire pour que lorsque le serveur renvoie des instantanés de l'état du cube au client, nous puissions savoir quel tact du client correspond à cet état, afin de pouvoir comparer cet état avec le client prédit (que nous ajouterons un peu plus tard).

 // 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); } } 

Tout est simple - le serveur attend les messages d'entrée, lorsqu'il les reçoit, il simule un cycle d'horloge. Il prend ensuite un instantané de l'état résultant du cube et le renvoie au client. Vous pouvez remarquer que tick_number dans le message d'état est supérieur de un tick_number dans le message d'entrée. Cela est dû au fait qu'il est personnellement plus intuitif pour moi de penser à "l'état du joueur au tact 100" comme à "l'état du joueur au début du tact 100". Par conséquent, l'état du joueur dans la mesure 100 en combinaison avec l'entrée du joueur dans la mesure 100 crée un nouvel état pour le joueur dans la mesure 101.

Etat n + Entrée n = Etat n + 1


Je ne dis pas que vous devez le prendre de la même manière, l'essentiel est la constance de l'approche.

Il faut aussi dire que je n'envoie pas ces messages via une vraie socket, mais que je les imite en les écrivant dans la file d'attente, en simulant le retard et la perte de paquets. La scène contient deux cubes physiques - un pour le client, l'autre pour le serveur. Lors de la mise à jour du cube client, je désactive le GameObject du cube serveur, et vice versa.

Cependant, je ne simule pas le rebond du réseau et la livraison de paquets dans le mauvais ordre, c'est pourquoi je fais l'hypothèse que chaque message d'entrée reçu est plus récent que le précédent. Cette imitation est nécessaire pour exécuter très simplement le «client» et le «serveur» dans une instance Unity, afin que nous puissions combiner les cubes serveur et client dans une même scène.

Vous pouvez également remarquer que si le message d'entrée est ignoré et n'atteint pas le serveur, le serveur simule moins de cycles d'horloge que le client et créera donc un état différent. C'est vrai, mais même si nous simulions ces omissions, l'entrée pourrait toujours être incorrecte, ce qui conduirait également à un état différent. Nous traiterons ce problème plus tard.

Il faut également ajouter que dans cet exemple il n'y a qu'un seul client, ce qui simplifie le travail. Si nous avions plusieurs clients, nous aurions besoin de a) lors de l’appel de Physics.Simulate () pour vérifier que seul un cube de joueur est activé sur le serveur, ou b) si le serveur a reçu des entrées de plusieurs cubes, simulez-les tous ensemble.


Délai 75 ms (aller-retour 150 ms)
0% de colis perdus
Yellow cube - serveur serveur
Blue cube - le dernier instantané reçu par le client

Tout semble bien pour l'instant, mais j'ai été un peu sélectif avec ce que j'ai enregistré sur la vidéo pour cacher un problème assez grave.

Échec de la détermination


Jetez un oeil maintenant à ceci:


Aïe ...

Cette vidéo a été enregistrée sans perdre de paquets, cependant, les simulations varient toujours avec la même entrée exacte. Je ne comprends pas très bien pourquoi cela se produit - PhysX devrait être assez déterministe, donc je trouve frappant que les simulations divergent si souvent. Cela peut être dû au fait que j'active et désactive constamment les cubes GameObject, c'est-à-dire qu'il est possible que le problème diminue lors de l'utilisation de deux instances Unity différentes. Cela peut être un bug, si vous le voyez dans le code sur GitHub, faites le moi savoir.

Quoi qu'il en soit, les prévisions incorrectes sont un fait essentiel dans la prévision du côté client, alors traitons-les.

Puis-je rembobiner?


Le processus est assez simple - lorsque le client prédit un mouvement, il enregistre un tampon d'état (position et rotation) et une entrée. Après avoir reçu un message d'état du serveur, il compare l'état reçu avec l'état prévu du tampon. S'ils diffèrent par une valeur trop grande, nous redéfinissons l'état du cube client dans le passé, puis simulons à nouveau toutes les mesures intermédiaires.

 // 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; } } } } 

Les données d'entrée et d'état tamponnées sont stockées dans un tampon circulaire très simple, où l'identifiant de mesure est utilisé comme index. Et j'ai choisi la valeur de 64 Hz pour la fréquence d'horloge de la physique, c'est-à-dire qu'un tampon de 1024 éléments nous donne de l'espace pendant 16 secondes, et c'est bien plus que ce dont nous pouvons avoir besoin.


La correction est en marche!

Transfert d'entrée redondant


Les messages d'entrée sont généralement très petits - les boutons enfoncés peuvent être combinés en un champ de bits qui ne prend que quelques octets. Il y a toujours un numéro de mesure dans notre message, occupant 4 octets, mais nous pouvons facilement les compresser en utilisant une valeur de 8 bits avec un report (peut-être que l'intervalle 0-255 sera trop petit, nous pouvons être sûrs et l'augmenter à 9 ou 10 bits). Quoi qu'il en soit, ces messages sont assez petits, ce qui signifie que nous pouvons envoyer beaucoup de données d'entrée dans chaque message (au cas où les données d'entrée précédentes auraient été perdues). Jusqu'où faut-il remonter? Eh bien, le client connaît le numéro de mesure du dernier message d'état qu'il a reçu du serveur, donc cela n'a aucun sens de revenir plus loin que cette mesure. Nous devons également imposer une limite à la quantité de données d'entrée redondantes envoyées par le client. Je ne l'ai pas fait dans ma démo, mais cela devrait être implémenté dans le code fini.

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

Il s'agit d'un simple changement, le client écrit simplement le numéro de mesure du dernier message d'état reçu.

 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); 

Le message d'entrée envoyé par le client contient désormais une liste de données d'entrée, pas seulement un élément. La partie avec le numéro de mesure obtient une nouvelle valeur - c'est maintenant le numéro de mesure de la première entrée de cette liste.

 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; } } 

Lorsque le serveur reçoit un message d'entrée, il connaît le numéro de mesure de la première entrée et la quantité de données d'entrée dans le message. Par conséquent, il peut calculer la mesure de la dernière entrée du message. Si cette dernière mesure est supérieure ou égale au nombre de mesures du serveur, alors il sait que le message contient au moins une entrée que le serveur n'a pas encore vue. Si c'est le cas, il simule toutes les nouvelles données d'entrée.

Vous avez peut-être remarqué que si nous limitons la quantité de données d'entrée redondantes dans le message d'entrée, alors avec un nombre suffisamment important de messages d'entrée perdus, nous aurons un écart de simulation entre le serveur et le client. Autrement dit, le serveur peut simuler la mesure 100, envoyer un message d'état pour démarrer la mesure 101, puis recevoir un message d'entrée à partir de la mesure 105. Dans le code ci-dessus, le serveur passera à 105, il n'essaiera pas de simuler des mesures intermédiaires sur la base des dernières données d'entrée connues. Que vous en ayez besoin dépend de votre décision et de ce que devrait être le jeu. Personnellement, je ne forcerais pas le serveur à spéculer et à déplacer le joueur sur la carte en raison du mauvais état du réseau. Je pense qu'il vaut mieux laisser le lecteur en place jusqu'à ce que la connexion soit rétablie.

Dans la démo "Zen of Networked Physics", il y a une fonction pour envoyer des "mouvements importants" par le client, c'est-à-dire qu'il envoie des données d'entrée redondantes uniquement lorsqu'elles diffèrent de l'entrée transmise précédemment. Cela peut être appelé compression delta d'entrée, et avec lui, vous pouvez réduire davantage la taille des messages d'entrée. Mais jusqu'à présent, je ne l'ai pas fait, car dans cette démo il n'y a pas d'optimisation du chargement du réseau.


Avant d'envoyer des données d'entrée redondantes: lorsque 25% des paquets sont perdus, le mouvement du cube est lent et tremblant, il continue d'être rejeté.


Après l'envoi de données d'entrée redondantes: avec une perte de 25% des paquets, il y a toujours une correction de contraction, mais les cubes se déplacent à une vitesse acceptable.

Fréquence d'instantané variable


Dans cette démo, la fréquence à laquelle le serveur envoie des instantanés au client varie. Avec une fréquence réduite, le client aura besoin de plus de temps pour recevoir la correction du serveur. Par conséquent, lorsque le client se trompe dans les prévisions, puis avant de recevoir un message d'état, il peut dévier encore plus, ce qui entraînera une correction plus notable. Avec une fréquence élevée d'instantanés, la perte de paquets est beaucoup moins importante, de sorte que le client n'a pas à attendre longtemps pour recevoir l'instantané suivant.


Fréquence de cliché 64 Hz


Fréquence de cliché 16 Hz


Fréquence de cliché 2 Hz

De toute évidence, plus la fréquence des instantanés est élevée, mieux c'est, vous devez donc les envoyer le plus souvent possible. Mais cela dépend aussi de la quantité de trafic supplémentaire, de son coût, de la disponibilité des serveurs dédiés, des coûts informatiques des serveurs, etc.

Correction du lissage


Nous créons des prévisions incorrectes et obtenons des corrections saccadées plus souvent que nous le souhaiterions. Sans accès approprié à l'intégration Unity / PhysX, je peux difficilement déboguer ces prévisions erronées. Je l'ai déjà dit, mais je le répète encore une fois - si vous trouvez quelque chose lié à la physique, dans lequel je me trompe, faites-le moi savoir.

J'ai contourné la solution à ce problème en lustrant les fissures avec un bon vieux lissage! Lorsqu'une correction se produit, le client adoucit simplement la position et la rotation du lecteur dans le sens de l'état correct pour plusieurs images. Le cube physique lui-même est corrigé instantanément (il est invisible), mais nous avons un deuxième cube pour l'affichage uniquement, qui permet le lissage.

 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; } } 

Lorsqu'une prévision erronée se produit, le client suit la différence de position / rotation après correction. Si la distance totale de correction de position est supérieure à 2 mètres, le cube se déplace simplement par à-coups - le lissage semble toujours mauvais, alors laissez-le au moins revenir à son état le plus rapidement possible.

 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; 

Dans chaque trame, le client effectue un lerp / slerp vers la position / rotation correcte de 10%, c'est une approche standard de loi de puissance pour calculer la moyenne du mouvement. Cela dépend de la fréquence d'images, mais pour les besoins de notre démo, cela suffit.


250 ms de retard
Perdu 10% des colis
Sans lissage, la correction est très perceptible


250 ms de retard
Perdu 10% des colis
Avec le lissage, la correction est beaucoup plus difficile à remarquer.

Le résultat final fonctionne plutôt bien, je veux créer une version qui envoie vraiment des paquets, plutôt que de les imiter. Mais au moins, c'est une preuve de concept pour un système de prévision côté client avec de vrais objets physiques dans Unity sans avoir besoin de plug-ins physiques et similaires.

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


All Articles