Compresión de datos multijugador y manipulación de bits

Crear un juego multijugador en Unity no es una tarea trivial, pero con la ayuda de soluciones de terceros, como PUN 2, ha facilitado mucho la integración de redes.

Alternativamente, si necesita más control sobre las capacidades de red del juego, puede escribir su propia solución de red utilizando la tecnología Socket (por ejemplo, multijugador autorizado, donde el servidor solo recibe la entrada del jugador y luego hace sus propios cálculos para asegurarse de que todos los jugadores se comporten de la misma manera, reduciendo así la incidencia de piratería).

Independientemente de si está escribiendo su propia red o utilizando una solución existente, debe tener en cuenta el tema que discutiremos en esta publicación, que es la compresión de datos.

Conceptos básicos de multijugador

En la mayoría de los juegos multijugador, se produce una comunicación entre los jugadores y el servidor, en forma de pequeños lotes de datos (una secuencia de bytes), que se envían de un lado a otro a una velocidad específica.

En Unity (y C# específicamente), los tipos de valor más comunes son int, float, bool, y string (además, debe evitar el uso de cadenas al enviar valores que cambian con frecuencia, el uso más aceptable para este tipo son los mensajes de chat o los datos que solo contienen texto).

  • Todos los tipos anteriores se almacenan en un número determinado de bytes:

int = 4 bytes
float = 4 bytes
bool = 1 byte
string = (Número de bytes utilizados para codificar un solo carácter, dependiendo del formato de codificación) x (Número de caracteres)

Conociendo los valores, calculemos la cantidad mínima de bytes que se necesitan enviar para un FPS multijugador estándar (First-Person Shooter):

Posición del reproductor: Vector3 (3 flotantes x 4) = 12 bytes
Rotación del reproductor: Quaternion (4 flotantes x 4) = 16 bytes
Objetivo de búsqueda del reproductor: Vector3 (3 flotantes x 4 ) = 12 bytes
Jugador disparando: bool = 1 byte
Jugador en el aire: bool = 1 byte
Jugador agachado: bool = 1 byte
Reproductor en ejecución: bool = 1 byte

Total 44 bytes.

Usaremos métodos de extensión para empaquetar los datos en una matriz de bytes y viceversa:

  • Cree un nuevo script, asígnele el nombre SC_ByteMethods y luego pegue el siguiente código dentro de él:

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);
    }
}

Ejemplo de uso de los métodos anteriores:

  • Cree un nuevo script, asígnele el nombre SC_TestPackUnpack y luego pegue el siguiente código dentro de él:

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));
    }
}

El script anterior inicializa la matriz de bytes con una longitud de 44 (que corresponde a la suma de bytes de todos los valores que queremos enviar).

Luego, cada valor se convierte en matrices de bytes y luego se aplica a la matriz de datos empaquetados mediante Buffer.BlockCopy.

Más tarde, los datos empaquetados se vuelven a convertir en valores mediante métodos de extensión de SC_ByteMethods.cs.

Técnicas de compresión de datos

Objetivamente, 44 bytes no son muchos datos, pero si es necesario enviarlos de 10 a 20 veces por segundo, el tráfico comienza a acumularse.

Cuando se trata de redes, cada byte cuenta.

Entonces, ¿cómo reducir la cantidad de datos?

La respuesta es simple, al no enviar los valores que no se espera que cambien y al apilar tipos de valores simples en un solo byte.

No envíe valores que no se espera que cambien

En el ejemplo anterior, estamos agregando el cuaternión de la rotación, que consta de 4 flotadores.

Sin embargo, en el caso de un juego FPS, el jugador generalmente solo gira alrededor del eje Y, sabiendo que solo podemos agregar la rotación alrededor de Y, reduciendo los datos de rotación de 16 bytes a solo 4 bytes.

Buffer.BlockCopy(transform.localEulerAngles.y.toByteArray(), 0, packedData, 12, 4); //Local Y Rotation

Apile varios valores booleanos en un solo byte

Un byte es una secuencia de 8 bits, cada uno con un valor posible de 0 y 1.

Coincidentemente, el valor booleano solo puede ser verdadero o falso. Entonces, con un código simple, podemos comprimir hasta 8 valores booleanos en un solo byte.

Abra SC_ByteMethods.cs y luego agregue el código a continuación antes de la última llave de cierre '}'

    //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 actualizado:

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 los métodos anteriores, hemos reducido la longitud de los datos empaquetados de 44 a 29 bytes (reducción del 34 %).

Artículos sugeridos
Introducción a Photon Fusion 2 en Unity
Crea un juego multijugador en Unity usando PUN 2
Creación de juegos multijugador en red en Unity
Crea un juego de coches multijugador con PUN 2
Unity agrega chat multijugador a PUN 2 Rooms
Guía para principiantes de Photon Network (clásico)
Tutorial de clasificación en línea de Unity