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

Artigos sugeridos
Faça um jogo multijogador no Unity usando PUN 2
Construindo jogos multijogador em rede no Unity
Faça um jogo de carros multijogador com PUN 2
Unity Adicionando Bate-papo Multijogador às Salas PUN 2
Photon Network (Clássico) Guia do Iniciante
Tutorial de classificação on-line do Unity
Sistema de login Unity com PHP e MySQL