前言

寫完上一篇文章([從零開始的Unity網路同步] 7.物理狀態的網路同步)之後,在Q羣有一位朋友提了一個問題,在這個網路框架下,無法正常處理物體與物體之間的碰撞,經過測試以後,發現確實會出現這樣的情況,如圖:

可以看到,在客戶端物體(藍色立方體)移動,然後碰撞到伺服器物體(紅色立方體)時,由於伺服器端的物體在客戶端是滯後的(在5.伺服器將狀態同步給客戶端(狀態緩存,狀態插值,估算幀))中有講到),而客戶端物體是本地預測的(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個包/秒,還有對數據流量進行壓縮也很有必要.


推薦閱讀:
相關文章