Многопользовательское сжатие данных и битовая манипуляция

Создание многопользовательской игры в Unity — нетривиальная задача, но с помощью сторонних решений, таких как PUN 2, сетевая интеграция стала намного проще.

В качестве альтернативы, если вам требуется больший контроль над сетевыми возможностями игры, вы можете написать свое собственное сетевое решение, используя технологию Socket (например, авторитетный многопользовательский режим, где сервер только получает входные данные игрока, а затем выполняет свои собственные вычисления, чтобы обеспечить что все игроки ведут себя одинаково, что снижает вероятность взломов).

Независимо от того, пишете ли вы собственную сеть или используете существующее решение, вам следует помнить о теме, которую мы будем обсуждать в этом посте, а именно о сжатии данных.

Основы многопользовательской игры

В большинстве многопользовательских игр между игроками и сервером происходит связь в виде небольших пакетов данных (последовательность байтов), которые передаются туда и обратно с заданной скоростью.

В UnityC# в частности) наиболее распространенными типами значений являются int, float, bool, и string (также следует избегать использования строки при отправке часто меняющихся значений, наиболее приемлемым использованием этого типа являются сообщения чата или данные, содержащие только текст).

  • Все вышеперечисленные типы хранятся в заданном количестве байтов:

int = 4 байта
float = 4 байта
bool = 1 байт
строка = (Количество байтов, используемых для кодировать один символ в зависимости от формата кодирования) x (Количество символов)

Зная значения, посчитаем минимальное количество байт, которое необходимо отправить для стандартного многопользовательского FPS (шутера от первого лица):

Позиция игрока: Vector3 (3 числа с плавающей запятой x 4) = 12 байт
Вращение игрока: Quaternion (4 числа с плавающей точкой x 4) = 16 байт
Цель просмотра игрока: Vector3 (3 числа с плавающей запятой x 4) = 12 байт
Игрок стрельба: bool = 1 байт
Игрок в воздухе: bool = 1 байт
Игрок приседает: bool = 1 байт
Игрок бегает: bool = 1 байт

Итого 44 байта.

Мы будем использовать методы расширения для упаковки данных в массив байтов и наоборот:

  • Создайте новый скрипт, назовите его SC_ByteMethods, затем вставьте в него приведенный ниже код:

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

Пример использования методов выше:

  • Создайте новый скрипт, назовите его 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[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));
    }
}

Приведенный выше скрипт инициализирует массив байтов длиной 44 (что соответствует сумме байтов всех значений, которые мы хотим отправить).

Затем каждое значение преобразуется в массивы байтов, а затем применяется к массиву упакованных данных с помощью Buffer.BlockCopy.

Позже упакованные данные преобразуются обратно в значения с использованием методов расширения из SC_ByteMethods.cs.

Методы сжатия данных

Объективно 44 байта — это не так уж и много данных, но если их нужно отправлять 10 — 20 раз в секунду, трафик начинает накапливаться.

Когда дело доходит до сети, каждый байт имеет значение.

Так как же уменьшить объем данных?

Ответ прост: не отправлять значения, которые не должны измениться, и укладывать простые типы значений в один байт.

Не отправляйте значения, изменение которых не ожидается

В приведенном выше примере мы добавляем кватернион вращения, который состоит из 4 поплавков.

Однако в случае игры FPS игрок обычно вращается только вокруг оси Y, зная, что мы можем добавить вращение только вокруг Y, уменьшив данные вращения с 16 байт до всего 4 байтов.

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

Сложите несколько логических значений в один байт

Байт представляет собой последовательность из 8 бит, каждый из которых имеет возможное значение 0 и 1.

По совпадению, значение bool может быть только true или false. Итак, с помощью простого кода мы можем сжать до 8 значений bool в один байт.

Откройте SC_ByteMethods.cs, затем добавьте приведенный ниже код перед последней закрывающей скобкой '}'.

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

Обновлен код 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));
    }
}

С помощью описанных выше методов мы уменьшили длину упакованных данных с 44 до 29 байт (уменьшение на 34%).

Рекомендуемые статьи
Введение в Photon Fusion 2 в Unity
Система входа Unity с PHP и MySQL
Создание многопользовательских сетевых игр в Unity
Учебное пособие по онлайн-таблице лидеров Unity
Создайте многопользовательскую автомобильную игру с помощью PUN 2
PUN 2 Компенсация задержки
Unity добавляет многопользовательский чат в комнаты PUN 2