Game Development, 게임개발/게임네트워크

Client - side Prection, 클라이언트 단 예측

게임이 더 좋아 2022. 5. 2. 00:28
반응형
728x170

[Game Developer, 게임개발자/게임네트워크] - 멀티플레이 게임과 네트워크 지연

이 글의 연장이 되는 글이다.

알아보자

 


 

우선 가장 중요한 플레이어의 입력에 대해서 알아보려고 한다.

일반적으로 움직임은 클라이언트가 직접 계산하지만

여러 멀티플레이어의 게임은 의도치 않은 상황을 막기 위해

입력조차도 서버권한으로 관리하는 경우가 많다.

하지만 이 때 걸리는 Latency가 높아서 우리는 예측을 하는 것이다.

 

즉, 우리가 가장 먼저 해야하는 것은

움직임 자체를 클라이언트에서 연산하여 작동하는 것을 비활성화 시켜야 한다.

다시 말하자면 클라이언트는 서버 응답에 관해서 작동을 해야하지

그냥 실제로 클라이언트의 값대로 동작하면 안된다는 말이다.

 


 

우선 플레이어를 움직이는 로직을 보자.

public class Logic : MonoBehaviour
{

   public GameObject player; //플레이어

   private float timer;

   private void Start()
   {
      this.timer = 0.0f;
   }

   private void Update()
   {
      this.timer += Time.deltaTime;
      while (this.timer >= Time.fixedDeltaTime)
      {
         this.timer -= Time.fixedDeltaTime;
         
         //입력이 바로 움직임으로 이어지지 않음 => 객체 또는 구조체가 만들어짐
         Inputs inputs;
         inputs.up = Input.GetKey(KeyCode.W);
         inputs.down = Input.GetKey(KeyCode.S);
         inputs.left = Input.GetKey(KeyCode.A);
         inputs.right = Input.GetKey(KeyCode.D);
         inputs.jump = Input.GetKey(KeyCode.Space);
         
         //만들어진 Input에 의해 Player의 Rigidbody가 움직임.
         this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs);
	     
         //해당 움직임은 이 때 시뮬레이션됨
         Physics.Simulate(Time.fixedDeltaTime);
      }
   }
}

 

 

여기선 서버와 주고받는 것이 없다.

바로 클라이언트에서 계산해서 동작한다.

 

 

즉, 우리는 클라이언트에서 만들어진 Input자체를 쓰는 것이 아닌 Server로 보내는 것이 필요하다.

// client
private void Update()
{
   this.timer += Time.deltaTime;
   while (this.timer >= Time.fixedDeltaTime)
   {
      this.timer -= Time.fixedDeltaTime;
      Inputs inputs = this.SampleInputs();
      //우리는 입력을 받는다면, 해당 입력으로 메세지가 만들어진다.
      //메세지에는 input의 종류, index와 같은 tick_number가 들어간다.
      InputMessage input_msg;
      input_msg.inputs = inputs;
      input_msg.tick_number = this.tick_number;
      this.SendToServer(input_msg);
      //inputs을 여전히 서버에서 받아온 응답이 아니라 그냥 쓰고 있다.
      this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs);

      Physics.Simulate(Time.fixedDeltaTime);
      
      ++this.tick_number;
   }
}

 

다만 여전히 서버의 응답을 받지는 않는다.

다시 말하면 서버는 저 메세지를 받았을 때 응답을 해줘야한다는 말이다.

 

// server
private void Update()
{
   //InputMessage를 받을 것이 있다면.
   while (this.HasAvailableInputMessages())
   {
      InputMessage input_msg = this.GetInputMessage();
      //여기서는 서버가 받아서 직접 움직이게 시킨다 => Server에도 똑같은 상황을 공유하고 있다.
      //다시 말해서 Client의 WorldState는 결국 Server가 알고 있다.
      Rigidbody rigidbody = player.GetComponent<Rigidbody>();
      this.AddForcesToPlayer(rigidbody, input_msg.inputs);

      Physics.Simulate(Time.fixedDeltaTime);
      
      //서버에서 움직여본 결과를 우리는 응답으로 준다.
      StateMessage state_msg;
      state_msg.position = rigidbody.position;
      state_msg.rotation = rigidbody.rotation;
      state_msg.velocity = rigidbody.velocity;
      state_msg.angular_velocity = rigidbody.angularVelocity;
      state_msg.tick_number = input_msg.tick_number + 1;
      this.SendToClient(state_msg);
   }
}

 

어차피 서버에서 보낸 응답으로 클라이언트의 상태가 정해진다는 것이다.

여기서 tick_number가 하는 역할을 짐작해볼 수 있는데

n1의 클라이언트 메세지를 받는다면

우리는 n1의 메세지를 이용해 서버에서 상태를 바꾸고 n2의 상태를 주게 되는 것이다.

뭐 어떻게 구현하든 상관 없지만 우리는 WorldState를 Consistent하게 유지해야 한다.

** We have to always keep it consistent with gameplay.

 


 

**여기서 의문을 품는 사람이 있을 것이다.

항상 클라이언트가 n시간에 보냈다면

n+1시간에 보낸 것보다 빨리 도착할 것인가?

 

response가 순서 보장이 될까??? 

 

전혀 아니다.

이렇게 무턱대고 보낸다고 해서 순서대로 응답을 받지는 않는다. 다만 여기서 다루지 않을 뿐이다.

실제로는 req, res의 순서보장을 해야 한다.

 

더군다나 클라이언트가 수백 개다...? 그러면 진짜 어려워지는 거다.

PUBG처럼.. 100명 중 1명만 움직여도 99명이 1명이 움직이는 것을 알아야 하고 정확해야 한다.

 

 


 

예시를 보면

여기서 Yellow가 실제 서버가 가지고 있는 Cube고

Blue가 클라이언트가 가지고 있는 Cube다.

 

 

 

오 잘움직이네??

하지만 언제나 그렇진 않다.

 

 

 

 

???? 뭐야???

이걸 Deterministic Fail 이라고 하는데

서버의 상태와 클라이언트의 상태가 일치하지 않는 것을 의미한다.

 

실제로 이런 경우가 많이 일어난다고 한다.

하지만 위의 코드 자체로는 나면 안되는데..?

서버에도 한 개의 큐브이고... 클라이언트도 한 개이기 때문이다.

아무튼 그런 경우가 잦다는 것을 알고 넘어가자.

=> 위 오류는.. 해당 플랫폼 특성이라고 하는데 정확하게는 모르겠다.

 

 

또한 말했듯이 Prediction은 실패도 동반한다.

다시 말해서 실패했을 경우Rewind가 되는지 여부도 중요하다.

물론 가능하다.

우리는 History를 가지고 있고 해당 History를 통해서 실제 있어야 할 위치로 되돌리기가 가능하다.

 

다시 말해서 서버의 응답을 받기 전에 예측을 해서 실제 State와 달라도

클라이언트는 언제든 다시 WorldState를 맞출 수 있다는 말이다.

 

// client => 이게 바로 History다.
private ClientState[] client_state_buffer = new ClientState[1024];
private Inputs[] client_input_buffer = new Inputs[1024];

private void Update()
{
   this.timer += Time.deltaTime;
   while (this.timer >= Time.fixedDeltaTime)
   {
      this.timer -= Time.fixedDeltaTime;
      Inputs inputs = this.SampleInputs();
      //매 프레임마다 메시지를 만들고
      InputMessage input_msg;
      input_msg.inputs = inputs;
      input_msg.tick_number = this.tick_number;
      this.SendToServer(input_msg);
      
      // 매 프레임마다 slot 번호를 갱신하여 해당 state를 저장한다.
      uint buffer_slot = this.tick_number % 1024;
      this.client_input_buffer[buffer_slot] = inputs;
      this.client_state_buffer[buffer_slot].position = rigidbody.position;
      this.client_state_buffer[buffer_slot].rotation = rigidbody.rotation;
      
      //클라이언트는 우선 서버에 보냈어도 움직이기 시작한다.
      this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs);

      Physics.Simulate(Time.fixedDeltaTime);

      ++this.tick_number;
   }
   
   
   //클라이언트는 이미 Prediction이 되어 움직였고
   while (this.HasAvailableStateMessage())
   {
   
      //실제로 이제 서버에서 응답을 받는다.
      StateMessage state_msg = this.GetStateMessage();
      //서버에서 받은 상태와 클라이언트의 상태를 비교한다.
      uint buffer_slot = state_msg.tick_number % c_client_buffer_size;
      Vector3 position_error = state_msg.position - this.client_state_buffer[buffer_slot].position;

      //에러가 일정 이상 난다? 그러면 바로잡아 주어야 한다.
      //이때 기준은 Server의 상태로 간다.
      if (position_error.sqrMagnitude > 0.0000001f)
      {
         // rewind & replay
         Rigidbody player_rigidbody = player.GetComponent<Rigidbody>();
         player_rigidbody.position = state_msg.position;
         player_rigidbody.rotation = state_msg.rotation;
         player_rigidbody.velocity = state_msg.velocity;
         player_rigidbody.angularVelocity = state_msg.angular_velocity;

         uint rewind_tick_number = state_msg.tick_number;
         //고친다면 Client의 History도 바꿔준다.
         while (rewind_tick_number < this.tick_number)
         {
            buffer_slot = rewind_tick_number % c_client_buffer_size;
            this.client_input_buffer[buffer_slot] = inputs;
            this.client_state_buffer[buffer_slot].position = player_rigidbody.position;
            this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation;

            this.AddForcesToPlayer(player_rigidbody, inputs);

            Physics.Simulate(Time.fixedDeltaTime);

            ++rewind_tick_number;
         }
      }
   }
}

 

 

 

 

 

오 이제는 잘 동작하네??

아까처럼 Deterministic error가 생기지 않는구나?

그렇다.. 우리는 Error Correction을 넣었기 때문이다.

 


 

간단하게 살펴봤지만

우리는 입력이 그렇게 간단하지 않은 것을 안다.

 

엄청나게 대량의 Input이 들어오면 어떡하지?

 

60프레임만해도.. 60개가 들어올 수도 있는데??

 

메세지를 60번 보낸다고? 서버에 요청을 60번이나 한다고??

 

서버가 남아날까???

 

맞다.

우리는 서버의 연산량도 생각해줘야 한다.

그래서 우리는 입력을 모아서 보내기도 한다.

예를 들면 60프레임 게임이라면.. 6프레임씩 입력을 모아서 보내기도 한다.

 

이런 식이다.

Inputs inputs = this.SampleInputs();

InputMessage input_msg;
input_msg.start_tick_number = this.client_last_received_state_tick;
input_msg.inputs = new List<Inputs>();
for (uint tick = this.client_last_received_state_tick; tick <= this.tick_number; ++tick)
{
   input_msg.inputs.Add(this.client_input_buffer[tick % 1024]);
}
this.SendToServer(input_msg);

 

이렇게 되면 실행하는 부분도 조금 달라진다.

while (this.HasAvailableInputMessages())
{
   InputMessage input_msg = this.GetInputMessage();

   // 메세지는 입력을 여러개 가진 List가 되고
   // 우린 결국 마지막의 상태만 받아와서 연산해도 된다.
   uint max_tick = input_msg.start_tick_number + (uint)input_msg.inputs.Count - 1;
   
   
   // max_tick을 넘었을 경우
   if (max_tick >= server_tick_number)
   {
      // 어디서부터 다시 연산해야할 지는 저렇게 정한다.
      uint start_i = server_tick_number > input_msg.start_tick_number ? (server_tick_number - input_msg.start_tick_number) : 0;

      // 이제 start_i를 알았다면 우리는 거기서부터 다시 연산을 진행하고 메세지를 보내주면 된다.
      Rigidbody rigidbody = player.GetComponent<Rigidbody>();
      for (int i = (int)start_i; i < input_msg.inputs.Count; ++i)
      {
         this.AddForcesToPlayer(rigidbody, input_msg.inputs[i]);

         Physics.Simulate(Time.fixedDeltaTime);
      }
      
      server_tick_number = max_tick + 1;
   }
}

 

?? 근데 어디서부터 시작해야하는지 tick_number를 알아야해??

 

물론이다.

60프레임 게임이라고 무조건 60의 입력이 오는 것은 아니다.

다시 말해서 List의 원소가 항상 같은 것이 아니란 말이다.

 

근데.. 요청 그거 몇개 보내는 거.. 차이 그렇게 커???

아래 2가지를 보자

 

1. 그냥 다보냄

 

2. List로 한꺼번에 보냄

 

 


 

하지만 또 문제가 있다.

만약 프레임은빠른데.. 렌더링과 같지 않다면..

계산은 하되.. 화면에 반영이 느리다면...?

그것도 문제가 된다.

 

64Hz와 2Hz를 보자

 

64

 

2

 

어떤가??

아무리 계산을 많이시킨다고 하더라도.. 우리는 보내야 한다.

보내지 않으면 게임 플레이를 할 수 없는 수준이 된다.

 

서버에 너무 많이 보내도.. 너무 적게 보내도,, 너무 느리게 보내도.. 너무 빨리 보내도 문제다.

다시 말해서.. 게임마다 다르게 설정해야하는 것이지 딱 정해진 황금율 같은 것을 존재하지 않는다.

 


 

그리고 앞서 말했듯이 Misprediction이 발생하면 Error Correction을 해야 한다.

다시 말해서 클라이언트가 부자연스럽게 보일 가능성이 있다는 말이다.

그렇다면...?

우리는 그것이 게임 플레이의 몰입을 깨지 않도록 바꿔야 한다.

여러가지 방법이 있겠지만

Correction을 한 번에 하는 것이 아닌 여러 프레임에 걸쳐서 하는 방법이 있다.

Vector3 position_error = state_msg.position - predicted_state.position;
float rotation_error = 1.f - Quaternion.Dot(state_msg.rotation, predicted_state.rotation);

if (position_error.sqrMagnitude > 0.0000001f ||
   rotation_error > 0.00001f)
{
   Rigidbody player_rigidbody = player.GetComponent<Rigidbody>();

   // 현재 Predicted된 값을 구함.
   Vector3 prev_pos = player_rigidbody.position + this.client_pos_error;
   Quaternion prev_rot = player_rigidbody.rotation * this.client_rot_error;

   // Correction을 하는 부분
   player_rigidbody.position = state_msg.position;
   player_rigidbody.rotation = state_msg.rotation;
   player_rigidbody.velocity = state_msg.velocity;
   player_rigidbody.angularVelocity = state_msg.angular_velocity;

   uint rewind_tick_number = state_msg.tick_number;
   while (rewind_tick_number < this.tick_number)
   {
      buffer_slot = rewind_tick_number % c_client_buffer_size;
      this.client_input_buffer[buffer_slot] = inputs;
      this.client_state_buffer[buffer_slot].position = player_rigidbody.position;
      this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation;

      this.AddForcesToPlayer(player_rigidbody, inputs);

      Physics.Simulate(Time.fixedDeltaTime);

      ++rewind_tick_number;
   }


   //이전 위치와 계산된 위치가 2m 이상 차이날 경우
   //그냥 error를 계산하지 않음.
   if ((prev_pos - player_rigidbody.position).sqrMagnitude >= 4.0f)
   {
      this.client_pos_error = Vector3.zero;
      this.client_rot_error = Quaternion.identity;
   }
   else
   {
      this.client_pos_error = prev_pos - player_rigidbody.position;
      this.client_rot_error = Quaternion.Inverse(player_rigidbody.rotation) * prev_rot;
   }
}

 

우선 위와 같이 했을 경우..

Correction이 일어나더라도 뭔가 뚝뚝 끊기게 된다.

 

 

그렇다면 코드로 Smoothing을 어떻게 해야할까?

 

this.client_pos_error *= 0.9f;
this.client_rot_error = Quaternion.Slerp(this.client_rot_error, Quaternion.identity, 0.1f);

this.smoothed_client_player.transform.position = player_rigidbody.position + this.client_pos_error;
this.smoothed_client_player.transform.rotation = player_rigidbody.rotation * this.client_rot_error;

//이렇게 할 경우 바로 에러를 고치는 것이 아닌 90%만 고친다고 생각하면 된다.
//error까지는 그냥 구하되 연산을 하기 전에 저런 값을 가지고 Correction을 하는 것이다.

잘나오는 것을 볼 수 있다.

 

?? 그럼 항상 부정확한 것 아니냐???

라고 할 수 있다.

맞다.. 부정확하다.

하지만 Error가 2m 이상과 같이 바로 correction이 되면 이상할 정도의 거리에 적용한다면

이것이 더 자연스러워 보일 것이다.

다시 말하자면 모든 곳에 Smoothing을 쓰자는 것은 아니다.

 

 


 

참고링크

https://www.codersblock.org/blog/client-side-prediction-in-unity-2018

https://www.youtube.com/watch?v=eMSVDbq0K50 

 

반응형
그리드형