Compressione dei dati multigiocatore e manipolazione dei bit

Creare una partita multiplayer in Unity non è un compito banale, ma con l'aiuto di soluzioni di terze parti, come PUN 2, ha reso l'integrazione di rete molto più semplice.

In alternativa, se hai bisogno di un maggiore controllo sulle capacità di rete del gioco, puoi scrivere la tua soluzione di rete utilizzando la tecnologia Socket (ad es. multiplayer autorevole, in cui il server riceve solo l'input del giocatore e poi fa i propri calcoli per garantire che tutti i giocatori si comportino allo stesso modo, riducendo così l'incidenza di hacking).

Indipendentemente dal fatto che tu stia scrivendo la tua rete o utilizzando una soluzione esistente, dovresti essere consapevole dell'argomento che discuteremo in questo post, che è la compressione dei dati.

Nozioni di base sul multigiocatore

Nella maggior parte dei giochi multiplayer, avviene la comunicazione tra i giocatori e il server, sotto forma di piccoli batch di dati (una sequenza di byte), che vengono inviati avanti e indietro a una velocità specificata.

In Unity (e C# in particolare), i tipi di valore più comuni sono int, float, bool, e stringa (inoltre, dovresti evitare di usare la stringa quando invii valori che cambiano frequentemente, l'uso più accettabile per questo tipo sono i messaggi di chat oi dati che contengono solo testo).

  • Tutti i tipi di cui sopra sono memorizzati in un determinato numero di byte:

int = 4 byte
float = 4 byte
bool = 1 byte
stringa = (Numero di byte utilizzati per codificare un singolo carattere, a seconda del formato di codifica) x (Numero di caratteri)

Conoscendo i valori, calcoliamo la quantità minima di byte che è necessario inviare per un FPS multiplayer standard (sparatutto in prima persona):

Posizione giocatore: Vector3 (3 float x 4) = 12 byte
Rotazione giocatore: Quaternion (4 float x 4) = 16 byte
Obiettivo aspetto giocatore: Vector3 (3 float x 4 ) = 12 byte
Giocatore che spara: bool = 1 byte
Giocatore in aria: bool = 1 byte
Giocatore accovacciato: bool = 1 byte
Player in esecuzione: bool = 1 byte

Totale 44 byte.

Useremo i metodi di estensione per comprimere i dati in un array di byte e viceversa:

  • Crea un nuovo script, chiamalo SC_ByteMethods quindi incolla il codice sottostante al suo interno:

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

Esempio di utilizzo dei metodi di cui sopra:

  • Crea un nuovo script, chiamalo SC_TestPackUnpack quindi incolla il codice sottostante al suo interno:

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

Lo script sopra inizializza l'array di byte con una lunghezza di 44 (che corrisponde alla somma di byte di tutti i valori che vogliamo inviare).

Ogni valore viene quindi convertito in array di byte, quindi applicato nell'array PackedData utilizzando Buffer.BlockCopy.

Successivamente, il pacchetto PackData viene riconvertito in valori utilizzando i metodi di estensione da SC_ByteMethods.cs.

Tecniche di compressione dei dati

Oggettivamente, 44 byte non sono molti dati, ma se è necessario inviarli 10-20 volte al secondo, il traffico inizia a sommarsi.

Quando si tratta di networking, ogni byte conta.

Allora come ridurre la quantità di dati?

La risposta è semplice, non inviando i valori che non dovrebbero cambiare e impilando tipi di valore semplici in un singolo byte.

Non inviare valori che non dovrebbero cambiare

Nell'esempio sopra stiamo aggiungendo il quaternione della rotazione, che consiste di 4 float.

Tuttavia, nel caso di un gioco FPS, il giocatore di solito ruota solo attorno all'asse Y, sapendo che possiamo solo aggiungere la rotazione attorno a Y, riducendo i dati di rotazione da 16 byte a soli 4 byte.

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

Impila più booleani in un singolo byte

Un byte è una sequenza di 8 bit, ciascuno con un possibile valore di 0 e 1.

Per coincidenza, il valore bool può essere solo vero o falso. Quindi, con un semplice codice, possiamo comprimere fino a 8 valori bool in un singolo byte.

Apri SC_ByteMethods.cs quindi aggiungi il codice qui sotto prima dell'ultima parentesi graffa di chiusura '}'

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

Codice SC_TestPackUnpack aggiornato:

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

Con i metodi di cui sopra, abbiamo ridotto la lunghezza di packetData da 44 a 29 byte (riduzione del 34%).

Articoli suggeriti
Introduzione a Photon Fusion 2 in Unity
Crea un gioco multiplayer in Unity usando PUN 2
Photon Network (Classic) Guida per principianti
Creazione di giochi in rete multiplayer in Unity
Tutorial sulla classifica online di Unity
Realizza un gioco di auto multiplayer con PUN 2
Sincronizza i corpi rigidi sulla rete usando PUN 2