Skip to content

빠른 진행 멀티플레이어

Note

This is translated work. The original post is written by 'Gabriel Gambetta', the link is here.

The translation was permitted on 2020/12/06. This is a combined long HTML version of each series.

Note

이 문서는 번역본입니다. 원본은 'Gabriel Gambetta'가 쓴 것으로, 링크를 통해 확인할 수 있습니다.

번역은 2020/12/06에 허가되었습니다. 이 문서는 각 시리즈를 하나의 HTML 파일로 편집한 것입니다.

파트 1: 클라이언트-서버 게임 아키텍쳐

소개

이 문서는 빠른 진행 멀티플레이어 게임을 만드는 것을 가능케 할 기술과 알고리즘을 소개하는 연재의 첫 번째 문서입니다. 만약 멀티플레이어 게임을 위한 컨셉들에 익숙하다면, 이 파트는 스킵하셔도 문제가 없을 것입니다. 이어지는 것은 소개를 위한 논의입니다.

어떤 종류의 게임을 개발하든 그것은 어렵습니다; 그러나, 멀티플레이어 게임은 완전히 새로운 차원의 문제들을 추가로 해결해야 합니다. 흥미롭게도, 핵심 문제들은 인간 본성과 물리학이죠!

부정 행위(cheating) 문제

부정 행위와 함께 모든 것은 시작됩니다.

게임 개발자로서 보통 싱글-플레이어 게임에서의 부정 행위는 신경 쓰지 않죠. 그가 취하는 행동이 스스로에게 영향을 줄 뿐이고 타인에게는 영향이 없기 때문입니다. 부정 행위자는 개발자가 설계한대로 게임을 경험하지 않을지 모르지만 부정 행위자 스스로의 게임인만큼 각자 원하는 방식으로 즐길 권리가 있죠.

하지만, 멀티 플레이어 게임은 다릅니다. 그 어떤 경쟁적 게임에서든, 부정 행위자는 스스로의 경험만을 더 낫게 만드는 것을 넘어서 타인의 경험을 더 나쁘게 만듭니다. 개발자로서, 그런 부정행위가 다른 유저를 떠나게 만들기 때문에 이런 상황을 피하기를 원할 것입니다.

부정행위를 막기 위한 수많은 방법이 있겠으나, 가장 중요한 방법(그리고 아마도 가장 의미있는 것)은 간단합니다: 플레이어를 신뢰하지 마세요. 항상 최악을 상정하세요 - 플레이어는 부정 행위를 시도할 것이라고 말이죠.

권한 있는 서버(authoritative servers)와 의존 클라이언트(dumb client)

이것은 어느 정도 간단한 해결책을 제시합니다 - 게임 내 모든 것은 중앙 서버에서 개발자 통제 아래 이루어지도록 만들고 클라이언트는 단지 승인 받은 게임 관찰자로 설계하는 것이죠. 다시 말해서, 게임 클라이언트가 인풋(눌린 키 값, 커맨드)을 서버로 보내고, 서버가 게임을 진행시킨 뒤, 그 결과를 클라이언트에게 재전송합니다. 일반적으로 이것을 권한 있는 서버라고 부릅니다. 왜냐하면 모든 일이 일어나는 것에 대한 단 하나의 권한을 서버가 쥐고 있기 때문이죠.

물론, 서버가 위험에 노출되어 무용지물이 될 수도 있겠지만 그 부분은 이 글의 범주를 벗어납니다. 그렇지만 권한 있는 서버를 활용하더라도 넓은 범위의 해킹을 예방할 수 있습니다. 예를 들어, 플레이어 체력치에 대해 클라이언트를 신뢰하지 않는다고 봅시다; 해킹된 클라이언트는 해당 값의 로컬 카피를 수정하여 플레이어가 10000%의 체력을 가지고 있다고 할지 모르지만 서버는 오직 10%만 남았음을 알고 있습니다 - 해킹된 클라이언트가 뭐라고 생각하든지 관계없이 플레이어는 공격당할 경우 사망할 것입니다.

게임 세계에서 플레이어 위치와 관련해서도 당신은 플레이어를 신뢰하지 않습니다. 만약 신뢰한다면 해킹된 클라이언트는 서버에게, 아마도 벽을 뚫고 지나가거나 다른 플레이어보다 빠른 이동 속도를 통해 "나는 (10,10)에 있어"와 잠시 후 "나는 (20,10)에 있어"를 전송할 것입니다. 대신에, 서버가 플레이어의 위치 (10,10)을 알고 있다면, 클라이언트는 서버에게 "나는 한 칸 우측으로 이동하고 싶어"를 전송할 것이고, 서버는 내부 상태를 플레이어 위치 (11,10)으로 업데이트 후 플레이어에게 "너는 (11,10)에 있어"를 전송할 것입니다:

간단한 클라이언트-서버 상호작용

요약하자면: 게임 상태는 서버 홀로 관리합니다. 클라이언트는 그들의 행동을 서버에 전송합니다. 서버는 주기적으로 게임 상태를 업데이트하고 새로운 게임 상태를 클라이언트에게 회신합니다. 클라이언트는 그 회신을 단순히 화면에 새로 그려낼 뿐입니다.

네트워크 처리

의존적 클라이언트 계획은, 예를 들어 전략 게임 혹은 포커와 같이 느린 턴 베이스 게임에서는 무난히 작동합니다. 또한 모든 실용적인 목적 하에서 커뮤니케이션이 즉각적으로 이루어지는 랜 세팅에서도 작동합니다. 하지만 인터넷 등의 네트워크에서 이루어지는 빠른 진행 게임에서는 제대로 작동하지 않습니다.

물리 얘기를 해봅시다. 당신이 샌프란시스코에 있다고 가정하고 뉴욕의 서버에 연결되었다고 합시다. 대략 4,000km 혹은 2,500 마일 거리입니다(또한 대강 리스본부터 모스코바의 거리이기도 하죠. [역자주: 서울에서 네팔의 히말라야 거리]). 그 무엇도 빛보다 빠를 순 없고, 인터넷상의 바이트 정보도 마찬가지입니다(원론적으로 바이트도 빛의 진동, 케이블의 전자, 전자기장 파동이지만 말이죠). 빛은 대략적으로 초당 300,000km를 이동하므로 4,000km를 이동하려면 13ms가 소요됩니다.

이는 꽤 빠르다고 느껴질지 모르지만, 굉장히 낙관적인 설정이기도 합니다 - 설정은 데이터가 빛의 속도로 직선 거리를 이동한다고 가정하는 것이지만 실제로는 거의 그렇지 않죠. 실상은 데이터가 라우터와 라우터 사이마다 연속적인 점프(네트워크 용어로는 hops라고 하는)를 거치고 그 점프는 대부분 빛의 속도로 이루어지지 않습니다; 라우터는 잠깐의 딜레이를 수반하는데 이는 패킷을 복사, 검사하고 재전송하는데 소요되죠.

논의를 위해 클라이언트로부터 서버까지 데이터 전송에 50ms가 소요된다고 합시다. 이것은 최선의 시나리오오ㅔ 불과합니다 - 만약 뉴욕에 있는데 도쿄 서버에 연결된다면 얼마나 소요될까요? 모종의 이유로 네트워크 혼잡이 발생한다면 어떨까요? 100ms, 200ms 혹은 심지어 500ms의 지연은 생소한 일이 아닙니다.

우리의 예시로 돌아가서, 클라이언트가 어떤 인풋 ("오른쪽 방향키를 눌렀어")를 서버로 전송했다고 합시다. 서버는 이것을 50ms 후에 받게 됩니다. 서버가 이 정보를 즉각적으로 처리하고 업데이트된 상태 전송도 순식간에 처리한다고 합시다. 그럼 클라이언트는 새로운 게임 상태("플레이어는 현재 (1,0)에 있음")을 50ms 후에 받게 되는 것이죠.

플레이어 관점에서, 오른쪽 방향키를 누르고도 1/10초 동안 아무일이 없다면 무슨 일이 벌어질까요?; 그리고 나서 갑자기 한 칸 우측으로 캐릭터가 이동합니다. 이런 인풋과 결과값 사이의 인지된 랙(lag)은 대단한 것으로 여겨지지 않을지 모르지만 주목할만합니다. - 그리고 물론, 이 랙이 0.5초 수준이 된다면 주목할만한 수준을 넘어서 게임을 플레이할 수 없을 지경에 이르게 됩니다.

요약

네트워크로 이루어지는 멀티플레이어 게임은 큰 재미를 주지만 완전히 새로운 수준의 도전 과제들을 안겨 주었습니다. 권한 있는 서버 아키텍쳐는 대부분의 부정 행위를 멈추는데 꽤 도움이 되지만 있는 그대로 실현하게될 경우 플레이어들에게 반응성이 떨어지는 게임을 만들 수 있습니다.

이어지는 내용에서 로컬이나 싱글 플레이 게임과 구분이 불가능할 수준까지 플레이어가 경험하는 지연을 최소화하는 시스템을 어떻게 권한 있는 서버 모델을 통해 구현할 수 있을지 살펴보겠습니다.

파트 2: 클라이언트 측 예측과 서버 측 조정

소개

파트 1 에서, 권한 있는 서버와 단지 인풋을 서버에 전달하고 회신받은 업데이트된 게임 상태를 그려낼 뿐인 의존 클라이언트 모델을 살펴보았습니다.

이 모델을 순진하게 있는 그대로 도입할 경우 유저 커맨드와 화면상 변화 사이에 지연이 생깁니다; 예를 들어, 플레이어가 오른쪽 방향키를 누를 경우, 캐릭터가 움직이기 시작하기까지 0.5초가 소요되는 등 말이죠. 이것은 클라이언트가 인풋을 반드시 서버로 먼저 전송시키고, 서버는 인풋을 전달받아 새로운 게임 상태를 업데이트한 후, 그 업데이트된 상태가 클리이언트에 재전달되어야만 하기 때문에 발생합니다.

네트워크 지연의 효과

인터넷과 같은 네트워크 환경에서는 지연이 1/10초 수준에서 생겨날 수 있고, 이는 최선의 경우 게임의 반응성이 떨어지는 것처럼 느껴지고, 최악의 경우는 플레이하지 못하는 수준이 될 수도 있습니다. 이번 파트에서는 해당 문제를 최소화하거나 심지어 제거할 수 있는 방법을 찾아보겠습니다.

클라이언트 측 예측

일부 부정 행위 플레이어가 존재하긴 하지만 대부분의 경우 게임 서버는 유효한 요청을 처리합니다 (비 부정행위 클라이언트로부터의 요청이나 혹은 그 시점에는 부정 행위를 하고 있지 않은 부정 행위 클라이언트의 요청). 이는 대부분의 요청받은 인풋값은 유효하고 게임 상태도 예상되는대로 업데이트될 것임을 의미합니다; 캐릭터가 (10,10)에 있고 오른쪽 방향키가 입력된 경우, 결국에는 (11,10)으로 이동하게 될 겁니다.

이것을 우리에게 유리하게 사용할 수 있습니다. 게임 세계가 충분히 결정론적(deterministic)이라면 말이죠(말하자면, 현재의 게임 상태와 입력이 가능한 모든 인풋 집합이 주어진 경우, 그 결과가 완벽히 예측가능한 상태입니다).

우리가 100ms의 랙을 가지고 있고 현재 칸에서 다음 칸으로의 캐릭터 이동 애니메이션이 100ms 걸린다고 합시다. 있는 그대로 설계할 경우, 모든 움직이에는 총 200ms가 소요됩니다:

네트워크 지연 + 애니메이션

게임 세계가 결정론적이라 가정했기 때문에, 서버로 전송한 인풋이 성공적으로 실행될 것임을 예상할 수 있습니다. 이 예측 하에서, 클라이언트는 인풋 처리 후 업데이트될 게임 상태를 미리 예측할 수 있고 대부분의 경우 이는 들어맞습니다.

인풋을 전송하고 결과를 그려내기 전에 업데이트된 새로운 게임 상태를 기다리기보다, 인풋을 전송함과 동시에, "참"인 게임 상태 결과를 서버로부터 회신받기 전까지, 참일 경우에 해당하는 결과값을 그려낼 수 있습니다. 그리고 대부분의 경우에는 그 예측값이 참일 뿐 아니라, 내부에서 게산한 게임 상태와 동일하겠죠.

서버가 액션을 확인함과 동시에 에니메이션이 재생됨

이를 통해 서버는 여전히 권한 있는 서버이면서도 플레이어와 액션과 화면의 결과값 사이에 지연율을 완벽히 제거해낼 수 있습니다. (만일 해킹된 클라이언트가 무효한 인풋을 전송하더라도, 그 클라이언트가 원하는대로 화면은 그려질지 모르나, 서버의 게임 상태에서는 효과가 없으므로, 무효한 상태로 다른 플레이어에게 보여집니다.)

동기화 문제

위의 예시에서, 저는 모든 것이 작동함에 문제가 없도록 수치를 섬세하게 선택했습니다. 그러나, 약간 수정된 상황을 고려하죠: 서버로 250ms의 랙이 존재하고, 칸 이동에 100ms이 소요된다고 합시다. 여기에 더해, 플레이어가 오른쪽 방향키를 2번 연속으로 눌러, 우측 칸으로 2칸 이동을 시도한다고 합시다.

지금까지의 기술을 사용하면, 아래와 같은 일이 발생합니다:

예측 상태와 권한 서버 상태의 불일치

새로운 게임 상태가 회신될 때, t = 250ms인 경우에는 아주 흥미로운 문제를 마주합니다. 클라이언트의 예측 상태는 x = 12인데 반해, 서버는 새로운 게임 상태가 x = 11라고 하죠. 서버에게 권한이 있기 때문에, 클라이언트는 캐릭터를 한 칸 좌측인 x = 11로 되돌려야 합니다. 그러나 그 때, 새로운 서버 상태가 t = 350에 회신되고, x = 12라고 주장합니다. 따라서 캐릭터는 다시 이동을 하고, 이번에는 우측으로 되돌아갑니다.

플레이어 입장에서 보면, 오른쪽 방향키를 두 번 눌렀더니; 캐릭터가 우측으로 [정상적으로] 두 칸 움직이고, 50ms 동안 머물렀다가, 한 칸 좌측으로 이동하더니, 100ms 머무르고, 다시 우측으로 한 칸 움직입니다. 당연히 이건 받아들일 수 없습니다.

서버 측 조정

이 문제를 해결하는 열쇠는 클라이언트가 현재 시점을 바라보고 있으나 랙으로 인해 서버로부터 회신되는 게임 상태는 사실상 과거의 상태라는 점을 깨닫는데 있습니다. 서버가 업데이트된 게임 상태를 회신할 때는 클라이언트로부터 전송된 모든 커맨드를 처리한 것은 아니라는 것이죠.

하지만, 이 문제를 해결하는 것은 엄청 난해하지는 않습니다. 먼저, 클라이언트는 각각의 요청에 연속 숫자를 부여합니다; 예를 들어, 첫 키 입력에는 요청 #1을 부여하고 두 번째 키 입력은 요청 #2를 부여합니다. 그러면, 서버가 회신할 때, 마지막으로 처리된 인풋의 요청 번호를 포함할 수 있습니다.

클라이언트 측 예측 + 서버 측 조정

이제, t = 250에, 서버는 "요청 #1에 따라, 위치는 x =11이다"라고 할 수 있게 됩니다. 서버에게 권한이 있기 때문에, 캐릭터 위치를 x = 11로 설정합니다. 자, 클라이언트가 서버로 전송한 요청의 복사본들을 가지고 있다고 해보죠. 새로 회신된 게임 상태에 따르면, 클라이언트는 서버가 요청 #1을 이미 처리한 것임을 알 수 있으므로 해당하는 복사본은 삭제할 수 있습니다. 하지만 클라이언트는 서버가 요청 #2의 처리값을 회신해야함도 알 수 있게 됩니다. 따라서 클라이언트 측 예측 모델을 다시 적용하면, 클라이언트는 권한 있는 서버가 마지막으로 회신한 게임 상태에 기반하고, 추가로 아직 서버가 처리해주지 않은 인풋을 고려하여 "현재" 게임의 상태를 계산할 수 있습니다.

그러므로, t = 250에, 클라이언트는 "마지막 처리된 요청 = #1, 현재 위치 x = 11"를 얻게 됩니다. 클라이언트는 전송된 입력값 요청 #1까지의 복사본을 삭제하지만 #2의 복사본은 유지합니다. 이는 서버로부터 아직 승인되지 않았기 때문입니다. 클라이언트는 서버 회신 게임 상태인 x = 11로 업데이트한 후 서버로부터 아직 회신되지 않은 모든 인풋을 적용합니다 - 이 경우는, 인풋 #2이고 "오른쪽으로 이동"입니다. 따라서, 결과값은 x = 12이며 맞는 결과값이기도 합니다.

우리의 예시를 계속 살펴보면, t = 350이 되서야 서버로부터 새로운 게임 상태가 회신됩니다. 이번에는 "마지막 처리된 요청 = #2, 현재 위치 x = 12"가 회신되죠. 이 시점에, 클라이언트는 #2까지의 모든 인풋 복사본을 삭제하고 게임 상태는 x = 12로 업데이트합니다. 더 이상 미처리된 인풋이 없음을 확인하고, 올바른 결과값과 함께 처리는 종료됩니다.

사소한 세부사항

논의한 예시들은 이동 상황을 예로 들지만 동일한 원칙은 다른 대부분의 경우에도 적용될 수 있습니다. 예를 들어, 턴 베이스 전투 게임에서는 플레이어가 다른 캐릭터를 공격하는 경우에 적용되는 데미지값을 보이는 수치와 체력치를 보일 수 있으나 서버가 허락하기 전까지 캐릭터의 체력 상황을 실제로 업데이트해서는 안 될 것입니다.

항상 쉽게 되돌릴 수는 없다는 게임 상황의 복잡도 때문에, 아무리 클라이언트 게임 상태에서 체력 수치가 0 이하로 내려간 캐릭터라도 서버가 허락하기 전까지는 해당 캐릭터를 죽이는 것을 피하고자 할 것입니다(만일 공격 당한 캐릭터가 마지막 일격을 당하기 전에 응급상자 아이템을 사용했으나 서버가 아직 공격자 클라이언트에 해당 상태를 전하지 않았다면 어떤 일이 발생할까요?).

이 것은 흥미로운 점을 시사합니다 - 아무리 게임 세계가 결정론적이고 그 어떤 클라이언트도 부정 행위를 하지 않는다고 가정하더라도 클라이언트 예측 상태와 서버가 회신한 상태가 조정 후에도 일치하지 않을 가능성이 존재한다는 점입니다. 이 경우는 이전에 언급한 것처럼 싱글 플레이어인 경우 불가능하지만, 서버에 한번에 다수가 접속하는 게임의 경우 발생하기 쉽습니다. 이 문제가 다음 파트의 주제입니다.

요약

권한 있는 서버 모델을 사용하는 경우 사용자에게 반응성이 있는 것과 같은 착각을 제공해야 합니다. 실제로는 서버가 실제로 인풋을 처리한 결과값을 기다리는 중이더라도 말이죠. 이를 위해서, 클라이언트는 인풋 결과값을 예측해야 합니다. 업데이트된 서버 상태가 도착하면 클라이언트는 서버가 업데이트해준 상태와 아직 승인되지 않은 요청등을 모두 고려하여 상태를 재산출해야 합니다.

파트 3: 객체 삽입(entity interpolation)

소개

앞선 파트들에서 권한 있는 서버 개념과 부정 행위 방지를 위한 유용성을 보였습니다. 그러나, 이를 있는 그대로 구현하는 경우 플레이성과 반응성 측면에서 사용이 불가능한 수준의 버그를 만들어낼 수 있습니다. 파트 2 에서는 클라이언트 측 예측을 이러한 한계 보완을 위한 방법으로써 제시하였습니다.

이 두 파트의 결과는 전송 지연이 있는 인터넷 연결 하에서도 권한 있는 서버를 활용해 마치 싱글 플레이어 게임을 하는 것과 동일한 느낌을 받을 수 있는 캐릭터 움직임 통제를 가능케 할 개념과 기술이었습니다.

이번 파트에서는, 동일 서버에 다른 플레이어의 통제를 받는 캐릭터가 있는 경우의 결과를 살펴보고자 합니다.

서버 시간 단계(server time step)

이전 파트들에서 서버들의 행동은 상당히 단순했습니다 - 클라이언트 인풋을 읽고 게임 상태를 업데이트 하고 클라이언트에게 회신하였죠. 그러나 하나 이상의 클라이언트가 연결되는 경우, 메인 서버 루프는 다소 달라집니다.

이 경우, 다수의 클라이언트는 인풋을 동시에 그리고 매우 빠른 속도로 전송할지도 모릅니다(플레이어가 커맨드를 입력하는 속도 또는 방향키 입력이나 마우스 이동, 화면 클릭 속도 수준을 말합니다). 각각의 클라이언트로부터 인풋이 입력될때마다 게임 상태를 업데이트하고 업데이트된 결과를 모든 클라이언트에 전역 회신하는 것은 너무 많은 CPU와 네트워크 대역폭을 소비합니다.

더 나은 접근 방법은 클라이언트 인풋을 처리 없이 수신된 순서대로 정렬하는 것(queue)입니다. 대신에, 게임 상태는 낮은 빈도로 때때로 업데이트 됩니다. 예를 들어, 초당 10회처럼 말이죠. 이 경우의 100ms처럼 업데이트 사이의 지연율을 시간 단계(time step)라고 부릅니다. 매 업데이트 루프 단계마다 모든 미처리 클라이언트 인풋이 적용됩니다(역학을 더 예측 가능하도록 하기 위해 시간 단계보다 적은 시간의 인풋이 연산됩니다). 그리고 새로운 게임 상태는 클라이언트들에 전역 회신합니다.

요약하자면, 예측 가능한 속도로, 현재 시점이나 클라이언트 인풋의 양과 독립적으로 게임 상태가 업데이트됩니다.

저-빈도 업데이트 다루기

클라이언트 관점에서는 이 접근법도 기존처럼 부드럽게 작동합니다. 클라이언트 측 예측은 업데이트 지연과 독립적으로 작동하므로 상대적으로 빈도가 낮더라도 예측가능한 상태 업데이트 하에서도 정상 작동할 것이 분명합니다. 그러나, 게임 상태가 저-빈도(예시대로 진행하면, 매 100ms 마다)로 전역 회신되기 때문에 클라이언트들은 게임 세계에서의 다른 객체들의 정보가 너무 부족합니다.

첫 번째 구현은 게임 상태를 회신할 때마다 캐릭터 위치를 업데이트 하는 것입니다; 이는 즉각적으로 끊기는 움직임이라는 문제를 일으키는데, 연속적인 부드러운 움직임이 아니라 100ms 마다 발생하는 이산적인 점프를 보이기 때문입니다.

클라이언트 2가 본 클라이언트 1

개발하고 있는 게임의 종류에 따라 이를 해결하기 위한 다양한 방법이 존재합니다; 일반적으로, 더 예측 가능한 객체들이 존재하는 경우 바로잡기 더 쉬워집니다.

추측 항법(dead reckoning)

자동차 레이싱 게임을 만들고 있다고 합시다. 굉장히 빠른 속도의 차는 예측하기 쉽습니다 - 예를 들어, 초당 100m를 전진하고 있는 경우, 1초 뒤에는 대략 시작 시점의 100m 앞에 위치할 것입니다.

왜 "대략"이라고 할까요? 그 1초 사이에 차는 조금 더 가속을 하거나 감속을 할수도, 방향을 왼쪽이나 오른쪽으로 조금 틀 수도 있습니다 - 여기서 중요한 단어는 "조금"입니다. 차량의 기동성이란, 플레이어가 실제로 무엇을 하는지와는 관계 없이, 고속에서 특점 시점이 지난 후의 위치는 그 이전 시점의 위치, 속도와 방향에 상당히 의존합니다. 다시 말해서, 레이싱 자동차는 즉각적으로 180° 회전할 수 없습니다.

이게 100ms마다 업데이트되는 서버와 무슨 상관이 있을까요? 클라이언트는 권한으로부터 경쟁 차량의 속도와 방향을 전송받습니다; 다음 100ms동안은 그 어떤 정보도 주어지지 않지만, 그들이 달리고 있는 모습을 어떻게든 보여야합니다. 가장 간단한 해결책은 해당 차량들이 이전에 주어진 변수를 상수로 유지한채 100ms동안 달리는 것으로 가정하고 차량 역학을 계산하는 것입니다.그리고 100ms가 지난 후에 서버 업데이트가 회신되면 차량의 위치를 수정합니다.

수정량은 여러가지 요인으로 인해 클 수도 있고 상대적으로 작을 수도 있습니다. 만약 플레이어가 차량을 직선으로 유지했고 속력도 변화시키지 않았다면 예측된 위치는 정확하게 일치할 것입니다. 반면에 플레이어가 무언가에 차량을 들이받았다면 예측된 위치는 완전히 틀린 것이 됩니다.

예측 항법은 낮은 속도의 상황에 적용된다는 것을 기억하세요. 예를 들어, 함선 등이 해당합니다. 사실, "예측 항법"이라는 용어는 해군 항해에서 비롯되었습니다.

객체 삽입

예측 항법이 전혀 사용될 수 없는 상황들이 존재합니다 - 특히, 플레이어의 방향이나 속력이 즉각적으로 변화되는 모든 경우에 그러합니다. 예를 들어, 3D 슈팅 게임에서 플레이어는 보통 매우 빠른 속도로 뛰고, 멈추며 코너를 돌아나갑니다. 이 경우 예측 항법 방식은 쓸모가 없게 됩니다. 위치와 속력이 이전 데이터로부터 유의미하게 예측될 수 없기 때문이죠.

그렇다고 권한 있는 서버의 데이터를 회신할 때마다 업데이트할 수는 없습니다; 플레이어들이 매우 짧은 거리를 100ms마다 순간이동하는 결과를 낳게 되어 게임을 즐길 수 없는 상태로 만들기 때문이죠.

우리가 가진 것은 매 100ms마다의 권한 위치입니다; 속임수는 그 사이에 벌어지는 일들을 어떻게 플레이어에게 보일 것인가 하는 것이죠. 열쇠는 사용자의 캐릭터보다 상대적으로 과거의 타 플레이어를 보이는 것입니다.

t = 1000에 위치 정보를 받는다고 가정합시다. 이미 t = 900에서 데이터를 수신했습니다. 따라서 당신은 플레이어가 t = 900일 때 어디에 위치했는지와 t = 1000일 때 어디에 위치하는지를 알고 있습니다. 따라서, t = 1000t = 1100 시점에 t = 900에서부터 t = 1000사이동안 다른 플레이어가 한 것을 보여줍니다. 이를 통해, 사용자의 실제 움직인 데이터를 보여줄 수 있습니다. 다만, 100ms "늦게" 보여줄 뿐이죠.

마지막으로 알려진 위치를 삽입하여 클라이언트 1의 "과거"를 보이는 클라이언트 2

객체 삽입을 위해 사용한 t = 900부터 t = 1000사이의 위치 데이터는 게임에 의존합니다. 그래도 삽입은 일반적으로 잘 작동합니다. 그렇지 않다면, 매 업데이트마다 서버가 더 상세한 이동 데이터를 제공하도록 하면 됩니다 - 예를 들어, 플레이어 진행 방향에 있는 일련의 직선 분할 또는 10ms 단위로 샘플링 된 위치 데이터는 삽입시 더 부드러워 보일 수 있습니다(이것이 곧 10배의 데이터를 전송해야 된다는 의미는 아닌데, 이는 매우 작은 움직임들에 해당하는 델타 값을 전송하는 것이기 때문에 전송시의 형식이 특정 케이스에 적합하도록 신중히 최적화될 수 있기 때문입니다).

이러한 기술을 사용할 경우, 모든 플레이어는 살짝 다른 게임 세계 렌더링 결과를 마주하게 되며, 이 것은 각 플레이어 본인은 현재 시점으로 적용되지만 타 플레이어는 아주 약간의 과거를 객체로 하기 때문입니다. 그러나, 빠른 진행의 게임일지라도 타 객체를 100ms로 보는 것은 일반적으로 크게 알아차릴만하지 않습니다.

예외는 존재합니다 - 예를 들어, 플레이어가 타 플레이어를 쏠 때 처럼, 부분적이고 일시적인 정확도가 요구될 때 입니다. 타 플레이어는 과거위치로 렌더링되기 때문에 100ms의 지연율을 가지고 조준하게 됩니다 - 즉, 100ms 이전의 타겟을 조준하는 것이죠! 이 문제는 다음 파트에서 다루도록 하겠습니다.

요약

권한 있는 서버를 이용하는 서버-클라이언트 모델에서는 잦지 않는 업데이트와 네트워크 지연율 하에서도 사용자에게 부드러운 움직임과 연속성의 착각을 제공해야만 합니다. 파트 2에서는 클라이언트 측 예측과 서버 측 조정을 통해 유저가 컨트롤하는 플레이어의 움직임을 실시간으로 적용하는 방안을 보았습니다; 이것은 로컬 플레이어에게는 즉각적인 인풋 결과가 발생하는 것을 보장하지만 게임을 플레이하기 힘들정도로 지연되는 렌더링을 제거할 수 있게 해주었습니다.

그러나, 다른 객체는 여전히 문제입니다. 이번 파트에서는 그 문제를 해결하기 위한 2가지 방법을 보았습니다.

첫 째는, 예측 항법 입니다. 이 방법은 객체의 이전 정보인 위치, 속도와 가속도 등을 바탕으로 수용가능한 수준으로 예측된 객체의 위치 정보를 가상화하는 방법입니다. 몇 가지 조건들이 마땅치 못할 경우 이 방법은 실패합니다.

둘 째는, 객체 삽입법 입니다. 이 방법은 미래를 전혀 예측하지 않습니다 - 단지, 서버로부터 제공받은 실제 객체 데이터만을 활용하며, 약간 지연된 시점의 객체를 보여줄 뿐입니다.

결과적으로 유저의 플레이어는 현재 시점으로 보여지고 다른 객체는 과거로 보여집니다. 이 방법들로 완벽한 경험을 만들어내곤 합니다.

그러나, 다른 해결책이 없을 경우, 이동하는 타겟을 사격하는 등의 부분적이고 일시적인 정밀함이 요구되는 경우에 이러한 착각은 모두 산산조각이 납니다: 클라이언트 2가 렌더링하는 클라이언트 1의 위치 정보는 서버의 것이든 클라이언트 1의 것이든 그 어떤 것의 위치 정보와도 일치하지 않으므로, 헤드샷은 불가능한 일이 됩니다! 그 어떤 게임도 헤드샷이 없이는 완벽할 수 없으므로 우리는 다음 파트에서 이 이슈에 대해 다루어볼 것입니다.

파트 4: 지연 보상(lag compensation)

소개

이전 파트들에서는 아래와 같이 요약이 가능한 클라이언트-서버 게임 설계를 설명했습니다:

  • 서버는 모든 클라이언트로부터 시간정보가 담긴 인풋을 받는다.
  • 서버는 인풋을 처리하고 게임 세계 상태를 업데이트한다.
  • 서버는 일반적인 세계 상태 정보를 모든 클라이언트에 전송한다.
  • 클라이언트는 인풋을 전송하며 그 효과를 지역적으로 가상화한다.
  • 클라이언트는 세계 업데이트 정보를 회신하고
    • 예측된 상태와 권한 상태를 동기화한다.
    • 타 객체의 과거 상태로 알려진 정보를 삽입한다.

플레이어 입장에서는, 두 개의 주요한 결과를 갖습니다:

  • 플레이어 본인현재 상태를 본다.
  • 플레이어는 타 객체과거 상태를 본다.

이 상황은 일반적으로 괜찮지만, 시간/공간적으로 민감한 이벤트에 있어서는 문제가 발생할 소지가 있습니다; 예를 들어, 적의 머리를 조준하는 경우처럼 말이죠.

지연 보상

당신은 저격총으로 적의 머리를 정확히 조준하였습니다. 발사하였습니다 - 실패할 수가 없는 저격이죠.

그러나 실패합니다.

이런 일은 왜 발생할까요?

앞서 설명한 클라이언트-서버 설계 때문에, 사격 전 100ms 이전의 적 머리를 조준한 것입니다 - 발사할 시점의 위치가 아니라요!

표현하자면, 빛의 속도가 매우, 매우 느린 세계에서 게임을 하는 경우와 유사합니다; 적의 이전 위치에 조준하지만, 방아쇠를 당기는 순간 적은 이미 멀리 사라지고 난 이후인 것이죠.

운이 좋게도, 이를 위한 매우 간단한 해결책이 있고, 이는 대부분의 플레이어를 대부분의 경우에 만족시킬 수 있습니다(아래에서 설명할 단 하나의 예외 상황을 제외하고 말이죠).

작동 원리는 아래와 같습니다:

  • 사격을 하면, 클라이언트는 이 사격과 관련된 완전한 정보를 서버에 전송합니다: 사격의 정확한 시간과 무기가 조준한 정확한 조준점에 대한 정보입니다.
  • 가장 중요한 단계입니다. 서버는 시간 정보가 담긴 인풋을 제공받기 때문에, 권한을 활용해 과거 어느 시점이든 관계없이 즉각 게임 세계를 재건설할 수 있습니다. 특히, 그 어떤 클라이언트든 특정 시점에 어떤 상황을 보고 있을지를 정확하게 재현해낼 수 있죠.
  • 이 것은 서버가 당신이 무기를 발사하는 정확한 시점에 무기 시야에 어떤 것이 보이고 있는지를 알 수 있다는 뜻입니다. 그 것이 적 머리의 과거 시점일지라도, 서버는 그것이 당신의 현재 시점에서 본 적 머리의 위치라는 것을 알고 있습니다.
  • 서버는 그 시점 에서의 사격을 처리하고 클라이언트들을 업데이트합니다.

그리고 모두는 행복하죠!

서버는 서버라서 행복합니다. 서버는 항상 행복해요.

당신은 적 머리를 조준하고 사격했는데 헤드샷 보상을 받을 수 있게 되어서 행복합니다.

적은 전적으로 행복하지만은 않은 유일한 사람이겠네요. 사격 당시 적이 가만히 서있었다면 그건 적의 잘못이죠, 맞죠? 움직이고 있었다면... 와 당신이 대단한 저격수네요.

하지만 만약 그가 열린 공간에 있다가, 벽 뒤로 숨게 되고, 매우 미세한 시간이 지나고 나서야 저격을 당했다면 어떨까요? 그는 안전하다고 생각했을텐데 말이죠.

흠, 이런 일이 발생할 가능성은 충분하죠. 그 부분이 감수해야할 부분입니다. 과거의 사격이기 때문에, 피격자는 안전한 곳으로 대피한 수 밀리세컨드 이후에 사격으로 사망할 수도 있게 됩니다.

다소간 불공정하지만 모두가 동시에 동의할 수 있는 해결책임은 확실합니다. 못 맞출 수가 없는 저격을 실패했다는 판정을 받는 것 보다 나으니까 말이죠!

결론

이 것으로 빠른 진행 멀티플레이어 시리즈가 막을 내립니다. 이런 종류의 일들은 정확하게 구현하기 어렵지만 개념적으로 명확히 이해하고 난다면 불가능할정도로 어려운 일은 아닙니다.

비록 이 내용이 게임 개발자를 대상으로 하지만, 다른 그룹의 독자들에게도 유용할 것 같아요: 게이머들이요! 게이머 입장에서도 특정 일들이 도대체 왜 벌어지는가에 대해 이해하는 것은 흥미로운 일이죠.

추가 읽을거리

이 기술들이 너무 현명한 방법인만큼, 일부라도 제 공으로 돌리기는 참 어렵네요; 여기 쓰여진 내용들은 제가 다양한 포스트와 소스 코드 그리고 일부 실험을 통해 배운 개념들을 알기 쉽게 설명한 가이드일 뿐입니다.

가장 관련있는 글 들은 게임 네트워크에 관해 모든 프로그래머가 알아야 할 것들클라이언트/서버 인게임 프로토콜 디자인과 최적화에서의 지연 보상 방법론 입니다.

TN-역자주

추가적으로 원본 글에서는 'Live Demo'가 제공됩니다.

이는 별도로 여기에 번역하지 않았으며, 링크를 통해 확인하시기 바랍니다


Last update: October 28, 2021
Back to top