FPS Sample是一个由Unity官方出品的FPS类型的教程游戏,整个教程游戏的制作水准无论是从画质还是网络同步效果都是比较高的,并且完全开源。游戏中有3个大的技术框架及其使用值得学习
High-Definition Render Pipeline(HDRP).这是unity新推出的两个渲染管线中的能够取得较高画质的一个,从游戏的实际运行效果也可见一斑。Data-Oriented Technology Stack (DOTS).这也是unity力推的一套提升游戏运行效率的技术,基本包括:①Entity Component System (ECS)机制 ②C# Job System ③burst compiler。youtube上面有一些测试视频能看到有时DOTS能够为场景提供几十倍的性能提升。状态同步机制。FPS Sample并没有特别复杂的服务端架构,仅仅是客户端连接上服务端,两端同步指令和状态数据。这个同步的过程正式这篇文章想要描述的主要内容,下文以每一个技术点为小节展开。考虑到实时性,FPS Sample也使用了UDP作为战场内数据同步的传输层协议,为了防止丢包,做了ACK机制和数据重传机制。这个数据重传并不和TCP一样丢了某一帧的数据就再把那一帧的数据重发一份完全一样的,毕竟在实时性很强的射击游戏里面,重传之前的老数据的意义也不大。FPS做的重传方式是将重要信息重传,譬如服务端想要下发生成entityA的一个命令,服务端会一直向发往客户端数据包中写入创建一个entityA的命令,直至收到客户端回复了创建entityA的ACK。在服务端做的另一个方面就是尽量使用一些持续的状态,而不是一个瞬发事件。客户端对于防丢包做法就是传输冗余数据,每一帧向服务端发送用户输入的时候把之前两帧的用户输入再次传输。由于用户输入数据量很小,重复传输用户输入可以很有效的防止丢包,如果一次丢包的概率是 1 0 − 5 10^{-5} 10−5,那三次都丢包的概率就只有 1 0 − 15 10^{-15} 10−15了。
#客户端预测回滚 在FPS Sample中,服务端有着绝对的权威,也就是说客户端并不会上传任何本地信息譬如英雄的血量、位置等,而是由服务端下发到客户端。但是这样就会造成一个问题:客户端接收到用户的输入并上传到服务端,服务端处理之后将整个数据快照发放到客户端这样一个流程至少需要一个rtt (Round-Trip Time),这还是在忽略服务端处理时间的情况下。这样的话很多时候都会给用户一种很卡的感觉。为了解决上述问题,FPS Sampe使用了比较常见的“预测回滚机制”。预测回滚机制包含两个部分,①一个是预测:客户端接受到用户的输入之后一方面将用户输入上传到服务端另一方面会按照和服务端一致的逻辑执行用户输入,这样的话无需等到服务端回复消息玩家控制的角色就可以响应用户输入,能够有效的提升用户体验。②另一个是回滚:单单有预测的话可想而知会造成客户端和服务端数据不一致,所以当服务端快照到达的时候,客户端会先将玩家控制角色的状态设置和服务端完全一致,然后将服务端还未处理的(因为rtt的存在)消息再走一遍预测逻辑。预测回滚机制只对本地玩家控制的角色生效,并不会对其他玩家控制的角色(也就是敌人)起作用,并且在预测的时候并不会创建新的entity,譬如在用户按下鼠标开枪的时候,客户端仅会播放开枪的动画而子弹entity的生成还是需要等到服务端通知才会发生。 为了定位哪些消息是服务端处理了的哪些消息服务端还未处理,客户端会维护几个变量,名称和含义如下表所示,这几个变量的主要逻辑都存在与ClientGameLoop.HandleTime(float frameDuration)中,这个函数在每个渲染帧(也就是Unity的MonoBehavior.Update)中都会被调用。
变量名称作用rttRoud-Trip time,和计算机网络中的rtt是同一个概念,就是平均情况下数据包从客户端到服务端再到客户端的所用的时间serverTime客户端记录的最近一次收到的有效数据包中记录的服务端的tick值,并且这个值在下次收到有效数据包之前不会发生变化PredictedTime客户端进行“预测回滚”以及“发送指令数据包”时使用的一个时间。PredictedTime是一个浮动的值在每一次HandleTime调用的时候,PredictedTime会增加frameDuration * frameTimeScale,其中frameTimeScale的值在 P r e d i c t e d T i m e < s e r v e r T i m e + r t t PredictedTime < serverTime + rtt PredictedTime<serverTime+rtt时等于1.01,在 P r e d i c t e d T i m e > s e r v e r T i m e + r t t PredictedTime > serverTime + rtt PredictedTime>serverTime+rtt时等于0.99,这样来看PredictedTime的值基本可表示为 P r e c i t e d T i m e = s e r v e r T i m e + r t t PrecitedTime = serverTime + rtt PrecitedTime=serverTime+rtt如此设计的目的是让PredictedTime的含义为“发送的用户输入到达服务端的时间”,这样就可以很方便地在客户端以PredictedTime为索引去收集、存储和发送客户端用户输入。以下图为例,假设rtt=6(单位是tick),在server tick=13这个时刻,客户端刚收到了一个在server tick=10发来的服务端消息,所以serverTime=10,所以PredictedTime=10+6=16。此时基本可以确定PredictedTime<10的数据包服务端都已经处理了,在预测的时候只需要处理 P r e d i c t e d T i m e ∈ [ 11 , 16 ] PredictedTime \in [11,16] PredictedTime∈[11,16]这个区间里面的客户端输入就行了。 FPS中对应的源码ClientGameLoop.ClientGameWord.Update截图如下。其中PredictedRollback会将整个游戏状态回滚到上一次服务端发送过来的状态,然后在for loop中运行所有服务端未确认接受的客户端指令。
预测回滚在处理ClientGameLoop.ClientGameWord.Update;时间相关变量处理在ClientGameLoop.HandleTime
###客户端插值 对于其他玩家控制的角色,如果仅仅是在服务端数据达到的时候更新本地状态就会造成即使客户端性能非常好能够跑到200FPS,如果服务端tick rate=30那么其他玩家控制的角色也仅仅会每秒更新30次而不是200次。这样明显会给玩家造成“卡顿”直观感受。为了解决这个问题FPS Sample使用了客户端插值技术。简单来说就是在接受到服务端快照的时候,并不会立马把角色属性设置到角色身上,而是在一个tick的时间间隔通过线性插值逐渐的将角色的状态从上一个tick的值改变成为这一个tick的值,效果如下动图。线性插值仅针对其他玩家控制的角色,不对本地玩家控制的角色生效客户端会维护一个RenderTime,用来记录当前插值到哪一个tick了,RenderTime基本上等于serverTime。有的时候可能会一次到达多个服务端快照,这时客户端会加速插值的过程,此外RenderTime还用在下一小节“服务端延迟补偿”中。
线性插值的入口代码代码在ClientGameLoop.update中的m_ReplicatedEntityModule.Interpolate(m_RenderTime);;实际执行插值逻辑的代码在InterpolatedComponentSerializer.Interpolate中
由于网络延迟和客户端插值的存在,本地玩家看到其他玩家控制的角色的状态总比服务器对应的角色晚一些,且时间间隔至少是1/2个rtt。试想这样一种情况,玩家A控制角色瞄准了一个墙缝伺机攻击敌人,玩家B控制角色从上往下走。在玩家A的视角中B恰好走到自己的准星上,A就会扣动扳机开枪。玩家A有没有击中B,是由服务端计算判断的,但是在服务端存储的数据中,由于延迟的存在玩家A开枪的时候玩家B其实已经从墙缝这里走过去了,也就是说A无法击中B。这对于A来说完全无法接受,因为从他的视角中来看他是能够恰好击中B的。 FPS Sample这里的做法是客户端在向服务端发送用户输入的时候会将RenderTime一并发送过去。服务端会记录下来每一个tick每个entity的所有碰撞体的位置,在进行受击测试的时候使用客户端发送过来的RenderTime取到历史记录中的碰撞体位置来进行判断是否能够击中。这样做对于玩家A的射击体验会有很大的提升,但是对于玩家B来说可能就会那么“友好”。如果A的客户端和服务器之间的rtt较大、B和服务器之前的rtt较小,从B的感官上来说可能会出现他已经走过墙缝很远了但是还是被A开枪打死了。所以这里可以添加一个判断如果客户端上传的RenderTime和实际的server tick相差较大就忽略这个RenderTime而使用实际的server tick进行受击判断。
记录entity每一帧碰撞体的入口代码在StoreColliderStates.Update中;在ProjectileSystemShared.CreateProjectileMovementCollisionQueries中能够看到在创建RaySphereQueryReceiver.Query的时候,将Query.hitCollisionTestTick设置为command.renderTick,也就是客户端的renderTime.tick,并且可以看到在后续的碰撞测试中,会通过hitCollisionTestTick字段去读取可发生碰撞的entity的collider的历史位置,通过和历史位置发生碰撞来进行延迟补偿。
FPS Sample团队在youtube的分享视频中花了较大的篇幅介绍了“Delta encoding using frame prediction”技术点(中文姑且叫快照压缩吧)。以前也有类似的技术,只不过都是基于1个旧的客户端已经确认收到的快照和最新快照来计算差量,FPS Sample中用到的技术是使用3个旧的快照和新的快照来计算差量。如下图示假如服务端想把快照83发送给客户端。服务端首先查看一下客户端已经确认接收的最近的3个快照分别是82、81和80,服务端将这三个快照为参数根据一个预测算法计算出来了一个预测快照 8 3 p 83_p 83p,然后计算一个预测快照和想要发送的快照之间的差值,并将将这个差值发送给客户端。客户端根据自己收到的快照82、81、80以及相同的快照预测算法计算出来一个和服务端相同的预测快照,然后根据服务端传来的差值反推出实际的快照83。
具体代码逻辑比较繁琐,这里就不再赘述。预测算法在NetworkPrediction.PredictSnapshot()中;客户端写入快照差值在NetworkServer.WriteSnaptshot的DeltaWriter.Write处;客户端和服务端共用预测算法,读取快照差值NetworkClient.ReadSnapshot的DeltaRead.Read处
这里还是有一些可以做的东西。以某个角色的位置为例,位置的变化可以归类为4中趋势:匀速、匀加速、静止(没变化)和不可根据已有快照预测的变化。这样的话可以用两个bit(FPS Sampe中只有一个bit用来表示是否和预测快照一致)分别表示这四种状态,并且只有在第四种“不可预测”的情况下,才需要将最新的快照数据传输到客户端。