Online Game Development in C++ 第五部分总结
I. 教程案例框架描述
该套教程做了一个简单的汽车控制系统,没有用到物理模拟。用油门和方向控制汽车的加速度和转向,同时还有一些空气阻力和滚动摩擦力的设置增加了真实感。汽车的位置是通过加速度和时间等计算出来的。
关键的参数包括:加速度,速度,质量,最大驱动力,最小转弯半径等。
详细的计算方式就不细说了,重点是没有用到物理模拟。
II. 联网游戏要面对的两大问题,以及模拟这些困难的方法
1. 网络更新频率
在每一个Actor中都能设置其网络更新(Replicate)的频率(前提是该Actor需要Replicate)。
具体方法是在BeginPlay中加入以下语句:
if (HasAuthority()) { NetUpdateFrequency = 1;//默认值是10 }
(注意NetUpdateFrequency是针对属性的Replicate的,和RPC无关)
这个频率和游戏运行帧率相比较来说要低很多。尽管这个值可以手动设置的更高,但是更新的越频繁,给网络带来的负担越大,在多人联网游戏中必须合理分配带宽,而不能无限制地提高某一个actor的数据传输量。
在一个对操作比较敏感的游戏中(例如极品飞车)如果不做任何处理,仅依靠0.1秒一次的更新是无法保证把本地的游戏对象的运动完美复制到另外一端的。玩家的操作是会随时发生很细腻的变化的,仅0.1秒更新一次输入会产生很不连续的跳动,其对操控对象造成的影响会产生更大的偏差,多种因素(例如油门和转向)造成的误差累积起来会相差很远。
正因为要克服这个问题,所以在开发时需要刻意将问题“夸大”,模拟这个困难,把更新频率调低,比如设置成1次/秒,更容易看出采取一些方法前后的效果差别。
2. 网络延迟
一方发出的操作指令通过路由到达另外一方需要一定的时间,这个时间就是网络延迟,通常以毫秒记。如果延迟过大,而不做任何处理,会看到操作对象有明显的“跳跃”。
延迟是网络环境决定的,在代码上无法避免延迟,但是我们可以做到让操作对象平滑地移动,虽然整体迟一些,但仍是连贯的。
为了夸大这个问题,我们可以输入以下console命令模拟网络延迟:Net PktLag=xxxx。 该值的单位是毫秒,例如我们可以设置为1000来模拟一秒钟的网络延迟。
III. Actor Role
教程中的一幅图对Actor Role总结的很好
图中上方绿色的框表示服务端,下方蓝色和红色的框分别表示两个客户端。
绿色方块代表一些在服务端控制其移动的对象,例如Listen Server自己控制的Pawn,或者移动的平台、机关等。
蓝色小人表示蓝色客户端的pawn,红色小人表示红色客户端的pawn。
IV. 三种Actor Role的同步方案
为了解决后面的一系列问题,将“输入”和“状态”分别进行封装。
“输入”封装为Move,包括油门数值、转向数值、DeltaTime、时间标签。
“状态”封装为State,包括Transform,速度,最后的输入。
这里只进行方法上的描述,具体代码就不列出了。
1. Authority
服务端所有的Actor都是Authority。自己控制移动的Actor当然是Authority,而其他玩家的Pawn的控制也是提交到服务端进行计算后再次同步给所有客户端的,而且服务器计算出来的就是标准,所以也称为Authority。
服务端自己控制的Actor自然可以在服务端本地让其平滑移动。但这个Actor在客户端怎么移动呢?实际上它在客户端的Role(即Remote Role)就变成了Simulated Proxy。
具体情况和处理方法见后面的Simulated Proxy。
2.Autonomous Proxy
客户端自己控制的Actor是Autonomous proxy(自治代理)。
Auonomouse Proxy它可以首先获取输入,所以在本地就可以平滑地模拟移动,然后在Tick中,创建一个Move,通过一个Reliable 的RPC函数SendMove将Move提交到服务端运行。由于是在Tick中,而且是Reliable函数,所以在服务端执行的频率和Tick是一致的,这是唯一比较耗费带宽的操作,带来的好处就是服务端也会平滑精确地响应其输入。
在该RPC函数中,会改变一个ServerState属性,是前面封装的State类型结构体。
而ServerState属性是一个OnRep函数,每次更新它,就会在客户端触发另外一个函数OnRep_ServerState(),在这个函数中覆盖客户端自己计算的汽车状态。
需要注意的是ServerState是属性,它的Replicate的频率并不是每个Tick一次,而是使用网络更新频率。虽然频率不高,但是在网络延迟不严重的情况下,服务端和客户端计算出来的位置应该不会有太大差别,所以这个覆盖行为也不会让玩家有明显的感觉。
但是如果考虑到网络延迟,上述方法仍不能完美应对。
可以手动将网络延迟模拟为1秒,就会明显感觉到车辆在移动和转弯时频繁地跳回之前的某个状态,非常难以控制,而且根本谈不上平滑。
原因也很好理解,因为根据上述方法的描述,服务端会定期覆盖本地的车辆状态,而因为网络延迟的原因,服务端的“反应”总是慢半拍。比如客户端车辆已经启动,并且走了一段距离了,服务端的车辆才刚刚启动,那么在同步时,服务端的车辆位置和客户端的车辆位置有一定的差距,生硬的去覆盖当然会产生一个跳跃,并且这个问题会持续下去,以至于根本无法进行正常的操作。
我们虽然无法避免网络延迟,但是我们有办法将操作变得平滑,不再跳跃。教程称之为:“Keeping ahead of the server”,但我宁愿称之为“缓存操作”。方法概述如下:
在客户端本地的Tick中创建Move(和之前是一样的),然后将Move缓存入一个数组(不同的地方),然后依照前面的方法,本地模拟,再通过RPC在服务端模拟。在同步ServerState时多做一些事情:在Autonomous Proxy中对比ServerState的LastMove中的时间标签和前面缓存的Move数组,时间早于LastMove的都清理掉(因为这些在服务端已经得到了执行)。然后通过一个For循环在本地瞬间模拟剩下的未执行的Move数组。
对比这个操作和直接生硬的从服务端往客户端覆盖,就会发现这个操作实际上把服务端还没接收到的一系列操作在本地瞬间重演了,并在服务端的结果上进行偏移给与客户端,这样虽然增加了计算量,但是保证了本地运动是平滑的,同时也最大程度的保证了服务器的权威性——因为仍是在服务器计算的结果之上进行的偏移。
3. Simulated Proxy
Simulated Proxy只存在于客户端(因为服务端的都是Authority),它无法受到自己控制,是从服务端同步来的(不管是谁控制的,可能是其他客户端,也可能是直接由服务器控制),所以叫做“模拟代理”。
方法1:
简单粗暴的做法是让其同步移动。
C++的做法是在构造函数中加入 bReplicateMovement = true;
蓝图中是在属性中搜索ReplicateMovement,将其打钩。
这样虽然可以同步其移动,但是因为同步频率的问题,在客户端会看到类似定格动画的移动,很不平滑。
方法2:
注意该方法需要设置bReplicateMovement=false。不让Movement自动同步,而是手动处理。
正常的处理方法是滞后一次更新,对物体位置进行Linear Interpolation,对旋转进行Slerp。这样Simulated Proxy的移动虽然会慢一点,但是好处是移动会平滑很多。
方法3:
注意该方法需要设置bReplicateMovement=false。不让Movement自动同步,而是手动处理。
更好的办法是利用Hermite Cubic Spline Interpolation。
重点公式:
Derivative(曲线斜率)= DeltaLocation/DeltaAlpha Velocity (速度)= DeltaLocation/DeltaTime DeltaAlpha = DeltaTime/TimeBetweenLastUpdates(两个点之间的时间差) 推倒得出: Derivative = Velocity * TimeBetweenLastUpdates 注意虚幻里的速度默认单位是m/s,而位置的单位是cm,所以在速度转换为位置的时候一定要记得*100。
虚幻里提供了两个函数可以帮助我们模拟Hermite Cubic Spline,分别是:
FMath::CubicInterp()
和
FMath::CubicInterpDerivative()。
前者用来求插值的位置,后者用来求插值的速度,具体用法如下:
FVector NewLocation = FMath::CubicInterp(初始位置,初始Derivative,目标位置,目标Derivative,比例);
FVector NewDerivative = FMath::CubicInterpDerivative(初始位置,初始Derivative,目标位置,目标Derivative,比例);
求出的NewDerivative转换成速度也很简单,直接除以(TimeBetweenLastUpdates*100)即可。
但是如果讲Velocity的方向设置为旋转方向,倒车时候就会瞬间调转车头,这是我们不希望看到的。所以旋转上最好还是结合第二种方法来做。总之旋转上没有非常好的平滑方法。
需要知道,上述这写方法都是针对非常低的同步频率(测试使用的是1次/秒)来处理的,在正常同步频率下(10次/秒)的表现还都是非常好的。