PDA

Voir la version complète : Unity Networking, The Experience en 3D



Louck
22/10/2014, 22h01
Bonsoir!

Depuis mon précédent jeu sous Java (Punxel Agent), j'ai signé un pacte avec Lucifer et j'ai installé Unity3D sur mon PC.

Cela fait maintenant plusieurs mois que je m'amuse avec et je suis conquis. Tellement conquis que je souhaite réaliser le jeu de mes rêves: Jouer un pirate de l'espace avec d'autres joueurs en coopération.

Mais beaucoup le savent: implémenter un mode "multijoueur en ligne" dans un jeu, c'est très difficile. Il faut voir les nombreux jeux indés qui ne comportent pas ce mode de jeu pour comprendre: Samurai Gunn, Towerfall (http://www.polygon.com/2014/3/11/5491146/why-you-dont-want-an-online-mode-towerfall)...
Et même s'il y a la possibilité de jouer avec ses copains à distance, beaucoup de jeux possèdent un netcode pas très performant, ou pas très cohérent (coucou Battlefield 4 :trollface: ).


Mais, étant fou et avec du temps de libre à perdre, je vais tenter cet aventure :lol:. Et ce topic sera mon devlog.

Pour résumer, dans ce topic, je vais écrire mes avancés sur la réalisation d'une architecture multijoueur sous Unity3D. Avec quelques conditions:

Je n'utilise que ce que m'offre Unity3D pour réaliser mon projet. Pas de uLink, de Photon machin, ou autres API.
L'architecture sera client/server avec un serveur autoritaire (pour limiter les possibilités de triche).
Une architecture assez modulable pour être réutilisée dans plusieurs projets.
Netcode optimisé (du moins, pour le format coop 8 joueurs :ninja:).


Ma finalité est de ne pas réaliser un jeu, mais une sorte d'API pour pouvoir intégrer facillement (et rapidement) un mode multijoueur pour mes futurs jeux.
De plus, mon but est de ne pas produire une architecture parfaite. Du moins, il faut que ca marche :).


Ma façon de procéder est simple: Je débute tout à zéro en mode amateur. J'ai les connaissances, mais aucune pratique (même si j'ai déjà codé un FTP en Java).
Je ne vais pas tenter de reproduire ces architectures, mais je vais m'y baser:
http://fabiensanglard.net/quake3/network.php
http://www.gamasutra.com/view/feature/131503/1500_archers_on_a_288_network_.php
https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking

Attention: j'écrirai en tant que développeur, pour les développeurs. Certains termes risquent de ne pas être compréhensibles pour les simples joueurs (Callback? Méthode abstraite?).
J'essayerai d'expliquer au mieux ma démarche au fil du projet, à chaque étape. Mais si j’explique mal ou si je ne détail pas assez, n'hésitez pas à m'en avertir :).
Je tenterai de faire un "rapport" chaque semaine au minimum. Mais je préviens: ca sera des mises à jours irréguliers (vu mes disponibilités). Je ferais de mon mieux.

Si vous avez des questions ou des envies, n'hésitez pas :).


FAQ1: Si j'utilise Unity3D pour ce projet, c'est parce que je veux réaliser un jeu sur le long terme (mais pas maintenant).
FAQ2: Je distribuerai le code source à la fin du projet ;).
FAQ3: J'ai des connaissances, mais aucune (vrai) pratique. Je ne suis pas un pro dans ce domaine, du coup il est fort possible que je fais des erreurs. N'hésitez pas à me critiquer!


A très bientôt!

yourykiki
23/10/2014, 11h21
Yeah ! Super idée ! Je suis en train me faire la main sur unity depuis peu. Au début c'était pour être en mesure d'aider un ami à réaliser un jeu, mais au final je suis devenu fan. J'avais soigneusement mis de coté l'idée de faire du multi pour me concentrer sur le gameplay et l'univers... Du coup ce topic me redonne espoir et je vais le suivre de prêt !!

Fenrir
23/10/2014, 11h28
Hello,

Bon courage pour ton projet ! Je te conseil aussi cet article :
http://www.gabrielgambetta.com/fast_paced_multiplayer.html

J'ai aussi implémenté un mode multi pour mon jeu dernièrement, mais ce n'était pas sous Unity, mais avec LOVE (http://love2d.org/), donc tout en LUA et en utilisant lua-enet (http://leafo.net/lua-enet/). J'avoue que j'en suis plutôt satisfait, j'ai aussi un serveur autoritaire qui peut soit tourner avec un client (dans un thread à part) ou en mode dédié. Par contre je me suis limité à 4 joueurs, il faudrait que j'optimise un peu si je veux faire tourner ça pour 8.

Et du coup, tu vas aussi faire une partie "lobby" pour gérer une liste de serveurs et mettre en relation les clients ? Il y a des choses dans Unity pour ça ? Si ce n'est pas déjà fait, jette un oeil aux techniques d'UDP Hole Punching, ça risque de t'être utile (ça a été plutôt galère à mettre en place de mon côté...). Je te conseil ce doc sur le sujet :
http://www.brynosaurus.com/pub/net/p2pnat/

Louck
23/10/2014, 12h00
Hello,

Bon courage pour ton projet ! Je te conseil aussi cet article :
http://www.gabrielgambetta.com/fast_paced_multiplayer.html

J'ai déjà lu cet article il y a quelques jours. Et en effet, ca raconte de bonnes choses ;).

Pour le reste, je vais surtout réaliser une version naïve de l'architecture client/server, qui fonctionne bien.
En gros, je commencerai par ca:

Etape 1: Connexion client/server (par IP)
Etape 2: Envoi données clients
Etape 3: Envoi données serveurs (snapshots)
Etape 4: Deconnexion


Mais dans un second temps, je tenterai de peaufiner la chose à coup de Prediction, Interpolation/Extrapolation, Master Server/Lobby, Physique, et j'en passe :). Tout ca accompagné de mes commentaires personnels :).



Je te conseil ce doc sur le sujet :
http://www.brynosaurus.com/pub/net/p2pnat/
J'ai regardé rapidement, mais j'ai l'impression que ca concerne les communications en P2P. Enfin, je ne vois pas trop l’intérêt d'utiliser ce modèle, surtout si on veut utiliser un serveur autoritaire (EDIT: à part pour faire de la communication textuelle ou vocale ?).

A voir! Je détaillerai mes pensées au cours de mes mésaventures, et vous pourrez critiquer (ou jeter des tomates) :).

Fenrir
23/10/2014, 12h32
J'ai regardé rapidement, mais j'ai l'impression que ca concerne les communications en P2P. Enfin, je ne vois pas trop l’intérêt d'utiliser ce modèle, surtout si on veut utiliser un serveur autoritaire (EDIT: à part pour faire de la communication textuelle ou vocale ?).

Ben en fait tout dépend si ton serveur tournera en dédié avec une IP publique ou avec l'un de tes clients (donc potentiellement derrière un NAT). Dans le premier cas effectivement ce papier ne sert à rien, mais si ton serveur est derrière un NAT ça explique comment initier la communication avec les autres clients (et éviter d'avoir à ouvrir manuellement des ports sur ton routeur ou ta box adsl).

Grosnours
23/10/2014, 12h36
Excellente initiative, je suis aussi en train de tâter le terrain pour une transition vers Unity en ce moment et je vais donc observer tes progrès avec grand intérêt. ;)

Louck
23/10/2014, 12h47
Ben en fait tout dépend si ton serveur tournera en dédié avec une IP publique ou avec l'un de tes clients (donc potentiellement derrière un NAT). Dans le premier cas effectivement ce papier ne sert à rien, mais si ton serveur est derrière un NAT ça explique comment initier la communication avec les autres clients (et éviter d'avoir à ouvrir manuellement des ports sur ton routeur ou ta box adsl).

Oh, je n'y pensais pas du tout!
Je prend note pour un futur proche :).

Metalink
23/10/2014, 21h20
J'ai réalisé un proto de MMO à la fac ya 2 ans, c'était pas la folie mais ça marchait pas mal !
Bon courage, je suivrais tes avancés :)

Louck
25/10/2014, 00h44
Oublié de préciser: j'écrirai en tant que développeur, pour les développeurs. Certains termes risquent de ne pas être compréhensibles pour les joueurs (Callback? Fonction anonyme?). Dans le doute, dites le et je ferais un petit lexique.
Sinon, lets go!


CHAPITRE 1: UN NOUVEAU PAQUET
Dans cette première partie, comme je l'ai précisé plus tôt, je vais faire dans le plus basique et le plus naïf. Je vais y fixer les bases de mon architecture, m'y féliciter, avant d'échouer majestueusement dans les futurs chapitres de mes mésaventures.


Etape 1: Lancement et connexion client/server
On commence par le plus simple et par le moins intéressant: une simple connexion entre un client et un serveur.

Mais avant tout, j'ai préparé le terrain en créant plusieurs composants vierges:

Composant Server: Contiendra tout le code dédié au serveur.
Composant Client: Contiendra tout le code dédié au client/joueur.
Composant Shared: Il arrive que certaines fonctions soient communes entre le serveur et le client. Pour éviter les copies, on colle tout ca dans ce composant. Au cas oû!
Composant Network Manager: Le chef d'orchestre de tout ce bordel. Il est surtout utilisé pour gérer la connexion et la déconnexion d'un serveur ou d'un joueur.


J'utiliserai, bien sûr, notre composant Network View qui nous permettra de communiquer avec le serveur ou avec les joueurs de la partie. D'ailleurs, sans ce composant, pas de réseau, pas de multijoueurs.

Tous ces composants seront utilisés par une seule entité (ou "gameobject", dans le langage d'Unity), qui portera le doux nom de Networking.
Pour simplifier, toute la partie réseau sera gérée par cet objet.




Retour avec la connexion des joueurs.
Je ne me suis pas trop embêté et j'ai honteusement copié une partie du code ici:
http://www.paladinstudios.com/2013/07/10/how-to-create-an-online-multiplayer-game-with-unity/

J'ai bien sûr modifié le code à ma sauce. Par exemple, je n'utilise pas le Master Server pour l'instant (je me connecte directement à mon serveur par IP). Et j'ai tout rangé dans le composant Network Manager.


De façon détaillé, lorsqu'on créé un serveur:

Invocation de la fonction callback OnServerInitialized(). Je charge le composant Server et je démarre tout le bordel...
... Mais vu qu'il y a rien à faire d'autre pour l'instant... on fait rien d'autre!


Du côté client, c'est un peu plus compliqué. Quand un client se connecte au serveur, du côté client:

Callback OnConnectedToServer(). J'active le composant Client, et je charge ce qu'il faut.
Et j'attend une réponse du serveur.


Du côté serveur:

Callback OnPlayerConnected() ("ouai j'ai des amis!"). Je récupère les informations du client dans cette méthode (objet NetworkPlayer).
A partir de ces informations, j'enregistre le client sur le serveur: Je lui attribue un identifiant et je lui réserve un petit espace de stockage, au chaud, au cas où s'il veut stocker des gameobjects dont il serait propriétaire (ce qui peut être très utile pour plus tard).
Ensuite, le serveur envoie au client son ID (ou identifiant, pour les noobs du fond qui ne suivent pas).
Le client stocke son ID.
The End.


http://zdnet1.cbsistatic.com/hub/i/r/2014/08/18/aaf85bc0-26bf-11e4-8c7f-00505685119a/thumbnail/770x578/d002054f91171c5858ecfdc82eda61d8/the-end-of-social.jpg

Ca serait une utopie que le netcode puisse se résumer à ca.


Pour l'instant, nous avons réussi à lancer un serveur et à faire connecter nos clients sur notre serveur. Youpie tralala. Champagne pour tout le monde.

La prochaine fois, on entrera dans le vif du sujet: Faire translater les données du jeu entre le client et le serveur.




En attendant, je vais vous parler de RPC et de "State Synchronization".
Globalement, sous Unity, il existe deux façons pour échanger les informations sur un réseau:

Via le "State Synchronization"-machin. Grosso modo, c'est Unity qui fait tout le travail, et qui translate les données d'un composant. Très utilise pour ne pas s’embêter à gérer la position et la rotation d'un protagoniste.
Via le RPC. Grosso merdo, un client invoque une méthode nommée sur le réseau, que les autres clients (et/ou serveur) peuvent intercepter. Le RPC ne fait pas tout, mais permet de translater beaucoup de chose sur le réseau et on a un peu plus de contrôle sur les données. Cette technique est surtout utile pour chatter avec les autres (ou pour insulter, si le jeu est un MOBA), et pour informer certaines actions spécifiques du joueur (tirer, s'accroupir, sauter, etc...).


Ces définitions, on les retrouve dans les documents d'Unity et dans 90% des tutorials. Ils n'ont pas tort, ces deux méthodes se complètent bien et ont chacun un intérêt.


Néanmoins, étant un truz rebelz #onlachrine, j'ai décidé de ne pas suivre cet exemple.

La raison principale est que le State Synchronization est une technique très gourmande en ressource réseau, malgré elle. Je soupçonne Unity d'envoyer trop de données avec cette méthode. En utilisant les RPC, j'arrive à envoyer les mêmes informations, sans faire sauter mon débit. La contre-partie est que je dois tout coder moi même, mais cela a un avantage sur le long terme.
L'autre point noir est qu'utiliser le SS implique d'invoquer le composant Network View sur d'autres gameobjects (protagonistes gérés par les joueurs, pnj, etc...) et de décentraliser toute notre gestion du réseau. Chose que je ne souhaite pas faire (dans mon cas bien sûr. Gérer le réseau par entité/composant a ses avantages)


Du coup, afin d'avoir un contrôle total sur le réseau, afin d'envoyer/recevoir des données de façon optimisés, afin de me simplifier la vie, je centralise tout le réseau dans un objet, je n'utiliserai qu'un seul Network View et je ne ferais appel qu'aux RPC.

Mais peux être que je me trompe.




PS: Je ne suis pas un fort en écriture. Si vous voyez des erreurs ou des fautes, n'hésitez pas.
Sinon, si vous avez des questions ou des critiques en attendant, je suis à l'écoute!

Fenrir
27/10/2014, 11h22
Du coup niveau bande passante, c'est quoi ton objectif (aussi bien pour le serveur que les clients) ?

Black Wolf
27/10/2014, 12h39
Je sais pas si tu as eu l'occasion de jeter un oeil à TNet (http://www.tasharen.com/?page_id=4518), la librairie réseau codée par le type qui à fait NGUI (une des plus puissante lib de GUI pour Unity). Elle fonctionne tout par RPC aussi et tout le code source est dispo si tu as acheté son package. Je m'en étais servi pour faire un petit shooter avec des vaisseaux vus de dessus, avec un chat qui va bien etc.. Hésite pas à m'écrire si tu veux plus d'infos ou y jeter un oeil.

Louck
27/10/2014, 22h06
Du coup niveau bande passante, c'est quoi ton objectif (aussi bien pour le serveur que les clients) ?
Je reviendrai là dessus dans un prochain chapitre.
La question des données (ou de la bande passante) dépend du jeu surtout. Le but de l'architecture réseau est de pouvoir optimiser un maximum l'échange de ces données.


Je sais pas si tu as eu l'occasion de jeter un oeil à TNet
Je jetterai un oeil à ca à la fin du projet. Mon but est de déjà faire fonctionner l'engin à partir des composantes de bases d'Unity, pour l'instant ;)


On continue!




ETAPE 2: Gestion des données sur le réseau du jeu
En faite, je suis un menteur. Ou un petit menteur, c'est vous qui voyez.

Avant d'attaquer le transfert des données clients/serveurs, il faut parler de ces fameuses données: Quelles données le client va envoyer au serveur ? Quelle information le serveur va envoyer à ses clients ? C'est bien gentil de parler des échanges de paquets entre deux postes, mais il faut déjà savoir ce qu'il faut mettre dedans.

Pour ce point là, pas de mystère: ca dépend du jeu - lui-même - et de son game-design.
Si le jeu est un STR tour par tour, globalement, le joueur informe le serveur des actions qu'il a effectué durant son tour. Dans un FPS en 3D, il faut gérer la position et la rotation du personnage, en plus des actions habituelles dans les jeux de tirs (comme tirer et insulter les mamans des autres).
Bref. Chaque jeu-vidéo a ses propres règles et ses propres données.

http://screenshots.en.sftcdn.net/en/scrn/40000/40234/3d-matrix-corridors-screensaver-2.jpg

Pour gérer les règles fixées par un jeu, j'ai créé une interface NetworkGameRules. L'interface contient une liste de méthodes qui décrivent le fonctionnement du jeu sur le réseau: Quelles informations à envoyer, comment les données seront traitées à la réception, qu'est ce qu'il faut faire en début de partie, etc...
Cette interface sera utilisée par mes composants réseaux (NetworkServer, NetworkClient, ...). Pour cela, j'ajoute un nouveau paramètre à mes composants, qui prendra en compte mon interface.

C'est un peu plus merdique que ca: vu que je ne peux pas passer directement un fichier C# en paramètre, j'ai du créer un gameobjet qui utilise un composant qui utilise mon interface, avant de passer cet objet en paramètre. Je pense qu'il y a une meilleure solution, mais je ne voulais pas me prendre la tête pour le moment.


Ce fichier nous sauve d'un gros problème. Mais il y en a un deuxième point à régler, avant de continuer: les entités (ou les gameobjects).

Durant une partie, le serveur doit pouvoir instancier, éditer, supprimer les éléments du jeu, dans son propre contexte et dans les contextes des joueurs. Dans le cas d'un FPS en ligne, genre Counter-Strike, le serveur doit gérer les protagonistes terroristes/antis du jeu, en plus des probables caisses, des armes au sol, des grenades, et de ces cons d’otages.

Or, il est fort possible que le contexte d'un jeu puisse être différent d'un poste à un autre. Si j'instancie un gameobject sur le réseau (méthode Network.Instantiate()), qui va me garantir que son état sera le même pour tous les clients ? Après que le serveur a créé l'objet, comment il va demander aux clients de modifier cet objet - en particulier - en cours de partie ?
C'est simple: il faut pouvoir identifier cet objet sur le réseau, et que son ID soit le même et connu de tous.


Le second point qu'il faut résoudre, avant de s'amuser à envoyer des messages à la gueule du serveur, c'est d'identifier un gameobject dans une partie.

SI on travaille traditionnellement à base de Network View, il n'est pas nécessaire de définir un ID pour chaque entité: C'est la ViewID du composant qui fait tout le travail (sauf si on s'amuse à le manipuler comme un fou).

Dans notre cas, il faut définir nous même cet ID, pour chaque entité, après chaque Network.Instantiate(). Etant donné que le serveur est roi, c'est lui qui va définir l'ID pour chaque nouvelle entité, avant de les transmettre aux clients.
Pour ca, j'ai créé un composant NetworkGameObject qui contiendra un champ ID. Tous les gameobjects qui seront influencés par le module réseau auront ce composant.


Et là, c'est le drame.

http://www.nooooooooooooooo.com/vader.jpg


Il manque quelque chose dans cet alchimie.
La méthode Network.Instantiate() est une sorte de RPC: elle invoque l'objet sur le poste propriétaire, avant de demander aux autres postes de l'invoquer chez eux.
De plus, il est très pratique: c'est un RPC bufferisé. Si des joueurs rejoignent tardivement le serveur, ils intercepteront quand même l'appel de cet RPC (et invoqueront lucifer à leur tour... what ?).

Là ce n'est pas le problème.
Après que l'objet soit créé, le composant NetworkGameObject va générer un ID du côté serveur, avant de l'envoyer aux clients, dans un autre appel RPC.


Interrogation!


Quel composant faut il utiliser, sur un gameobject, pour que son appel RPC fonctionne ?
Un composant Network View


Etant donné que je souhaite centraliser tous le réseau, pensez-vous que mon gameobject en question possède ce composant ?
NON!
Traduction: j'ai perdu 30 minutes pour trouver ca.




Du coup, malgré mon effort de vouloir centraliser toutes les échanges réseaux, j'ai du ajouter le composant Network View, en plus du composant NetworkGameObject à mon objet instancié.

Bon. C'est un mal pour un bien. Avec ce composant en plus, j'ai pu implémenté une gestion automatisée des ID. En ajout, ce composant est très important pour gérer la suppression de l'objet sur le réseau.

Petite note d'ailleurs: Quand on supprime un objet du réseau, il faut aussi penser à supprimer le fameux RPC bufferisé de notre méthode Network.Instantiate(), pour l'objet en question. Si on ne le supprime pas, ceux qui rejoignent le serveur en cours de partie vont encore créer cet objet (qui est mort).



Ouf.
Mais ce n'est pas finis.

Car créer un gameobject sur le réseau, c'est cool. Mais dans le cas où on créé un gameobject pour un joueur en particulier (un personnage terroriste dans Counter-Strike, par exemple), il faut pouvoir le "lier" au joueur et le gérer quand ce dernier le demande. Son ID ne suffira pas et il faudra le stocker quelque part.

La chance est que mon serveur réserve déjà une "zone de stockage" (ou une liste) pour chaque client connecté. J'ajoute donc la référence de mon gameobject dans cette liste, pour le client spécifique.


A partir de là, tout dépend des règles de notre jeu et de comment on va gérer notre objet.



Comme d'habitude, si il y a des erreurs, n'hésitez pas avec les tomates.

Black Wolf
28/10/2014, 16h50
(A propos de TNet) Je jetterai un oeil à ca à la fin du projet. Mon but est de déjà faire fonctionner l'engin à partir des composantes de bases d'Unity, pour l'instant ;)


Au fait TNet est exactement ce que tu fais :) une librairie construite sur les fonctions de bases d'Unity. Vu que ton but est d'apprendre en le faisant par toi même c'est parfait, mais si tu sèches sur un concept ou autre ça peut toujours te donner une source d'inspiration.



C'est un peu plus merdique que ca: vu que je ne peux pas passer directement un fichier C# en paramètre, j'ai du créer un gameobjet qui utilise un composant qui utilise mon interface, avant de passer cet objet en paramètre. Je pense qu'il y a meilleure solution, mais je ne voulais pas me prendre la tête pour le moment.

Pour simplifier un peu tu peux passer juste le composant en paramètre plutôt que tout le GameObject, mais tu as peut être mal formulé et c'est déjà ce que tu fais. Sinon tu n'est pas forcé avec Unity d'utiliser que des GameObjects. Tu peux très bien créer une classe C# standard et la passer en paramètre si t'en a besoin, mais comme tu dis c'est pas une priorité pour le moment.

Hideo
28/10/2014, 23h34
Très intéressant, merci de prendre le temps d'écrire tout ça lucsk c'est super instructif :)

Louck
29/10/2014, 11h21
Au fait TNet est exactement ce que tu fais :) une librairie construite sur les fonctions de bases d'Unity. Vu que ton but est d'apprendre en le faisant par toi même c'est parfait, mais si tu sèches sur un concept ou autre ça peut toujours te donner une source d'inspiration.
Ah d'accord.
Je jetterai un oeil plus tard, ca peut servir :). Pour l'instant, j'avance en mode "autodidacte".



Pour simplifier un peu tu peux passer juste le composant en paramètre plutôt que tout le GameObject, mais tu as peut être mal formulé et c'est déjà ce que tu fais. Sinon tu n'est pas forcé avec Unity d'utiliser que des GameObjects. Tu peux très bien créer une classe C# standard et la passer en paramètre si t'en a besoin, mais comme tu dis c'est pas une priorité pour le moment.
C'est ca.
Je sais qu'on peut passer autre chose que des GameObjects en paramètre d'un composant, dans l'éditeur. Mais je ne voulais vraiment pas me prendre la tête sur le moment :p.


J'ai corrigé/édité quelques phrases de mon étape 2. J'étais un peu fatigué ce jour là.

Louck
04/11/2014, 11h20
Petit message pour dire que je suis très occupé ce mois-ci, pour travailler sur mon projet technique.
Je tente de faire avancer mon architecture réseau, mais lentement, afin d'avoir de la matière à présenter :).

Louck
17/12/2014, 23h38
Oooohhh que vois-je en ce mois de décembre ?
Du temps libre !
Youpie!


ETAPE 3: L'envoi des paquets clients au serveur
Maintenant que le nécessaire est en place, c'est le moment idéal de débuter la communication entre notre client et notre serveur chéri.
Pour le contexte, on va supposer que notre but (enfin, mon but) est de créer le jeu suivant:
CUBE MMO SIMULATOR

En gros, le jeu consiste en 3 principes:

Un joueur = Un cube dans le jeu.
Le joueur peut déplacer son cube sur un espace en 2D.
Si le joueur se déconnecte, le cube génère une explosion (parce que https://www.youtube.com/watch?v=v7ssUivM-eM)


Easy! Commençons.

http://24.media.tumblr.com/tumblr_m5602sLrWJ1qiyytzo1_400.jpg

Quand le client se connecte au serveur, ce dernier doit générer un cube pour le joueur. Sinon, avec quoi il va pouvoir jouer ? Radin!
Pour ca, j'ai prévu une méthode abstraite OnClientRegistered() dans mon interface NetworkGameRules. Pour mon jeu, cette méthode va simplement instancier une prefab "cube" sur le réseau (via Network.Instantiate()).
Ensuite, je précise au serveur que ce nouveau objet est actuellement contrôler par notre joueur. Cette information m'aidera pour la suite (cf étape 2).


Ca, c'étais la partie la plus facile.
Maintenant, on va s'amuser.


L'objectif du client est d'informer le serveur des "commandes" ou actions du joueur.
Dans l’absolu, le client peut envoyer n'importe quoi au serveur: la position actuelle du joueur, l'animation courante, où il vise, etc etc... Mais il faut faire attention à deux choses:

A ne pas trop surcharger inutilement le paquet. Je reviendrai là dessus dans un prochain chapitre sur l'optimisation.
Mais surtout: aux tricheurs.


Il faut savoir qu'il est TRES facile d'envoyer un paquet personnalité au serveur ou autres autres joueurs, sans être un génie en hacking. De même, il n'est pas difficile de lire les paquets qui s'échangent sur le réseau. En lisant ces paquets, il est possible de connaitre certaines informations sur la partie - comme la position des adversaires - et de réaliser un wallhack ou un aimbot.

http://getfreehack.com/wp-content/uploads/2013/10/BF4-Hacks-Battlefield-4-Cheats-Tool-Free-Aimbot.png

La seule solution face à ce problème est de contrôler les paquets (s'ils sont au bon format, si les données sont correctes, ...) et de restreindre les informations à translater. Mais c'est beaucoup plus facile à dire qu'à faire, et les tricheurs trouveront toujours une alternative.


Cependant, il existe un problème avant de parler d'optimisation et de tricherie: quelles données faut-il envoyer sur le réseau ?

A un moment donné dans la réalisation du jeu, le game designer doit concevoir des interfaces qui lient le joueur au jeu et/ou à son avatar: le monde à afficher à l'écran, le HUD, les menus... et l'interface d'entrée (ou les contrôles du jeu).
Pour mon jeu, les commandes sont:

Ma souris pour cliquer sur le bouton "Connecter".
Les "touches fléchées" pour déplacer le cube.
Le bouton "Echap" pour se déconnecter automatiquement de la partie.


Au niveau du réseau, il n'est pas nécessaire de translater toutes les actions du client. Tout dépend du jeu et des nécessités pour faire fonctionner une partie multijoueurs.
Par exemple, dans le jeu de carte Hearthstone, il est utile d'informer le joueur adverse de ce qu'on fait avec nos cartes. Mais il n'est pas important de lui informer de la position de notre souris et de ses cliques les bords du plateau.

Pour mon jeu, c'est très simple: seul les commandes de déplacement du cube et les boutons "Echap" et "Connecter" seront envoyées au serveur.
Dans l'interface NetworkGameRule, j'ai ajouté deux méthodes abstraites:
- Du côté client - BuildInputState(): Doit retourner un code qui correspond aux commandes exécutées par le joueur. Si le joueur appuie sur la touche "Flèche haut", la méthode retournera le code 1. Le bouton "Flèche droite" retourne le code 2. Si le joueur appuie sur ces deux boutons, la méthode retournera le code 3 (1 + 2). J'utilise une énumération afin de bien lister les actions et leurs codes.
- Du côté serveur - ExecuteInputState(): Cette méthode exécutera les fonctions du jeu, selon le code client. Par exemple, si la méthode reçoit le code 1 de notre client, alors elle exécutera la fonction de déplacement du cube, dans la direction "haut".


Après avoir définie les données, il faut les envoyer.
La technique la plus connue est d'envoyer les informations à une certaine fréquence, nommée tickrate. Un tickrate à 60 signifie que le client (ou serveur) enverra un paquet 60 fois par seconde. Dans cette configuration, un paquet est envoyé toutes les 16 millisecondes (1000/60). En théorie, plus le tickrate est grand, plus le contexte de la partie sera synchronisée et cohérente pour les joueurs et le serveur, mais plus la bande passante sera utilisée.
Dans mon architecture, la méthode Update() contient le code nécessaire pour l'envoi des données, du côté client et et du côté serveur. La fréquence d'exécution de cette méthode est proportionnelle au tickrate.

Dès que le client peut envoyer les données, il génère un code avec la méthode BuildInputState() et il l'envoie au serveur via un appel RPC: networkView.RPC("MethodeDuServeur", RPCMode.Server, code).
Du côté serveur, après avoir contrôlé le paquet reçu par l'appel RPC, il exécute la méthode ExecuteInputState() avec le code du client et met à jour sa partie.
Et c'est finis!
Tout fonctionne!
Le cube se déplace selon la volonté du joueur sur l'écran du serveur!

http://tof.canardpc.com/preview/297862df-c897-4948-aa70-84d5364ba4d2.jpg (http://tof.canardpc.com/view/297862df-c897-4948-aa70-84d5364ba4d2.jpg)
The next gen is here.


Il y a quand même un petit problème sur ma façon de procéder.
Actuellement, le client informe le serveur des commandes qui sont exécutées par le joueur. Mais quid des commandes un peu plus exotiques, autres que des boutons ? Comment informer le serveur que le joueur a déplacé sa souris de 10cm ? Ou qu'il utilise le joystick de sa manette xBox?
A mon avis, la solution serait de faire un second appel RPC, qui informera le serveur de ces commandes supplémentaires, qui ne peuvent être traduites par la méthode BuildInputState().
Mais nous reverrons cela dans un prochain chapitre.



Je m'attaque à la prochaine étape, très bientôt ;).

Rodwin
02/01/2015, 14h40
C'est quand "très bientôt" ?
Merci en tout cas, ce que tu décris est très intéressant.

Louck
02/01/2015, 20h06
Dès que je trouve le temps, pour ne pas mentir. J'essaye de faire la suite la semaine prochaine ;).

Louck
06/01/2015, 00h47
ETAPE 4 : L'envoi des paquets serveurs aux clients

L'envoi des informations du client au serveur est simple. La réciproque n'est pas non plus compliquée.
Techniquement, c'est la même chose: le serveur construit un paquet à partir de ses données, et l'envoi à tous ses clients via un appel RPC. Les clients liront le paquet et mettront à jour leurs contextes.
La seule différence avec l'envoi client => serveur, c'est les données à envoyer.

Jusqu'à maintenant, le serveur reçoit toutes les commandes des clients et met à jour son environnement. Le serveur est celui qui possède le contexte le plus à jour. Ainsi, son objectif est d'informer les clients de l'état de la partie à un instant T: Il envoi donc un snapshot de son contexte.

On peut aussi parler de snapshot du côté client: il envoi un "état" de ses actions au serveur. Durant tout le jeu, seul les snapshots clients et serveurs sont translatés sur le réseau, à l'exception pour la connexion/déconnexion/instanciation. Durant tout le jeu, les clients et les serveurs s'informent, sans repos, de l'état de leurs contextes. Je viens de le remarquer maintenant. J'aurais du parler de ca depuis le début. My bad.

https://developer.valvesoftware.com/w/images/e/ea/Networking1.gif

La question est de savoir ce que va contenir le snapshot serveur, ou de comment traduire le contexte d'un jeu en données.

Comme je l'ai répété à plusieurs reprises dans les précédentes étapes, les données dépendent du jeu lui-même. Or, je cherche à réaliser une architecture qui peut être réutilisée pour de nombreux jeux... tant que c'est réalisé via Unity. Du coup, je dois trouver un moyen pour traduire le contexte d'une scène sous Unity, en données.


Une scène d'Unity est composé d’entités (ou de gameobjects). Dans l'étape 2, j'ai pu générer un identifiant réseau pour certaines entités du jeu. L'idée serait de récupérer les informations de chacune de ces entités identifiées.
Pour mon jeu, les cubes se déplacent et changent de position. Seule cette information est importante.
Pour d'autres jeux, la position des entités n'est pas exclusivement importante. Dans les jeux en 3D, la rotation des entités est très utile. Sur certains FPS et RPG, connaitre l'équipement que porte les personnages est essentiel.

Plus l'entité possède de nombreux états, plus il y aura de données à gérer, plus le snapshot sera important, et plus le paquet sera... gros. Mais c'est normal: c'est le serveur qui gère tout le réseau de la partie. Il est responsable et doit posséder une bonne connexion pour échanger les données avec TOUS les joueurs de la partie. Contrairement au client qui ne communique qu'avec le serveur.
Pour autant, il ne faut pas trop surcharger le paquet. Mais nous verrons ca dans un prochain chapitre, sur l'optimisation. Pour l'instant, je reste simple et naïf sur la réalisation de mon architecture multijoueurs.

http://1.bp.blogspot.com/-i9AIfnIKlS0/TkjEEpwEEmI/AAAAAAAAAGE/7sktOUZmuwc/s1600/client-server.jpg

Bref!

Pour mon jeu, pour chaque cube, je récupère leurs identifiants et leurs positions via ma méthode abstraite BuildGameObjectSnapshot(), dans l'interface NetworkGameRules. Je ne m’embête pas, je traduis toutes ces données au format texte avec un séparateur (qui doit être un caractère très unique, et qui ne doit pas se confondre avec les autres caractères utilisées par la donnée utile. Genre le code 254 ou 255 de la table ASCII).
Le résultat est une longue chaîne de caractères qui sera mon snapshot. Et donc mon futur paquet.

Précision: un snapshot n'est pas un paquet. Dans mon cas, un snapshot, c'est une donnée. Un paquet, c'est un "conteneur" de données, dans le cadre d'une transmission sur le réseau.
Je le précise au cas où il y a des non-développeurs qui me lisent ;).

Après avoir récupéré le snapshot via un appel RPC du serveur, le client le découpe en plusieurs "mini-snapshots" (selon le caractère séparateur défini). Enfin, pour chaque entité du jeu et son mini-snapshot dédié, il fera appel à la fonction abstraite ExecuteGameObjectSnapshot() qui appliquera les modifications d'états sur l'entité en question.
Dans mon jeu, le mini-snapshot contient la nouvelle position du joueur. La méthode va simplement déplacer mon cube à sa nouvelle position.


Enfin! Le contexte du client est synchronisé sur celui du serveur. Je peux voir mon cube bouger sur mon écran de joueur :) ...




... à quelques millisecondes prêts :|. Merci au temps de latence, le plus grand fléau du jeu-vidéo.

http://tealtalkblog.files.wordpress.com/2014/03/h673b8d2c.jpg


Dans la technique, tant que nous maîtrisons l'envoi des données du client au serveur (étape 3), la technique est identique pour la situation inverse.
Néanmoins, le plus grand défi du jeu en réseau se réside dans la donnée - savoir ce qu'il faut envoyer et ce qu'il ne faut pas envoyer.
Ca, et le ping.
Et les tricheurs.


L'essentiel est en place. Il ne manque plus que la... déconnexion!
La prochaine étape sera très courte. Mais j'en profiterai pour en faire le point, et pour planifier le second chapitre qui sera.... très intéressant.

Zouhh
06/01/2015, 15h28
Salut !

M'étant déjà intéressé au développement de jeux vidéos, je m'étais bien entendu un peu penché sur le fonctionnement d'une architecture client/serveur.
Cependant, j'étais (et je suis toujours) loin d'être un bon développeur. Mes seules expériences étant de la prog en école d'ingé ou du dev de sites webs, je me suis heurté à pas mal de problèmes de compréhensions...

Et là je suis tombé sur ton topic qui est TRES intéressant ! :)
Merci beaucoup de partager tout ça avec nous ! :)

Louck
09/01/2015, 11h06
Merci pour les encouragements :).

Pour l'instant, ce chapitre n'est qu'une mise en place du réseau. C'est la version bête et logique de comment les joueurs peuvent communiquer entre eux.
Les prochains chapitres vont être un peu plus technique.

Je prévois un chapitre sur l'optimisation du réseau (Unity fait déjà un gros travail, mais je vais quand même faire mumuse avec Wireshark). Et un autre - sûrement le plus intéressant - sur la gestion de la latence dans les jeux (Prediction, Interpolation/Extrapolation, etc...).

Quand l'architecture sera finis, je vais peux être réaliser un mini-jeu d'action multijoueurs, jouable sur le web, pour que tout le monde puisse tester :).

Louck
27/01/2015, 14h55
ETAPE 5 : La déconnexion du client (et aussi du serveur, mais on s'en fout de lui)

La déconnexion est l'inverse de la connexion: au lieu d'instancier les éléments, nous les détruisons. C'est la partie la moins complexe à gérer. Ca se résume à:

Si le client se déconnecte, nettoyer la partie de toutes ses traces.
Si le serveur se déconnecte, "finaliser" la partie et débrancher tous les clients.


Pour cette étape, on va surtout parler de la déconnexion cliente: La déconnexion du serveur est bien gérée par Unity et invoque automatiquement le callback OnDisconnectedFromServer(). A ce moment là, on peut demander aux clients rejetés de revenir à la page d'accueil. Pas de problèmes :).


Du côté de la déconnexion cliente, l'unique difficultés est de gérer le "lien" que possède les objets du jeu avec le joueur. Si un objet est lié au joueur (car créé et/ou géré par lui) mais est nécessaire au déroulement de la partie, faut-il le garder ou le supprimer ?

Exemple très classique: La joueuse Alice tire une balle en direction de son adversaire Bob. Faute de posséder une FAI de merde, Alice subit une coupure réseau et se déconnecte - soudainement - de la partie. Est-ce que la balle tirée est supprimée de la partie (car dépendante de la tireuse) ou doit-elle continuer son chemin et atterrir dans le corps de Bob ? Si la balle touche sa cible, quel score allons-nous afficher ? "NULL a tuer Bob" ?
Prenons un autre exemple: Carol construit une maison dans le jeu très populaire MonCraft. Carole possède le même FAI qu'Alice, et subit - elle aussi - une déconnexion forcée. Est-ce que la maison construire par Carol doit être détruite suite à sa déconnexion ?


Selon le gamedesign, soit on garde les entités gérés par les joueurs, soit on les détruit. La solution la plus simple, mais pas forcement la meilleure, est de tout supprimer.
Si la suppression n'est pas la réponse, alors nous devons rendre ces objets persistants: Créé et/ou gérer par un joueur, mais qui peut fonctionner sans lui. Ces entités n'ont pas de vrai liens avec le joueur, mais ont un simple mémo "Créé par Carol". Ces éléments sont dépendants de la partie et non du joueur.

D'oû le terme "Monde persistant" dans les MEUPORG.

http://www.nomnomnom.fr/wp-content/uploads/2010/03/meuporg-17.jpg

La persistance n'est pas difficile à gérer. L'objectif est de simplement dissocier les objets des propriétaires et de les faire fonctionner avec et sans les autres joueurs. Mais il ne faut pas non plus tout séparer: Certaines entités sont très dépendantes de leurs auteurs et leurs suppressions peuvent être préférables... dont la balle tirée par Alice.
Pour conclure: tout dépend de la conception du jeu, ou de son gamedesign. Encore :tired:.


Pour mon jeu, je souhaite supprimer les cubes et générer une explosion à leur disparition. Je met en place la solution suivante:
- Quand le joueur se déconnecte du jeu (brusquement ou via Network.Disconnect()), le callback OnPlayerDisconnected() est invoqué du côté serveur.
- Je supprime toutes les données et instances liées au client (dont le cube) et son espace de stockage. Il existe une jolie fonction pour détruire les gameobjects sur le réseau: Network.Destroy()
- Je vide tous les RPC bufferisés du client. Seul le RPC "Création du cube" est bufferisé. Sa suppression est nécessaire pour empêcher la création d'un cube "fantome" quand de nouveaux joueurs rejoindront la partie.
- J'informe la disparition du cube aux autres joueurs et je génère une explosion à sa position.

Actuellement, le seul moyen que j'ai pour informer les autres clients de la déconnexion de notre joueur aimé, c'est via un snapshot du serveur. Mais étant donné que cela arrive que dans une situation assez unique (le joueur se connecte et se déconnecte qu'une seule fois durant sa session) je trouve plus intéressant d'envoyer cette information via un autre appel RPC, sans surcharger le snapshot.
Pour cela, j'ai créé les méthodes SendRawData() et ReadRawData() pour les clients et le serveur (plus techniquement, dans la classe SharedNetwork). Via ces fonctions, je peux envoyer n'importe quelle donnée aux membres du réseau, que ce soit un message ou une action spécifique. Comme la déconnexion d'un joueur.

De cette façon, quand le client se déconnecte de ma partie, le serveur envoie un message "explosion" aux autres clients (SendRawData()). A sa lecture (ReadRawData()), le client générera un effet graphique - une explosion de pixels - à l'emplacement du cube disparu.


Et c'est sur ce dernier point que je finis la réalisation de la première version de mon architecture multijoueurs. Hallelujah!

http://danceswithfat.files.wordpress.com/2011/08/victory.jpg

Mais ceci n'est que le premier pas. La réalité est qu'il reste encore beaucoup de choses à gérer, dont les problèmes de latence, la physique du jeu et les pertes de synchronisations. Au mieux.

On en reparlera de tout ca... Dans un nouveau chapitre ;).



Désolé pour le temps que j'ai mis pour publier toutes ces étapes. Etant bien occupé en ce moment, il me faut plus de deux semaines pour avancer ce projet ET pour mettre à jour le devlog. D'ailleurs, je vais avoir besoin d'un peu plus de temps pour préparer le prochain chapitre.
J'ai quand même pour objectif de finir ce bousin pour cette année, avec un mini-jeu en prime.

Le premier obstacle est passé, le plus fun arrive :).

http://www.3cx.com/wp-content/uploads/Wireshark_Icon.png


See you soon!

Hideo
28/01/2015, 03h10
http://www.drodd.com/images10/clapping-gif1.gif

Merci pour ces retours, à chaque nouvel épisode c'est un réel plaisir.
Je touche pas du tout à Unity ('fais de la prog par ailleurs) et justement c’était un sujet sur lequel je me posais pas mal de questions !

J'attend le suivant avec impatience, hâte de voir la suite ;)

Zouhh
05/02/2015, 12h11
Je pense que pour gagner en visibilité, tu devrais faire des liens vers chacun des chapitres dans ton tout premier post ! :)
Merci beaucoup en tout cas !

Louck
08/02/2015, 21h19
J'y pense à faire un sommaire dans le premier post de ce topic. Je le ferais en même temps que l'introduction du chapitre 2 :).

Chose intéressante en passant, la couche transport personnalisé d'Unity gère beaucoup de choses... tout en étant UDP. En gros, ils font le gros du travail pour l'optimisation des transferts des paquets.
Mais il n'y a pas que ca que je peux optimiser ;).

Louck
03/03/2015, 15h00
CHAPITRE 2: LA MENACE DU LAG

Nous avons mis en place une architecture multijoueurs classique. Rien de fantastique, à l'exception du fait que je n'utilise que les RPC pour faire fonctionner le bousin. Techniquement, ca marche. Mais elle peut être améliorée sur plusieurs points. D’où ce chapitre, sur l'optimisation des paquets.


Etape 1: Le calme avant la tempête

Avant de continuer, j'ai du modifier certaines éléments sur mon jeu de cube. Contrairement à ce qui était marqué dans le premier chapitre, tout n'était pas parfait. En résumé:

Lorsque le serveur ou le client se déconnectait de la partie, les entités du jeu étaient toujours présentes sur le poste du client. La solution est de tout nettoyer... ou de recharger simplement la scène.



Il n'y avait aucune indication pour que le joueur repère son propre cube. Mon but est d'afficher le cube du joueur propriétaire en rouge, mais uniquement sur son écran. Il me fallait donc une méthode pour exécuter du code côté client (au niveau de l'interface).
J'ai alors modifié le composant NetworkGameObject qui servait jusque-là à gérer les identifiants des objets sur le réseau. Je lui ai ajouté un nouveau paramètre "script". Ce script s'exécutera seulement si le joueur est propriétaire de l'objet en question et uniquement sur le poste du dit joueur (les autres ne verront pas les changements).
J'ai rapidement créé un petit script (plutôt un composant, pour simplifier le truc) qui change la couleur du cube en rouge. Si le joueur est propriétaire du dit cube, ce dernier sera peint en rouge.
J'en ai profité pour rajouter un autre paramètre script, mais du côté serveur. Peux-être que j'améliorerai ce système plus tard.



L'entrée utilisateur (clavier, souris, joystick...) est testée uniquement à la génération du snapshot. En gros, le joueur a un délai extrêmement court, à une frame prêt (= quelques millisecondes) pour que ses commandes soient traitées par ma fonction. En-dehors de ce laps de temps, ca ne fonctionne pas.
Sur Unity, le buffer d'entrée n'est géré que nativement, nous n'avons pas accès. Du coup j'ai du le gérer à ma façon, via une méthode qui s'exécute à chaque frame (Update()) qui met à jour une liste d'entrée (le buffer) qui sera lu au moment de la génération du snapshot client.



Le déplacement du cube était dépendant du tickrate: J'appliquais l'action de mouvement (Transform.Translate()) à la lecture du snapshot. Avec un tickrate à 100 c'est assez fluide. Mais avec un tickrate à 1 ?
Ma solution est d'utiliser une sorte d’interrupteur, qui change son état activé/désactivé à la lecture du snapshot client par le serveur. Dans la méthode Update() du cube, tant que l’interrupteur est activé, le serveur déplace l'entité dans la direction définie par le joueur.


Il y a eu d'autres broutilles, mais ce n'étais rien comparé à tout ca.

http://rules.ssw.com.au/Management/RulesToSuccessfulProjects/PublishingImages/bug-feature.png

Aujourd'hui, mon simulateur de cube marche à merveille :). Mais ce n'étais pas compliqué, vu le peu de données à gérer sur le réseau. Si je devais réaliser un jeu beaucoup plus important, avec de la 3D, une bonne IA et une gestion de l'inventaire, ca ne serait pas aussi facile.


Dans ce chapitre, nous allons toucher à la forme de la donnée et sur comment elle est lu sur le réseau. Je vais optimiser tout ce bordel.
Par contre, il faudra considérer que n'importe quel jeu peut translater n'importe quelle donnée sur le réseau, sans perdre le moindre détail: il faut préserver le "fond" de la donnée (= l'action de se déplacer, d'utiliser un objet, d'attaquer un monstre, etc...).


Unity se charge déjà du gros du travail avec l'API RakNet. En utilisant le protocole de transport UDP, l'API ajoute une couche de contrôle pour s'assurer que les paquets arrivent "sans défauts" à destination (= gestion du packet loss). La bibliothèque comporte d'autres fonctionnalités comme la compression des données ou le cryptage des paquets.

Néanmoins, cette API est utilisée nativement dans le framework Unity et elle n'est pas accessible pour les développeurs. Il nous ai impossible de la reconfigurer à notre besoin. Il y a aussi un manque de documentation sur certains détails techniques, que ce soit du côté de RakNet ou d'Unity.


Ainsi et pour le fun, j'ai décidé de sortir mon joujou Wireshark pour voir ce que cache tous ces paquets qui sont échangés sur le réseau :ninja:.

Ci-dessous, l'échange client (port 57688) et serveur (port 2500), suite à un appel RPC avec un paramètre message "a".
http://tof.canardpc.com/preview2/23742fb3-fa13-4593-b271-9b00421ba19a.jpg (http://tof.canardpc.com/view/23742fb3-fa13-4593-b271-9b00421ba19a.jpg)

Plus en détail:
http://tof.canardpc.com/preview2/9cca7768-2b1f-4298-b865-334cfc079020.jpg (http://tof.canardpc.com/view/9cca7768-2b1f-4298-b865-334cfc079020.jpg)

Comme on peut le voir, la partie Data (= données, pour les noobs de l'anglais) n'est pas lisible. Est-ce lié à la compression de données ? A l'encryption ? Mystère!



J'ai analysé les échanges de paquets, pour mon jeu de cube. J'ai pu notifier certaines choses:

La taille de l'en-tête de ces paquets est de 28 octets (20 octets pour le protocole réseau IP et 8 octets pour le protocole transport UDP, normal). Pour la partie Data, on retrouve la couche RakNet qui réserve 28 octets au minimum. Le reste du Data correspond aux informations de l'appel RPC (dont le nom de la fonction) et à ses paramètres s'il y a. En invoquant mon RPC sans paramètres, le paquet pèse au total 69 octets (dont 13 octets de "données utiles").

A la réception du paquet, le destinataire émet un paquet de 43 octets (15 de Data) pour l'envoyeur. Sûrement un paquet ACK (ou "accusée de réception"). Ou pour lui dire Merci et que sa famille va bien imothep.

En dehors de ces appels RPC, le framework translate beaucoup de paquets (environ une dizaine) entre le client et le serveur, toutes les 5 secondes en moyenne. Cette fréquence semble varier selon l'activité du réseau. Malheureusement, je n'ai aucune idée de ce qu'ils transmettent :/.

La compression des données semble se perfectionner sur le temps, selon la documentation RakNet. Les informations de compression sont translatés sur le réseau. Mais comment Unity gère ca ? I dunno lol! Pour l'instant ce que je sais, c'est que la compression n'est pas "parfaite" dans les premières échanges. Je dois le retester sur la durée.

Une partie de la Data (dans la couche RakNet ?) est réservée aux informations d'encryption et de compression. Sa taille est proportionnelle à la taille de la donnée utile.

Les statistiques affichées par Unity n'indique que la taille de la partie Data (couche RakNet compris), sans compter celle des en-têtes (IP+UDP). Pour un manque de précision de 28 octets, on ne va pas se plaindre :p.


Fâcheusement, sans documentation, je n'en sais pas plus sur les données transmissent par tous ces paquets. J'ai pu trouver des détails concernant la couche RakNet ici (http://www.jenkinssoftware.com/raknet/manual/systemoverview.html) mais c'est tout :/. Il est possible que je raconte des conneries aussi, mais tout me semble logique.

Néanmoins, je peux conclure sur une chose.
Oû l'optimisation aura le plus d'impact, ce n'est pas sur la taille du paquet que nous transmettons, mais sur la fréquence des échanges clients/serveurs. On le néglige beaucoup, mais ce qui est le plus coûteux dans le transfert du snapshot client, ce n'est pas la donnée (4 octets dans mon cas) mais les en-têtes du paquet et la couche RakNet (56 octets minimum).
Or, il ne faut pas restreindre le tickrate, au risque de rendre le jeu moins fluide. Au mieux, il n'est pas important d'avoir un tickrate supérieur à 100 paquets/secondes (sauf pour les PGM qui ont des réflexes surhumains). Mais cela dépend des besoins du jeu: est-ce qu'il est nécessaire de mettre à jour aussi fréquemment le contexte de la partie ?

http://cdn-static.gamekult.com/gamekult-com/images/photos/30/50/23/73/titanfall-de-limportance-dun-bon-tickrate-ME3050237385_2.jpg

De même, cela ne veut pas dire qu'il ne faut pas optimiser la taille des données. Certains joueurs ne possèdent pas la fibre, ni une connectique ADSL 100% optimale (comme moi). Un peu de respect pour eux (et pour moi :emo:).


Au final, je peux optimiser deux choses:

La forme du snapshot: Que nous évitions d'envoyer 1MO de data par paquet.
La fréquence d'envoie de ces snapshots: Pour ne pas envoyer des paquets qui servent à rien aux autres.



Ainsi, nous débutons le sujet par la révision de la forme des snapshots client et serveur ;).
See you soon!



Petite précision: Durant ces tests, je n'utilise pas la toute dernière version d'Unity. Récemment la société a prévue de revoir toute l'API réseau et il y aura sûrement des modifications. Je n'en sais pas plus, malheureusement.

Je vais essayer d'accélérer la cadence de mes posts. J'ai bien dis "essayer". Je veux finir mon architecture (avant d'attaquer les "services", comme le masterserver) au maximum fin août, afin d'avoir le temps pour réaliser un mini-jeu pour la fin de l'année.

Louck
04/03/2015, 11h31
Note en passant: dans les prochaines versions d'Unity, ils vont mettre ne place leur propre framework réseau, sous le doux nom d'Unet.
Aucune idée s'ils vont remplacer le fonctionnement des State Synchronization ou des RPC.

En attendant, je reste sur l'ancienne version d'Unity. Quand le nouveau API sortira, je ferais un mini-chapitre là dessus :).

Louck
15/03/2015, 20h35
Etape 2: Un vrai snapshot

Dans le premier chapitre, je m'amusais à envoyer des valeurs entières ou des chaines de caractères sur le réseau, dans l'espérance d'avoir bien optimisé le système.
Après vérification, c'étais de la merde.


Aujourd'hui, nous allons faire un snapshot "propre". Ca ne sera plus une suite de chiffres ou de caractères pas très lisibles. Ca sera un objet composé de champs, chacun représentant une donnée du jeu (position, rotation, etc, vous aurez compris). Je cherche à rendre beaucoup plus simple la gestion des snapshots dans mon architecture. Nous allons utiliser des objets.

Je vais passer de ca:
"0.7|0.4"

à ca:
class SnapshotClient{
float inputX = 0.7f;
float inputY = 0.4f;
}


Le problème d'utiliser des objets ou des structures pour représenter les snapshots, c'est de trouver un moyen pour les translater sur le réseau. Nous pouvons sérialiser l'objet en question (avec l'annotation [Serializable]) mais le résultat peut être... effrayant.

https://i-msdn.sec.s-msft.com/dynimg/IC20067.gif

Vous voyez ma classe SnapshotClient ? Sa sérialisation via BinaryFormatter a générée 323 octets.
323 octets!
Juste pour savoir si le joueur se déplace horizontalement et/ou verticalement!

Pour faire des sauvegardes sur son poste, ce n'est pas un problème. Mais pour du Networking, il faut éviter cette technique.


Plan B... Et si je ne sérialisais pas l'objet mais plutôt ses champs ? La seule règle serait que les champs soient de type primitifs.
Je réalise ma petite méthode qui lit les champs de l'objet (via FieldInfo), je vérifie si chacun de ces champs possède une valeur, je leur attribue un identifiant pour la futur désérialisation, et j'enregistre le tout dans un tableau d'objet.

Je sérialise le tableau.. et j'obtient 51 octets. C'est mieux, mais ce n'est pas satisfaisant.
Je pense qu'il existe une meilleure alternative que d'utiliser un tableau d'objet pour cette situation. Mais je ne me suis pas plus creusé la tête


Plan C.... je fais quelques recherches sur le net. Et je suis tombé sur ca:
Protocol Buffers (https://developers.google.com/protocol-buffers/)

Je tente le coup!
J'ajoute les annotations ProtoContract et ProtoMember à mon objet SnapshotClient, je modifie un chouia ma méthode pour sérialiser et désérialiser. Et je test.
Je sérialise l'objet et j'obtiens une suite de 10 octets! :lol: C'est déjà beaucoup plus intéressant.

J'en ai profité pour un autre test: J'ajoute un nouveau champ "keyCode", de type entier, à ma classe SnapshotClient. Avant la sérialisation, seul ce champ possède une valeur (les autres sont définies à NULL).
J'exécute ma méthode sérialisation et je reçoit.... 2 octets! Excellent! :lol: :lol: :lol:

En testant un peu plus, cette bibliothèque se révèle être un bijoux. Elle est très performante et elle cherche à s'adapter aux données qu'on lui donne :). C'est LA solution à mon problème se sérialisation.


Maintenant, il faut mettre en place un vrai système de snapshots.
Je créé donc deux interfaces et une classe:

ISnapshotClient: Contient les instructions et inputs du client, à envoyer au serveur.
ISnapshotGameObject: Un snapshot pour chaque élément du jeu, contenant son état (sa position, son équipement, etc...).
SnapshotWorld: Un "container" de ISnapshotGameObject, qui sera envoyé au client.


Chacune de ces interfaces contient les méthodes abstraites suivantes:

Reset(): Met à NULL les champs de l'objet (exemple, inputX = NULL).
Update(): Met à jour les champs de l'objet par rapport à l'environnement du jeu (inputX = 10).
Execute(): Exécute X ou Y actions selon les valeurs des champs (le personnage se déplace de case horizontalement).


Aussi, j'ai revu les fonctions de la classe NetworkGameRule. Je lui ai rajouté les méthodes MakeSnapshotClient() et MakeSnapshotGameObject() qui retournent une nouvelle instance des interfaces ISnapshotClient et ISnapshotGameObject.

Le fonctionnement des nouveaux snapshots est les suivant:
Après avoir créé une nouvelle instance du snapshot, mon client/serveur fait appel à sa méthode Reset() et Update() pour le mettre à jour avec le contexte de la partie, avant de le sérialiser en byte[].
Ensuite je métamorphose le résultat en String car les appels RPC n'acceptent pas les byte[] [spoiler]dans ma version d'Unity[/b] et je le transfère aux autres participants de la partie.
Enfin, l'opération inverse se produit chez le récepteur du paquet: Je convertie le paquet String en byte[], je le déserialise pour obtenir mon snapshot, et je fais appel à sa fonction Execute(). Fin!

Dans le cas du SnapshotWorld du côté serveur, la procédure est la même, à l'exception que je créé une instance ISnapshotGameObject pour chaque entité de la partie, avant de tous les stocker dans mon objet SnapshotWorld. Ce sera ce dernier qui sera sérialisé et translaté sur le réseau.


En théorie, tout doit fonctionner jusqu'à là.












Et bien non.

http://i2.kym-cdn.com/photos/images/newsfeed/000/210/332/ohcrap2.png

Vu le nombre de messages rouges dans mon débogueur, je vais faire une simple liste des "oublis" qu'il y a eu dans mon code.
Tout d'abord, il fallait bien annoter les classes (ProtoContract + ProtoMember) ET ses interfaces.
Pour les listes (pour SnapshotWorld), il faut ajouter l'attribut OverwriteList=true dans l'annotation. Et si le type d'objet utilisé par la liste n'est pas de type primitif, il faut l'annoter. Lui aussi.

Pour continuer, idiot comme une machine, le sérialiseur ne fait pas le lien entre mon objet instancié SnapshotClient et l'interface qu'il hérite ISnapshotClient. Il faut informer le sérialiseur que mon objet est un "sous-type" de mon interface. Un petit tour de magie - RuntimeTypeModel.Default.Add(typeof(ISnapshotGameO bject), true).AddSubType(1, typeof(TestSnapshotClient)); - et c'est repartie!


Pour finir, il reste la conversion byte[] en String qui n'était pas correcte. Je fais un [i]new string(byte[]);. Oui, je suis une brute. Mais je le vaux bien.

Voyez-vous le problème ?

Le problème est très amateur: Je n'encode pas mon paquet. Un paquet non encodé peut être "illisible" pour les autres machines du réseau. Pire encore, il peut être mal interprété par certains protocoles réseaux. Dans ce dernier cas, je suis certain que le paquet n'arrivera jamais à destination.
Pour résoudre ce défaut, je reste simple et j'encode ma suite d'octets en Base64 (via Convert.ToBase64String()). Je suis sûr que je n'aurais jamais de problèmes avec ce type d'encodage :).






Et. Ca. MARCHE!


Je parle beaucoup pour juste expliquer que j'ai créé des classes et que j'utilises Protobuf.
Avant de poursuivre avec la prochaine étape, je vais mesurer la consommation moyenne de la bande passante, en local du côté serveur, avec un tickrate à 100:

http://tof.canardpc.com/view/3b6e76d1-0ccb-44b4-990d-a6ba2b7c6cdd.jpg

D'un premier coup d'oeil, nous pouvons dire que la consommation de la bande passante est assez élevée, pour si peu de données et avec un seul joueur en partie.
Cependant, il faut prendre en compte que la couche RakNet ajoute environ 28 octets dans chaque paquet et qu'il n'y a pas que des appels RPC dans ces échanges (ACK, heartbeat, et j'en passe). Même si ces informations représentent la "réalité" d'une partie multijoueurs, elles cachent la vrai donnée que je veux mesurer: la donnée utile.
J'ai fixé un tickrate très haut, ce qui est aussi la cause d'une bande passante plus élevée. Or pour les tests de ce chapitre, à mon sens c'est l'idéal.

Je modifie légèrement ma classe NetworkServer pour qu'il puisse m'informer du nombre d'octets utiles que le serveur envoie et reçoit.

http://tof.canardpc.com/view/78c5317d-76f7-465b-a1bc-56fa36d73316.jpg

Les valeurs sont en octets et sont réinitialisés toutes les secondes:
- "Send" correspond aux nombres d'octets qui sont envoyés en une seconde.
- "Receive" correspond aux nombres d'octets qui sont reçus en une seconde.
- "par packet" correspond à la moyenne d'octets envoyés par paquet.
- "nb appels" correspond au nombre d'invocation RPC.

Maintenant, avec 4 clients + 1 serveur.

http://tof.canardpc.com/view/48c1baee-6dae-4ce0-a848-9f51b388da32.jpg

Première chose qu'on peut remarquer, c'est qu'il y a en moyenne 60 invocations RPC avec un tickrate à 100. Le problème vient de ma méthode Update() des classes NetworkClient et NetworkServer qui sont synchronisées au nombre de frames :(. A voir plus tard si cela passe mieux avec un thread dédié.

Sinon, la taille des paquets reste assez élevée. En fouillant un peu plus, j'ai vu que l’encodage en Base64 a doublé la taille de mon snapshot client. De plus, Protobuf ne semble pas vouloir optimiser les listes d'éléments (probablement pour respecter la structure) ce qui n'est pas pratique quand il y a un certain nombre de joueurs en partie.

Avec ce jeu de données, le snapshot du chapitre 1 (envoi d'un entier ou d'une suite de caractères) est la meilleure solution, même si elle n'est pas maintenable. Il faudra essayer avec beaucoup plus de données ou dans un vrai contexte de jeu.
Mais pour l'instant, je continue avec cette nouvelle solution qui reste très pratique... surtout pour la suite ;).


La prochaine étape sera courte mais concernera la gestion des snapshots par clients et par entités. Ensuite, nous attaquons l'optimisation des paquets ;).



EDIT:
En faite, je dis que les prochaines étapes seront plus courtes mais c'est l'inverse :o.
Désolé pour les fautes d'orthographes, s'il y a.

Hideo
18/03/2015, 17h45
Cette joie à chaque nouveau chapitre :lol:

Louck
23/03/2015, 15h28
Merci :).

Etape 3: Gestion du snapshot
Note: Ce qui suit concernera uniquement l'architecture serveur (le client aura sa part du gâteau à la fin).


Avoir un snapshot tout beau tout propre ne suffit pas. Pour optimiser l'envoi des paquets sur le réseau, notre objectif est de réaliser un "Snapshot Delta": un snapshot qui ne contient que les changements d'états des entités, depuis le précédent envoi. Un peu comme une sauvegarde différentielle du jeu. L'idée est de ne pas transmettre des informations que les clients connaissent déjà.

Par exemple, si le joueur se déplace verticalement, seule sa position Y sera modifiée. Quand le serveur souhaite informer les clients de la nouvelle position du joueur, il est inutile de leur envoyer sa position X. Seulement sa nouvelle position Y.


Or, pour faire un delta (ou une différence) d'un objet, il faut pouvoir récupérer son état antérieur. Avant de créer notre "Snapshot Delta", il faut savoir comment stocker les précédentes créations. Mais un simple champ previousSnapshot dans la classe NetworkServer ne suffit pas.
Il faut considérer qu'un joueur ne possède pas le même contexte qu'un autre joueur et qu'il n'a pas besoin des mêmes informations que tous les autres participants de la partie. De plus les joueurs n'ont pas besoin d'avoir une vue sur tous les éléments du jeu, au même moment.
Du coup, il faut pouvoir gérer les snapshots par client ET par gameobjects.

http://fd.fabiensanglard.net/quake3/q3_network_t4.png
(Merci quake3!)


Alors, je revois la méthode d'envoi du snapshot server - SnapshotWorld - aux clients:

Au départ, je génère le SnapshotWorld et ses SnapshotGameObject du jeu. Ce snapshot doit contenir toutes les informations de la partie à un temps T.

Ensuite, pour chaque client, je génère un nouveau objet SnapshotDelta qui est techniquement une copie vide de SnapshotWorld. Je pense que vous aurez compris le but de ce snapshot.
Pour l'instant, nous n'allons pas étudier ce qui sera dans ce SnapshotDelta. Tout ce que je peux dire, c'est qu'il contiendra une liste de SnapshotGameObjectDelta qui est une version delta de SnapshotGameObject. Je traiterai cette partie dans la prochaine étape et nous allons supposer que j'ai produit par magie notre snapshot delta.

http://curezone.com/upload/Members/trapper/2013/its_magic.jpg


Avant d'envoyer notre chef d'oeuvre au client, il faut l'historier pour les prochaines générations de snapshots delta.
Pour cela, je vais réutiliser l'objet ClientGameData - où j'enregistre toutes les informations de connexion du joueur, son identifiant et les gameobjects qu'il utilise. A cet objet, je peux lui ajouter un nouveau champ: une liste de snapshots par gameobject.
Après avoir produit un SnapshotGameObjectDelta, je le duplique pour sauvegarder sa copie dans la liste que je viens de créer. L'original sera inséré dans le conteneur SnapshotDelta.
Ainsi, je viens d’historier les snapshots par client et par gameobject. Youpi tralala!

Dans un même temps, je peux copier les instances de SnapshotWorld pour le serveur, dans une autre liste. Cela ne me sert à rien pour le moment... Mais peux-être dans les prochains chapitres :teaser:.

Nous pouvons peaufiner cette gestion de snapshots en ajoutant un paramètre: si le client a besoin d'un snapshot delta ou complète (= FULL). Ce paramètre est très important, surtout quand le joueur a besoin de réinitialiser son contexte de la partie après une perte de paquet (Packet loss) ou après une perte de connexion. Ou sur la demande du joueur.


Après avoir historié tout ca, je peux enfin transmettre mon super SnapshotDelta à mon client (après avoir sérialisé et encodé le tout en Base64, bien sûr).
Il ne reste plus qu'à reproduire tout ce traitement pour tous les autres clients de la partie, et ca sera finis :p.


http://24.media.tumblr.com/tumblr_m98cwgWIQQ1r3oud1o1_400.jpg


Tout ca c'est bien... Mais je n'ai parlé que de l'architecture serveur. Et du côté client ?

Nous pouvons appliquer ce même procédé pour le client: Avant d'envoyer le SnapshotClient au serveur, nous faisons un delta avec le précédent envoi et nous historions chaque version du snapshot. C'est facile (le client ne gère qu'un seul snapshot et ne l'envoi qu'au serveur), ca fonctionne très bien et cela peut grandement optimiser la bande passante.

Sauf dans un cas spécifique.

Peux-être que je ne l'ai pas précisé plus tôt, mais il est fortement conseillé que le snapshot client ne stocke que des "états" de commandes: Le joueur appuie actuellement sur la touche "déplacement gauche", le joueur est en train de sauter, etc. Il est compliqué d'informer précisément le serveur que le joueur "tire une seule fois à un instant T", surtout quand le lag est présent. Il existe des solutions à ce problème, mais qui ne sont pas simples à mettre en place. Rendez vous dans les prochains chapitres!
Si ce genre d'information détaillée est importante pour le serveur et pour le jeu (ce qui n'est pas rare), rien n'empêche le développeur de le gérer. Par contre, il faudra que le snapshot delta puisse prendre en compte ce détail ;).


Allez, la prochaine étape, j'optimise tout ce bordel!

Louck
30/03/2015, 16h29
Etape 4: Le snapshot delta

Enfin, nous entrons dans le vif du sujet :).
Prêt pour optimiser comme un gros bourrin ?

http://images.amcnetworks.com/ifc.com/wp-content/uploads/2011/08/rambo-3-08092011.jpg

Sur ceux, j'exagère beaucoup. Nous n'allons pas réduire la taille d'un paquet de 80 octets à 4 octets en un clin d'oeil. Mais nous allons faire le nécessaire pour que notre snapshot soit beaucoup moins lourd.


Je vais utiliser les méthodes suivantes et dans l'ordre:

N'envoyer le snapshot que si c'est nécessaire, selon les conditions du joueur et du jeu.
Générer un snapshot delta.
Ne pas envoyer un snapshot "vide".


Nous allons surtout travailler du côté de l'architecture serveur. Je vais reprendre les dires de l'étape 3.


1) N'envoyer le snapshot que si c'est nécessaire, selon les conditions du joueur et du jeu.

Après avoir généré le SnapshotWorld du serveur, je veux produire un SnapshotDelta pour chacun des clients. La procédure n'est pas monstrueuse. Pour un client donné, je créé l'objet conteneur SnapshotDelta, vide au départ. Puis, pour chaque SnapshotGameObject de mon SnapshotWorld, je génère un SnapshotGameObjectDelta et je l’intègre dans mon conteneur.
Petit rappel: Chaque SnapshotGameObject correspond au snapshot d'une entité du jeu. Chaque entité a son propre snapshot, pour un moment T.

Mais avant, je dois vérifier si le client a vraiment besoin d'être informé de l'état du gameobject ou s'il a besoin de ce SnapshotGameObject.
Pour cela, j'ajoute la fonction abstraite CanBeExecuteByClient() dans l'interface ISnapshotGameObject. Cette fonction contiendra toutes les vérifications nécessaires qui dépendent du Game Design (exemple classique: "Est-ce que l'entité est visible par le joueur ?"). Cette méthode retourne un booléen. Si le résultat est FALSE, j'arrête de traiter ce SnapshotGameObject et je passe au suivant :).


2) Générer un snapshot delta.

Après les tests, je prépare le terrain pour créer le fameux SnapshotGameObjectDelta.
Tout d'abord, je dois faire une copie du snapshot SnapshotGameObject. Cette copie est très importante car je veux garder l'état du snapshot original pour l'historisation serveur et pour les prochains traitements. Sa copie subira des modifications.
Ensuite, je récupère la précédente version du snapshot en fouillant dans l'historique du client.

Toutes les informations en main, je peux commencer à comparer les deux versions du snapshot - la version antérieure et la copie de la nouvelle version - et produire notre snapshot delta :).
Cependant, si le client ne possède aucune historique pour le gameobject concerné OU si le client a besoin d'une version complète du snapshot, alors je ne fais aucune comparaison et je renomme la copie en SnapshotGameObjectDelta. C'est tout.


La comparaison entre ces deux snapshots se fera au niveau de leurs champs. Pour comparer le contenu de ces champs, quelque soit la forme du snapshot, je dois faire de la réflexion: via l'instruction snapshot.GetType().GetFields(), je récupère une liste de FieldInfo, chacun contenant les attributs d'un champ de ma classe SnapshotGameObject.
En utilisant la méthode FieldInfo.GetValue(), je récupère les valeurs des champs de mes deux instances snapshots, avant de les examiner. Si les valeurs d'un champ sont identiques, alors je fixe la valeur NULL pour ce champ, sur le snapshot copié.

C'est de cette façon que je compare une version d'un snapshot avec une autre, et c'est ainsi que ma copie de SnapshotGameObject se transforme, au fur et à mesure des comparaisons et modifications, en un SnapshotGameObjectDelta.


La suite? C'est Protobuf qui s'en charge en mettant en avant l'un de ses atouts: Lors de la sérialisation, le plugin ne prend pas en compte les champs sans valeurs pour amortir la taille de l'objet sérialisé.
Donc, en définissant les champs inutiles de mon SnapshotGameObjectDelta à NULL, Protobuf ne sérialisera que les champs nécessaires, réduisant ainsi le poids de mon snapshot avant son envoi. Le gain en taille peut être très important avec cette technique, mais nous verrons cela à la fin.


3) Ne pas envoyer un snapshot "vide".

Ce point n'est pas très compliqué à faire. C'est même très rapide: après avoir fait la comparaison des versions, je vérifie s'il existe au moins un champ qui possède une valeur dans le SnapshotGameObjectDelta. Si ce n'est pas le cas - si tous ses champs sont à NULL - alors il est inutile d'envoyer le snapshot en question. Je l'abandonne et je passe à la suite.

Pour compléter, si l'objet SnapshotDelta ne contient aucun SnapshotGameObjectDelta à translater, alors je ne le transmet pas au client.
En gros, s'il n'y a aucun changement dans le contexte du client, alors le serveur ne lui transmettra rien. 0 octets en somme. N'est-ce pas l'objectif ultime de l'optimisation ? :p.

Bon, en réalité, il y aura toujours des échanges entre le serveur et le client, même si le jeu ne bouge pas. Au moins pour savoir si l'un ou l'autre est toujours "vivant" ou présent dans la partie (hearbeat ou ping).


http://www.videobuzzy.com/images/v/400/1/Bref-Serie-Canal-plus-4902.JPG


Après avoir produit mon SnapshotGameObjectDelta, je le duplique:

L'un sera historisé pour le client.
L'autre sera stocké dans le SnapshotDelta.


Et c'est finis :). Du moins, pour ce SnapshotGameObject et sa version delta. Je dois recommencer toute cette procédure pour les autres snapshots/gameobjects contenus dans le SnapshotWorld.
A la fin, j'aurais produit mon SnapshotDelta qui sera envoyé à mon client.


C'est l'heure du test!

Contexte 1 client + 1 serveur.
Du point de vue du serveur, je me focalise sur la taille du snapshot.

Voila ce qu'il se passe quand aucun joueur ne bouge dans le jeu:
http://tof.canardpc.com/view/2e15214c-3876-4987-9276-a58da3a39457.jpg

Aucun snapshot généré par le client. Aucun par le serveur. Donc rien sur le réseau. Super! :lol:


Quand un joueur se déplace sur l'axe X ou Y:
http://tof.canardpc.com/view/ccca4040-28f1-41b8-b7f4-12e01b34eaa1.jpg

Quand un joueur se déplace sur les deux axes:
http://tof.canardpc.com/view/6ed11753-8b0e-4617-b43e-7e6892034f4b.jpg

Ces captures sont prisent au moment où le joueur appuie sur les touches du clavier. Comme nous pouvons le voir dans la partie "Receive", le joueur n'envoie qu'un ou deux paquets au serveur quand il active une commande. Il n’enverra pas d'autres paquets tant qu'il ne change pas de direction ou tant qu'il ne s'arrête pas. Le joueur peut se déplacer dans une seule direction pendant plusieurs secondes ou minutes, il n'aura envoyé qu'un seul paquet au total.

Du côté de l'envoi du serveur, c'est un peu plus mitigé. Par rapport aux résultats de l'étape 2, le paquet pèse 3 octets de plus, pour cause de la nouvelle gestion du snapshot. Mais ce paquet est plus lourd que l'ancienne version lorsque le cube se déplace sur les deux axes. Plus précisément, lorsque le serveur envoie un snapshot FULL au client.
Sinon, quand le cube se déplace sur une seule axe, la taille du snapshot est moins importante. Ce qui est prévu.

En dehors de ca, il y a toujours ce problème de nombre d'appels, en moyenne 50, qui ne concorde pas avec le tickrate, qui est fixé à 100. Est-ce que mes méthodes prennent trop de temps à s'exécuter ? Je m'occuperai de ca plus tard.


Enfin, quand un joueur se déplace sur une axe, dans une partie à 4 clients + 1 serveur. La capture est prise après quelques secondes:
http://tof.canardpc.com/view/33a3ff5d-58b4-42ba-b15e-b80e30e8f517.jpg

L'optimisation est là. Quand un cube se déplace sur le jeu, le serveur transmet aux clients la position de ce cube et seulement de ce cube. Le serveur ne va pas spammer les joueurs de la position des autres cubes immobiles.


Pour conclure, je suis bien content du résultat. Il n'y a que très peu de données en jeu, mais malgré ca le bilan est positif :). A retenter avec un jeu un peu plus étoffé!

Je n'ai pas encore testé l'usage de la bande passante avec cette solution. Mais il y a encore plein de petites choses à régler avant de vérifier ca ;).



----

En espérant que je m'explique bien :p.

Grhyll
09/04/2015, 12h56
(Je le dis en passant, j'ai pas tout lu, mais ça a l'air vraiment intéressant, et je garde définitivement ce topic sous le coude pour le jour où j'aurai besoin d'une telle technologie !)

Louck
11/04/2015, 19h53
N'hésitez pas si vous avez des questions ou demandes :) Ou si vous voyez des erreurs.

Etape 5: Et après ?

J'ai cherché d'autres solutions pour optimiser - encore - la taille et l'envoi des snapshots.
Je suis venu.
J'ai vu.
Mais on m'a vaincu :'(.


Il existe bien des solutions qui peuvent améliorer la transmission des snapshots, mais ils ne fonctionnent pas toujours aussi bien que l'on pense. En voici une petite liste non exhaustive:


Compresser le snapshot
http://scientopia.org/img-archive/goodmath/img_366.png

L'API RakNet possède déjà sa propre fonctionnalité de compression de paquet. Mais vu que c'est une grosse boite noir dans Unity, difficile d'aller l'analyser et le configurer à notre sauce.
Tout ce que je sais, c'est qu'elle compresse la donnée utile du paquet et qu'elle est de plus en plus performante au fur et à mesure des échanges sur le réseau (elle "évolue" en cours de partie).
Le problème est que pour bien compresser les données, il faut beaucoup de ... données. La compression n'apporte rien de bon avec des petits snapshots.

J'ai quand même fait le test de compresser mes snapshots, avant leurs envois, avec la bibliothèque intégrée GZipStream et la bibliothèque externe LZ4: avec des snapshots qui font moins de 100 octets, la compression me retourne un résultat qui pèse... plus lourd. :( C'est qui est l'inverse de ce que je cherche à obtenir.

L'autre inconvénient de vouloir compresser ces données, c'est que ce n'est pas toujours rapide. La compression peut avoir un impact sur le temps de production des snapshots, surtout quand elle doit être générée en moins de 10ms (tickrate 100). Plus il y aura de joueurs, plus il y aura de snapshots à traiter et à réduire.
Avec mes contextes de test (1 client + 1 serveur et 4 clients + 1 serveur) et mon jeu de données, je n'ai pas eu de problèmes. Mais dans un vrai environnement de jeu, ce défaut peut se faire sentir.

Toutefois, ces API peuvent toujours nous servir pour diminuer la taille d'un très gros paquet, tant que ce n'est pas fréquent. Par exemple, c'est utile pour l'envoi d'images, de fichiers, ou de contenu généré procéduralement (ca se dit ?)


Revoir la sérialisation et l'encodage du paquet
Je ne vais pas cracher sur Proto-buff. Il fait un très bon travail concernant la sérialisation de notre objet snapshot. Néanmoins, je peux critiquer cet objet et son encodage.

Pour le moment, je sérialise un objet qui contient une liste de snapshots. Le fait d'utiliser une liste et de nombreux objets ont un coût sur le résultat de la sérialisation. De plus, l'encodage en Base64 peut doubler la taille de mon snapshot. Nous avons vu cela dans les précédentes étapes.
En revoyant ces éléments, il est possible de minimiser notre paquet final.

Mais est-ce que cela vaut le coup ? Probablement. Mais ce n'est pas simple et cela peut nécessiter l'utilisation "d'astuces techniques" qui peuvent rendre notre code moins lisible voir moins maintenable (et peut nous faire faire passer pour un guru).

Il y a sûrement une alternative à utiliser des listes ou l'encodage Base64. Mais si j'utilise ces éléments, c'est pour faciliter ma vie de développeur. Est-ce que ca vaut le coup de redévelopper une fonctionnalité dont certaines personnes - sûrement plus compétentes - ont déjà fait ? :unsure:

Et même si j'arrive à le faire, je perdrai un temps fou pour gagner quelques octets sur le paquet.


Revoir les données du jeu
Nous revenons toujours à la question des données du jeu. Quoi qu'on en dise, ces données ont un réel impact sur la taille du snapshot et sur sa fréquence.
J'ai déjà intégré des méthodes abstraites de mises à jour et de tests pour avoir un contrôle sur les snapshots. Le vrai défi est de définir les bonnes informations à mettre dans ces snapshots, à un moment T. C'est principalement un travail technique même s'il faut prendre en compte le design du jeu.

Sinon, il existe de nombreuses techniques pour pouvoir réduire le nombre de données à soumettre sur le réseau: Dead Reckoning, Space partitioning, Extrapolation/Interpolation, Time Dilation, etc... Mais chacune de ces méthodes ne fonctionnent pas pour tous les types de jeux (un jeu de voiture n'a pas les mêmes besoins réseau qu'un jeu de tir) et ont leurs propres avantages et inconvénients. Je reviendrai là dessus dans un autre chapitre.


Il existe une autre solution qui peut améliorer grandement les performances de mon architecture réseau:

Ne plus utiliser les RPC

http://www.memegen.fr/m/aysu72.jpg

L'idée est de ne plus utiliser les RPC. Même si j'ai un meilleur contrôle sur les données, les RPC prennent tout de même de la place dans mon paquet réseau et ont quelques restrictions qui peuvent nous gêner plus tard. Dont son côté "Reliable" (ou le "j'attend la réception du paquet coûte que coûte, au pire de retarder les autres échanges").

Mieux. Nous pouvons programmer notre propre fonctionnalité réseau en .Net et ne plus utiliser Raknet ou ce qu'offre Unity. :lol:
Cependant, cela impliquerai de réinventer la roue et je n'ai pas forcement le temps de tout recoder :tired:.


Il existe bien une alternative aux RPC... Le State Synchronization, le "truc" que j'ai boudé tout au début car il n'était pas assez performant.
Aujourd'hui, avec les mises à jours et un peu plus d'expériences, je peux essayer d'adapter mon architecture multijoueurs avec le State Synchronization.


Tentons le coup!


Utiliser le State Synchronization

Pour résumer, le SS permet de synchroniser un objet sur le réseau. Pour l'utiliser il faut passer par le composant NetworkView. Un NetworkView (ou NV pour la suite) a deux paramètres: le propriétaire et l'observé.
L'observé correspond au composant du jeu que nous voulons synchroniser. Par exemple, nous pouvons synchroniser le composant Transform du gameobject (dont sa position + sa taille + sa rotation). Le propriétaire correspond au joueur qui est.... propriétaire de l'objet. Il se charge alors de soumettre les mises à jours de l'état de l'objet aux autres joueurs.
Il existe un autre paramètre mais dépendant du contexte du jeu: le SendRate. En gros, c'est notre tickrate :).

L'idée est de réadapter le SS à mon architecture, en utilisant les NV comme des canaux de communications. Pour chaque joueur, il nous faut deux canaux (Client => Serveur et Serveur => Client).
Donc, je créé un gameobject "Communication" qui regroupe deux gameobjects fils: "ClientToServer" et "ServerToClient". Chacun de ces deux gameobjects possèdent le composant NV, qui observera mon nouveau script: SnapshotCommunication.

Le rôle de ce dernier script est simple: lors de la "mise à jour", le script récupère le dernier snapshot généré par le client/serveur et le transmet sur le réseau via stream.Serialize().
Précision: Cette dernière méthode ne peut pas envoyer mon snapshot sérialisé - type String - sur le réseau, mais les caractères. J'appel une première fois cette méthode pour avertir les membres du réseau du nombre de caractères à récupérer (stream.Serialize(tailleSnapshot)), avant de transférer les caractères de mon snapshot.

Il ne me reste plus qu'à initier l'objet "Communication" pour le client et le serveur. Ca va se passer au moment où le client se connecte:

Via RPC (oui, ca sert toujours), le serveur envoi au client son "identifiant" (via la méthode Network.AllocateViewID()). Cet identifiant est très important pour que le client puisse connaitre le propriétaire du canal "ServerToClient".
Le client initialise l'objet "Communication" avec l'identifiant serveur et l'identifiant généré par le client. Ensuite, il envoi son identifiant au serveur.
Enfin, le serveur initie son propre objet "Communication" pour communiquer avec le client (et seulement lui) en utilisant ces deux identifiants.


Au départ, j'ai voulu initier cet objet du côté serveur, sans l'identifiant client (que je recevrai plus tard). Mais les appels RPC prennent du temps et les NV sont impatients, même s'ils ne sont pas initialisés: ces derniers tentent d'envoyer des données sur le réseau, coûte que coûte, avant de cracher des erreurs par dizaines dans la console d'Unity :|.

Il reste encore une chose à régler, qui est la génération du snapshot côté serveur. A l'origine, je génère le SnapshotWorld avant de produire des snapshots pour chaque client, à chaque tickrate.
Maintenant que ces deux tâches sont séparées, j'ai du revoir le code du serveur pour pouvoir générer le SnapshotWorld tout un certain temps (plus fréquemment que le tickrate/sendrate), avant d'être manipulé par mon nouveau script SnapshotCommunication.

Quelques paramétrages.. et ca fonctionne :D.
Faisons une comparaison de la bande passante entre la version RPC et la version State Synchronization. Je vais utiliser l'application NetBalancer pour avoir un résultat peu plus précis (par rapport à l'interface d'Unity):

Tickrate fixé à 30.
Vue serveur.

Contexte 1 serveur + 1 client
RPC
Download: 1.8ko
Upload: 3.3ko


State Synchronization
Download: 1.8ko
Upload: 2.5ko


A première vue, le résultat me semble bon :). Voyons voir avec ce deuxième contexte:

Contexte 1 serveur + 4 clients
RPC
Download: 7.2ko
Upload: 13ko


State Synchronization
Download: 7.2ko
Upload: 20.4ko


What.
The.
Fuck :O

Dans le contexte de 4 clients, la version SS consomme beaucoup plus de bande passante à l'envoi serveur!


...


En faisant une recherche, j'ai conclu que c'étais normal et que j'étais dans l'erreur.

Le problème vient des NV.
Le problème est qu'ils ne communiquent pas excluseivement avec la personne qui possède le même NV.
Non.

Ils communiquent avec TOUT LE MONDE, même avec le joueur qui ne possède aucun objet ou NV dans son contexte!
D'ailleurs chose drôle. Si le joueur A ne possède pas le NV du joueur B et C, et si le joueur B communique avec le joueur C, alors le joueur A recevra une erreur comme quoi il ne possède pas l'objet en question (en plus de consommer de la bande passante) :|.

J'ai beau cherché, je n'ai rien trouvé pour résoudre ce problème. Les méthodes d'Unity pour limiter la communication entre un ou plusieurs membres de la partie ne concernent que les RPC. Malheur...
Je n'ai rien trouvé pour contourner ce problème. Et même si j'en trouve une, il reste le problème des cheaters à régler (vu que le SS ne permet pas de faire une architecture serveur autoritaire facilement).


Bref. J'ai perdu de nombreux soirs à travailler sur ce sujet, avec pour conclusion que ce n'est pas du tout adapté à mon architecture et que ce n'est pas plus performant.

Amen.


http://img.over-blog-kiwi.com/1/33/66/46/20141217/ob_60ddcd_conclusion.png


En prenant un peu de recul, ce chapitre sur l'optimisation est moins dense que prévu. J'ai surtout revisité les snapshots, produit des snapshots delta et fait une historique. J'ai tenté d'autres méthodes mais:
Soit le résultat n'est pas assez satisfaisant pour être mis en place, ou produit l'effet inverse.
Soit la mise en place de la solution est très longue et/ou trop compliquée, nécessitant tout un chapitre/étape pour tout expliquer... pour un résultat pas exceptionnel.


Ce qui est certain, pour bien optimiser les échanges réseaux, il faut adapter son architecture à son jeu (ou au genre du jeu).
Chaque jeu a ses propres règles et ses propres fonctionnalités. Certains jeux ne se déplacent que dans une direction en utilisant la force (utilisation du Dead Reckoning), d'autres fonctionnent au tour par tour (abandon du tickrate), quelques-uns utilisent des terrains de jeux très importants (Space partitioning), etc... Chaque jeu a sa ou ses propres méthodes d'optimisations. Des méthodes que nous ne pouvons pas généraliser, malheureusement.


Il est possible que je vais revenir sur ces techniques quand je traiterai certains cas de mon projet. Mais pour ce chapitre - dans le cas de l'optimisation globale - je ne connais qu'une méthode qui fonctionne très bien: le Delta compressing ou l'utilisation des snapshots delta.

Si vous en avez d'autres, je suis tout ouïe ;).



A très bientôt.

Louck
18/04/2015, 21h47
Après avoir passé de nombreux soirs sur le State Synchronisation, j'ai conclu qu'il est inutile d'en faire un troisième chapitre. Du coup, j'ai complété la dernière étape du chapitre 2 avec ce que j'ai réalisé.

Je vais réfléchir sur ce que je vais faire pour le troisième chapitre, mais je vais avoir besoin de temps.

Hideo
21/04/2015, 13h33
Toujours aussi intéressant, tout le même plaisir à lire tes chapitres. ;)

Bon courage pour la suite et prends tout le temps dont tu auras besoin :)

Ifit
24/04/2015, 20h55
Je regarde depuis un moment ton topic et c'est super intéressant.

J'utilise aussi protobuf au boulot, il y a pas mal d'optimisations possible avec les extensions / optional etc... pour éviter les trop gros paquets.

Pour le protocole tu peux regarder "quic" http://en.wikipedia.org/wiki/QUIC , c'est google qui fait son custom protocole UDP. ( UDP + fiabilitée)
Par contre je sais pas si Unity implémente déja quic.

Louck
24/04/2015, 22h00
Unity Network fonctionne via l'API RakNet, qui est particulier. Je ne pense pas que ca intègre QUIC.

Par contre, cela a l'air très intéressant ton truc :). Peux-être qu'un jour je tenterai d'implémenter ce protocole (mais ce n'est pas ma priorité et ca nécessite beaucoup beaucoup de travail).


Merci pour les encouragements ;).

Je promet un petit jeu multijoueurs pour la fin de l'année :).

Blasteguaine
04/05/2015, 21h19
C'est bien il en faut des gros tarés qui font ce que tu fais.
Et sinon tout le monde est au courant que la prochaine version d'Unity devrait contenir une toute nouvelle API réseau ? (deux en fait, mehbon)

Louck
05/05/2015, 15h29
Je l'ai déjà précisé je pense. Mais pour la version de référence de mon sujet, je reste sur Unity 4 :). Dès que l'API sera disponible et patché, j'irai en parler dans un nouveau chapitre.

En attendant, ce que j'évoque est valable pour n'importe quelle version, à l'exception de certains points très techniques et de ce qui touche à la couche transport du réseau (et inférieur). Du moins, dans le fond.

gbrinon
21/05/2015, 19h57
Salut,

Je suis tes posts avec beaucoup d'intérêt. Merci de prendre le temps de faire ça, c'est une vrai mine d'or.
Félicitations :)

Louck
21/05/2015, 21h52
Merci beaucoup :).

S'il y a des points qui sont mal expliquées ou mal formulées, n'hésitez pas à me le dire.

Je vais avoir un peu de retard sur mon planning (le féniantisme est fatal) mais j'avance doucement sur mon prototype qui me servira de sujet pour la suite :).

Louck
10/06/2015, 12h02
http://unity3d.com/unity/whats-new/unity-5.1

La nouvelle API d'Unity Network est sortie.

Actuellement, je travaille toujours sur la version 4.6 d'Unity. J'attend que la nouvelle version soit peaufinée/patchée avant de passer là dessus.
Il y aura donc un décalage dans les chapitres: Le prochain concernera la mise à jour de mon architecture avec cette nouvelle version de l'API (en plus de quelques tests). Ca sera un petit chapitre avant de passer à ce qui était prévu: gérer la latence dans le jeu (interpolation, prédiction, lag compensation, etc.. :) ).


EDIT: Après lecture du document, je dois tout revoir :tired:. Ca réutilise beaucoup d'éléments que j'ai déjà conçu.

Hideo
13/06/2015, 15h28
Courage :p

Louck
24/06/2015, 19h25
Je suis entrain de finir le chapitre. J'ai pas mal galéré avec la nouvelle API d'Unity3D, mais je m'en sors vivant.
Ca sera bon pour lundi ou mardi prochain.

Louck
03/07/2015, 12h35
Chapitre 3 - UNet

Depuis plus d'un an, l'équipe derrière Unity3D avait pour projet de renouveler son API réseau pour une version beaucoup plus performant et beaucoup plus simple d'utilisation. Les développeurs voulaient faire une API à leur sauce.
Il n'était pas trop compliqué d'innover. Avant, il n'y avait que le composant NetworkView qui gérait la synchronisation de l'état d'un gameobject sur le réseau. A l'exception de ca, il fallait tout coder soit même, pour administrer les clients de notre serveur, pour mettre en place l'interpolation des états, intégrer le lobby et le matchmaking, ... Bref, quasiment tout.


Aujourd'hui (enfin, depuis plus d'une semaine), la version 5.1 d'Unity3D est sortie, avec sa première version de l'API UNet.
Et soyons franc, il y a du gros :lol:


La nouvelle API est divisée en deux:

Une API de "bas niveau": Au niveau de la couche de transport. Actuellement, il n'y a pas beaucoup de documentations sur les couches de bas niveaux d'UNet, ni sur son paquet réseau. Ce que nous savons pour la prochaine version de cette API, c'est qu'il y aura d'avantages de fonctions qui vont nous permettre de réaliser une architecture réseau un peu plus complexe pour des projets plus ambitieux, dont des MMO.
Une API de "haut niveau", ou HLAPI: Elle fonctionne sur l'API de bas niveau et nous offre de multiples composants pour mettre en place le mode multijoueurs de notre jeu.



Etant donné les gros changements, je vais commencer doucement en travaillant sur l'HLAPI. Je m'attaquerai à la couche de transport une autre fois :).

http://docs.unity3d.com/uploads/Main/NetworkLayers.png

Par quoi commencer ?

Nous pouvons déjà faire la liste des composants essentiels de cette HLAPI:

Le composant NetworkManager: Ce composant se chargera de tout. De la connexion du serveur et de ses clients, du chargement des prefabs de la partie, à la gestion des scènes et des messages. D'ailleurs, il contient les objets NetworkClient et NetworkServer... Cela me rappelle quelque chose. Si ce composant n'offre pas ce que nous souhaitons, il est toujours possible de coder notre propre version du NetworkManager (chose que je vais faire pour mon architecture multijoueurs).
La classe abstraite NetworkBehaviours: C'est le cousin de notre classe MonoBehaviours. Ils font la même chose, à l'exception que le premier intègre des fonctionnalités liés à la synchronisation d'états. Maintenant l'essentiel de la sérialisation se passe par l'attribut [SyncVars] sur nos propriétés, même si les méthodes OnSerialize() et OnDeserialize() sont toujours utilisables.
Les composants NetworkIdentity et NetworkTransform: L'un permet d'identifier l'objet sur le réseau (tiens donc!), l'autre permet de synchroniser (et d'interpoler) automatiquement le composant Transform du GameObject.


Il existe bien d'autres composants et classes (NetworkAnimator, NetworkLobbyManager, etc...). Mais l'essentiel est là (et je n'en n'ai pas besoin... pour le moment) ;).

Même les RPC ont changés: Nous parlons maintenant de Commands (client => serveur) et de ClientRpc (serveur => client). Dixit le manuel, leurs utilisations sont bien plus gourmandes qu'avant, ce qui est génant si je souhaite reproduire mon architecture sur cette nouvelle API... Mais il existe une nouvelle solution : Les messages réseaux.
Les messages réseaux sont simplement des messages "brutes" qui sont envoyés à un ou plusieurs destinataires. Le mot "brute" est sûrement un peu vulgaire car il est possible d'envoyer toute sorte de messages, que ca soit une simple chaine de caractères ou le résultat de la sérialisation d'une grosse classe.
Les classes NetworkClient et NetworkServer possèdent de nombreuses méthodes d'envoi de messages, dont un simple Send() et un plus détaillé SendBytes().


UNet semble bien fourni en composants et en fonctionnalités. Cependant, est-ce qu'il est bien plus performant que l'ancienne API RakNet ?
Sérieusement, difficile à dire. Les paquets construisent par UNet semblent un peu plus léger que ceux fabriqués par RakNet. Mais ces paquets sont tous de tailles différentes, codifiés, et il n'existe pas de documentations détaillées sur la structure de ces trames (sauf quelques détails du côté RakNet, mais rien de fou).
Néanmoins, l'API UNet offre au développeur de multiples méthodes au développeur pour pouvoir gérer la transmission de ses données dans sa partie, dont le QoS ("Quality of Service"). Des options qui ne sont pas présents (ou pas assez développés) dans l'ancienne version d'Unity.

Tant que nous savons bien l'utiliser, UNet sera bien plus performant que RakNet. En théorie. :)


Maintenant, la grande question est de savoir si mon architecture réseau marchera toujours avec cette nouvelle HLAPI.

http://orig15.deviantart.net/cfc6/f/2012/078/0/8/aoh__it__s_time_to_get_serious_by_reverseeclipse-d4t84fo.png

Pour cela, je pensais faire un prototype maison pour tester le bousin. Mais étant un gros féniant, j'ai préféré faire mes tests sur une démo déjà existante. J'ai pris la démo "2dshooter" dans le lien suivant : http://forum.unity3d.com/threads/unet-sample-projects.331978 .

C'est un bête shoot'em'up multijoueurs avec des vaisseaux qui tirent et des powerups. C'est très classique et contient le strict nécessaire pour faire un jeu multijoueurs (sans le système de lobby. On ne peut pas tout avoir !)
Le tout marche avec un seul NetworkManager (et un GUI de test), avec des NetworkBehaviours, et avec un NetworkTransform et un NetworkIdentity pour chaque entité du réseau. Il ne nous faut rien de plus.


Sans plus attendre, j'ai tenté de réadapter mon architecture homemade pour cette nouvelle API. Au début, je m'imaginais à tout refaire de A à Z, à réutiliser les nouveaux composants d'Unity pour tout optimiser à fond. Mais au final, j'ai quasiement tout copier/coller.

A quelques détails:

Vu que le système de RPC a entièrement changé et semble bien plus gourmand, j'ai décidé d'utiliser les messages réseaux pour transmettre les snapshots entre les clients et le serveur. J'ai commencé à utiliser l'objet StringMessage (qui étend l'interface MessageBase) pour encapsuler mon snapshot. Mais j'ai trouvé mieux: l'objet NetworkWriter qui permet de fabriquer et d'envoyer un [b]MessageBase[b] à partir de données binaires. Avec cette méthode, j'évite la sérialisation du StringMessage et je minimise la taille de mon paquet.
Maintenant que le composant NetworkIdentity se charge d'identifier un gameobject sur le réseau, il ne m'est plus nécessaire de gérer leurs identifications. Pourtant, je dois toujours gérer les données des clients et je dois définir qui est propriétaire de quoi.


J'en ai aussi profité pour améliorer le système de snapshots dans le but de pouvoir instancier un ISnapshotGameObject propre à une entité. Un cube ou une balle ne gêrent pas les mêmes états ou propriétés.


Dès à présent, tout est remis au propre, je fais mes premiers tests...
Et je tombe sur un gros problème. http://i.imgur.com/dsMxUB7.png

http://tof.canardpc.com/view/983dd208-c208-4b93-b162-757587671ca2.jpg

Ci-dessus, nous voyons en rouge le débit montant et en vert le débit descendant, du côté serveur. Cela se passe au moment où le serveur transfert ses snapshots à mon client. Ce dernier ne fait rien de particulier.
Le problème est que le débit descendant est supérieur au débit montant, alors que le serveur ne fait qu'envoyer des données.

J'en ai aucune idée du pourquoi du comment du mystère du truc de cette courbe. Mon hypothèse est que le client informe le serveur de la réception de son message. Vu que le serveur n'encapsule pas beaucoup de données dans son paquet (gloire à l'optimisation!) il est possible que le paquet d’acquittement du client soit plus lourd, mais ca serait assez étonnant.
Difficile de conclure là dessus, vu que les paquets sont encryptés par sécurité. Mais c'est le seul hypothèse viable que j'ai en tête.
Il est possible aussi que ca soit un bug de l'API d'Unity. A confirmer dans les prochaines versions.


Après quelques recherches, j'ai pu corriger le problème en manipulant un peu les canaux et le QoS. Par défaut, les canaux sont configurés sur le mode Reliable (= la livraison des paquets est assurée, mais pas son ordre d'envoi).
Pour mon architecture, le mode le plus adapté est le StateUpdate: La délivrance des paquets n'est pas assurée et ne livre que l'état le plus récent.
Suite à ca, le graphique affiche de meilleurs résultats et mon problème n'est plus présent. Mais ce QoS implique que nous devons gérer le packet loss et son ordre de délivrance. Je traiterai ce sujet très bientôt.


Après avoir tout paramétré, j'ai décidé de tester et de comparer mon architecture avec le State Synchronization d'Unet.

Tickrate à 10 (oui, par défaut, le tickrate est à 10 sur UNet) http://i.imgur.com/T1EH3ix.gif
Vue serveur

Contexte 1 serveur + 1 client
Messages Réseaux (mon architecture)
- Download: 208 o/s
- Upload: 988 o/s

State Synchronization (UNet)
- Download: 290 o/s
- Upload: 1.5 ko/s


Contexte 1 serveur + 4 clients
Messages Réseaux (mon architecture)
- Download: 1 ko/s
- Upload: 4 ko/s

State Synchronization (UNet)
- Download: 1.5 ko/s
- Upload: 6.1 ko/s


A première vue, mon architecture semble bien plus performant que la technique classique de la nouvelle HLAPI. Mais il ne faut pas oublier que cette dernière utilise par défaut le QoS Reliable, plus gourmand en bande passante, pour s'assurer que notre message arrive bien à destination. Chose que je vais devoir m'en charger avec mon architecture (et qui va sûrement me coûter un peu en perfs).

Malgré tout, mon architecture affiche toujours son gros avantage: J'ai un meilleur contrôle sur les données du jeu et je peux les filtrer si nécessaire. A ce sujet, je suis tombé sur quelque chose d'étrange avec le mode SS d'Unet: même s'il se passe rien à l'écran (vaisseaux immobiles, joueurs ne touchent à rien), les clients et le serveur s'échangent à plusieurs reprises des trames de tailles variables. Je n'ai pas la moindre idée de ce qu'ils foutent à ce moment là.


En outre de ces éloges.

Quelque soit les techniques utilisés, l'API d'Unity3D consomme constamment de la bande passante. Tous les clients transmettent, toutes les demi-secondes, un paquet de 57 octets au serveur, avant que ce dernier en fasse autant avec ses clients. Au final, il y a un upload et un download de 114 octets qui sont utilisés, ni plus ni moins, par seconde. Est-ce un système de ping ou de heartbeat ? Dieu seul le sait et Wireshark ne peut nous sauver.


En parlant de Wireshark, j'ai pu examiner le paquet envoyé par le serveur au client, avec mon architecture.

http://tof.canardpc.com/view/40a359d8-66ba-46aa-bc91-ff39cc7467d1.jpg

Taille du paquet: 80 octets
Taille de la partie données: 52 octets
Taille des données utiles (selon mon système): 28 octets

Nous retrouvons bien l'en-tête de 28 octets pour les protocole IP et UDP. Il nous reste donc à déterminer ce que représente les 24 octets. Il y a au moins l'encapsulation du message là dedans. Mais ensuite ? Est-ce qu'il y a des données propres à Unity3D ou à l'API ? Une description sur l'encryptage ou la compression de la trame ? Des informations propres au serveur ? Raahh le manuel ne donne pas assez de détails! http://i.imgur.com/zF64ssw.gif
Par contre, en comparant avec mon projet sur la précédente version (qui utilise les RPC), la partie donnée est moins dense. Cela peut s'expliquer par l'absence des contraintes des RPC en utilisant les messages réseaux, mais aussi de l'utilisation du QOS unreliable.

A l'exception de ca, il faudra attendre la prochaine mise à jour de l'API, qui concerne la couche de transport, pour se faire une meilleure idée de ce que cache ces octets en trop.


Bref. Qu'est ce que nous pouvons dire à tout ce bazar ?

Dans l'absolu, même si la nouvelle API apporte de nouveaux outils et de nouvelles méthodes sur la réalisation d'un mode multijoueurs à notre jeu, elle n'apporte pas grand chose à mon architecture. Exception à la transmission des messages, qui est une bonne alternative aux RPC ou Commands.

Mais face à ce qu'offre UNet, mon architecture - même performante - semble très amateur. Je suis content de ce que j'ai fais jusqu'à maintenant, mais le chemin est encore long avant que ma création soit au poil et puisse offrir tout ce qu'il faut pour faire fonctionner un jeu multijoueurs dans de très bonnes conditions.


D'ailleurs, ca sera ma finalité:

Comme je l'ai indiqué il y a un petit moment, j'ai pour projet de réaliser un jeu multijoueurs pour la fin de l'année. Je m'y attaque dès le premier août. Vous aurez un peu plus d'informations à ce sujet dans un autre topic :).
Ce jeu multijoueurs n'utilisera que les composants d'UNet. Quand le jeu sera finis, je pourrais l'utiliser comme un modèle d'exemple pour tester et pour améliorer mon architecture :).


A très bientôt ;).

Hideo
03/07/2015, 13h00
Cool de la lecture ! :lol:

Du coup le développement d'une partie multijoueur (en tout cas la partie qui gère le dialogue Client <-> Serveur) devient beaucoup plus simple à mettre en place avec cette nouvelle API ? Si c'est le cas on aura surement plus de modes multi dans les petits jeux amateurs/indés.

Bon courage pour ton projet, je suivrai ça ;)

Ps: Y'a une liste qui a un peu foiré dans ton texte


A quelques détails:[LIST] Vu que le système de RPC a entièrement changé et semble bien plus gourmand, j'ai décidé d'utiliser les messages réseaux pour transmettre les snapshots entre les clients et le serveur. J'ai commencé à utiliser l'objet StringMessage (qui étend l'interface MessageBase) pour encapsuler mon snapshot. Mais j'ai trouvé mieux: l'objet NetworkWriter qui permet de fabriquer et ......

Louck
03/07/2015, 13h19
Corrigé, merci :).


Oui, la nouvelle API rend beaucoup plus simple la réalisation d'un jeu multijoueurs sur Unity3D. Il y a même un document qui explique comment transformer un jeu solo à un jeu multijoueurs (grosso merdo):
http://docs.unity3d.com/Manual/UNetConverting.html

Par contre, il faut toujours garder en tête que l'API ne va pas s'occuper de tout. Le développeur devra tout de même concevoir son jeu pour le multijoueurs: gérer la synchronisation des états, paramétrer correctement la partie selon les situations (QoS, attributs du NetworkBehaviour, ...), faire attention aux interpolations et aux problèmes de latences...

Unity3D n'offre que des outils au final, mais les contraintes d'un jeu multijoueurs sont toujours présentes.
Mais il est vrai que la nouvelle API rend beaucoup plus simple ce travail, par rapport à avant :).

am0
27/07/2015, 17h57
Et bien, le moins que je puisse te dire Lucskywalker, c'est un énorme merci ;)
Je me suis récemment mis à éplucher/apprendre l'implémentation du multi dans unity et tes explications me sont/seront fort utiles et m'aideront beaucoup.
Je ne suis pas programmeur de formation et j'avoue que la couche réseau avait quelque chose de...comment dire...trop "abstrait" pour que je l'assimile correctement.
C'est vraiment très sympa de prendre du temps pour partager tes connaissances avec les autres canards-développeurs !

Louck
06/08/2015, 10h54
De rien :).

Si tu as des questions, n'hésites pas!

Uriak
06/08/2015, 11h14
J'ai commencé à touiller la nouvelle API cette semaine mais je suis un peu agacé.

À ce que j'ai compris les nouveaux NetworkBehavior et l'utilisation des RPC et state synchronization nécessitent de spawner les éléments côté clients suite à une commande serveur.
Pour mon appli les clients et serveurs comportaient les mêmes scènes à quelques détails près et des NetworkView faisaient le lien. Je me trompe où ça n'est plus possible? Pour mon besoin, quasiment rien n'est instancié au runtime, le reste est déjà présent dans l'éditeur. J'utilise un système d'argument de commandes pour paramétrer le tout ce qui me permet de ne faire qu'un build unique.
(je tente de l'affichage d'une scène sur plusieurs écrans, une seule machine fait tourner la physique, les autres ne font qu'afficher)

Du coup j'ai commencé à jouer avec les messages mais pour notre appli en LAN j'ai besoin de transmettre les données au rythme d'unity lui-même (tickrate de 60 donc ) Je ne sais pas si ça tiendra la route. Du coup tu conseilles carrément d'aller au niveau transport et de changer les QOS? (ou peut-on le faire dans la HAPI avec les messages?

L'utilisation des writer/reader est un peu obscure. Ils donnent en exemple l'envoi d'une trame binaire
avec du genre
writer.Startmessage(msgId);
msg.serialize(writer);
writer.EndMessage(msgId);
client.SendWriter(writer);

Je ne vois pas trop l'intérêt par rapport à simplement implémenter serialize/deserialize dans mon message et ensuite appeler un Send(msg). D'autant plus qu'il nest pas expliqué dans le manuel comment je récupère le résultat de mon sendWriter

Autre souci de perf, dans les handlers de reception, on caste le message pour le récupérer, mais je suppose que le constructeur et la deserialization du message sont invoqués par unity. Ce qui me pose un souci car ça m'impose l'instanciation de la structure de reception à chaque fois. Si mon message comporte un array ça va vite finir par l'appel au garbage collector, ce qui est ennuyant.
J'envisage éventuellement de fournir un pool de mémoire appelable dans Deserialize, mais bonjour le code opaque pour l'utilisateur...

Du coup je risque de taper dans la LAPI, mais c'est quand même irritant de voir 90% du nouveau contenu non utilisé, et reste la question des command/RPC

PS : je partais en vacances avec au retour un débat sur notre future architecture, je ne m'attendais pas à voir ce sujet abordé ici-même ;)

Louck
06/08/2015, 14h39
Je pense avoir compris :p.

Tout d'abord, les NetworkViews et l'attribut RPC ne fonctionnent plus avec la nouvelle API. Ils sont toujours là, mais ca reste des fonctionnalités obsolètes (et je n'ai aucune idée s'ils fonctionnent toujours).


Pour résumer le fonctionnement standard d'UNet (en State Synchronisation):

Pour chaque gameobject du jeu, si on veut que leurs états soient synchronisés, il faut:
- Le composant NetworkIdentity (pour identifier l'élément sur le réseau);
- Tout composants qui peuvent synchroniser son état, héritant du NetworkBehaviour. Ca peut être un composant perso (avec les attributs Async) ou un composant existant (dont NetworkTransform).

Ensuite, il faut comprendre qu'il existe maintenant un gameobject "Joueur" qui a un fonctionnement particulier: C'est à travers cet objet que le joueur peut communiquer avec le serveur (via l'attribut Commands, sorte de RPC). Cet objet doit être une prefab, pour la propriété "Player Prefab" du NetworkManager. Je ne l'ai probablement pas expliqué dans le dernier chapitre.

Quand un joueur se connectera au jeu, le prefab sera automatiquement instancié par le serveur.
Plus de détails ici:
http://docs.unity3d.com/Manual/UNetPlayers.html

Ensuite il y a bien cette histoire de spawn ou d'autorité sur l'instance. Mais pour simplifier la chose: Le serveur est le roi et il fait tout. Et si tu veux instancier un prefab sur le réseau, il faut l'indiquer au NetworkManager.


Concernant les messages réseaux, tu peux lier un type de message à un handler , via RegisterHandler du NetworkClient et NetworkServer.
Ensuite en utilisant le NetworkWriter, tu indiques le type de ton message dans la méthode StartMessage(). Et ca sera bon :).
Pour la réception du message dans le handler, je n'ai pas trouvé mieux que de déserialiser avec la classe StringMessage, avant de le convertir en un tableau d'octets (via Convert.FromBase64String()).

Après, c'est une façon comme une autre de transmettre un message au serveur ou aux clients de la partie. Passer par les méthodes serialize/deserialize du message est une autre façon de faire. Par contre, aucune idée si cette technique est bien plus performante.

Par contre, rien ne t'empêche d'avoir un tickrate client différent au tickrate serveur, surtout si la connexion cliente ne permet pas un bon upload. De plus, qui dit que le client va effectuer une tâche toutes les 16 millisecondes ?
Au mieux, informes le serveur des changements d'états du client (s'il marche ou non, dans quelle direction, etc...). En faisant un delta entre la précédente et la nouvelle état, tu sauras quand il faudra transmettre un message au serveur :) (avec une limitation bien sûr, pour éviter le flood de message).


Ne t’embêtes pas avec les problèmes du GC ou d'optimisations pour l'instant. Essayes déjà de faire fonctionner ton projet avant de travailler avec la couche de transport de l'API (surtout qu'à part vouloir faire son propre réseau avec ses propres méthodes, HLAPI fournit déjà tout ce qu'il faut).


Est-ce que je répond à tout ?

Uriak
06/08/2015, 15h17
Network et Network view fonctionnent encore même si ça génère des warnings du type deprecated (encore heureux)

En fait ma question portait plutôt sur le fait de savoir si les gameobjects comportant un networkidentity devaient bien être des prefabs instanciés suite à une commande serveur.

Dans notre utilisation précédente on avait disons un Gameobject avec un networkview. Côté serveur, un component supplémentaire modifie la transform associée, modification reportée via le NetworkView. Mais l'important est que les gameobject sont présents dans la scène au départ des DEUX côtés. J'ai l'impression que le système des NetworkIdentity ne s'applique en fait qu'à des prefabs, tu me confirmes? Si je veux symplement synchroniser deux transforms je vais devoir les instancier grâce au player.

Pour le reste J'ai déjà pu tester une alternative avec les messages, dans lesquels j'encode une série de displacements (la combinaison d'un Vector3 et d'un Quaternion.) En surchargeant serialize/deserialize, ça fonctionne mais ce qui m'ennuie c'est qu'au décodage (et un string message aurait le même soucis) il faut instancier les classes de receptions (un string dans ton cas, deux arrays dans le mien), parce que deserialize ne prend en argument que le writer. J'aimerais être capable en fait d'acoir une fonction du type

Deserialize (reader/stream/bytes, Vector3[] res) ce qui me permettrait de garder un buffer de recption dans lequel j'écrirais sans instanciation à chaque lecture. (encore une fois parce que le GC débarque de ce fait régulièrement et provoque un hiccup)

Pour revenir à l'encodage des messages ma question serait du genre : si j'envoie explicitement un message via sendWriter, quelle différence avec juste sendMessage? En plus dans ce de dernier cas, je sais quel type réceptionner dans mon handler du genre msg.read<MaClasseDeMessage>(); Si je fais un sendWriter j'ai juste ajouté le short msgType qui indiquel quel handler(s) vont le prendre en charge.

et merci pour les réponses :)

Louck
06/08/2015, 17h39
En fait ma question portait plutôt sur le fait de savoir si les gameobjects comportant un networkidentity devaient bien être des prefabs instanciés suite à une commande serveur.

A vrai dire, je n'ai jamais fait quelque chose de ce genre. Mais dixit la doc d'Unity sur le NetworkIdentity:

The NetworkIdentity is used to synchronize information in the object with the network. Only the server should create instances of objects which have NetworkIdentity as otherwise they will not be properly connected to the system.

En théorie, les gameobjects liés à une scène seront instanciés au lancement de cette scène. Même s'ils ont le composant NetworkIdentity, ils ne seront pas instanciés par le serveur, et donc ne seront pas synchronisés sur le réseau.

Mais comme je l'ai dis, je n'ai jamais testé ce cas là. Mais je doute que ca fonctionne autrement.


Donc oui, le mieux est de pouvoir instancier des prefabs qui possèdent le composant NI. Et de les enregistrer dans le NetworkManager avant de pouvoir les générer.

Par contre attention, c'est le serveur qui synchronise l'état des objets de la partie (les [ASYNC]). Pas le client, même s'il peut avoir une autorité sur un objet.



Pour le reste J'ai déjà pu tester une alternative avec les messages, dans lesquels j'encode une série de displacements (la combinaison d'un Vector3 et d'un Quaternion.) En surchargeant serialize/deserialize, ça fonctionne mais ce qui m'ennuie c'est qu'au décodage (et un string message aurait le même soucis) il faut instancier les classes de receptions (un string dans ton cas, deux arrays dans le mien), parce que deserialize ne prend en argument que le writer.

Là-dessus, je suis assez d'accord, ce n'est pas une fonctionnalité qui est très pratique.
En effet, je pense que tu peux trouver une solution dans la couche de transport. Mais est-ce que ca vaut le coup pour un simple problème de GC ?



Pour revenir à l'encodage des messages ma question serait du genre : si j'envoie explicitement un message via sendWriter, quelle différence avec juste sendMessage? En plus dans ce de dernier cas, je sais quel type réceptionner dans mon handler du genre msg.read<MaClasseDeMessage>(); Si je fais un sendWriter j'ai juste ajouté le short msgType qui indiquel quel handler(s) vont le prendre en charge.


C'est une façon comme une autre d'envoyer des messages. Ces fonctions ont les mêmes retours et peuvent invoquer le même handler, mais possèdent des paramètres et un fonctionnement différents.
Dans mon cas, au départ je souhaite transmettre la sérialisation d'un Snapshot, et je ne voulais pas m’embêter à créer une classe Message. Au pire, je créé un "MessageSnapshot" qui puisse contenir ma suite d'octets. Mais dans ce cas, autant faire un StringMessage avec un bon encodage. Au final ca fait la même chose.

Le NetworkWriter est la solution que j'ai prise car cela m'évite de gérer/instancier une classe pour une simple donnée. Mais ca finit de la même façon que les autres méthodes.


Techniquement, je ne sais pas ce qu'ils se passent dans ces méthodes. Il faudra inspecter les trames pour se faire une idée des données qui sont envoyées.


PS: Concernant le QoS, tu peux le paramétrer dans le NetworkManager.

Uriak
06/08/2015, 18h01
Que veux tu dire par ça t'évite d'instancier une classe pour une simple donnée?

Typiquement dans mon cas j'ai dans ma classe un MaClasseDeMessage msg instancié au start. Je mets à jour son contenu au fil de l'eau et dans l'update je fais un NetworkServer.SendAll(msg); Je n'instancie rien à chaque fois.
Ce que tu me dis me rassures je vais pouvoir jouer avec la QoS j'ai un souci (peut être ) concernant l'ordre de de réception.
Mon problème est moins celui de la bande passante (on bosse en LAN) que du délai. Surtout si onsouhaite afficher la même scène sur plusieurs machines. Jusque là j'utilisais une lib externe mais elle pose trop de soucis et je souhaitais passer à aute chose, d'autant qu'un test d'un serveur vers 10 clients utilisant les networkview m'a un peu refroidi question rendu...


j'avoue que j'aime bien le C# mais dès fois je regrette de ne pas pouvoir utiliser la pile plus que ça, surtout si c'est pour manipuler de grosses données.

Louck
10/08/2015, 15h58
Que veux tu dire par ça t'évite d'instancier une classe pour une simple donnée?

Je manipule une simple chaîne de caractère, je n'ai pas besoin de faire une nouvelle classe pour un simple String.
Sinon, si je devais passer par le système de message classique,je peux utiliser la classe StringMessage pour mon cas. Mais à partir de là je ne sais pas ce qu'il fait, et j'ai peur qu'il fasse un traitement inutile sur ma donnée (comme la sérialiser... de nouveau).

C'est (aussi) pourquoi j'utilise le NetworkWriter car je sais ce que je transmet sur le réseau.



Surtout si onsouhaite afficher la même scène sur plusieurs machines.

Pour la manipulation de grosses données, tu peux passer par un système de compression (lz4) et de tout transmettre au départ au joueur. Ensuite s'il y a des modifications dans la scène, le mieux est de pouvoir informer le joueur de ces changements et de ne pas tout re-transférer.
N'hésitez pas à jouer avec le QoS, surtout si vous avez beaucoup de données à transférer, plus ou moins importants.

Sinon le plan B est de faire du Streaming. Mais je ne suis pas sûr que Unity3D soit la meilleure solution pour ca. A voir dans la couche de transport si tu peux faire quelque chose à ce sujet.

Louck
27/08/2015, 17h43
http://tof.canardpc.com/preview2/6620a8ac-8b30-4990-a275-3ec9182b9efb.jpg (http://tof.canardpc.com/view/6620a8ac-8b30-4990-a275-3ec9182b9efb.jpg)

Soon.
(dans un autre topic :p)

Louck
25/11/2015, 16h59
Je fais une courte parenthèse concernant le tickrate, suite à une discussion sur le topic Overwatch.

La question sur le topic:

Excusez mon ignorance, mais un tickrate de 20, c'est une bonne ou une mauvaise nouvelle ?

Ma réponse sur le topic:

Si on exagère, c'est la pire mauvaise nouvelle que nous pouvons avoir. Mais uniquement si on exagère ;).

Le problème du mauvais tickrate peut se révéler dans des jeux "très rapides" comme Quake. Quand le joueur peut se déplacer à une très grande vitesse, faire des bunny hopes, tourner/esquiver quand il veut, il est essentiel que le joueur en face en soit informé au plus vite, et le plus précisément possible.

Le tickrate est surtout pour rendre le monde plus cohérent, plus précis aux yeux du joueur. Pendant que les données s'échangent sur le réseau, le monde géré par le serveur évolue fluidement.
Pour rassurer, avec les techniques utilisés aujourd'hui, ne pas avoir un très gros tickrate n'est pas si pénalisant que ca, dans une partie publique. Après, tout dépend du jeu en question (edit: et de comment c'est coder, bien sûr).


Suite à ca, un canard fait la réflexion suivante:

BF n’est pas un jeu super rapide comme Quake et pourtant c’était extrêmement gênant. Quant au fait que de nos jours un tickrate bas ne soit pas pénalisant j’aimerais bien un exemple parce que là je vois pas

Ainsi, et parce que j'ai un peu de temps à perdre, je me suis à coder quelques exemples :p

EDIT: Je n'ai jamais joué à BF. J'ai juste entendu qu'ils ont un mauvais netcode, mais cela m'étonnerai que la faute provient du mauvais tickrate étant donné le genre du jeu. Mais si vous avez des exemples, je veux bien les étudier :).


Tout d'abord, lorsque je parle du tickrate, je fais référence à la fréquence d'envoi des snapshots/mises à jours/données sur le réseau par le serveur ou le client.

Il y a bien eu une époque où le tickrate pouvait faire référence au rafraîchissement du jeu - du contexte et de ses entités - par le serveur. Mais c'étais dans une période où les ordinateurs n'étaient pas forcement très puissant pour pouvoir gérer des grosses parties, contrairement à aujourd'hui.




Première démo

A gauche: vue serveur
Au milieu: vue client, tickrate 20
A droite: vue client, tickrate 100

http://s1.webmshare.com/zYPgm.webm
(http://s1.webmshare.com/zYPgm.webm, pour voir en plus grand)

La démo est en deux parties:
- Du côté serveur, les cubes suient un parcours identique, à vitesse égale. Fréquemment, il transmet au client la position courante du cube à un moment T (T= selon le tickrate fixé. Si c'est 20, ca sera toutes les 0.05 secondes, si c'est 100, ca sera toutes les 0.01 secondes).
- Du côté client, dès qu'il reçoit une donnée du serveur, il met à jour la position du cube (en le "téléportant").

Le cube de gauche est une exception à la démo, car il sert à montrer la différence entre la vue serveur et la vue client.


C'est un exemple très classique: le mouvement de déplacement du cube de milieu (tickrate 20) est saccadé, alors que celui de droite (tickrate 100) est fluide.
La vitesse est assez lente, du coup on a l'impression que le cube de droite (100) est très fluide. Mais si j'accélère la vitesse, il est possible que le cube saccade légèrement :). Dans les deux cas, nous avons vraiment l'impression que le cube se déplace, contrairement à son copain du milieu (tickrate 20) qui "saute" à un certain rythme.

La position du cube ne change pas tant que le client ne reçoit pas la donnée. C'est un peu ce qu'il se passe au niveau du réseau, et c'est ce que je souhaite montrer en démo (en accéléré :p).


De ce point de vue, je suis d'accord que de jouer à un jeu avec un faible tickrate, ce n'est pas très plaisant...


Cependant, nous sommes en 2015 et des techniques permettent de masquer cette horreur.


Seconde démo
http://s1.webmshare.com/NMznX.webm
(http://s1.webmshare.com/NMznX.webm)

Vue client exclusivement, étant donné que la vue serveur n'a pas changée depuis (sauf la latence).

Ceux deux cubes sont configurés sur deux tickrates différents: l'un est à 100, l'autre est à 10.
Savez-vous qui est qui ? :p


Pour cette seconde démo, j'ai appliqué la technique de l'interpolation: au lieu de mettre à jour automatiquement et brutalement la position du cube, le client le mémorise et fait "déplacer" le cube vers cette nouvelle position.
A l'exception d'un léger décalage sur la position de départ des cubes (dont mon code est le coupable), ces cubes se déplacent avec fluidité sur le terrain.


Cependant, faire de l'interpolation ne sauve pas un mauvais tickrate d'un jeu très rapide: si un personnage change de directions des centaines de fois par seconde, le client ne récupérera qu'une fraction de toutes ces modifications (en y applique une "moyenne"). Pour un jeu comme Quake, c'est impardonnable. (EDIT) Dans ce cas, il est important d'avoir un tickrate élevé.
Si besoin, je peux faire une démo qui reproduit ce cas. Mais il me faudra une meilleure capture vidéo :p.




Ce qu'il faut prendre conscience dans une partie multijoueurs avec une architecture client/serveur, c'est que le client n'est qu'un clavier attaché à une fenêtre, qui a une vue (décalée) sur une partie gérée par le serveur. Le "tickrate" ne sert qu'à rafraîchir plus ou moins souvent cette vue, et il existe de nombreuses techniques pour rendre l'expérience du jeu plus ou moins fluide.

Le "tickrate" n'est pas d'impact sur les règles du jeu ou le gameplay.
Le "tickrate" n'est pas coupable d'un mauvais test de collision ou d'un tir à travers les murs.
Par contre, il l'est si un des personnage peut agir et peut se déplacer bien plus rapidement que le taux de rafraîchissement.


Le tickrate ne sert qu'à ça. C'est pour cela que je dis qu'un tickrate faible n'est pas forcement signe d'un très mauvais netcode. Par contre, vous pouvez vous plaindre sur la compensation de latence (ou lag compensation) ;).


PS: Je ne fais pas de démo pour le tickrate côté client=>serveur (ou sendrate, comme certains l'évoquent). C'est un cas totalement différent et bien moins problématique s'il est bien géré.

PS2: Je n'ai pas eu le temps, mais pour ceux qui veulent je peux leur fournir une version compilée du projet, pour tester chez eux.

Raymonde
25/11/2015, 17h10
http://i.imgur.com/B6MI53v.png

Uriak
25/11/2015, 17h16
Tiens je reviens sur ce sujet pour dire qu'au final après avoir fait fonctionner UNET, j'ai été relativement agacé par ses contraintes sur notre modèle de développement. Nous faisons fonctionner une architecture client-serveur avec différentes machines de visualisation qui ne font qu'afficher une scène coordonée par une machine maître.

Dans ce contexte UNET
- comporte quantité de réglages non nécessaires à une utilisation en LAN
- impose des contraintes sur la scène initiale. Les éléments utilisant les NetworkIdentity devant être instanciés à la volée ou activés une fois le localhost établi
- propose un rafraîchissement décevant pour les network tranform
- l'appel aux RPC ne s'applique pas à la machine connectée au localHost (ce qui oblige à dupliquer le code à chaque appel)

Du coup j'ai finalement conclu "fuck this sh*t" et j'ai ré-implémenté tout ce dont j'avais besoin à l'aide de la librairie NetMQ (implémentation c# de ZeroMQ), en particulier j'ai refais les FPS et la synchro de variable à la mode de l'ancien network Unity (CallRPC("toto", RPCMode.ALL, args)
Je ne recommande pas la démarche en général mais clairement l'outil n'était pas adapté aux besoins de mon cas.

schouffy
25/11/2015, 17h47
Cependant, nous sommes en 2015 et des techniques permettent de masquer cette horreur.


Quand tu dis "nous sommes en 2015" c'est une façon de parler, ou ces techniques sont vraiment récentes ? Car ça parait vraiment trivial comme concept ?!

Orhin
25/11/2015, 18h04
Quand tu dis "nous sommes en 2015" c'est une façon de parler, ou ces techniques sont vraiment récentes ? Car ça parait vraiment trivial comme concept ?!
C'est une façon de parler, ça existe depuis un paquet d'année.

Louck
25/11/2015, 18h28
Comme dit, c'est une façon de parler :p.

Par contre il faut savoir que j'ai mis en place une interpolation toute bête pour ces démos.
En réalité, il faut réfléchir si notre jeu-vidéo est viable avec de l'interpolation ou de l'extrapolation, et il faut penser à comment bien le mettre en place. Car dans les faits, l'interpolation génère une certaine latence: l'information du serveur arrive un peu plus tardivement à notre écran.

Une mauvaise interpolation combiné à de la prédiction, tu as le fameux "peeking advantage" de CS:GO ;).

Mais cela n'a jamais été un problème très simple à régler. Tous les jeux ont ce genre de défaut ou quelque chose qui s'en approche.

xviniette
26/11/2015, 20h44
Le problème c'est que le tickrate signifie le nombre de "simulation" du monde par seconde et non le nombre d'envoie de snapshot/sec. Donc oui ça impacte les collisions, hittest, etc...

Pour plus d'informations : https://developer.valvesoftware.com/wiki/Tickrate#Basic_networking

Je pense qu'il est compliqué de critiquer le networking de CS:GO qui est un des (si ce n'est le) plus propres des jeux actuels.
Le peeking advantage est logique (même sans interpolation il y a peeking advantage), et je ne vois pas de façon de le supprimer.

Louck
26/11/2015, 21h17
Concernant la définition, de ce que je disais:


Tout d'abord, lorsque je parle du tickrate, je fais référence à la fréquence d'envoi des snapshots/mises à jours/données sur le réseau par le serveur ou le client.

Je suis d'accord que dans ta définition - où le serveur peut restreindre la fréquence de simulation de son monde - le tickrate peut avoir un certain impact sur le gameplay du jeu.

Si je l'ai précisé, c'est que la définition varie selon les moteurs utilisés, ou selon le contexte. Même si nous sommes d'accord que le wiki de Valve est une grosse référence quand on s'intéresse aux jeux en réseau, leurs définitions de tickrate/sendrate/updaterate sont propres à eux et à leur moteur de jeu.
Par exemple, dans l'ancienne version de l'API d'Unity3D, il est possible de paramétrer le "SendRate" du côté client et du côté serveur. Pour Valve, le "SendRate" n'est invoqué que du côté client. Ce n'est pas la même chose :).

Bref, je ne veux pas jouer avec les mots non plus :). Mais il est vrai qu'il est important de bien détailler sa propre définition des termes afin qu'il n'y ai pas de confusion. Certains joueurs possèdent ta définition du tickrate, d'autres ont la mienne (et bien d'autres ont leurs propres termes).



Je pense qu'il est compliqué de critiquer le networking de CS:GO qui est un des (si ce n'est le) plus propres des jeux actuels.
Le peeking advantage est logique (même sans interpolation il y a peeking advantage), et je ne vois pas de façon de le supprimer.

Sans aucun doute.

Il y avait eu une certaine mise à jour de CS:GO qui impactait un paramètre de l'interpolation (je n'ai plus le nom malheureusement). Hors à ce moment le paramètre était mal réglé, et le peeking advantage était obvious.
Mais je suis d'accord que tant que le jeu supporte la prediction, il y aura le peeking advantage. Etant donné que l'avantage est mesuré en dixième de secondes (sauf si mauvais paramétrage), je ne sais pas si cela une vrai importance dans les jeux compétitifs (EDIT: Sur les parties publiques, c'est "contré" par le lag compensation).


PS: Comment je vais bien me casser la tête sur mon jeu multijoueur pour bien configurer l'interpolation et la prédiction.