[从零开始的Unity网络同步] 8.物理碰撞的网络同步(在客户端预测服务器单位)

发表于2018-11-20
评论8 1.02w浏览

前言

写完上一篇文章([从零开始的Unity网络同步] 7.物理状态的网络同步)之后,在Q群有一位朋友提了一个问题,在这个网络框架下,无法正常处理物体与物体之间的碰撞,经过测试以后,发现确实会出现这样的情况,如图:

可以看到,在客户端物体(蓝色立方体)移动,然后碰撞到服务器物体(红色立方体)时,由于服务器端的物体在客户端是滞后的(在[从零开始的Unity网络同步] 5.服务器将状态同步给客户端(状态缓存,状态插值,估算帧)中有讲到),而客户端物体是本地预测的([从零开始的Unity网络同步] 6.客户端本地预表现),当发生碰撞时,不能及时地产生碰撞反馈,所以导致碰撞的结果两端不一致,然后客户端就预测失败,产生很强烈的抖动和拉扯.这显然不是我们想要的结果.
那么如何来解决这样的问题呢???

1.思路

原因已经找到了,因为在客户端,客户端的物体是本地预测的,而服务器的物体是根据收到的状态包进行插值,两者在当前时刻,物理状态有差异,所以导致的碰撞异常,既然是因为服务端和客户端的物体,模拟的步调不一致导致的,那么可不可以在客户端去预测服务端的物体,使两者能够保持相同的模拟步调呢???
GDC2018演讲 《火箭联盟》的物理与网络细节(需要科学上网)这个视频中,从37分22秒开始,演讲者演示了在《火箭联盟》中是如何做到在客户端对服务器的球的物理状态进行预测.
因此,”在巨人的肩膀上”,在之前的网络同步架构之下,做一点拓展,使在客户端预测服务端物体的物理状态.

2.模仿《火箭联盟》制作汽车(Car)和球(Ball)

新建一个预设Car,样子大概这样:

新建一个预设Ball,样子是这样:

为了让球(Ball)更像真实的球,给它添加带弹性的物理材质:

3.为汽车(Car)和球(Ball)添加控制逻辑,以及需要同步的网络状态

汽车的控制代码:

//执行操作输入,根据按键施加不同方向的力
public override void ExecuteCommand(Command command)
{
    CommandInput input = command.input;
    if (input.forward)
        rigidbody.AddForce(transform.forward * driveForce * rigidbody.mass, ForceMode.Force);  //按W键,向前加力
    if (input.backward)
         rigidbody.AddForce(-transform.forward * driveForce * rigidbody.mass, ForceMode.Force); //按S键,向后加力
    if (input.left)
         rigidbody.AddTorque(Vector3.down * turnForce * rigidbody.mass, ForceMode.Force);  //按A键,添加扭矩,向左转
    if (input.right)
         rigidbody.AddTorque(Vector3.up * turnForce * rigidbody.mass, ForceMode.Force);  //按D键,添加扭矩,向右转
    if (input.jump)
         rigidbody.AddForce(Vector3.up * jumpForce * rigidbody.mass, ForceMode.Force);  //按Space键,向上加力

    Physics.Simulate(Time.fixedDeltaTime);            //物理模拟一次

    command.result.velocity = rigidbody.velocity;                //模拟完立刻能取到模拟结果
    command.result.angularVelocity = rigidbody.angularVelocity;  //模拟完立刻能取到模拟结果
}

球(Ball)不接收按键输入,只有需要同步的物理状态,物理状态跟汽车(Car)是相同的

// 球的状态
public class BallState
{
    public Vector3 position;                   //位置
    public Quaternion rotation;                //旋转
    public Vector3 velocity;                  //刚体速度
    public Vector3 angularVelocity;          //刚体角速度
}
// 汽车的状态
public class CarState
{
    public Vector3 position;                   //位置
    public Quaternion rotation;                //旋转
    public Vector3 velocity;                  //刚体速度
    public Vector3 angularVelocity;          //刚体角速度
}

就这样,汽车(Car)和球(Ball)都创建好了,可以进行基本的碰撞同步检测了,效果如图:

可以看到,在汽车(Car)冲撞到球(Ball)之后,球发生了剧烈的抖动,接下来,就要解决这个问题了.

4.在客户端为服务器物体进行物理状态预测

在目前的同步框架下,服务器的物体在客户端是基于状态进行插值变化的.所以是滞后了,为了能在客户端预测它,我们可以创建一个假的球(DummyBall),然后把真正的球(ServerBall)隐藏(PS:仅仅是隐藏,同步逻辑还是一样的),这样,就可以做到
>汽车(ClientCar)不和ServerBall发生物理碰撞,只和DummyBall发生碰撞
>可以在客户端对DummyBall进行物理预测,而不是影响ServerBall

这可能有点绕,简而言之,就是为了在客户端预测服务器的物体,客户端创建了一个假的”欺骗”玩家,但不是真的欺骗,DummyBall在预测之前的物理状态必须是服务器下发的最新状态,DummyBall的代码如下:

// Dummyball,为减少篇幅,使用单例
public class DummyBall : MonoBehaviour 
{
    public static DummyBall instance;
    public Entity actualEntity;
    public new Rigidbody rigidbody;

    public static void Create(Entity entity)
    {
        //创建一个跟ServerBall一模一样的DummyBall
        GameObject dummy = GameObject.Instantiate(entity.gameObject);
        DontDestroyOnLoad(dummy);
        dummy.name = "Server Dummy";
        dummy.layer = Layer.Dummy;
        instance = dummy.AddComponent<dummyball>();
        instance.actualEntity = entity;
        //设置成紫红色
        foreach (var mr in dummy.GetComponentsInChildren<meshrenderer>())
            mr.material.SetColor("_Color", Color.magenta);

        //将真正的Ball隐藏起来
        instance.rigidbody = dummy.GetComponent<rigidbody>();     
        entity.gameObject.SetActive(false);
        Collider[] cols = entity.gameObject.GetComponentsInChildren<collider>();
        foreach (Collider collider in cols)
            collider.enabled = false;
    }

    public void SetDummyBallState()
    {
        rigidbody.position = actualEntity.lastState.position;            //使用最后收到的状态来设置position
        rigidbody.rotation = actualEntity.lastState.rotation;            //使用最后收到的状态来设置rotation
        rigidbody.velocity = actualEntity.lastState.velocity;            //使用最后收到的状态来设置velocity
        rigidbody.angularVelocity = actualEntity.lastState.angularVelocity;  //使用最后收到的状态来设置angularVelocity
    }
}

然后客户端为自己(ClientCar)做预测的同时,也为DummyBall做预测,代码:

// 每个模拟帧要执行的方法
public void Simulate()
{
    OnSimulateBefore();
    if(isLocalPredicted)            //如果是需要本地预测的单位,获取指令,直接执行指令即可
    { 
        if (DummyBall.instance != null)
            DummyBall.instance.SetDummyState();        //每次客户端预测前,DummyBall都应用最新的State
        foreach (Command cmd in commandQueue)
        {
            if((cmd.flags & CommandFlags.HAS_EXECUTED) && !(cmd.flags & CommandFlags.VERIFIED))          //本地已经执行过 且 没有被服务确认过的指令
            {
                 ExecuteCommand(cmd);            
            }               
        }
        Command cmd = new Command ();        
        cmd.input =  CollectCommandInput();      // 获取指令      
        ExecuteCommand(cmd);                    // 执行指令
        cmd.flags |= CommandFlags.HAS_EXECUTED;        //标记这个命令执行过了
        commandQueue.Enqueue(cmd);              //已经执行过的指令,需要缓存
    }
    OnSimulateAfter();
}

在汽车(Car)的执行操作指令的逻辑中,因为Physics.Simulate()是全局的,所以客户端预测执行一次,DummyBall也预测模拟了一次.

Physics.Simulate(Time.fixedDeltaTime);            //物理模拟一次.包括 ClientCar 和 DummyBall.

看看效果吧(蓝色车是客户端控制,紫色球是假球DummyBall,都是客户端做预测的):

可以看到,在客户端的预测下,汽车(Car)碰撞到球(Ball)时,产生了很及时的碰撞反馈.此方案可行
再把真实的球(ServerBall)给显示出来对比一下(蓝色车是客户端控制,紫色球是假球DummyBall,都是客户端做预测的, 红色球是ServerBall,是由服务器下发的状态包来做插值):

5.小结

通过创建DummyBall在客户端实现对服务器物体的物理预测,虽然感觉像是玩家在踢”假球”,但是可以换个说法,玩家是在踢”未来的球”,这样听起来就很Amazing了~
在不确定性的物理模拟和较高的网络波动环境下,这样的做法总会发生误差,为了减少误差带来的游戏体验,在带宽允许的条件下,可以尽可能的增加网络传输的频率,比如:20个包/秒,还有对数据流量进行压缩也很有必要.

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引