手把手教你实现Unity网络同步
现如今,网络同步的技术在各种游戏里被广泛应用和发展,那么,如何在Unity中搭建网络模块?如何使服务器和客户端之间通信?如何做到网络同步?本文作者烂笔头-27将从自身经验出发,为大家一一解答这些疑问。
1.在Unity中配置网络
虽然在Unity中有个自带的挂在GameObject上的Network组件,但这篇文章为了讲解清楚,就不介绍这些组件了,我们使用C#代码来搭建网络模块,Unity网络编程相关需要引入UnityEngine.Networking 命名空间。
1.1 启动服务端
以下是创建一个Server的代码:
仅仅几行代码,服务端的启动功能就完成了,成功启动的话,会返回true,如果启动失败,可以查看一下端口是否被占用了。此外,服务端还有一些配置相关的东西,在此就不做细讲了。
1.2 启动客户端
接下来,创建一个客户端,其实跟Server的很类似,代码如下:
客户端调用Connect方法向服务器发送连接请求,当连接成功时,服务端会回调OnClientConnected方法,客户端会回调OnConnectedServer方法。
需要注意的是,当连接成功时,需要把NetworkMessage中的NetworkConnection保存起来,这个对象是 客户端与服务端的连接的封装,后续接收消息包/发送消息包需要用到。
1.3 效果图
开启两个Unity,一个作为服务端,另一个作为客户端。制作两个按钮,一个启动服务端,另一个启动客户端。
首先启动服务端,然后再启动客户端。服务端打印如下:
客户端打印如下:
就这样,很轻松的在Unity中搭建了网络模块。
介绍了如何在Unity中启动服务端和客户端以及客户端连接服务端的方法之后,接下来,将通过自定义消息类型,实现服务端与客户端之间的通信。
1.自定义消息类型(CustomMsgTypes)
其实在上一章中,已经知道如何注册消息回调了,就是使用RegisterHandler方法,这个方法的第一个参数short类型的变量,第二个参数是NetworkMessageDelegate类型的委托,如果消息注册方法,我们就可以自定义MsgType,来处理游戏内的逻辑。代码如下:
这个消息的定义,需要注意UnityEngine.Networking下内置的MsgType已经占用了一些数值,避免与Unity自带的MsgType重复。然后把CustomMsgTypes注册到网络组件的消息回调:
2.定义自定义消息
已经定义了消息类型,接下来,要定义这个消息类型对应的消息内容,在UnityEngine.Networking组件下,定义的Message要继承自MessageBase,基类有两个方法 Deserialize 和 Serialize 需要重写,也就是消息的序列化和反序列化。代码如下:
需要注意的是 Serialize 方法是msg在发送的时候自动调用的,而Deserialize方法需要在收到消息包的时候主动调用:
3. 发送自定义消息
好了,现在自定义消息的类型与消息内容都有了,现在我们只需要在客户端往消息里填充数据,执行NetworkConnection.Send方法就可以发送消息了。
在服务端,就可以接受到消息,通过Deserialize可以获取到消息内容。
4.小结
通过上述的例子,实现 服务端注册自定义消息 => 客户端构造消息并发送 => 服务端接收解析获取数据,如果想要把服务端的消息发到客户端,也是这个流程。
1.原因
因为使用UnityEngine.Networking,发送或接受的消息都要继承自MessageBase,而MessageBase的读写操作,使用的是(读)NetworkReader,(写)NetworkWriter,查看文档会发现,写入操作都是直接按照基本数据类型所占字节长度来整个写入的,读取也是如此。比如:一个int类型,占4个字节,但是如果这个int变量是10的话,二进制表示为1010,只需要写入4个位(半个字节)就行了,为了节约网络消息包的大小(毕竟带宽有限~),有的时候完全没必要写入全部的字节长度。所以需要对网络消息包进行一下封装,实现按照自己的需要读写指定长度网络数据。
2.思路
计算机系统中一切数据的本质都是0和1,一个0或1表示1个位(bit),每8个位表示1个字节(byte),基本的数据类型所占位数:
>bool = 1位
float = 32位
double = 64位
short, ushort = 16位
int, uint = 32位
long, ulong = 64位
string = 1个ASCII字符占8位,中文字符占16位
另外还涉及到浮点数(科学计数法)与负数(最高位为1)0的二进制表示形式,网上有很多讲解,在此不再赘述。
既然要实现按己所需读写指定长度的数据,那么就需要一个类似游标(指针)的变量来标记整个数据包写到哪了,数据包读到哪了。
3.编码
Packet类
每个数组类型的写入其实都可以当成是将一个字节按照指定位数从某个位置开始写。
有了这个最基本的实现方法,那么其他的基本类型都很好写了:
其他类型的写入方式大体相似,就不一一举例了。对应的读取方式,最根本的方法:
与写入方式类似,其他基本类型的读取,就将需要读的位数传入,拿到返回的字节以后,组合成对应的数据类型即可。
4.结语
这样,对网络消息包的封装基本就完成了,使用这个类,可以更灵活的构造网络传输中的数据,包括数据压缩、解压,检测数据溢出,截断数据流等等,都可以很方便了。
1.服务端与客户端相同频率模拟(Simulate)
在Unity中,有三个更新方法:Update、LateUpdate、FixedUpdate。
Update和LateUpdate属于渲染帧,它们每帧间隔的时间会受到渲染物体的时间影响(LateUpdate是在所有的Update方法执行完后再执行),打个比方说:相同的游戏,在性能好的机器上可以跑60帧每秒,但是在差的机器上,可能只能跑30帧每秒。两者相差了1倍,甚至更多。
FixedUpdate是固定频率更新,常常用来处理Unity中物理相关的东西,它不受渲染效率的影响,以固定的时间间隔调用。
所以为了保证服务端和客户端的模拟频率一致,那么在Unity中,就选用FixedUpdate方法。在Unity中可以在Edit->Project Setting->time中找到Fixed timestep进行修改,也可以在代码中设置Time.fixedDeltaTime的值。
这样,服务器和客户端的FixedUpdate方法都会按照相同的频率调用,然后把操作的模拟(Simulate)放在里面执行。
2.相同的状态 + 相同的操作指令 = 相同的新状态
为了让服务端和客户端模拟的结果相同,首先必须保证服务端和客户端的模拟逻辑代码一致,尽量减少使用默认的物理模拟(引擎的物理模拟有些会带有随机数,一旦服务器和客户端的随机数不一致,会导致结果不一致),先来定义操作指令类(Command)。
Simulate方法需要做的应该就是收集操作指令CommandInput,然后执行,得到CommandResult。
CollectCommandInput和ExecuteCommand方法中,客户端和服务端的代码应该是一致的。
服务器和客户端,执行完Command以后,填充result需要的数据。这样,一个Command就完成了,经过网络同步以后,利用sequence(指令序号)来对比操作的结果是否一致。
操作结果如果:
3.小结
有了这个基本的Command的结构和相同频率的Simulate,后续就要考虑服务端和客户端如何去同步这些Command。
在上一章中,已经可以在服务器上直接根据服务器自己的操作指令,模拟得出结果,修改球的位置了。接下来,将要考虑如何将服务器模拟的位置如何同步到客户端。
1.服务器向客户端发送单位实体(Entity)状态
首先需要设定一个发包的频率(SendRate),目前设置的是每10个模拟帧发送一次,对于60模拟帧每秒的游戏世界来说,这也相当于6个包每秒。这个包的数据应该是描述Entity在当前模拟帧的状态。
发送的方法:
这样就把Entity的状态打包发向所有的客户端了。
2.客户端接收到服务端的状态包
客户端接收到服务端的数据包,然后从数据包中拿到描述Entity状态的数据后,需要考虑的是,如果是第一个状态,可以直接拿来应用到Entity上,如果不是第一个状态的话,那就不能直接应用,因为网络传输抖动的因素,服务端虽然是每隔10帧发一个包,但是客户端收包频率不一定是每隔10帧就收到的,如果直接应用的话,必然会导致抖动。这个时候,我们就需要在客户端对服务器端进行状态缓存(StateBuffer)和状态插值(StateInterpolation)。
为什么需要状态缓存和状态插值?
客户端收到的状态包都是带帧号(Frame),帧号表示了这个状态是服务器在那帧模拟得到的状态,客户端想要,去除抖动,平滑的渡过的状态之间的时间的话,就需要在State_A与State_B进行插值计算,插值计算的公式应该是这样:
> Current = MathUtils.Interpolate(State_A, State_B, ???? / (State_B.frame -State_A.frame ))
在公式右侧,除了????,其他都是已知的,想要得到插值结果,那么????应该是什么呢?
因为分母的两个状态的帧号差,所以分子应该也是帧号才对,客户端的帧号跟服务端帧号不一致(因为服务器肯定早就启动了,客户端是后来才连接服务器的),这个时候就要新增一个变量用来表示客户端估算出来的服务器帧(RemoteEstimatedFrame)。
这个估算帧用来表示客户端在本地估测服务器模拟的帧号,它的第一次赋值应该是客户端收到服务器的帧号时。
估算帧也是按照模拟频率一直累加的,但是估算不一定总是准的,有时提前收到包,有时延迟收到包,甚至丢包。所以如果收到的包帧号跟估算帧相差太大的时候,就需要对估算帧重新调整。
效果如下:
从这个图可以看出,服务器移动很平滑,但是客户端移动可以明显看出抖动的情况,问题在哪呢?其实问题是出在估算帧的设置问题,从状态A插值到状态B的过程,由于估算帧等于(或者接近)状态A的帧号,而状态B的包客户端还没有收到,这就造成了在状态B到来之前,客户端没办法插值,只好原地等待,当状态B的包到来的时候。立即设置了位置,所以造成了抖动,那么如何解决这个问题呢?
做法是故意让估算帧的帧号在实际的状态包帧号之前,让客户端滞后:
将delay = 10(因为服务器每10帧发个包)这样尽可能的预留出一个状态包用来做插值计算了,看看效果:
可以看到客户端的抖动几乎看不出来了,但是代价是延迟比较大了(为了更好的表现,这个牺牲是必要的)。
3.小结
服务端模拟结果,下发状态给客户端基本就完成了,需要补充的是,在估算帧的计算中,可以根据估算帧和实际帧的差距动态的调整本地模拟的频率,比如:
>如果估算帧滞后太多了,那客户端就每帧加2,甚至加3(默认是每个模拟帧加1)来追赶。
如果估算帧超前很多,那客户端就估算帧的累加可以暂停来等待,通过这样的方式来缓和。
现在客户端通过插值,实现了比较平滑的表现,但是有比较明显的延迟,这个可以通过加大发包的频率来缓解这个问题。
后续实现了客户端的预表现后,这个问题也就不那么重要了。
在上章已经介绍完在服务端控制的物体通过把状态发到客户端,客户端去”追赶”服务器的状态来实现同步的,现在来谈谈如何在客户端做本地预表现。
1.什么要本地预表现?为什么要本地预表现?
本地预表现(本地预测),就是玩家操作游戏角色时,按下按键立刻得到操作的反馈。
有些竞技游戏尤其FPS游戏,讲究及时的操作响应性,试想,如果没有本地预表现,那么玩家按下一个按键想要释放技能,却要等待服务器的回包之后才释放得出来,由于网络波动延迟的影响,回包的时间还不确定,如果延迟很低的话可能还可以接受,对于延迟很高的玩家就比较难受了。为了解决这样的用户体验,最好是能实现客户端的本地预表现。
2.客户端生成操作指令并且本地模拟,向服务器发送操作指令。
对于需要本地预表现的单位来说,当它得到了操作输入指令(CommandInput)的时候,应该立即把这个指令拿去执行,而不需要等服务器的回包。
这样客户端就是一直获取操作输入,然后执行操作指令,然后就需要把操作指令上传到服务端,客户端发包应该也有一个发包频率(ClientSendRate),因为客户端只跟服务器通信,所以它可以比服务器的发包频率快。
因为本地的模拟频率是60帧/秒,相当于每秒产生了60个Command,客户端需要按ClientSendRate把指令上传到服务端,所以需要把Command缓存进队列。
客户端执行过的操作指令都缓存在队列里,然后就要队列指令都发送给服务端了。
3.服务器接收到客户端的操作指令并且逐帧模拟,向客户端发送模拟结果。
服务器拿到客户端的操作输入之后,接下来就要为客户端模拟输入指令。
服务器把客户端的指令模拟完了以后,模拟的结果还是缓存在commandQueue中的(因为Command类包含了Input和Result),那么在服务器向客户端发包的时候,就需要把Result给发送到客户端了。
4.客户端与服务端发来的模拟结果对比
终于到这里了,因为客户端也维护了一个指令队列(commandQueue),它包含了客户端本地预表现的所有执行过的指令输入和结果,当客户端收到了服务器下发的指令结果以后,就可以本地模拟的结果和服务器模拟的结果做对比。在如何实现确定性的网络同步中,定义的Command类中是有个sequence变量来表示指令序号的。
那么现在,客户端的指令队列(commandQueue)中包含了很多指令,因为上一章服务器将状态同步给客户端说明了,服务器会将状态发给客户端,对于本地模拟的客户端来说,收到的状态包可以直接设置。这里就会出现一个问题了,如果直接设置的话,因为客户端本地预表现了,收到的状态是旧的。直接设置不就造成抖动了吗?所以解决的办法就是客户端在一帧把之前所有的执行过的指令(除了服务器验证过的)重新执行一遍。
一旦从服务器回包发现预测失败,我们把你的全部输入都重播一遍直至追上当前时刻。
当客户端收到描述角色状态的数据包时,我们基本上就得把移动状态及时恢复到最近一次经过服务器验证过状态上去,而且必须重新计算之后所有的输入操作,直至追上当前时刻。
对于预表现的客户端,需要在模拟之前OnSimulateBefore()的时候直接应用服务器下发的状态,每个模拟帧,客户端都把本地已经执行过而且没有被服务确认过的指令都执行一遍,然后再生成新的指令。如此,预表现的实现就基本完成了。
总的流程应该是这样:
5.小结
对于客户端的预表现,核心在于要遵循确定性的原则,一个状态 + N个指令 = 新的状态,客户端跟服务器的模拟结果应该是一致的,这样就能保持稳定的同步。
对于丢包导致的预测失败,需要在客户端做丢包重发的机制,而服务器也可以适当的从之前的指令来推测客户端操作来模拟,以缓和丢包的情况。
最近看“GDC2018演讲《火箭联盟》的物理与网络细节(需要科学上网)”,在《Rocket League》(以下简称RT)中,汽车和足球用的都是真实物理模拟,开发的引擎是Unreal Engine 3,使用的物理引擎是Bullet,在游戏中,客户端操控的东西都是带预测的,包括汽车的运动、球的运动,那么如何在Unity实现物理状态的网络同步呢?接下来就尝试着实现一下。
1.根据操作输入,改变物理状态
在场景中创建一个Cube,然后挂上Rigidbody组件,这样就拥有一个很简单的带物理的物体了。
接收按键输入W、S、A、D,按下一个键就对Cube施加一个力。代码如下:
通过对刚体施加力,基本的物理操作就完成了。
2.Unity下的确定性物理模拟
在Unity中,内置的物理引擎是PhysX,依据确定性原则,首先得了解PhysX是确定性的吗?可以预测吗?我在网上找了很多文章,也没找到一个确定的结果,有的说法说PhysX是确定性的,测试过很多次的结果都一致(文章Deterministic physics options),又有的说法说PhysX为了实现高效,牺牲了确定性,在不同平台下可能导致物理模拟结果不同等等,反正还没有得到确定的答案(毕竟PhysX的物理组件在Unity中没有开源,只有访问接口)。
Any way,先暂且把Unity中的物理模拟是确定性的(如果认为它是不确定的,那这篇就没必要写了:)),要实现物理的预测,需要自己手动调用物理模拟,Unity提供了一个API-Physics.Simulate,通过将来Physics.autoSimulation = false关闭Unity内部的自动物理模拟,然后手动执行Physics.Simulate(fixedDeltaTime)来模拟一次物理.在调用结束后,就可以拿到模拟的结果了。
3.服务器将物理状态同步给客户端
在第五章中,物体的状态只同步了position和rotation,现在把rigidbody的velocity和angularVelocity加上。
按照第五章的流程,服务器将状态同步给客户端的表现如下(6packet/second ):
4.客户端上传操作给服务端,同时预测物理模拟。
跟第六章的流程一样,因为客户端调用了手动物理模拟Physics.Simulate(Time.fixedDeltaTime),每次收到服务器下发新的状态后,预测的客户端都重置状态,然后把缓存的指令执行一遍,同时调用Physics.Simulate(Time.fixedDeltaTime)。客户端预测的表现如下(6packet/second ):
5.小结
从上面的结果来看,Unity中物理模拟在网络同步中都用挺不错的表现,但是,其实还是存在问题的:
>手动物理模拟方法Physics.Simulate()是全局的,这就意味着调用一次,游戏内所有的物理物体都会模拟一次,没办法仅对单个物体进行模拟,想要改进的话,只有自己在代码逻辑中对物体的状态做备份。
如果涉及到游戏内很多的物体,频繁的单帧调用多次Physics.Simulate()尚不明确会带来多严重的效率问题。
所以如果真的想在Unity中实现物理模拟的网络同步,建议最好能够使用自己可控的,确定性的物理模拟,比如说自己实现的简单物理模拟逻辑,或者使用插件Bullet Physics For Unity(独立于Unity之外,开放源代码,确定性的物理引擎)等等。
>>>未完待续,请持续关注本微信公众号。
↓↓↓点击阅读原文,了解更多。