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%).