Kompresja danych i manipulacja bitami w trybie wieloosobowym
Utworzenie gry wieloosobowej w Unity nie jest trywialnym zadaniem, ale przy pomocy rozwiązań innych firm, takich jak PUN 2, znacznie ułatwiło integrację sieciową.
Alternatywnie, jeśli potrzebujesz większej kontroli nad możliwościami sieciowymi gry, możesz napisać własne rozwiązanie sieciowe, korzystając z technologii Socket (np. autorytatywny tryb dla wielu graczy, w którym serwer otrzymuje tylko dane wejściowe gracza, a następnie wykonuje własne obliczenia, aby zapewnić aby wszyscy gracze zachowywali się w ten sam sposób, zmniejszając w ten sposób częstotliwość hakowania).
Niezależnie od tego, czy piszesz własną sieć, czy korzystasz z istniejącego rozwiązania, warto mieć na uwadze temat, który będziemy poruszać w tym poście, czyli kompresję danych.
Podstawy gry wieloosobowej
W większości gier wieloosobowych pomiędzy graczami a serwerem odbywa się komunikacja w postaci małych partii danych (sekwencji bajtów), które są przesyłane tam i z powrotem z określoną szybkością.
W Unity (a konkretnie C#) najpopularniejszymi typami wartości są int, float, bool, i string (należy także unikać używania ciągu znaków podczas wysyłania często zmieniających się wartości, najbardziej akceptowalnym użyciem tego typu są wiadomości na czacie lub dane zawierające tylko tekst).
- Wszystkie powyższe typy są przechowywane w określonej liczbie bajtów:
int = 4 bajty
float = 4 bajty
bool = 1 bajt
string = (Liczba bajtów używanych do zakodować pojedynczy znak, w zależności od formatu kodowania) x (Liczba znaków)
Znając wartości, obliczmy minimalną ilość bajtów, jaka jest potrzebna do wysłania w standardowym trybie wieloosobowym FPS (First-Person Shooter):
Pozycja gracza: Vector3 (3 liczby zmiennoprzecinkowe x 4) = 12 bajtów
Rotacja graczy: Quaternion (4 zmiennoprzecinkowe x 4) = 16 bajtów
Wygląd gracza docelowego: Vector3 (3 liczby zmiennoprzecinkowe x 4) = 12 bajtów
Gracz odpalanie: bool = 1 bajt
Gracz w powietrzu: bool = 1 bajt
Gracz kucający: bool = 1 bajt
Gracz biegnący: bool = 1 bajt
Łącznie 44 bajty.
Będziemy używać metod rozszerzających do pakowania danych w tablicę bajtów i odwrotnie:
- Utwórz nowy skrypt, nadaj mu nazwę SC_ByteMethods i wklej do niego poniższy kod:
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);
}
}
Przykładowe zastosowanie powyższych metod:
- Utwórz nowy skrypt, nazwij go SC_TestPackUnpack i wklej do niego poniższy 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[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));
}
}
Powyższy skrypt inicjuje tablicę bajtów o długości 44 (co odpowiada sumie bajtów wszystkich wartości, które chcemy wysłać).
Każda wartość jest następnie konwertowana na tablice bajtowe, a następnie stosowana do tablicy PackData przy użyciu polecenia Buffer.BlockCopy.
Później spakowaneData są konwertowane z powrotem na wartości przy użyciu metod rozszerzenia z SC_ByteMethods.cs.
Techniki kompresji danych
Obiektywnie rzecz biorąc, 44 bajty to niezbyt dużo danych, ale jeśli trzeba je przesłać 10–20 razy na sekundę, ruch zaczyna się sumować.
W przypadku sieci liczy się każdy bajt.
Jak zatem zmniejszyć ilość danych?
Odpowiedź jest prosta: nie wysyłaj wartości, które nie powinny się zmienić, i umieszczaj proste typy wartości w jednym bajcie.
Nie wysyłaj wartości, które nie powinny ulec zmianie
W powyższym przykładzie dodajemy kwaternion obrotu, który składa się z 4 pływaków.
Jednak w przypadku gry FPS gracz zwykle obraca się tylko wokół osi Y, wiedząc, że możemy dodać obrót tylko wokół Y, redukując dane dotyczące rotacji z 16 bajtów do zaledwie 4 bajtów.
Buffer.BlockCopy(transform.localEulerAngles.y.toByteArray(), 0, packedData, 12, 4); //Local Y Rotation
Ułóż wiele wartości logicznych w jednym bajcie
Bajt to sekwencja 8 bitów, każdy o możliwej wartości 0 i 1.
Przypadkowo wartość bool może mieć tylko wartość true lub false. Zatem za pomocą prostego kodu możemy skompresować do 8 wartości bool w jednym bajcie.
Otwórz plik SC_ByteMethods.cs, a następnie dodaj poniższy kod przed ostatnim nawiasem zamykającym „}”
//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;
}
Zaktualizowany kod SC_TestPackUnpack:
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));
}
}
Dzięki powyższym metodom zmniejszyliśmy długość pakowania danych z 44 do 29 bajtów (redukcja o 34%).