寫完上一篇文章([從零開始的Unity網路同步] 7.物理狀態的網路同步)之後,在Q羣有一位朋友提了一個問題,在這個網路框架下,無法正常處理物體與物體之間的碰撞,經過測試以後,發現確實會出現這樣的情況,如圖:
可以看到,在客戶端物體(藍色立方體)移動,然後碰撞到伺服器物體(紅色立方體)時,由於伺服器端的物體在客戶端是滯後的(在5.伺服器將狀態同步給客戶端(狀態緩存,狀態插值,估算幀))中有講到),而客戶端物體是本地預測的(6.客戶端本地預表現),當發生碰撞時,不能及時地產生碰撞反饋,所以導致碰撞的結果兩端不一致,然後客戶端就預測失敗,產生很強烈的抖動和拉扯.這顯然不是我們想要的結果.
那麼如何來解決這樣的問題呢???
原因已經找到了,因為在客戶端,客戶端的物體是本地預測的,而伺服器的物體是根據收到的狀態包進行插值,兩者在當前時刻,物理狀態有差異,所以導致的碰撞異常,既然是因為服務端和客戶端的物體,模擬的步調不一致導致的,那麼可不可以在客戶端去預測服務端的物體,使兩者能夠保持相同的模擬步調呢???
在GDC2018演講 《火箭聯盟》的物理與網路細節(需要科學上網)這個視頻中,從37分22秒開始,演講者演示了在《火箭聯盟》中是如何做到在客戶端對伺服器的球的物理狀態進行預測.
因此,」站在巨人的肩膀上」,為之前的網路同步架構做一點拓展,使在客戶端能夠預測服務端物體的物理狀態.
新建一個預設Car,樣子大概這樣:
新建一個預設Ball,樣子是這樣:
為了讓球(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)之後,球發生了劇烈的抖動,接下來,就要解決這個問題了.
在目前的同步框架下,伺服器的物體在客戶端是基於狀態進行插值變化的.所以是滯後了,為了能在客戶端預測它,我們可以創建一個假的球(DummyBall),然後把真正的球(ServerBall)隱藏(PS:僅僅是隱藏,同步邏輯還是一樣的),這樣,就可以做到
汽車(ClientCar)不和ServerBall發生物理碰撞,只和DummyBall發生碰撞 可以在客戶端對DummyBall進行物理預測,而不是影響ServerBall
汽車(ClientCar)不和ServerBall發生物理碰撞,只和DummyBall發生碰撞
這可能有點繞,簡而言之,就是為了在客戶端預測伺服器的物體,客戶端創建了一個假的」欺騙」玩家,但不是真的欺騙,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()
Physics.Simulate(Time.fixedDeltaTime); //物理模擬一次.包括 ClientCar 和 DummyBall.
看看效果吧(藍色車是客戶端控制,紫色球是假球DummyBall,都是客戶端做預測的):
可以看到,在客戶端的預測下,汽車(Car)碰撞到球(Ball)時,產生了很及時的碰撞反饋.此方案可行
再把真實的球(ServerBall)給顯示出來對比一下(藍色車是客戶端控制,紫色球是假球DummyBall,都是客戶端做預測的, 紅色球是ServerBall,是由伺服器下發的狀態包來做插值):
通過創建DummyBall在客戶端實現對伺服器物體的物理預測,雖然感覺像是玩家在踢」假球」,但是可以換個說法,玩家是在踢」未來的球」,這樣聽起來就很Amazing了~
在不確定性的物理模擬和較高的網路波動環境下,這樣的做法總會發生誤差,為了減少誤差帶來的遊戲體驗,在帶寬允許的條件下,可以儘可能的增加網路傳輸的頻率,比如:20個包/秒,還有對數據流量進行壓縮也很有必要.