网络游戏的移动同步(三)平滑算法

引文

本篇文章想解决的是引入航位预测后,预测位置与当前位置出现偏差的平滑处理算法,如第一篇所做的简单跳跃的话,会出现很不舒服的跳跃,这里用一些常用的插值方法解决那些生硬的跳跃。

一些问题

插值平滑就是处理p0插值到p1的问题,但是在游戏中,这难免会出现一些问题,比如我们在处理位置插值时,如何处理p0到p1与碰撞检测系统的冲突?如何选择更为合适的插值方法?

线性插值

我们先选择简单的线性插值,在代码中做如下更改,记录下目标位置,初始位置及时间。

1
2
3
4
5
6
7
8
var delta:Number = (getTimer() - rp.time) / 1000;
 
netPlayer.targetPos = new Vector2().addVectors(rp.position, rp.velocity.clone().multiply(delta).add(rp.acceleration.clone().divide(2).multiply(delta * delta)));
netPlayer.startPos = netPlayer.position.clone();
netPlayer.velocity = rp.velocity.clone();
netPlayer.acceleration = rp.acceleration.clone();
netPlayer.method = 2;
netPlayer.smoothTick = netPlayer.smoothTime = delta;

这里我做了一下比较特别的处理,首先目标位置并不是发来的位置,而是发来的位置加上延迟时间内又移动的距离,所以目标位置稍微远一些,另外平滑时间我设置成于延迟时间相关,所以延迟越大平滑时间越长。

在玩家类中,需要通过平滑计时来判断当前在平滑阶段,还是普通的预测阶段,代码如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
if (smoothTick > 0) {
    smoothTick -= Global.elapse / 1000;
    var dt:Number = 1 - smoothTick / smoothTime;
    position.x = startPos.x + (targetPos.x - startPos.x) * dt;
    position.y = startPos.y + (targetPos.y - startPos.y) * dt;
} else {
    if (! acceleration.isZero()) {
        velocity.x += acceleration.x * Global.elapse / 1000;
        velocity.y += acceleration.y * Global.elapse / 1000;
    }
    if (! velocity.isZero()) {
        position.x += velocity.x * Global.elapse / 1000;
        position.y += velocity.y * Global.elapse / 1000;
    }
}

从代码中可以看到,位置可以从两个阶段得到,当在插值阶段时,使用的是插值计算出的值,如果不在插值阶段,则为状态计算出的值。最终效果如下。

Network4.swf

可以看到有了平滑的算法后,网络场景的物体看起来不那么生硬了,不过平滑的时候,你会发现平滑的方向跟速度方向不一致,看起来很不自然。下面的平滑算法会做得更好一些。

立方样条插值

选择使用这种插值方式的原因是,这种插值使得插值路径更加真实,自然,可参加如下图

cube_splines

这里可以看到当前速度,期望最终速度都位移插值路径的最终切线上,与期望值一致。

实现这个插值需要4个坐标值。

坐标1:开始位置(即本地当前位置)

坐标2:坐标1经过一定时间后的位置(速度为当前速度)

坐标4:最终位置(即网络协议发送的最新位置加上一定的延迟时间后的位置)

坐标3:坐标4反向移动一定时间后的位置(速度为网络最新速度)

插值坐标公式为:

x = At3 + Bt2 + Ct + D

y = Et3 + Ft3 + Gt + H

其中

A = x3 – 3x2 +3x1 – x0

B = 3x2 – 6x1 + 3x0

C = 3x1 – 3x0 D = x0

E = y3 – 3y2 +3y1 – y0

F = 3y2 – 6y1 + 3y0

G = 3y1 – 3y0

H = y0

cube_spline2

代码修改

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var delta:Number = (getTimer() - rp.time) / 1000;
// 预测点,在延迟时间5倍以后
// 延迟越严重,预测越远
var scheduled:Number = delta * 5;
scheduled = Math.min(scheduled, 0.8);
 
var pos1:Vector2 = netPlayer.position.clone();
var pos2:Vector2 = new Vector2().addVectors(pos1, netPlayer.velocity.clone().multiply(0.1));
var pos4:Vector2 = new Vector2().addVectors(rp.position, rp.velocity.clone().multiply(scheduled).add(rp.acceleration.clone().divide(2).multiply(scheduled * scheduled)));
var pos3:Vector2 = new Vector2().subVectors(pos4, rp.velocity.clone().add(rp.acceleration.clone().multiply(scheduled)).multiply(0.1));
 
netPlayer.smoothTick = netPlayer.smoothTime = scheduled;
netPlayer.A = pos4.x - 3 * pos3.x + 3 * pos2.x - pos1.x;
netPlayer.B = 3 * pos3.x - 6 * pos2.x + 3 * pos1.x;
netPlayer.C = 3 * pos2.x -  3 * pos1.x;
netPlayer.D = pos1.x;
 
netPlayer.E = pos4.y - 3 * pos3.y + 3 * pos2.y - pos1.y;
netPlayer.F = 3 * pos3.y - 6 * pos2.y + 3 * pos1.y;
netPlayer.G = 3 * pos2.y -  3 * pos1.y;
netPlayer.H = pos1.y;
 
// 插值位置
position.x = A * dt * dt * dt + B * dt * dt + C * dt + D;
position.y = E * dt * dt * dt + F * dt * dt + G * dt + H;

示例

Network4.swf

加权平均插值

这个在之前的插值介绍中提过,这种插值方式最简单,不需要记录dt,只需要记录期望位置即可。至于具体实现及方案,我将与下面的碰撞检测检测冲突一起给出。

碰撞检测冲突

目前插值的目标都是position,但是如果仅仅是对此值进行插值会存在一些问题,比如position.x = 1,期望位置为position.x = 3,而恰巧position.x = 2的位置是一个障碍点,那插值会导致与碰撞检测代码冲突,这种情况非常容易出现。

我处理这个问题的方案是,插值对象更改,更改为一个修正值modify。而在最终位置的选取上position要加上这个modify,这样插值可以跟碰撞检测规避开。

具体的实现如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 设置修正只
netPlayer.modify.x = netPlayer.x - rp.position.x;
netPlayer.modify.y = netPlayer.y - rp.position.y;
//如果位置偏差实在过大,直接跳跃
if (netPlayer.modify.lengthSQ > 50 * 50) {
    netPlayer.modify.set(0, 0);
}
// 注意这里直接设置到了期望位置
netPlayer.position.x = rp.position.x;
netPlayer.position.y = rp.position.y;
netPlayer.velocity = rp.velocity.clone();
netPlayer.acceleration = rp.acceleration.clone();
 
// 玩家更新代码
var smoothFactor:Number = 0.075;
// 修正值平滑
modify.x *= (1 - smoothFactor);
modify.y *= (1 - smoothFactor);
 
// 显示位置
x = position.x + modify.x;
y = position.y + modify.y;

这个很酷的方案最终效果如下

Network6.swf

这种方案的好处是更新过程不需要区分插值过程与预测计算过程,也不需要记录dt,代码显得比较间接,过渡相对比较平滑,不会与游戏其他系统相互冲突。

停止的不自然

之前的例子因为都有延迟波动的影响,所以停止过程经常出现不舒服的回拉,这个解决方案比较简单,如果停止时位置波动在某个阈值内,则不进行插值平滑即可。

最终效果全集

Network7.swf

参考

updatedupdated2021-01-202021-01-20