Multiplayer-Datenkomprimierung und Bitmanipulation

Das Erstellen eines Multiplayer-Spiels in Unity ist keine triviale Aufgabe, aber mithilfe von Lösungen von Drittanbietern wie PUN 2 hat es die Netzwerkintegration erheblich vereinfacht.

Wenn Sie mehr Kontrolle über die Netzwerkfunktionen des Spiels benötigen, können Sie alternativ Ihre eigene Netzwerklösung mithilfe der Socket-Technologie schreiben (z. B. autoritativer Mehrspielermodus, bei dem der Server nur Spielereingaben empfängt und dann seine eigenen Berechnungen durchführt, um sicherzustellen). dass sich alle Spieler gleich verhalten, wodurch die Häufigkeit von Hacking verringert wird.

Unabhängig davon, ob Sie Ihr eigenes Netzwerk schreiben oder eine vorhandene Lösung verwenden, sollten Sie sich des Themas bewusst sein, das wir in diesem Beitrag besprechen werden, nämlich der Datenkomprimierung.

Multiplayer-Grundlagen

In den meisten Multiplayer-Spielen erfolgt die Kommunikation zwischen Spielern und dem Server in Form kleiner Datenmengen (einer Folge von Bytes), die mit einer bestimmten Geschwindigkeit hin und her gesendet werden.

In Unity (und speziell C#) sind die häufigsten Werttypen int, float, bool, und string (Sie sollten außerdem die Verwendung von Zeichenfolgen vermeiden, wenn Sie sich häufig ändernde Werte senden. Die akzeptableste Verwendung für diesen Typ sind Chat-Nachrichten oder Daten, die nur Text enthalten.)

  • Alle oben genannten Typen werden in einer festgelegten Anzahl von Bytes gespeichert:

int = 4 Bytes
float = 4 Bytes
bool = 1 Byte
string = (Anzahl der verwendeten Bytes kodiert ein einzelnes Zeichen, abhängig vom Kodierungsformat) x (Anzahl der Zeichen)

Wenn wir die Werte kennen, berechnen wir die Mindestmenge an Bytes, die für einen Standard-Multiplayer-FPS (Ego-Shooter) gesendet werden muss:

Spielerposition: Vector3 (3 Floats x 4) = 12 Bytes
Player-Rotation: Quaternion (4 Floats x 4) = 16 Bytes
Player-Look-Ziel: Vector3 (3 Floats x 4) = 12 Bytes
Player Schießen: bool = 1 Byte
Spieler in der Luft: bool = 1 Byte
Spieler kauert: bool = 1 Byte
Spieler läuft: bool = 1 Byte

Insgesamt 44 Bytes.

Wir verwenden Erweiterungsmethoden, um die Daten in ein Byte-Array zu packen, und umgekehrt:

  • Erstellen ein neues Skript, nennen Sie es SC_ByteMethods und fügen Sie dann den folgenden Code ein:

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

Beispielhafte Verwendung der oben genannten Methoden:

  • Erstellen ein neues Skript, nennen Sie es SC_TestPackUnpack und fügen Sie dann den folgenden Code ein:

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

Das obige Skript initialisiert das Byte-Array mit einer Länge von 44 (was der Byte-Summe aller Werte entspricht, die wir senden möchten).

Jeder Wert wird dann in Byte-Arrays konvertiert und dann mit Buffer.BlockCopy in das gepackteData-Array angewendet.

Später werden die gepackten Daten mithilfe von Erweiterungsmethoden aus SC_ByteMethods.cs wieder in Werte konvertiert.

Datenkomprimierungstechniken

Objektiv gesehen sind 44 Bytes keine große Datenmenge, aber wenn sie 10 bis 20 Mal pro Sekunde gesendet werden muss, summiert sich der Datenverkehr.

Bei der Vernetzung zählt jedes Byte.

Wie kann man also die Datenmenge reduzieren?

Die Antwort ist einfach: Senden Sie nicht die Werte, von denen keine Änderung erwartet wird, und stapeln Sie einfache Werttypen in einem einzelnen Byte.

Senden Sie keine Werte, deren Änderung nicht zu erwarten ist

Im obigen Beispiel fügen wir das Quaternion der Rotation hinzu, das aus 4 Floats besteht.

Bei einem FPS-Spiel dreht sich der Spieler jedoch normalerweise nur um die Y-Achse, da er weiß, dass wir nur die Drehung um die Y-Achse hinzufügen können, wodurch die Rotationsdaten von 16 Byte auf nur 4 Byte reduziert werden.

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

Stapeln Sie mehrere Boolesche Werte in einem einzelnen Byte

Ein Byte ist eine Folge von 8 Bits, jedes mit den möglichen Werten 0 und 1.

Zufälligerweise kann der Bool-Wert nur wahr oder falsch sein. Mit einem einfachen Code können wir also bis zu 8 Bool-Werte in ein einzelnes Byte komprimieren.

Öffnen Sie SC_ByteMethods.cs und fügen Sie dann den folgenden Code vor der letzten schließenden Klammer „}“ ein.

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

Aktualisierter SC_TestPackUnpack-Code:

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

Mit den oben genannten Methoden haben wir die Länge der gepackten Daten von 44 auf 29 Byte reduziert (Reduzierung um 34 %).