客户端-服务端之间的位置同步一直是游戏开发中的一道难题,特别是还涉及到复杂的物理运动时。对于这个话题,来自《太空工程师》游戏的工程师在GDC 2023上为我们带来了他们的分享——《《太空工程师》中基于预测物理的多人游戏》(Predicted Physics Based Multiplayer in ‘Space Engineers’)。PPT可点击链接获取。
原PPT内容较长,足有120多页。本文取其精华以飨读者。
《太空工程师》是一款关于在太空中建造、探索和生存的沙盒游戏。游戏中所有的物品都可以被组装和拆解。玩家可以建造属于自己的太空飞船和宇宙空间站,还可以选择相互合作或者战斗。
对于这样一款涉及到复杂物理运动的游戏,有不少同步相关的问题需要解决。例如:
- 运动状态是以服务端还是客户端为准?
- 当遇到延时时,如何保证客户端的运动流畅?
- 如果客户端和服务端不同步,应该如何做修正?
- 对于有强关联性的一组物体(如太空飞船和在它表面的宇航员),它们的同步较容易产生状态不一致,这时应如何解决?
带着这些问题,我们看下《太空工程师》的应对策略。我们会从一个最简单的同步方案开始,一步步进化成完整的方案。
首先考虑基础同步方案(Naive Postion Updates)。主流的同步方案可以按服务端承担责任的轻重,粗略分为两大类:一类是客户端将状态发给服务端,服务端只做转发;另一类是服务端有一个完整的物理世界,运动状态以服务端每帧的计算结果为准,客户端只发送玩家操作给服务端,然后使用服务端的推送结果来修正当前的运动状态。由于《太空工程师》中的物理交互非常多而且重要,所以选择后面一种方案。
通信协议基于UDP,只对部分类型的包保证可靠性。这样做可以兼顾延时和可靠性。
状态推送采用一种分层AOI的策略。例如,玩家操纵的人物只接受他周围3km以内物体的更新;在20米半径内每4帧更新一次,在接近3km的地方降低到每60帧更新一次。
服务端每帧向客户端推送状态消息,在存在延时的情况下,包可能乱序到达,甚至出现丢包的情况。
解决办法是在服务端使用播放延迟缓冲器(PDB,playout delay buffer)。这个缓冲器包含4个包的存储位,每帧接收一个客户端的包,插入缓冲器中,并与已存储的包一起按序号顺序排列。如果存储的包总数大于4个,那么会把序号最小的一个包移出缓冲器,并推送到客户端。例如,服务端先后接收到2、5、3三个包,会按下图排列成2、3、5的顺序。
使用PDB的好处是可以在一定范围内将乱序的包重新排列成有序。缺点是会增加额外的延迟时间。
游戏中会用转子、活塞之类的部件把不同物体组合到一起。整体会运动,每个部件也会独立运动。如果为每个部件单独做运动插值,那么合并起来运动必然是断裂的。解决办法是使用相对位置同步(Relative Position Updates),具体来说是先定义物体之间的层次关系,再对子级物体做相对于它的父级物体的运动插值,即本地空间中的运动插值。一般选择被控制的物体或者最大的物体作为根级物体。
接下来我们考虑延时(Lag)对同步的影响。这里的延时是指从按下操作键到屏幕上出现反馈的时间。这个时间通常是难以忽视的,例如,当ping为50ms时,由于网络传输、渲染、GPU等多个步骤的时间累积,估算总延时高达200多ms。这里引述了卡马克的一句话:“我发送一个IP包到欧洲,速度竟然快过发送一个像素到屏幕上”。
对于这样高的延时,如果客户端完全等待服务端推送才能动起来,那么玩家体验必然很差。解决办法是预测服务端的状态(Basic Prediction),让客户端先动起来。
预测方法是:客户端每帧向服务端同步当前的操作和位置,并且在本地保存同步的历史记录;服务端接到消息后推送给所有客户端;客户端接到服务端推送后与历史记录比较,若有误差则修正当前运动状态。
有时候客户端或者服务端会出现掉帧,掉帧会影响同步的正确性。出现掉帧时,我们可以让客户端临时提升或者降低帧频,以保证和服务端一致。
当客户端接收到服务端的推送和本地历史记录不一致时,即为预测不同步。这时我们需要按服务端的推送数据来对客户端的运动状态做预测修正(Prediction Correction)。例如,在下图的示例中,服务端某个时刻出现了一个新的障碍物,这个情况暂时还没能同步到客户端那边。因此服务端和客户端会有不同的运动状态:服务端会尝试碰撞障碍物但进不去,然后沿着其表面向上运动;客户端会进入到障碍物所在的位置再向上,直到接收到服务端的推送与本地记录不符,再修正成退出障碍物所在区域。
由于浮点运算等原因,服务端推送结果不可能与客户端历史记录100%匹配,所以我们只在差异足够大的情况下做修正。
另一个要解决的重要问题是时间矛盾(Time Paradox),即由于网络传输延时导致的客户端、服务端状态不一致,多见于多个运动物体发生交互时。例如,在太空中,宇宙飞船和宇航员同样以50m/s的速度向左运动,宇航员试图进入到船舱中。由于存在网络延时,客户端的宇宙飞船运行状态落后于服务端2.5m,导致客户端看到宇航员正对着舱门,而服务端对着墙壁。
这样造成的结果是:客户端上宇航员成功进入船舱,而服务端会碰壁,稍后服务端把碰壁消息推送给客户端,客户端据此修正,又将宇航员移出船舱。
如果纯靠客户端修正的话,玩家体验不佳。优化方法是使用相对预测(Relative Prediction)。
具体做法是:当宇航员接近宇宙飞船时,将宇航员设为飞船的子物体,并且将服务端的状态立即通知客户端做修正。这里的父子物体概念与前面通过物理限制连接在一起有所不同,这里的概念是逻辑上的。将宇航员和飞船绑定后,宇航员的运动状态更新改为相对飞船的相对运动。
当发生绑定时,客户端的宇航员状态要根据服务端的相对位置做修正,这个过程使用插值保证平滑过渡。
最终结果就是不再会出现客户端上宇航员进舱后又被拖出来的尴尬场景。
这里需要提及一下客户端物理设置的标准。标准是:所有动画驱动的物体设置成静态刚体。当物体切换为受控状态时,对应刚体也切换成动态刚体,并且向客户端持续推送运动状态更新。如果该物体有父级物体,那么需要继承父级物体的运动。
最后是对本次分享整体思路的总结,都浓缩在下张PPT中: