[TIL] 게임 네트워킹 - 게임 플레이 네트워킹

KYJ의 Tech Velog·2024년 3월 12일
0

게임 플레이를 위한 네트워크 처리 개발 방법에 대해 알아보도록 하겠습니다. 게임 기획이 되어 있을 때 이를 어떻게 컴퓨터 간 상호 작용으로 만들면 좋을지 알아보고 로그온, 네트워크 동기화 등 게임 플레이의 온라인 처리 및 설계 방법에 대해 알아보겠습니다.


UML

UML은 단순히 플로우 차트 이상의 역할을 할 수 있습니다. 데이터와 프로세스를 정의하기가 편해집니다. 프로그램 구조 명세를 표현하는 대표적인 수단이라고 할 수 있겠습니다.

UML 시퀀스 다이어그램

객체와 메시지를 사용합니다. 객체는 상호 작용의 주체로 다음 그림에 네모 상자에 해당합니다. 메시지는 상호 작용의 내용으로 다음 그림에 가로 화살표입니다.

겍체 사이에 메시지를 주고받는 것을 한눈에 알아보기 쉽게 표현할 수 있습니다. 그러나 메시지를 어떻게 처리하는지 자세하게 표현하는 데에는 한계가 있습니다. 여기에 플로우 차트를 함께 사용할 수 있다면 더 자세하게 프로그램의 실행 방식을 묘사할 수 있겠죠.

액티비티 다이어그램

플로우 차트와 비슷하지만 차이가 있습니다. 실린더 도형은 활동 상태를 의미합니다. 맨 위에 있는 것은 최초 상태를 의미합니다. 최초 상태라는 뜻으로 동그라미 모양을 덧붙이기도 합니다. 화살표를 이용해서 활동 상태의 변화 과정인 실행 흐름을 표현합니다.

조건문도 사용할 수 있습니다. 마름모로 조건 분기문을 보여줄 수 있고, 상태 하나에서 둘 이상의 화살표로 표현할 수도 있습니다.


게임 플레이 네트워킹

모든 역할을 서버에서 하기

가장 고전적인 방식의 모델입니다. 이 때의 클라이언트는 멍텅구리 터미널과 역할이 비슷합니다.

  1. 사용자 입력(키 입력, 마우스 좌표)
  2. 화면 출력

서버에서는 이를 뺀 나머지 역할을 모두 담당합니다.

  1. 게임 로직 연산
  2. 화면 렌더링(그래픽 데이터 보유)
  3. 화면 송출(비디오 스트리밍)

이를 시퀀스도로 표현하면 다음과 같습니다.

온라인 게임에서 오래 전에 쓰던 방식이라고 합니다. 텍스트 모드에서 터미널로 플레이하는 게임들이 주로 사용한다고 하네요.

터미널은 키보드 입력을 서버로 전송하고 서버에서 오는 글자를 화면에 출력하는 역할만 합니다. 게임 플레이에 어떠한 기능도 할 수 없습니다.

하지만 지금은 그래픽 품질이 매우 좋은 게임들이 많습니다. 화면은 1초에 30~60번씩 렌더링을 해대죠. 렌더링과 게임 로직을 전부 서버에서 감당하는 것은 쉽지 않습니다.

온라인 게임을 방해하는 레이턴시가 길어지는 요인도 다양합니다.

  1. 서버의 거리가 멀면 네트워킹 중에 레이턴시가 추가
  2. 클라우드 서버 내의 가상 머신은 다른 가상 머신이 CPU 사용량을 잠식하면서 지연 시간 발생
  3. 패킷 드롭으로 인한 재송신
  4. 인터넷이 느린 국가
  5. 무선 네트워크

서버 운영의 경제성 문제도 있습니다.

  1. 고퀄리티 그래픽을 60프레임으로 렌더링하려면 그래픽카드 하나의 능력을 총동원해야합니다. 서버에서 이를 처리하려고 하면 접속해 있는 사용자 수만큼 그래픽카드가 필요할 것입니다.
  2. 일반적인 MMORPG 서버 컴퓨터는 플레이어 처리를 2000~20000개까지 해야 경제성이 나옵니다.

이를 해결하기 위해 클라이언트와 서버 간의 상호 작용을 최적화해주어야 합니다.

렌더링은 클라이언트에서 하기

렌더링을 클라이언트에서 담당하는 방법입니다.

  • 서버는 렌더링을 위한 최소 정보인 게임 월드 상태만 클라이언트에 전송. 월드 상태 연산은 서버에서 수행.
  • 렌더링은 클라이언트에서 수행. 그래픽 리소스도 클라이언트가 보유.
  • 서버와 클라이언트의 월드 상태 동기화.

그림의 과정을 설명하자면 이렇습니다.

  1. 서버는 월드의 상태를 다 알고 있지만, 클라이언트는 전혀 모릅니다. 그래서 서버는 클라이언트에게 씬 상태 전체를 전송하죠. 예를 들어 월드에 있는 캐릭터의 위치와 기타 정보를 담은 배열 또는 각각 메시지로 만들어 전송합니다. 서버와 클라이언트의 월드 상태를 동기화하는 것이죠.
  2. 게임 플레이어가 어떤 행동을 취하면 이를 메시지로 만들어 서버에 전송합니다. 서버는 이를 받아 월드 상태를 변화시키는 데 사용합니다. 만약 플레이어의 행동과 무관하게 행동하는 요소가 있다면 메시지 없이도 변화가 일어나겠죠.
  3. 월드 상태가 변하면 변한 부분만 클라이언트에게 전송합니다. 클라이언트는 이 메시지를 받아 자신의 월드에 반영합니다.
  4. 게임 플레이어가 행동을 취할 때마다 행동에 대한 메시지를 만들어 서버에 전송합니다.
  5. 서버는 월드 상태가 변할 때마다 메시지를 만들어 클라이언트에 전송합니다.

이렇게 해서 서버와 클라이언트의 월드 상태를 동기화하는 것입니다.

클라이언트에서 서버로 보내는 플레이어의 행동 명령 메시지는 다음과 같은 일회성 메시지입니다.

  1. 채팅 메시지를 입력했습니다.
  2. 플레이어를 특정 방향으로 이동하라고 명령했습니다.
  3. 플레이어가 이동을 멈추라고 명령했습니다.
  4. 특정 아이템을 사용하라고 명령했습니다.

서버에서 클라이언트로 보내는 월드 상태 변화 메시지는 다음과 같습니다.

  1. 캐릭터가 등장했습니다(데이터 추가).
  2. 캐릭터가 특정 방향으로 이동했습니다(데이터 변경).
  3. 캐릭터가 웃습니다(데이터 변경).
  4. 캐릭터가 사라졌습니다(데이터 소멸).

클라이언트에서 1을 받으면 월드인 씬 안에 캐릭터를 생성해 주어야 합니다. 4를 받으면 해당 캐릭터를 소멸시켜야 합니다. 2, 3은 서버에서 일정 시간마다 반복적으로 보내주는 메시지가 됩니다. 클라이언트가 이를 받으면 씬에서 해당 캐릭터 상태에 변화를 주어야 합니다. 이렇게 씬의 상태에 영구적 변화를 가하는 것은 '지속성 이벤트'라고 합니다.

이런 메시지도 있을 수 있습니다.

  1. 특정 좌표에서 수류탄이 터졌습니다(단발성 이벤트).

지속성 이벤트와 달리 씬의 변화에 잠깐 영향을 주고 사라집니다. 터지는 순간만 서버가 알려주면 클라이언트는 터지는 연출을 보여주고 잠시 뒤에 연출은 사라집니다. 이를 지속성 이벤트로 만들면 괜히 서버가 클라이언트에 보내는 메시지의 양만 늘겠죠. 이렇게 미래가 서로 약속된 결정적인 경우에는 단발성 이벤트가 더 효과적입니다.

이렇게 월드의 상태 변화를 서버에서 보내 줍니다. 게임 플레이 연산은 모두 서버에서 하죠. 클라이언트는 어떤 판단도 하지 않습니다. 심지어 채팅 메시지를 입력해도 서버에서 표시해라라는 응답이 오기 전까지는 표시되지 않습니다.

이 방식이 모든 것을 서버에서 처리하는 것보다는 효율적입니다. 하지만 해결해야 할 문제들은 남아있죠. 이 방식이 원활하려면 다음과 같아야 합니다.

  1. 서버에서는 1/60초마다 월드 상태 업데이트
  2. 서버는 1/60초마다 월드 상태의 변화를 클라이언트에 전송
  3. 클라이언트가 지체없이 수신
  4. 클라이언트가 수신한 내용을 월드 상태에 반영. 이를 다음 렌더링 프레임에서 이를 그림.

이를 위해서는 서버와 클라이언트 간의 레이턴시가 1/60초보다 훨씬 낮아야 됩니다. 레이턴시가 항상 균일하기도 해야 하죠. 로컬 네트워크가 아닌 이상 현실적으로 불가능합니다.

클라이언트 수가 많아질수록 서버에서 해야 하는 일은 엄청 늘어납니다. 월드에 캐릭터가 많아지면 클라이언트에서 수신하는 데이터의 양도 많아질 것입니다. 이를 1/60초마다 전송하는 것은 불가능에 가깝습니다.

이를 해결하기 위해서는 1/30초 또는 1/10초 단위로 월드 변화를 클라이언트에 전송하면 됩니다. 하지만 월드 상태가 1/10초마다 변하면 움직임이 굉장히 딱딱해 보이겠죠.

만약 1/60초마다 캐릭터의 X축 위치 값이 1씩 늘어난다고 가정해봅시다. 이 연산은 서버에서 시행이 됩니다. 이 상태 변화를 1/10초마다 클라이언트에 전송한다면, 클라이언트에서는 캐릭터의 X값이 1/10초마다 6씩 증가하는 것으로 보이게 됩니다.

이 딱딱한 움직임을 해결하기 위해서는 X 값을 부드럽게 만들어야 합니다. 이를 상태 값 보정이라고 합니다.

상태 값 보정

서버에서 받는 상태 변화 메시지를 바로 반영하는 것이 아니라 일정 시간에 걸쳐 서서히 목적 상태로 변하게 하는 것입니다.

위의 예시 상황에서 다시 생각해보겠습니다. X가 변하는 시점에 바꿔주는 것이 아니라 그 변하는 값으로 서서히 올려주는 것입니다. 이렇게 하면 서서히 캐릭터가 서서히 움직이는 것처럼 보이게 되겠죠.

이렇게 부드럽게 해 주는 방법으로 선형 보간이나 스플라인 같은 곡선형 보간이 있습니다. 그 결과는 다음과 같습니다.

추측항법

하지만 결국 원하는 결과는 아닙니다. 원하는 것은 여러 클라이언트가 같은 결과를 보는 것입니다. 하지만 위의 결과는 어느 정도 차이가 있다는 것을 알 수 있습니다.

게임의 종류에 따라 이는 큰 문제가 될 수 있습니다. FPS게임의 경우 이 자그마한 위치 차이로 총이 맞느냐 안 맞느냐가 갈리게 됩니다.

이쪽에서 저쪽 캐릭터의 위치 정보를 받았을 때는 약간의 시간이 지난 상태입니다. 이 지난 시간의 차이를 예측해 주면 문제를 해결할 수 있겠죠. 그 방법이 추측항법입니다.

추측항법이란 상대방의 움직임을 어느 정도 예상해서 그 위치로 갈 수 있게 보정시키는 방법을 의미합니다. 이 방법을 위해서는 몇 가지 배경지식이 필요합니다. 먼저 두 기기 간의 레이턴시를 알아야 합니다. 대표적으로 라운트 트립 레이턴시를 측정하는 방법이 있습니다.

  1. 기기 A에서 기기 B에 패킷을 전송
  2. 기기 B는 이를 받으면 기기 A에 패킷 전송
  3. 기기 A는 과정 1의 시간과 현재 시간의 차이를 계산하고 2로 나누기

그리고 캐릭터의 속도도 가지고 있어야 합니다. 이동하는 캐릭터는 보통 속도도 가지고 있죠.

캐릭터 원본이 있는 곳을 P0, 캐릭터 이동 정보를 받는 곳, 즉 사본이 있는 곳을 P라 하겠습니다. P0(t)는 시간 t의 원본 위치입니다. P(t)는 시간 t에서 사본 위치입니다.

t = 0일 때, P0(t = 0)을 전송합니다. t = 1일 때, P0(t = 1)을 전송하겠죠. 그러면 받는 곳에서는 P(t = 0 + a), P(t = 1 + a), P(t = 2 + a)를 받습니다. a는 레이턴시입니다.

이동 정보를 받은 곳에서는 이미 a만큼 시간이 지난 상태이기 때문에 캐릭터의 정확한 위치는 a만큼의 미래가 되어야 합니다. 받는 곳에서는 t = 0 시점에서 위치와 속도도 이미 받은 상태입니다. t = 0 + a 시점에서 실제 캐릭터 위치를 다음처럼 예측해봅시다.

P(t + a) = P0(t) + a * V0(t)

V0는 P0와 함께 보냈던 속도 벡터입니다. 받은 곳에서 캐릭터의 실제 위치는 a만큼 미래로 계산했습니다. 속도 값도 이미 받은 상태기 때문에 이에 따라 계속 이동해야 합니다.

P(t = 1 + a) = P0(t = 1) + a * V0(t = 1)

아직 문제가 있습니다. 새로 계산된 P(t = 1 + a)는 기존에 이동하고 있던 P(t = 0 + a + ?)와 위치가 동떨어져 있습니다. 이를 그대로 화면에 보여주면 기기 B에서는 메시지를 받을 때마다 캐릭터가 살짝 점프하는 것처럼 보일 것입니다.

이를 개선하기 위해 P 대신 P를 계속해서 쫓아가는 또 다른 위치 값을 정의하는 것입니다. P는 타깃, P를 쫓아가는 개체를 팔로워라고 합시다.

타깃은 일정한 속도로 이동을 하다 가끔 약간의 거리를 워프합니다. 팔로워는 타깃을 향해 이동하다가 워프를 하면 방향을 바꿔 다시 이동하게 만들면 됩니다. 팔로워가 타깃을 향해 이동하게 하는 알고리즘은 앞서 설명했던 보정 방법을 사용하면 됩니다. 위치뿐만 아니라 바라보고 있는 방향, 모션 상태 값도 추측항법을 적용해서 더 정확한 행동을 보여줄 수 있습니다.

레이턴시를 완전히 없앨 수는 없습니다. 하지만 이처럼 레이턴시로 발생하는 문제를 안 보이게 가릴 수 있죠. 이를 레이턴시 마스킹이라고 합니다.

0개의 댓글