多人数据压缩和位操作

Unity 中创建多人游戏并不是一项简单的任务,但在第三方解决方案(例如 PUN 2)的帮助下,它使网络集成变得更加容易。

或者,如果您需要对游戏的网络功能进行更多控制,您可以使用 Socket 技术编写自己的网络解决方案(例如,权威多人游戏,其中服务器仅接收玩家输入,然后进行自己的计算以确保所有玩家的行为方式相同,从而减少黑客攻击)的发生率。

无论您是编写自己的网络还是使用现有的解决方案,您都应该注意我们将在本文中讨论的主题,即数据压缩。

多人游戏基础知识

在大多数多人游戏中,玩家和服务器之间会以小批量数据(字节序列)的形式进行通信,这些数据以指定的速率来回发送。

Unity(特别是 C#)中,最常见的值类型是 intfloatbool、 string (此外,在发送频繁更改的值时应避免使用字符串,此类型最可接受的用途是聊天消息或仅包含文本的数据)。

  • 上述所有类型都存储在一定数量的字节中:

int = 4 个字节
float = 4 个字节
bool = 1 个字节
string =(用于对单个字符进行编码,具体取决于编码格式)x(字符数)

知道这些值后,我们来计算标准多人 FPS(第一人称射击游戏)需要发送的最小字节数:

播放器位置:Vector3(3 个浮点 x 4)= 12 字节
播放器旋转:四元数(4 个浮点 x 4)= 16 字节
播放器观看目标:Vector3(3 个浮点 x 4)= 12 字节
播放器开火:bool = 1 byte
玩家在空中:bool = 1 byte
玩家蹲伏:bool = 1 byte
玩家奔跑:bool = 1 byte

总共 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 将其应用到 PackedData 数组中。

随后,使用 SC_ByteMethods.cs 中的扩展方法将 PackedData 转换回值。

数据压缩技术

客观来说,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));
    }
}

通过上述方法,我们将 PackedData 长度从 44 字节减少到 29 字节(减少了 34%)。

推荐文章
Unity 中的 Photon Fusion 2 简介
在 Unity 中构建多人网络游戏
Unity在线排行榜教程
使用 PUN 2 制作多人汽车游戏
PUN 2 滞后补偿
Unity 将多人聊天添加到 PUN 2 房间
使用 PHP 和 MySQL 的 Unity 登录系统