지연시간 보장을 위한 클라이언트/서버 인게임 프로토콜 설계와 최적화


https://developer.valvesoftware.com/wiki/Latency_Compensating_Methods_in_Client/Server_In-game_Protocol_Design_and_Optimization

Yahn W. Bernier (yahn@valvesoftware.com), 2001
Software Development Engineer

우선 그대로 적고 검수 예정이다.


개요

액션 게임에서 온라인 플레이를 지원하는 것은 게임이 근본적으로 성공하는 요인이자 그 수명을 늘린다. 하지만 1인칭 액션 게임의 이러한 인터넷 플레이 설계 작업은 고된 작업이다. 사용자의 다양한 PC 환경에 맞춰 개발자가 넓은 범위의 하드웨어와 네트워크 연결 또한 지원해야 하기 때문이다.

현재 온라인 게이밍 환경에서 광대역 연결을 만병통치약으로 여기고 있지만 안타깝게도 광대역도 게임 디자인에서 지연이나 다른 네트워크 요소에 대한 구현을 무시하게 할 만한 쉬운 답안이 되진 못한다. 기본적으로 서버가 있는 곳의 광대역에 도달할 때까지 약간의 시간이 걸리며 클라이언트가 다른 위치에 있다면 거기서도 전송되는 시간이 또 생기게 되는데 여기서 열악한 솔루션을 적용하였다면 종종 높은 대역폭을 가진 유저가 들어와도 연결에 상당한 지연이나 패킷 손실을 겪게 되는 경우가 많다.

Your game must behave well in this world. 이 논제에서 인터넷에서의 액션 경험을 위한 몇 가지 절충안과 많은 온라인 게임에서 채택된 클라이언트 / 서버 구조의 배경과 작동 방식, 예측 모델링이 어떻게 지연에 영향을 미치는지와 연결 상태에 상관없이 플레이를 보장하는 특별한 매커니즘인 렉 보정을 소개한다.

클라이언트 / 서버 게임의 기본 구조

오늘날 네트워크에서 즐기는 대부분의 액션 게임들은 이 구조에서 수정된 것을 이용한다. Half-Life와 그 모드인 Counter-Strike와 Team Fortress Classic, 그 외의 Quake3 엔진이나 Unreal Tournament 엔진을 기반으로 한 시스템에서도 마찬가지로 이용된다. 이 게임들에선 메인 게임의 로직 수행을 담당하는 하나의 권위적인 서버를 두어 하나 또는 다수의 클라이언트들과 연결한다. 여기서 클라이언트는 단순히 유저 입력을 처리하고 서버에서 그것을 실행하도록 전달하여 이후 서버에서 입력 명령어를 실행하게 되면 다른 오브젝트들을 움직이게 한 뒤에 클라이언트에게 렌더링 할 오브젝트 목록을 다시 전달한다. 물론 실제 시스템에는 더 많은 컴포넌트가 포함되지만 서버의 예측렉 보정과 같은 처리 작업을 이해하기 쉽게 단순화하였다.

이 점을 염두에 두어 전형적인 클라이언트/ 서버 게임 엔진 구조는 아래와 같다:

클라이언트: 유저 입력 데이터 처리, 오브젝트 렌더링
서버: 유저 입력 데이터 실행, 오브젝트를 움직이게 함

여기서는 클라이언트와 서버 간의 연결을 위한 메세지나 조정 작업은 생략된다.
클라이언트의 프레임 루프는 아래와 같다.

  1. 시작 시간을 찾는 처리 작업
  2. 유저 입력 데이터 처리 (마우스, 키보드, 조이스틱)
  3. 이동 명령어를 시뮬레이션 시간에 묶어서 전송
  4. 네트워크 시스템 상에서 서버에서 온 패킷을 읽기
  5. 패킷을 이용해 클라이언트에게 보일 오브젝트와 상태를 정함
  6. 장면을 렌더링
  7. 종료 시간을 찾는 처리 작업
  8. 종료 시간 – 시작 시간을 다음 프레임의 시뮬레이션 시간으로 정함

클라이언트에서 각 루프를 통과할 때마다 다음 프레임에 시뮬레이션 될 시간을 정하는 ‘프레임 시간’이 정해진다. 여기서 프레임 레이트가 안정적인 상태라면 프레임 시간은 일정하고 정확하게 측정될테지만 그렇지 않은 경우에는 그 다음 프레임의 실행 시간을 알 수가 없게 된다. (이에 따로 좋은 해결책이 있진 않다.)

서버에서는 아래와 비슷한 루프를 거친다.

  1. 시작 시간을 찾는 처리 작업
  2. 네트워크에서 클라이언트의 입력 데이터를 읽음
  3. 클라이언트 유저 입력 데이터를 실행
  4. 지난번 루프에서 구한 시뮬레이션 시간으로 서버에서 제어하는 오브젝트들을 시뮬레이션 함
  5. 연결된 클라이언트 하나하나에 그들에게 보일 오브젝트 / 월드 상태를 묶어서 보냄
  6. 종료 시간을 찾는 처리 작업
  7. 종료 시간 – 시작 시간을 다음 프레임의 시뮬레이션 시간으로 정함

이런 구조에서 받은 패킷으로 플레이어 오브젝트가 움직이는 와중에도 NPC 오브젝트는 순전히 서버에서만 돌아가게 된다. 물론 이런 일을 수행하기 위한 방법으로 이 작업들만 해야한다는 말은 아니다.

유저 입력 데이터의 내용

게임 ‘Half-Life’의 유저 입력 데이터는 몇 가지 근본적인 필드로 구성된 꽤 단순한 데이터 구조체로 캡슐화 되어있다.

typedef struct usercmd_s
{
	// 클라이언트에서 보간할 시간
	short		lerp_msec;   
	// 명령어의 지속시간 (ms)
	byte		msec;      
	// 명령어 시야 각도
	vec3_t	viewangles;   
	// 의도된 속도값 (velocity)
	// 전방을 향한 속도값
	float		forwardmove;  
	// 옆을 향한 속도값
	float		sidemove;    
	// 위를 향한 속도값
	float		upmove;   
	// 입력한 버튼들
	unsigned short buttons; 
	//
	// 추가적인 필드는 생략..
	//
} usercmd_t;

위에 나온 필드들은 이 중 특히 들여봐야 할 것인데 우선 msec 필드는 해당 명령어의 시뮬레이션(프레임 시간) 지속시간 (millisecond)이다. viewangle 필드는 해당 프레임에 플레이어가 보고있던 방향을 vector로 표기한 것을 의미하고 나머지 forward, side, upmove 필드는 플레이어가 조작한 이동 조작키를 누른 것에 따라 정해진다. 마지막으로 buttons 필드는 플레이어가 누른 키에 할당된 비트를 세팅한 비트열이다.

이러한 클라이언트 / 서버 구조의 데이터 구조체를 사용할 때, 시뮬레이션의 핵심은 클라이언트가 유저 명령어를 서버에 보내고 그 다음에 서버가 이 명령어를 실행하여 업데이트 된 모든 오브젝트의 위치를 다시 클라이언트에 보내면 다른 오브젝트의 모든 업데이트가 반영된 장면을 렌더하게 된다는 것이다. 단순하지만 인터넷 연결의 지연이 상당히 체감될 수 있는 실제 상황에는 잘 반응되질 않는다.
이 핵심의 문제는 정말 단순히 서버에서 이동 입력을 처리하고 클라이언트가 그 결과를 받는 걸 기다려야 한다는 점에서 생긴다. 만약 클라이언트가 서버와의 연결에서 500ms의 지연을 겪고 있다면 거기서 하는 행동을 서버에서 인지하고 클라이언트에서 다시 결과를 받아 움직이는데 500ms의 시간이 걸린다는 말이다.
이런 왕복에서 생기는 지연은 LAN(근거리 통신망) 환경이라면 용납되겠지만 인터넷에서 그럴 순 없다.

클라이언트-사이드 예측 Client Side Prediction

위의 문제를 개선하기 위한 한 가지 방법으론 서버에서 실행될 클라이언트의 이동 명령어를 클라이언트 상에서 임시로 직접 실행하는 방식이 있는데 이런 기법을 클라이언트-사이드 예측이라 부른다.

클라이언트에서 직접 움직임을 예측하고자 한다면 단순하게 처리하거나 클라이언트에 대한 최소한의 규칙을 져버려야 한다. 이는 중앙 서버가 없는 P2P(peer-to-peer) 게임처럼 클라이언트가 시뮬레이션을 전부 통제하진 않고 여전히 권위적인 서버에서 시뮬레이션을 통제하는 것을 의미하며, 서버의 시뮬레이션이 클라이언트와 차이가 생기더라도 서버에서 클라이언트 측의 틀린 결과를 수정한다. 이 방법의 단점은 연결이 왕복하는 와중의 지연 시간으로 인해 수정이 이뤄지지 않아서 생긴 오차를 고치고자 플레이어의 위치가 눈에 띌만큼 움직여질 수 있다는 것이다.

클라이언트에서 움직임 예측을 구현한다면 일반적으로 클라이언트의 입력이 처리되어 유저 명령어가 생성되면 그것을 서버에 보낸다. 이때, 클라이언트는 사용했던 명령어들을 저장하여 예측 알고리즘에 사용한다.

예측을 위해 움직임 명령어를 시뮬레이트하여 유저 명령어가 실행되고 그 플레이어의 정확한 위치(와 다른 상태 정보)를 다시 클라이언트에 보낸 시점을 서버에서 마지막으로 인정한 움직임으로 시작점 삼게 되는데 이때 연결에 렉 현상이 있었다면 이 명령어가 과거 시점에 존재하게 된다. 예를 들면 50 fps로 구동 중인 클라이언트가 100ms 만큼의 지연시간을 가지고 있었다면 클라이언트에서 5개의 유저 명령어를 서버보다 더 많이 저장하게 된다. 클라이언트상의 예측을 위해 이 5개의 명령어를 시뮬레이트 해본다면 클라이언트에서 서버의 가장 마지막 데이터를 시작점으로 이 5개의 명령어를 ‘서버에서 클라이언트의 움직임을 시뮬레이트 했던 것과 비슷한 작동 방식’으로 실행하여 해당 위치를 렌더하는 현재 프레임 장면을 최종적으로 만들게 된다.

Half-Life에서는 서버와 클라이언트 사이의 차이를 줄이고자 예측에 사용되는 코드를 서버의 게임 코드와 일치시켰다. 이 루틴은 HL SDK내의 pm_shared/ (“player movement shared”의 준말) 에 위치한다. 입력에서 이 루틴에 접근하는 과정은 이전 플레이어의 상태 값인 “from state”와 유저 명렁어로 캡슐화 되어있고 출력은 실행된 유저 명령어로 인한 새로운 플레이어의 상태 값이다. 일반적인 알고리즘은 아래와 같다:

"from state" <- 서버에서 마지막으로 인한 유저 명령어가 실행된 후 플레이어 상태 값

"command" <- 서버에서 마지막으로 유저 명령어를 실행한 후의 첫 명령어 

while (true)
{
	"from state" 상태에서 "command"를 실행하여 "to state"를 생성;
	if (이게 가장 최근의 "command" 였다면)
		break;

	"from state" = "to state";
	"command" = 다음 "command";
};

“to state”는 예측 결과로서 해당 프레임의 렌더에 쓰인다. 여기서 명령어를 실행하는 부분은 단순히 모든 플레이어의 상태 값을 shared(공유) 데이터 구조체에 복사하고 해당 유저 명령어를 실행(Half-Life의 경우라면 pm_shared 루틴으로)하여 생성된 “to state”를 다시 “from state”로 복사한다.

여기엔 몇 가지 중요한 주의사항이 있다. 첫 번째로는 클라이언트의 지연시간과 유저 명령어를 생성하는 속도(즉, 프레임 레이트)에 따라 서버에서 인정하여 아직 인정되지 않았던 명령어의 리스트(Half-Life의 경우, 슬라이딩 윈도우 형식)에서 제거할 때까지 같은 명령어를 계속 실행하게 될 것이다. 여기서 우선 고려해야 할 것은 공유 코드 상의 효과음이나 비주얼 이펙트를 어떻게 처리하느냐이다. 왜냐하면 이전 명령들을 예측 위치를 업데이트하기 위해 계속해서 실행될 수 있기 때문에 발소리 같은 것을 생성하지 않게 하는 것이 중요하다. 또한 이미 클라이언트에서 예측하여 생성된 클라이언트 이펙트를 서버에 보내지 않는 것도 중요하다. 안타깝게도 이렇게 이전 명령들을 계속 보내지 않으면 서버에서 클라이언트의 잘못된 예측을 수정할 방법이 없게 된다. 적어도 이를 위한 쉬운 해결책으로 클라이언트에서 아직 예측하지 않은 명령어를 표시하고 이렇게 표시된 명령어들에 한해서 이펙트를 재생하게 하게 할 순 있다.

또 다른 주의사항으로는 클라이언트의 “to state” 데이터는 서버에 업데이트되지 않는다는 점이다. 이런 데이터 없이도 서버에서 마지막으로 인정된 상태 값을 시작점으로 삼아 예측을 제자리에서 실행하여 최종 상태 값을 얻어낼 수 있으며 (렌더링에 필요한 클라이언트 자신의 좌표까지) 예측을 위해 마지막으로 인정된 상태에서 현재 시간까지 생기는 결과들을 모두 유지해야 할 필요가 없어진다. 그러나 클라이언트쪽에서 어떤 로직을 수행할 때 (앉는 것과 같은 눈의 위치를 바꾸는 로직들을 포함 (이 데이터들도 서버에서 시뮬레이트를 거치므로 완전한 클라이언트-사이드는 아님)) 네트워크 계층 제어로 인해서 서버와 클라이언트 간의 필드 복사가 이뤄지지 않는 경우에는 중간 결과를 저장해야 한다. 이런 경우에는 슬라이딩 윈도우로 해결할 수 있다. “from state”를 시작점으로 예측을 통해 각 명령어들을 거치면서 그 다음 상태 값을 윈도우에 저장한다. 마침내 서버에서 명령어들을 인정했을 때, 인정되었던 명령어들과 클라이언트에서 예측했던 것들을 보며 단순히 해당 데이터를 새로운 시작점 또는 “from state”로 복사할지를 결정하면 된다.

지금까지 위에 적은 절차는 클라이언트의 움직임 예측을 나타낸다. 이 시스템은 QuakeWorld의 시스템과 유사하다.

무기 발사의 클라이언트-사이드 예측

위의 시스템에 따라 무기 발사 이펙트를 계층화하는 과정은 직관적이다. 우선 클라이언트의 로컬 플레이어가 어떤 무기를 가지고 있는지, 그 중 어떤 것이 활성화 되었는지 그리고 그 무기에 탄약이 얼마나 남았는지와 같은 추가적인 상태 정보가 필요하다. 이런 정보와 함께 발사 버튼의 상태가 유저 명령어 데이터 구조체에 포함되어 클라이언트와 서버에 서로 공유 될 수 있으므로 발사 로직은 움직임 로직의 최상단에 계층화 될 수 있다. 물론 무기의 발사 로직이 서버와 클라이언트가 서로 다르다면 복잡해질 수 있다. Half-Life에선 이런 복잡한 경우를 피하고자 플레이어의 움직임 구현처럼 하나의 ‘공유 코드’에 담아냈다. 따라서 무기의 상태에 영향을 끼치는 모든 변수(탄약, 다음 발사 가능 시간, 현재 무기의 애니메이션 등)는 서버에는 일부 또는 전체, 클라이언트에서 전체적인 변수를 담아내 무기 상태의 예측에 사용될 수 있도록 한다.

무기 발사를 예측하는 것으로 무기의 교체, 들기, 집어넣는 것 또한 예측할 수 있다. 이런 방식으로 유저가 게임 내의 움직임, 무기 조작같은 활동을 100% 반응한다고 느끼게 된다. 이는 오늘날의 인터넷 기반 액션 게임 활동에서 많은 플레이어들의 연결 지연시간 체감 최소화에 이바지하였다.

엄, 할게 많네요?

클라이언트에 필요한 필드를 복제하는 것과 중간 상태를 저장하는 것은 그럴 수 있다. 어쩌면 서버에서 처리하지 않고 그냥 클라이언트에서 각 움직임을 처리하고 그 결과를 보내주면 되지 않느냐고 생각할 수도 있다. 물론 클라이언트를 믿을 수 있다면 괜찮다. 수많은 군사 시뮬레이션이 이런 형태로 작동된다. (즉, 서로 가까운 시스템이자 모든 클라이언트를 믿을 수 있음) P2P 게임에서도 일반적으로 이렇게 작동되지만 Half-Life에서는 현실적으로 치트 문제가 있어 그럴 수 없었다. 상태 데이터를 그렇게 캡슐화한다면 클라이언트를 굳이 해킹할 필요도 없이 수정할 수 있게 된다. 이런 게임에서는 그런 리스크가 너무나도 크므로 서버 중심의 처리로 돌아가야 한다.

클라이언트에서 움직임이나 무기 이펙트를 예측하는 시스템은 꽤 먹힌다. 예를 들어 이런 시스템이 쓰이는 Queke3 엔진에서도 (즉시 적중하는 무기같은) 목표물에 대한 처리 과정에 따라 여전히 지연을 느껴야 한다는 것이 문제다. 거의 즉각적으로 소리를 들을 수 있고 위치도 완전히 최신이지만 발사의 결과가 나오기까진 여전히 지연시간이 있다는 것이다. 예를 들면 내가 100ms의 지연시간을 가진 채로 내 시야에서 수직으로 초당 500 unit을 달리는 플레이어를 조준하고 있다고 치자. 즉시 적중되는 무기로 맞추기 위해서는 목표물의 50 unit 만큼 앞의 위치를 조준해야 한다. 지연시간이 길다면 여기서 더 길어진다. Queke3에서는 이를 완화하고자 적중한 것을 확인을 받으면 짧은 텀을 재생하였었는데 이렇게 하여 무기를 빠르게 연사하여 플레이어가 감으로 이 차이를 조정할 수 있도록 하였다.
그렇지만 명백하게도 상대방이 충분한 지연시간 가진 상태에서 활발히 피하고 있다면 일관되고 충분한 피드백을 받는게 꽤 어려워진다. 만약 지연시간이 불규칙적이라면 더욱 힘들어질 수 있다.

타켓의 표시

유저가 게임의 응답성을 인식하는 것에 영향을 미치는 또 다른 중요한 측면은 클라이언트에서 다른 플레이어들을 렌더하는 위치를 결정하는 매커니즘에 달려있다. 이러한 오브젝트가 표시되는 위치를 정하는 가장 기초적인 두 가지의 매커니즘으로 보간보외가 있다.

보외는 서버 상의 다른 플레이어나 오브젝트의 마지막 위치, 방향, 속도로부터 시간에 따라 다소 탄도적인 방식으로 시뮬레이트된다. 따라서 100ms만큼의 렉이 있었고 위에 적은대로 업데이트가 있어서 시야 내의 다른 플레이어가 초당 500 unit만큼 수직으로 움직이고 있다면 클라이언트에서는 그 플레이어가 실시간으로 마지막 위치에서 50 unit에서 움직였다고 추정할 수 있다. 그러면 그냥 그 보외된 위치를 클라이언트에 렌더하도록 하여 로컬 플레이어가 해당 플레이어를 직접 맞출 수 있게 된다.

이런 보외법의 가장 큰 약점은 대부분의 FPS 게임의 플레이어 움직임이 구현을 위해 모델의 물리를 비현실적으로 주어지게 하여 임의의 각도로 큰 가속을 만들 수 있게 한 경우에 그다지 탄도적이지 않아서 비결정적이고 순식간에 엄청 움직이게 되는 특성에 따라 이러한 보외법은 실제 움직임과 꽤 다르게 된다. 만일 이렇게 이 플레이어들이 보외의 에러로 순간이동 하게 된다면 게임 플레이에 어려움을 겪고 말 것이다. 개발진들은 이런 에러를 줄이고자 보외하는 시간에 제한을 두었다. (QuakeWorld의 경우, 100ms의 시간만 보외한다.) 이렇게 제한을 둠으로써 이후에 실제 플레이어 위치를 받게되면 보외의 결과가 실제 위치와 틀렸어도 보정되는 거리가 짧아지기 때문이다. 그렇지만 전세계 대대수의 플레이어가 150ms 이상의 지연시간을 가지고 있는데, 이런 플레이어가 누군가를 맞추기 위해선 여전히 실제 위치보다 앞선 지점에 조준해야 한다.

오브젝트와 플레이어를 표시하는 다른 방법으로는 ‘보간’이 있다. 보간은 움직이는 오브젝트를 서버에서 수신한 과거의 위치를 관계지어 본다. 예를 들면 서버에서 게임의 상태 정보를 매 초마다 정확히 10번씩 한다면 보간의 수행을 위해 100ms의 렌더 지연시간을 가지게 되고 다음 프레임의 렌더가 되는 때에 오브젝트의 이전 100ms 전의 위치와 마지막으로 업데이트 된 위치 사이의 값을 보간한다. 서버에서 업데이트를 받을 때(위의 경우에는 매초 10번의 업데이트를 받아 100ms 마다 들어온다)에 그저 오브젝트 업데이트 정보를 위치로만 받게 되지만 그 다음 100ms간의 위치를 따라 움직일 수 있게 된다.

여기서 업데이트 패킷이 손실되는 경우에 2가지 선택지가 있다: 위에 적었던대로 (잠재적인 에러가 있는) 보외를 하거나 해당 플레이어의 위치를 다음 업데이트를 받을 때까지 이전 위치에 그대로 머물게 할 수 있다. (이는 움직임이 버벅일 수 있다.)

이런 유형의 기본적인 알고리즘은 다음과 같다:

  1. 서버의 각 업데이트는 생성된 시점에서의 타임스탬프를 포함
  2. 클라이언트의 현재 시간까지, 보간 시간만큼 해당 시간을 차감한 시간을 ‘대상 시간’으로 정한다.
  3. 만약 대상 시간이 마지막 업데이트와 그 전 업데이트 사이에 있었다면 해당 경과된 시간 비율을 결정한다.
  4. 이 비율을 보간에 사용한다. (즉, 위치나 각도)

본질적으로 보간에 대해서 위의 예제처럼 생각해볼 수 있다. 따라서 다른 플레이어들도 과거에 있던 지점에서 클라이언트의 지연시간과 보간을 거친 시간을 합한 지점에 렌더할 수 있게 된다. 가끔 생기는 패킷 손실에 대해서는 보간 시간을 그저 100ms에서 200ms로 늘려볼 수 있다. 이건 (서버에서 초당 10개의 업데이트를 받으니) 아예 하나의 업데이트를 잃게 되어도 플레이어가 유효한 위치로 보간하여 홱 움직여버리는 현상 없이 움직일 수 있게 된다. 물론 보간 시간이 늘어난다는 것은 시각적으로 부드러울 수는 있어도 추가적인 지연시간이 늘어나버린다는 것을 의미한다. (보간된 플레이어를 맞추는 것이 어려워진다.)

또한 이런 유형의 보간은 (클라이언트가 마지막 두 업데이트를 추적하여 다음 업데이트을 향해 바로 이동하는 것) 서버 업데이트 사이에 고정된 시간 간격이 필요하다. 또한 시각적인 품질에 문제가 생길 수 있다. 예를 들어서 (플레이어와 비슷한 경우로) 통통 튕기는 공을 보간했을 때는 극단적으로 공중에 떠오르거나 지면에 박히는 경우가 있을 것이다. 평소라면 공이 이 사이에 있겠지만 만약에 가장 마지막 위치만을 보간한다면 이 움직임을 ‘단조롭게’ 만들어서 애초에 충돌이 없었던 것처럼 땅에 있거나 높은 위치에 있지 않을 가능성이 매우 높다. 이는 처리 과정의 고질적인 문제로 인한 것이며 월드 상태 정보를 더 자주 처리하는 것으로 완화할 수 있다. 그래도 여전히 움직임을 단조롭게 되어 땅이나 높은 지점에 위치하지 하지 못할 가능성이 크다.

또한 매 초마다 10번의 업데이트를 하도록 강제하는 것은 유저에게 불필요한 공통요소가 생기게 한다. Half-Life에서는 유저에게 이런 업데이트가 얼마나 일어나게 할 것인지 설정할 수 있도록 하였다. (한도 내에서) 따라서 빠른 연결을 가진 유저라면 매 초마다 50번의 업데이트를 받을 수 있다. 기본적으로 서버에서 20번의 업데이트를 보내고 클라이언트는 100ms의 시간동안 각 플레이어(또는 많은 오브젝트들을)들을 보간한다.

튕기는 공의 움직임이 단조로워지는 문제를 우회하기 위해서는 보간에 다른 알고리즘을 사용해야 한다. 이 방법에서는 각 오브젝트에 ‘위치 기록’을 더욱 세세하게 남겨 보간할 수 있도록 한다.

이 위치 기록은 오브젝트의 타임스탬프와 위치, 각도(또는 보간에 필요한 다른 데이터)를 뜻한다. 서버에서 각 업데이트를 받을 때, 해당 타임스탬프, 위치와 각도를 가진 새로운 위치 기록 항목을 생성한다. 이를 보간하기 위해서 위치 기록에서 ‘대상 시간’을 산출하지만 이번에는 위치 기록에서 역순으로 대상 시간에 걸쳐 있는 업데이트 쌍을 찾는다. 그러면 이제 해당 프레임에서 사용될 최종 위치를 산출해내어 사용할 수 있게 된다. 이렇게 하면 처리된 지점들이 모두 곡선에 부드럽게 따를 수 있게 된다. 만약 클라이언트에 다가오는 업데이트 처리비율보다 프레임이 높다면 위에서 언급한 단조롭게 움직이는 문제를 최소화하여 처리된 지점을 통해 부드럽게 움직일 수 있다. (당연하게도 월드 업데이트 자체의 순수한 처리 속도로 인해 아예 없어지진 않는다.)

이제 보간에 남은 고려사항은 오브젝트가 억지로 텔레포트되는 것의 결정을 계층화하는 것이다. 이동되는 거리가 길다면 물체를 부드럽게 움직이고자 너무 빨리 움직인 걸로 보이게 할 수 있다. 여기서 업데이트에 ‘보간 하지 않음’ 또는 ‘위치 기록을 지우기’ 플래그를 넣거나 업데이트 상의 위치와 기록 상의 위치가 너무 멀다면 텔레포트나 워프했다고 추정하게 할 수 있다. 아마도 이 경우에는 그냥 마지막 위치에서 보간하여 움직여서 해결할 수도 있다.

렉 보정

보간 또한 유저 경험에서 또 다른 지연이 되므로 이해하는 것이 중요하다. 플레이어가 보간된 다른 오브젝트 보고 있는 만큼, 보간된 정도에 따라 서버에서 플레이어의 조준을 계산한다는 것을 고려해야 한다.

렉 보정은 각 플레이어가 각자의 유저 명령어들을 실행한 후의 월드 상태를 정규화하는 방법이다. 유저가 어떤 동작을 했던 그 시점으로 서버의 시간을 뒤로 돌린다고 생각할 수 있다. 알고리즘은 아래와 같다.

  1. 플레이어의 현재 유저 명령어를 실행하기 전에, 서버에서는:
    1. 플레이어의 지연시간을 산출
    2. (현재 플레이어) 서버의 월드 업데이트 기록에서 플레이어에게 보내거나 플레이어가 움직이는 명령어를 보낸 때를 찾는다.
    3. 그 업데이트에서 (그 시점의 정확한 시간을 기준으로) 각 플레이어를 그 시점의 위치로 옮긴다. 이때, 지연시간과 클라이언트가 해당 프레임에 사용했던 보간을 모두 고려해야 한다.
  2. 유저 명령어가 실행되도록 허용한다. (무기의 발사와 같이 다른 플레이어의 ‘이전’ 위치를 레이 캐스트한다.)
  3. 아까 움직여뒀던 모든 플레이어를 다시 현재 위치로 되돌린다.

참고로 플레이어의 위치를 되돌렸을 때에는 별도로 추가적인 정보가 필요할 수 있다. (예를 들면, 그 당시의 생존이나 앉아있었는지의 여부 등). 그리고 렉 보정이 끝난 결과는 각 로컬 플레이어가 다른 플레이어를 아무런 문제 없이 직접 조준할 수 있어야 한다. 물론 이건 게임 디자인적으로 협상해야 한다.

렉 보정의 게임 디자인적 의미

이런 렉 보정이 각 플레이어의 시간대에서 지연시간이 명백히 없어지도록 하지만 어떤 모순이나 불일치 현상이 생길 수 있는 것 또한 염두에 둬야 한다. 물론, 서버에 단순한 클라이언트가 있는 기존 시스템에서도 자체적인 모순이 있었지만 결국에는 게임 디자인 영역에서 협상해야 했다. Half-Life를 개발할 때에도 이런 렉 보정이 게임 디자인면에서 정당하다고 판단하였다.

기존 시스템의 첫 번째 문제점은 플레이어가 조준해야 하는 목표에 어느 정도의 지연시간으로 인한 영향이 생길 수 있었다는 것이다. 따라서 다른 플레이어를 직접 조준하고 발사 버튼을 누르는 게 거의 놓치게 되는데, 이런 불일치는 플레이어가 체감이 잘 되지 않을뿐더러 조작에 예측할 수 없는 민감함을 가지게 한다.

렉 보정이 있다면 이런 불일치 현상은 해소되어 대부분의 플레이어가 조준하는 실력이 충족하다면 능숙해지게 된다. 렉 보정은 이렇게 플레이어가 (즉시 피격하는 무기들로) 목표에 향해 직접 조준하고 발사할 수 있도록 한다. 대신에 아까와는 다르게 발사하는 플레이어 관점에서 가끔씩 불일치 현상이 생기게 된다.

예를 들어, 렉이 엄청 걸리는 플레이어가 그나마 덜 렉 걸리는 플레이어를 쏜다면 덜 렉 걸리는 플레이어쪽에서 ‘총알이 코너를 돌아온 것 같이’ 보일 수 있다. 극단적인 예시로 렉이 덜 걸린 쪽에서 코너 안에 숨었지만 렉이 엄청 걸리는 유저에게는 아직 숨기 전의 시점으로 보여 아직 직접적으로 보일 수 있기 때문에 생길 수 있었던 것이다. 코너를 돌아 상자 뒤쪽에 숨어 앉기까지 했어도 극단적으로 500ms 정도의 지연이 있는 플레이어의 유저 명령어가 서버에 도착하면 숨었던 사람을 그 시점으로 되돌리게 되어 이런 상황이 생길 가능성이 꽤 생기게 된다. 우리는 이 문제에 게임 디자인 관점에서 그냥 이렇게 각자의 월드와 무기에서 완벽하게 반응하도록 하는 것으로 결정하였었다.

게다가 보통의 전투 상황이라면 명백하게 이런 불일치 현상이 막 발생되진 않는다. 1인칭 슈팅 게임에는 2가지의 전형적인 경우가 있는데 첫 번째로는 두 플레이어가 전방을 향해 달리면서 발사버튼은 누른 경우다. 이 경우에는 렉 보정이 플레이어의 움직임을 뒤로 돌릴 가능성이 크다. 피격당한 사람은 ‘총알이 벽을 돌은 것 같은’ 느낌을 받지 않을 것이다.

그 다음의 경우로는 두 플레이어가 있는 상황에 한 플레이어가 조준하고 있고 다른 한쪽이 전방에서 수직으로 떨어지고 있는 경우다. 이런 경우에는 전적으로 다른 이유로 모순 현상이 최소화된다. 플레이어의 시야의 범위는 90도거나 그보다 작으므로 조준하는 플레이어의 시야를 가로지르며 도망가고 있는 플레이어는 누가 자신을 조준하는지 볼 수 없어서 (트인 공간에서 미친듯이 뛰어다녔다면 당연하게도) 피격당하는 것이 딱히 불합리하다고 생각되지 않을 것이다. 물론 플레이어가 한 방향으로 달리면서 다른 방향을 볼 수 있는 탱크 게임이라면 이는 덜 깔끔해진다.

결론

렉 보정은 오늘날 액션 게임의 지연시간 효과를 개선하는 도구다. 판정이 게임의 느낌을 좌우하므로 이런 시스템의 구현 여부는 게임 디자이너에게 달려있다. Half-Life와 Team Fortress 그리고 Counter-Strike의 이런 렉 보정은 그 해택이 위에서 언급한 불일치 현상을 가볍게 능가하였다.

각주

  1. Half-Life 엔진에서는 클라이언트-사이드 예측 알고리즘 수행을 위해 지연시간의 일부를 요청할 수 있다. 유저는 엔진에 콘솔 명령어 ‘pushlatency’ 수치를 조정하여 예측을 수행할 최대 ms를 변경할 수 있다. (음수) 수치가 유저의 현재 지연시간보다 높다면 현재 시간까지의 전체 예측이 수행된다. 이 경우에는 유저는 자신의 움직임에 지연이 없는 것으로 느껴게 된다. 커뮤니티의 잘못된 미신으로 많은 유저들이 pushlatency를 자신의 평균 지연시간의 절반으로 설정하는 것이 적당한 설정으로 주장한다. 물론 이것도 여전히 플레이어 움직임을 지연시간의 절반만큼 (아이스 스케이트를 타고 돌아다니는 것처럼) 지연시킨다. 이런 혼동은 전체 예측은 항상 해야하며 ‘pushlatency’ 변수를 엔진에서 제거해야 된다는 오해를 낳는다.
  2. http://www.quakeforge.net/files/q1source.zip
  3. 부정행위와 이에 방지하기 위해 개발자가 할 수 있는 일은 이 문서의 범위에 벗어난다.
  4. 이 외에도 이 둘을 적절하게 섞거나 교정하는 방법도 가능하다.
  5. “엄청나게 많이 움직이는” 경우는 가속력이 변하는 빠르기를 의미한다.
  6. 여기서 클라이언트의 시간은 서버의 시간과 연결 지연시간을 나머지 연산한 값을 직접적으로 동기화한다. (?) 다른 말로는 서버에서 각 업데이트를 클라이언트에 보낼 때 서버의 시간을 보내고 클라이언트에서는 그 시간을 채택한다. 따라서 서버와 클라이언트의 시간이 일치되고 클라이언트가 과거의 어느 시점에서 행동할 수 있게 된다. (클라이언트의 현재 지연시간만큼 과거로 간다.) 클라이언트 시점의 불일치 현상은 여러가지 방식으로 완화될 수 있다.
  7. 이때 업데이트의 시간 간격은 굳이 고정할 필요는 없다. (특히 인터넷 연결이 느린 유저에게) 바쁘게 움직이는 시간동안 게임에서 연결 상태보다 더 많은 데이터를 보낼 수 있기 때문이다. 여기서 고정된 시간 간격을 사용했다면 다음 간격을 기다리는 동안 다음 패킷을 클라이언트에 보낼 수 없어 대역폭을 효율적으로 사용할 수 없다. 대신에 서버에서 플레이어에게 패킷을 보낼 때마다 다음 패킷을 언제 보낼 수 있는지 유저의 대역폭이나 “rate” 설정 그리고 초마다 업데이트를 요청하는 횟수에 따라 정한다. 만약 유저가 매 초마다 20개의 업데이트를 요청했다면 다음 패킷을 전송하는게 적어도 50ms가 걸린다. 대역폭 제한에 걸린 경우에는 (그리고 서버가 높은 프레임레이트를 가진 경우) 61 정도 될 수 있다. 따라서 Half-Life의 패킷은 좀 임의적으로 간격을 가진다. 이런 최신 목표 보간 방식에서의 단순한 이동은 위치 이력 보간에서는 (이전 이동 기준점이 가변적이 되므로) 잘 작동되지 않는다. (아래에서 설명)
  8. 이전에 Half-Life에서 위에 설명한 usercmd_t 구조체의 lerp_msec 필드를 인코딩 함
  9. 투사체를 발사하는 무기의 렉 보정은 더욱 문제가 생긴다. 서버에서 자율적으로 존재하는 투사체는 어느 시간대에 있어야 하는가? 다른 플레이어들도 서버의 시뮬레이트에 이 투사체들을 뒤로 돌려야하는가? 만약 그렇다면 다른 플레이어들은 어떻게 뒤로 돌려야 하는가? 이와 같은 흥미로운 고려사항들에 Half-Life에서는 이를 피하기로 하여 투사체에 렉 보정을 하지 않는다. (클라이언트의 무기 발사 소리는 여전히 보정하며 그저 투사체만을 의미한다.)
  10. 이게 우리 유저 커뮤니티에서 불일치 현상을 뜻하는 구절이다.
, ,

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다