前言
写完上一篇文章([从零开始的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();
instance.actualEntity = entity;
//设置成紫红色
foreach (var mr in dummy.GetComponentsInChildren())
mr.material.SetColor("_Color", Color.magenta);
//将真正的Ball隐藏起来
instance.rigidbody = dummy.GetComponent();
entity.gameObject.SetActive(false);
Collider[] cols = entity.gameObject.GetComponentsInChildren();
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个包/秒,还有对数据流量进行压缩也很有必要.