Multiplayer-datakomprimering och bitmanipulation

Att skapa ett spel för flera spelare i Unity är inte en trivial uppgift, utan med hjälp av tredjepartslösningar, som PUN 2, det har gjort nätverksintegration mycket enklare.

Alternativt, om du behöver mer kontroll över spelets nätverkskapacitet, kan du skriva din egen nätverkslösning med hjälp av Socket-teknik (t.ex. auktoritativ multiplayer, där servern bara tar emot spelarinput och sedan gör sin egna beräkningar för att säkerställa att alla spelare beter sig på samma sätt, vilket minskar förekomsten av hacking).

Oavsett om du skriver ditt eget nätverk eller använder en befintlig lösning, bör du vara uppmärksam på ämnet som vi kommer att diskutera i det här inlägget, vilket är datakomprimering.

Grunderna för flera spelare

I de flesta multiplayer-spel finns det kommunikation som sker mellan spelare och servern, i form av små batchs av data (en sekvens av bytes), som skickas fram och tillbaka i en specificerad hastighet.

I Unity (och C# specifikt), är de vanligaste värdetyperna int, float, bool, och sträng (du bör också undvika att använda sträng när du skickar värden som ändras ofta, den mest acceptabla användningen för denna typ är chattmeddelanden eller data som bara innehåller text).

  • Alla typerna ovan lagras i ett visst antal byte:

int = 4 byte
float = 4 byte
bool = 1 byte
sträng = (Antal byte som används för att koda ett enstaka tecken, beroende på kodningsformat) x (Antal tecken)

När vi känner till värdena, låt oss beräkna det minsta antal byte som behövs för att skickas för en standard FPS för flera spelare (First-Person Shooter):

Spelarposition: Vector3 (3 flöten x 4) = 12 byte
Spelarrotation: Quaternion (4 flöten x 4) = 16 byte
Spelarens utseendemål: Vector3 (3 flöten x 4) = 12 byte
Spelare avfyrar: bool = 1 byte
Spelare i luften: bool = 1 byte
Spelare hukar: bool = 1 byte
Spelare som kör: bool = 1 byte

Totalt 44 byte.

Vi kommer att använda förlängningsmetoder för att packa data i en mängd byte, och vice versa:

  • Skapa ett nytt skript, döp det till SC_ByteMethods och klistra sedan in koden nedan i det:

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

Exempel på användning av metoderna ovan:

  • Skapa ett nytt skript, namnge det SC_TestPackUnpack och klistra in koden nedan i det:

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

Skriptet ovan initierar byte-arrayen med en längd på 44 (vilket motsvarar bytesumman av alla värden som vi vill skicka).

Varje värde konverteras sedan till byte-arrayer och appliceras sedan i packedData-arrayen med hjälp av Buffer.BlockCopy.

Senare konverteras packedData tillbaka till värden med förlängningsmetoder från SC_ByteMethods.cs.

Datakompressionstekniker

Objektivt sett är 44 byte inte mycket data, men om det behövs skickas 10 - 20 gånger per sekund börjar trafiken läggas ihop.

När det kommer till nätverk, räknas varje byte.

Så hur kan man minska mängden data?

Svaret är enkelt, genom att inte skicka de värden som inte förväntas förändras, och genom att stapla enkla värdetyper i en enda byte.

Skicka inte värden som inte förväntas ändras

I exemplet ovan lägger vi till Quaternion för rotationen, som består av 4 flöten.

Men i fallet med ett FPS-spel roterar spelaren vanligtvis bara runt Y-axeln, med vetskapen om att vi bara kan lägga till rotationen runt Y, vilket minskar rotationsdata från 16 byte till bara 4 byte.

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

Stapla flera booleaner i en enda byte

En byte är en sekvens av 8 bitar, var och en med ett möjligt värde på 0 och 1.

Av en slump kan boolvärdet bara vara sant eller falskt. Så med en enkel kod kan vi komprimera upp till 8 boolvärden till en enda byte.

Öppna SC_ByteMethods.cs och lägg sedan till koden nedan före den sista avslutande klammerparentesen '}'

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

Uppdaterad SC_TestPackUnpack-kod:

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

Med metoderna ovan har vi minskat packedData-längden från 44 till 29 byte (34 % minskning).