Comprimarea datelor multiplayer și manipularea biților

Crearea unui joc multiplayer în Unity nu este o sarcină banală, dar cu ajutorul unor soluții terțe, cum ar fi PUN 2, a făcut integrarea în rețea mult mai ușoară.

Alternativ, dacă aveți nevoie de mai mult control asupra capacităților de rețea ale jocului, puteți scrie propria soluție de rețea folosind tehnologia Socket (de exemplu, multiplayer cu autoritate, în care serverul primește doar intrarea jucătorului și apoi își face propriile calcule pentru a se asigura că ca toți jucătorii să se comporte în același mod, reducând astfel incidența hacking).

Indiferent dacă vă scrieți propria rețea sau folosiți o soluție existentă, ar trebui să fiți atenți la subiectul pe care îl vom discuta în această postare, care este compresia datelor.

Noțiuni de bază pentru multiplayer

În majoritatea jocurilor multiplayer, există o comunicare care are loc între jucători și server, sub formă de loturi mici de date (o secvență de octeți), care sunt trimise înainte și înapoi la o rată specificată.

În Unity (și în special C#), cele mai comune tipuri de valori sunt int, float, bool și string (de asemenea, ar trebui să evitați să utilizați șir atunci când trimiteți valori care se schimbă frecvent, cea mai acceptabilă utilizare pentru acest tip sunt mesajele de chat sau datele care conțin doar text).

  • Toate tipurile de mai sus sunt stocate într-un număr stabilit de octeți:

int = 4 bytes
float = 4 bytes
bool = 1 byte
string = (Numărul de octeți utilizați pentru codificați un singur caracter, în funcție de formatul de codare) x (Număr de caractere)

Cunoscând valorile, să calculăm cantitatea minimă de octeți care trebuie trimiși pentru un FPS standard multiplayer (First-Person Shooter):

Poziția jucătorului: Vector3 (3 floats x 4) = 12 bytes
Rotația jucătorului: Quaternion (4 floats x 4) = 16 bytes
Player look target: Vector3 (3 floats x 4) = 12 bytes
Player tragere: bool = 1 octet
Jucător în aer: bool = 1 octet
Jucător ghemuit: bool = 1 octet
Jucător care rulează: bool = 1 octet

Total 44 de octeți.

Vom folosi metode de extensie pentru a împacheta datele într-o matrice de octeți și invers:

  • Creați un script nou, denumiți-l SC_ByteMethods apoi inserați codul de mai jos în interiorul acestuia:

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

Exemplu de utilizare a metodelor de mai sus:

  • Creați un script nou, denumiți-l SC_TestPackUnpack, apoi inserați codul de mai jos în el:

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

Scriptul de mai sus inițializează matricea de octeți cu o lungime de 44 (care corespunde sumei de octeți a tuturor valorilor pe care dorim să le trimitem).

Fiecare valoare este apoi convertită în matrice de octeți, apoi aplicată în matricea packedData folosind Buffer.BlockCopy.

Ulterior, packedData este convertit înapoi la valori folosind metode de extensie din SC_ByteMethods.cs.

Tehnici de comprimare a datelor

Obiectiv, 44 de octeți nu reprezintă o mulțime de date, dar dacă trebuie să fie trimiși de 10 - 20 de ori pe secundă, traficul începe să se adună.

Când vine vorba de rețea, fiecare octet contează.

Deci, cum să reduceți cantitatea de date?

Răspunsul este simplu, prin netrimiterea valorilor care nu se așteaptă să se schimbe și prin stivuirea unor tipuri de valori simple într-un singur octet.

Nu trimiteți valori care nu sunt de așteptat să se schimbe

În exemplul de mai sus adăugăm Quaternionul de rotație, care constă din 4 flotoare.

Cu toate acestea, în cazul unui joc FPS, jucătorul se rotește de obicei doar în jurul axei Y, știind că, putem adăuga doar rotația în jurul lui Y, reducând datele de rotație de la 16 octeți la doar 4 octeți.

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

Stivuiți mai multe valori booleene într-un singur octet

Un octet este o secvență de 8 biți, fiecare cu o valoare posibilă de 0 și 1.

Întâmplător, valoarea bool poate fi doar adevărată sau falsă. Deci, cu un cod simplu, putem comprima până la 8 valori bool într-un singur octet.

Deschideți SC_ByteMethods.cs, apoi adăugați codul de mai jos înainte de ultima acoladă de închidere „}”

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

Cod SC_TestPackUnpack actualizat:

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

Cu metodele de mai sus, am redus lungimea packedData de la 44 la 29 de octeți (reducere de 34%).

Articole sugerate
Introducere în Photon Fusion 2 în Unity
Crearea de jocuri multiplayer în rețea în Unity
Creați un joc cu mașini multiplayer cu PUN 2
Unity Adăugând chat multiplayer în camerele PUN 2
Faceți un joc multiplayer în Unity folosind PUN 2
Sistem de conectare Unity cu PHP și MySQL
Sincronizați corpuri rigide prin rețea folosind PUN 2