Multiplayer datakomprimering og bitmanipulation
At skabe et multiplayer-spil i Unity er ikke en triviel opgave, men ved hjælp af tredjepartsløsninger, såsom PUN 2, har det gjort netværksintegration meget nemmere.
Alternativt, hvis du har brug for mere kontrol over spillets netværksmuligheder, kan du skrive din egen netværksløsning ved hjælp af Socket teknologi (f.eks. autoritativ multiplayer, hvor serveren kun modtager spillerinput og derefter foretager sine egne beregninger for at sikre at alle spillere opfører sig på samme måde, hvilket reducerer forekomsten af hacking).
Uanset om du skriver dit eget netværk eller bruger en eksisterende løsning, bør du være opmærksom på det emne, som vi vil diskutere i dette indlæg, som er datakomprimering.
Grundlæggende om multiplayer
I de fleste multiplayer-spil er der kommunikation, der sker mellem spillere og serveren, i form af små batches af data (en sekvens af bytes), som sendes frem og tilbage med en bestemt hastighed.
I Unity (og specifikt C#) er de mest almindelige værdityper int, float, bool, og streng (du bør også undgå at bruge streng, når du sender hyppigt skiftende værdier, den mest acceptable brug for denne type er chatbeskeder eller data, der kun indeholder tekst).
- Alle ovenstående typer er gemt i et bestemt antal bytes:
int = 4 bytes
float = 4 bytes
bool = 1 byte
streng = (Antal bytes brugt til at kode et enkelt tegn, afhængigt af kodningsformat) x (Antal tegn)
Når vi kender værdierne, så lad os beregne det mindste antal bytes, der er nødvendige for at blive sendt til en standard multiplayer FPS (First-Person Shooter):
Spillerposition: Vector3 (3 floats x 4) = 12 bytes
Spillerrotation: Quaternion (4 floats x 4) = 16 bytes
Player look target: Vector3 (3 floats x 4) = 12 bytes
Spiller affyring: bool = 1 byte
Spiller i luften: bool = 1 byte
Spiller, der sidder på huk: bool = 1 byte
Spiller, der kører: bool = 1 byte
I alt 44 bytes.
Vi vil bruge udvidelsesmetoder til at pakke dataene ind i en række bytes og omvendt:
- Opret et nyt script, navngiv det SC_ByteMethods og indsæt derefter koden nedenfor 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);
}
}
Eksempel på brug af metoderne ovenfor:
- Opret et nyt script, navngiv det SC_TestPackUnpack og indsæt derefter koden nedenfor 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));
}
}
Scriptet ovenfor initialiserer byte-arrayet med en længde på 44 (hvilket svarer til bytesummen af alle værdier, som vi ønsker at sende).
Hver værdi konverteres derefter til byte-arrays og anvendes derefter i packedData-arrayet ved hjælp af Buffer.BlockCopy.
Senere konverteres packedData tilbage til værdier ved hjælp af udvidelsesmetoder fra SC_ByteMethods.cs.
Datakomprimeringsteknikker
Objektivt set er 44 bytes ikke meget data, men hvis det er nødvendigt at sende 10 - 20 gange i sekundet, begynder trafikken at stige.
Når det kommer til netværk, tæller hver byte.
Så hvordan reducerer man mængden af data?
Svaret er enkelt, ved ikke at sende de værdier, der ikke forventes at ændre sig, og ved at stable simple værdityper i en enkelt byte.
Send ikke værdier, der ikke forventes at ændre sig
I eksemplet ovenfor tilføjer vi rotationens Quaternion, som består af 4 flydere.
Men i tilfælde af et FPS-spil roterer spilleren normalt kun omkring Y-aksen, vel vidende at vi kun kan tilføje rotationen omkring Y, hvilket reducerer rotationsdata fra 16 bytes til kun 4 bytes.
Buffer.BlockCopy(transform.localEulerAngles.y.toByteArray(), 0, packedData, 12, 4); //Local Y Rotation
Stak flere booleanere i en enkelt byte
En byte er en sekvens på 8 bits, hver med en mulig værdi på 0 og 1.
Tilfældigvis kan bool-værdien kun være sand eller falsk. Så med en simpel kode kan vi komprimere op til 8 bool-værdier til en enkelt byte.
Åbn SC_ByteMethods.cs og tilføj derefter koden nedenfor før den sidste afsluttende klammeparentes '}'
//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;
}
Opdateret SC_TestPackUnpack-kode:
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 metoderne ovenfor har vi reduceret packedData-længden fra 44 til 29 bytes (34 % reduktion).