Compression de données multijoueur et manipulation de bits

Créer un jeu multijoueur dans Unity n'est pas une tâche triviale, mais avec l'aide de solutions tierces, telles que PUN 2, il a rendu l'intégration réseau beaucoup plus facile.

Alternativement, si vous avez besoin de plus de contrôle sur les capacités de mise en réseau du jeu, vous pouvez écrire votre propre solution de mise en réseau à l'aide de la technologie Socket (par exemple, multijoueur faisant autorité, où le serveur ne reçoit que les entrées des joueurs puis effectue ses propres calculs pour s'assurer que tous les joueurs se comportent de la même manière, réduisant ainsi l'incidence du piratage).

Que vous écriviez votre propre réseau ou que vous utilisiez une solution existante, vous devez garder à l'esprit le sujet dont nous parlerons dans cet article, à savoir la compression des données.

Bases du multijoueur

Dans la plupart des jeux multijoueurs, une communication se produit entre les joueurs et le serveur, sous la forme de petits lots de données (une séquence d'octets), qui sont envoyés dans les deux sens à un débit spécifié.

En Unity (et C# en particulier), les types de valeur les plus courants sont int, float, bool, et chaîne (vous devez également éviter d'utiliser une chaîne lors de l'envoi de valeurs changeant fréquemment, l'utilisation la plus acceptable pour ce type sont les messages de chat ou les données qui ne contiennent que du texte).

  • Tous les types ci-dessus sont stockés dans un nombre défini d'octets:

int = 4 octets
float = 4 octets
bool = 1 byte
string = (Nombre d'octets utilisés pour encoder un seul caractère, selon le format d'encodage) x (Nombre de caractères)

Connaissant les valeurs, calculons le nombre minimum d'octets à envoyer pour un FPS (First-Person Shooter) multijoueur standard:

Position du joueur: Vector3 (3 floats x 4) = 12 octets
Rotation du joueur: Quaternion (4 floats x 4) = 16 octets
Cible d'apparence du joueur: Vector3 (3 floats x 4 ) = 12 octets
Joueur qui tire: bool = 1 octet
Joueur en l'air: bool = 1 octet
Joueur accroupi: bool = 1 octet
Joueur en cours d'exécution: bool = 1 octet

Total 44 octets.

Nous utiliserons des méthodes d'extension pour regrouper les données dans un tableau d'octets, et vice versa:

  • Créez un nouveau script, nommez-le SC_ByteMethods puis collez-y le code ci-dessous:

SC_ByteMethods.cs

using System;
using System.Collections;
using System.Text;

public static class SC_ByteMethods
{
    //Convert value types to byte array
    public static byte[] toByteArray(this float value)
    {
        return BitConverter.GetBytes(value);
    }

    public static byte[] toByteArray(this int value)
    {
        return BitConverter.GetBytes(value);
    }

    public static byte toByte(this bool value)
    {
        return (byte)(value ? 1 : 0);
    }

    public static byte[] toByteArray(this string value)
    {
        return Encoding.UTF8.GetBytes(value);
    }

    //Convert byte array to value types
    public static float toFloat(this byte[] bytes, int startIndex)
    {
        return BitConverter.ToSingle(bytes, startIndex);
    }

    public static int toInt(this byte[] bytes, int startIndex)
    {
        return BitConverter.ToInt32(bytes, startIndex);
    }

    public static bool toBool(this byte[] bytes, int startIndex)
    {
        return bytes[startIndex] == 1;
    }

    public static string toString(this byte[] bytes, int startIndex, int length)
    {
        return Encoding.UTF8.GetString(bytes, startIndex, length);
    }
}

Exemple d'utilisation des méthodes ci-dessus:

  • Créez un nouveau script, nommez-le SC_TestPackUnpack puis collez-y le code ci-dessous:

SC_TestPackUnpack.cs

using System;
using UnityEngine;

public class SC_TestPackUnpack : MonoBehaviour
{
    //Example values
    public Transform lookTarget;
    public bool isFiring = false;
    public bool inTheAir = false;
    public bool isCrouching = false;
    public bool isRunning = false;

    //Data that can be sent over network
    byte[] packedData = new byte[44]; //12 + 16 + 12 + 1 + 1 + 1 + 1

    // Update is called once per frame
    void Update()
    {
        //Part 1: Example of writing Data
        //_____________________________________________________________________________
        //Insert player position bytes
        Buffer.BlockCopy(transform.position.x.toByteArray(), 0, packedData, 0, 4); //X
        Buffer.BlockCopy(transform.position.y.toByteArray(), 0, packedData, 4, 4); //Y
        Buffer.BlockCopy(transform.position.z.toByteArray(), 0, packedData, 8, 4); //Z
        //Insert player rotation bytes
        Buffer.BlockCopy(transform.rotation.x.toByteArray(), 0, packedData, 12, 4); //X
        Buffer.BlockCopy(transform.rotation.y.toByteArray(), 0, packedData, 16, 4); //Y
        Buffer.BlockCopy(transform.rotation.z.toByteArray(), 0, packedData, 20, 4); //Z
        Buffer.BlockCopy(transform.rotation.w.toByteArray(), 0, packedData, 24, 4); //W
        //Insert look position bytes
        Buffer.BlockCopy(lookTarget.position.x.toByteArray(), 0, packedData, 28, 4); //X
        Buffer.BlockCopy(lookTarget.position.y.toByteArray(), 0, packedData, 32, 4); //Y
        Buffer.BlockCopy(lookTarget.position.z.toByteArray(), 0, packedData, 36, 4); //Z
        //Insert bools
        packedData[40] = isFiring.toByte();
        packedData[41] = inTheAir.toByte();
        packedData[42] = isCrouching.toByte();
        packedData[43] = isRunning.toByte();
        //packedData ready to be sent...

        //Part 2: Example of reading received data
        //_____________________________________________________________________________
        Vector3 receivedPosition = new Vector3(packedData.toFloat(0), packedData.toFloat(4), packedData.toFloat(8));
        print("Received Position: " + receivedPosition);
        Quaternion receivedRotation = new Quaternion(packedData.toFloat(12), packedData.toFloat(16), packedData.toFloat(20), packedData.toFloat(24));
        print("Received Rotation: " + receivedRotation);
        Vector3 receivedLookPos = new Vector3(packedData.toFloat(28), packedData.toFloat(32), packedData.toFloat(36));
        print("Received Look Position: " + receivedLookPos);
        print("Is Firing: " + packedData.toBool(40));
        print("In The Air: " + packedData.toBool(41));
        print("Is Crouching: " + packedData.toBool(42));
        print("Is Running: " + packedData.toBool(43));
    }
}

Le script ci-dessus initialise le tableau d'octets avec une longueur de 44 (ce qui correspond à la somme d'octets de toutes les valeurs que nous voulons envoyer).

Chaque valeur est ensuite convertie en tableaux d'octets, puis appliquée dans le tableau packedData à l'aide de Buffer.BlockCopy.

Plus tard, le packedData est reconverti en valeurs à l'aide des méthodes d'extension de SC_ByteMethods.cs.

Techniques de compression des données

Objectivement, 44 octets ne représentent pas beaucoup de données, mais s'il faut les envoyer 10 à 20 fois par seconde, le trafic commence à s'accumuler.

En matière de mise en réseau, chaque octet compte.

Alors comment réduire la quantité de données ?

La réponse est simple, en n'envoyant pas les valeurs qui ne devraient pas changer et en empilant des types de valeurs simples dans un seul octet.

Ne pas envoyer de valeurs qui ne devraient pas changer

Dans l'exemple ci-dessus, nous ajoutons le Quaternion de la rotation, qui se compose de 4 flotteurs.

Cependant, dans le cas d'un jeu FPS, le joueur ne tourne généralement qu'autour de l'axe Y, sachant que, nous ne pouvons ajouter que la rotation autour de Y, réduisant les données de rotation de 16 octets à seulement 4 octets.

Buffer.BlockCopy(transform.localEulerAngles.y.toByteArray(), 0, packedData, 12, 4); //Local Y Rotation

Empiler plusieurs booléens dans un seul octet

Un octet est une séquence de 8 bits, chacun avec une valeur possible de 0 et 1.

Par coïncidence, la valeur booléenne ne peut être que vraie ou fausse. Ainsi, avec un code simple, nous pouvons compresser jusqu'à 8 valeurs booléennes en un seul octet.

Ouvrez SC_ByteMethods.cs puis ajoutez le code ci-dessous avant la dernière accolade fermante '}'

    //Bit Manipulation
    public static byte ToByte(this bool[] bools)
    {
        byte[] boolsByte = new byte[1];
        if (bools.Length == 8)
        {
            BitArray a = new BitArray(bools);
            a.CopyTo(boolsByte, 0);
        }

        return boolsByte[0];
    }

    //Get value of Bit in the byte by the index
    public static bool GetBit(this byte b, int bitNumber)
    {
        //Check if specific bit of byte is 1 or 0
        return (b & (1 << bitNumber)) != 0;
    }

Code SC_TestPackUnpack mis à jour:

SC_TestPackUnpack.cs

using System;
using UnityEngine;

public class SC_TestPackUnpack : MonoBehaviour
{
    //Example values
    public Transform lookTarget;
    public bool isFiring = false;
    public bool inTheAir = false;
    public bool isCrouching = false;
    public bool isRunning = false;

    //Data that can be sent over network
    byte[] packedData = new byte[29]; //12 + 4 + 12 + 1

    // Update is called once per frame
    void Update()
    {
        //Part 1: Example of writing Data
        //_____________________________________________________________________________
        //Insert player position bytes
        Buffer.BlockCopy(transform.position.x.toByteArray(), 0, packedData, 0, 4); //X
        Buffer.BlockCopy(transform.position.y.toByteArray(), 0, packedData, 4, 4); //Y
        Buffer.BlockCopy(transform.position.z.toByteArray(), 0, packedData, 8, 4); //Z
        //Insert player rotation bytes
        Buffer.BlockCopy(transform.localEulerAngles.y.toByteArray(), 0, packedData, 12, 4); //Local Y Rotation
        //Insert look position bytes
        Buffer.BlockCopy(lookTarget.position.x.toByteArray(), 0, packedData, 16, 4); //X
        Buffer.BlockCopy(lookTarget.position.y.toByteArray(), 0, packedData, 20, 4); //Y
        Buffer.BlockCopy(lookTarget.position.z.toByteArray(), 0, packedData, 24, 4); //Z
        //Insert bools (Compact)
        bool[] bools = new bool[8];
        bools[0] = isFiring;
        bools[1] = inTheAir;
        bools[2] = isCrouching;
        bools[3] = isRunning;
        packedData[28] = bools.ToByte();
        //packedData ready to be sent...

        //Part 2: Example of reading received data
        //_____________________________________________________________________________
        Vector3 receivedPosition = new Vector3(packedData.toFloat(0), packedData.toFloat(4), packedData.toFloat(8));
        print("Received Position: " + receivedPosition);
        float receivedRotationY = packedData.toFloat(12);
        print("Received Rotation Y: " + receivedRotationY);
        Vector3 receivedLookPos = new Vector3(packedData.toFloat(16), packedData.toFloat(20), packedData.toFloat(24));
        print("Received Look Position: " + receivedLookPos);
        print("Is Firing: " + packedData[28].GetBit(0));
        print("In The Air: " + packedData[28].GetBit(1));
        print("Is Crouching: " + packedData[28].GetBit(2));
        print("Is Running: " + packedData[28].GetBit(3));
    }
}

Avec les méthodes ci-dessus, nous avons réduit la longueur de packedData de 44 à 29 octets (réduction de 34%).

Articles suggérés
Introduction à Photon Fusion 2 dans Unity
Créez un jeu multijoueur dans Unity en utilisant PUN 2
Créez un jeu de voiture multijoueur avec PUN 2
Unity ajoute un chat multijoueur aux salles PUN 2
Guide du débutant Photon Network (classique)
Créer des jeux multijoueurs en réseau dans Unity
Tutoriel de classement en ligne Unity