多人数据压缩和位操作
在 Unity 中创建多人游戏并不是一项简单的任务,但在第三方解决方案(例如 PUN 2)的帮助下,它使网络集成变得更加容易。
或者,如果您需要对游戏的网络功能进行更多控制,您可以使用 Socket 技术编写自己的网络解决方案(例如,权威多人游戏,其中服务器仅接收玩家输入,然后进行自己的计算以确保所有玩家的行为方式相同,从而减少黑客攻击)的发生率。
无论您是编写自己的网络还是使用现有的解决方案,您都应该注意我们将在本文中讨论的主题,即数据压缩。
多人游戏基础知识
在大多数多人游戏中,玩家和服务器之间会以小批量数据(字节序列)的形式进行通信,这些数据以指定的速率来回发送。
在 Unity(特别是 C#)中,最常见的值类型是 int、float、bool、 和 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%)。