Compressão de dados multijogador e manipulação de bits
Criar um jogo multiplayer em Unity não é uma tarefa trivial, mas com a ajuda de soluções de terceiros, como como PUN 2, tornou a integração de rede muito mais fácil.
Como alternativa, se você precisar de mais controle sobre os recursos de rede do jogo, você pode escrever sua própria solução de rede usando a tecnologia Socket (ex. multijogador oficial, onde o servidor apenas recebe a entrada do jogador e, em seguida, faz seus próprios cálculos para garantir que todos os jogadores se comportem da mesma maneira, reduzindo assim a incidência de hacking).
Independentemente de você estar criando sua própria rede ou usando uma solução existente, você deve estar atento ao tópico que discutiremos nesta postagem, que é a compactação de dados.
Noções básicas do multijogador
Na maioria dos jogos multijogador, ocorre comunicação entre os jogadores e o servidor, na forma de pequenos lotes de dados (uma sequência de bytes), que são enviados e recebidos a uma taxa especificada.
Em Unity (e C# especificamente), o os tipos de valor mais comuns são int, float, bool, e string (além disso, você deve evitar usar string ao enviar valores que mudam com frequência, o uso mais aceitável para este tipo são mensagens de chat ou dados que contenham apenas texto) .
- Todos os tipos acima são armazenados em um determinado número de bytes:
int = 4 bytes
float = 4 bytes
bool = 1 byte
string = (Número de bytes usados para codificar um único caractere, dependendo do formato de codificação) x (Número de caracteres)
Sabendo os valores, vamos calcular a quantidade mínima de bytes que são necessários para serem enviados para um FPS multijogador padrão (First-Person Shooter):
Posição do jogador: Vector3 (3 floats x 4) = 12 bytes
Rotação do player: Quaternion (4 floats x 4) = 16 bytes
Alvo de aparência do player : Vector3 (3 floats x 4) = 12 bytes
Jogador disparando: bool = 1 byte
Jogador no ar: bool = 1 byte
Jogador agachado : bool = 1 byte
Jogador rodando: bool = 1 byte
Total de 44 bytes.
Usaremos métodos de extensão para empacotar os dados em uma matriz de bytes e vice-versa:
- Crie um novo script, nomeie-o SC_ByteMethods e cole o código abaixo dentro dele:
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);
}
}
Exemplo de uso dos métodos acima:
- Crie um novo script, nomeie-o SC_TestPackUnpack e cole o código abaixo dentro dele:
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));
}
}
O script acima inicializa a matriz de bytes com um comprimento de 44 (que corresponde à soma de bytes de todos os valores que queremos enviar).
Cada valor é convertido em matrizes de bytes e aplicado na matriz packData usando Buffer.BlockCopy.
Mais tarde, o packData é convertido de volta para valores usando métodos de extensão de SC_ByteMethods.cs.
Técnicas de compressão de dados
Objetivamente, 44 bytes não são muitos dados, mas se for necessário enviar 10 a 20 vezes por segundo, o tráfego começa a aumentar.
Quando se trata de rede, cada byte conta.
Então, como reduzir a quantidade de dados?
A resposta é simples, não enviando os valores que não devem ser alterados e empilhando tipos de valores simples em um único byte.
Não envie valores que não devem mudar
No exemplo acima estamos adicionando o Quaternion da rotação, que consiste em 4 floats.
No entanto, no caso de um jogo FPS, o jogador geralmente gira apenas em torno do eixo Y, sabendo disso, podemos apenas adicionar a rotação em torno de Y, reduzindo os dados de rotação de 16 bytes para apenas 4 bytes.
Buffer.BlockCopy(transform.localEulerAngles.y.toByteArray(), 0, packedData, 12, 4); //Local Y Rotation
Empilhar vários booleanos em um único byte
Um byte é uma sequência de 8 bits, cada um com um valor possível de 0 e 1.
Coincidentemente, o valor bool só pode ser verdadeiro ou falso. Assim, com um código simples, podemos compactar até 8 valores booleanos em um único byte.
Abra SC_ByteMethods.cs e adicione o código abaixo antes da última chave de fechamento '}
//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;
}
Código SC_TestPackUnpack atualizado:
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));
}
}
Com os métodos acima, reduzimos o tamanho do packData de 44 para 29 bytes (redução de 34%).