원문: http://unreal.epicgames.com/Network.htm
기록:
2007년 5월 - 최초 번역
2011년 2월 - 갱신 (사소한 수정)
2011년 4월 - 최종 정리
Tim Sweeney
Epic MegaGames, Inc.
Audience: Advanced UnrealScript Programmers, C++ Programmers.
Last Updated: 07/21/99
Note: References to Unreal's C++ code are only relevant to Unreal licensees who have access to the Unreal source.
멀티플레이어 게임은 공동의 현실(Shared Reality)에 대한 것이다. 같은 월드에서 일어난 이벤트를 서로 다른 시각에서 바라보고, 모든 플레이어는 서로 같은 월드에 존재한다고 느낀다. 멀티플레이어 게임은 둠(Doom)과 같은 소규모의 2인 모뎀 게임부터 시작해서 퀘이크(Quake) 2, 언리얼(Unreal), 울티마(Ultima) 온라인같이 더 크고, 지속적이며, 자유로운 형태의 상호작용을 할 수 있는 형태로 발전해 왔다. 이러한 공동의 현실을 구현하는 기술도 놀란만하게 발전했다.
초기에는 둠(Doom)과 듀크 뉴켐(Duke Nekem) 같은 P2P(peer-to-peer) 게임이 있었다. 이들 게임에서는 게임에 참여한 각 컴퓨터(Machine)가 똑같이 동작하도록 되어있다. 사용자 입력, 다른 플레이어와의 시간차와 같은 정보를 정확하게 동기화했고, 각 컴퓨터는 같은 입력을 기반으로 같은 게임 로직을 수행했다. 완벽한 결정론적인(deterministic, 고정 프레임에 랜덤이 아닌) 게임 로직과 함께, 모든 플레이어들은 같은 현실을 지각했다. 이 방식의 장점은 단순함에 있다. 단점은 다음과 같다:
지속성의 결여. 모든 플레이어는 동시에 게임을 시작해야 하며, 새로운 플레이어가 중간에 참여할 수 없다.
플레이어 수에 유연하지 못함. 락-스텝(lock-step, 역주: 다음 단계로 갈 수 있을 때 까지 멈춤) 방식의 네트워크 아키텍처이기 때문에, 상호작용(coordination)에 대한 오버헤드와 네트워크 오류가 플레이어 숫자에 비례해서 선형적으로 증가한다.
프레임 레이트(frame rate)가 유연하지 못함. 모든 플레이어는 내부적으로 같은 프레임 레이트로 동작해야 하므로, 여러 가지 하드웨어를 지원하기 어렵다.
다음으로, 퀘이크에서 시도하고 나중에 울티마 온리인에서도 사용되었던, 단일(monolithic) 클라이언트-서버 아키텍처가 있다. 하나의 컴퓨터가 지정된 "서버"가 되어서 게임 플레이에 대한 모든 결정(decision)을 내리도록 책임진다. 다른 컴퓨터들은 "클라이언트"이고, 게임 로직을 처리할 수 없는(dumb) 렌더링 터미널로 간주된다. 클라이언트는 키 입력을 서버에 전송하고, 렌더링할 오브젝트의 목록을 받는다. 이러한 방식은, 게임 서버가 전 세계에 퍼져있는 인터넷에 연결되어 있으므로, 대규모의 인터넷 게임 플레이를 가능하게 한다. 클라이언트-서버 아키텍처는 나중에 퀘이크월드(QuakeWorld)와 퀘이크 2에서, 낮은 대역폭(bandwidth)에서도 시각적인 디테일을 높일 수 있도록, 클라이언트에서 시뮬레이션과 예측 로직을 수행하는 방식으로 발전했다. 클라이언트는 렌더링할 오브젝트의 목록만 받는 것이 아니라, 오브젝트가 움직이는 궤도도 받아서 오브젝트 모션을 간단히(rudimentary) 예측해서 수행하게 된다. 뿐만아니라, 지연시간(latency)에 따른 자연스럽지 못한 움직임을 처리하기 위해서 락-스텝을 예측하는 프로토콜도 소개되었다. 하지만, 여전히 몇 가지 단점이 있다:
끊임없이 개발해야 함. 새로운 종류의 오브젝트(새로운 무기나, 새로운 조작)가 추가되면, 새 오브젝트에 맞는 시뮬레이션과 예측을 수행하기 위해서 그에 특화된(glue) 로직을 작성해야 한다.
예측 모델의 어려움. 네트워크 코드와 게임 코드는 다른 모듈에 있지만, 게임 상태(state)를 적절하게(reasonably) 동기화하기 위해서 서로 다른 모듈의 구현을 모두 알고 있어야 한다. 다른 모듈 사이의 강한 결합(coupling)은 확장성을 떨어뜨리기 때문에 바람직하지 못하다.
언리얼은 멀티플레이어 게임 플레이에서 일반화된 클라이언트-서버 모델(Generalized client-server model)이라는 새로운 접근 방식을 소개한다. 이 모델에서도 서버는 게임 상태를 처리하는 믿을만한 존재이다. 하지만, 클라이언트는 정확한(accurate) 게임 상태의 일부를 유지한다. 반드시 필요한 데이터 통신을 최소화 하고, 대략 같은 데이터를 가지고 서버와 같은 코드를 실행함으로써 게임의 흐름을 예측할 수 있다. 게다가, "게임 상태"는 스스로 유연하게 처리할 수 있고, 객체 지향 스크립트 언어는 게임 로직과 네트워크 코드의 결합을 완전히 제거할 수 있다. 네트워크 코드는 이 방식으로 일반화될 수 있고, 언어로 표현할 수 있는 어떤 게임도 처리할 수 있다. 이것은, 오브젝트의 행위(behavior)가 내부 구현을 알기 위해 깊이 연관된(hard-wired) 코드의 일부와 어떤 의존관계도 없이, 완전히 스스로 기술해야 한다는 개념(concept)인 확장성(extensibility)을 높여주는 객체 지향의 목표를 달성하는 것이다.
우리의 목표는 언리얼의 네트워킹 아키텍처를 아주 정확히 정의하는 것이다. 정확하게 정의하지 못하면, 잘못 이해하기 쉬운 복잡한 것들이 아주 많기 때문이다.
기본 용어들을 정확하게 정의해 보자:
변수(variable)는 정해진(fixed) 이름과 변경될 수 있는 값의 연관(association)이다. 변수의 예로는 X=123 같은 정수. Y=3.14 같은 부동 소수. Team="Rangers" 같은 문자열. V=(1.5,2.5,-0.5) 같은 벡터가 있다.
오브젝트(object)는 정해진 변수(variable)들의 집합을 내장한 자료구조이다.
액터(actor)는 레벨(level) 위에서 독립적으로 움직일 수 있으며, 다른 액터와 상호작용할 수 있는 오브젝트이다.
레벨(level)은 액터(actors)의 집합을 포함하고 있는 오브젝트이다.
틱(tick)은 DeltaTime 이라고 하는 시간이 지나간 양에 따라 전체 게임 상태(game state)를 업데이트하는 연산(operation)이다.
게임 상태(game state)란 한 레벨(level) 안에서 틱(tick) 연산이 수행되지 않는 어떤 시점에서의 모든 액터(actors) 집합과 그 액터의 변수(variables)가 가지는 값을 의미한다.
클라이언트는 월드에서 일어나는 이벤트를 근접하게(approximately) 시뮬레이션하거나 대략 비슷한 화면(view)을 렌더링하기 위해, 거의 맞는 게임 상태(game state)의 부분 집합(subset)을 유지하는 Unreal.exe 실행 인스턴스이다.
서버(server)는 하나의 레벨(level)에서 틱(tick) 연산을 수행하고, 모든 클라이언트(clients)와 신뢰할 수 있는 게임 상태(game state)를 전달하는 Unreal.exe 실행 인스턴스이다.
위에서 나열한 개념은 대부분 쉽게 이해할 수 있지만, 틱(tick)과 게임 상태(game state)는 좀 더 자세한 설명이 필요하다. 먼저, 언리얼의 업데이트 루프에 대한 간략한 설명을 보자:
내가 서버(server)이면, 현재의 게임 상태를 모든 클라이언트에게 전달한다.
내가 클라이언트(client)이면, 내 요청을 보내고, 새 게임 상태 정보를 받아서 현재 상태를 렌더링한다.
지난 틱에서 지난 시간의 양에 따라 틱 연산을 수행하고 게임 상태를 업데이트한다.
틱 연산은 레벨에 존재하는 모든 액터를 업데이트 하거나, 액터에 대한 물리 연산을 수행하거나, 발생한 게임 이벤트를 처리하고, 필요한 스크립트 코드를 실행하는 것에 관여한다. 둠이나 퀘이크와 같은 과거의 여러 게임과는 달리, 언리얼의 물리 연산과 업데이트는 시간이 지난 양에 따라 처리할 수 있도록 설계되었다. 예를 들어, 둠에서 이동 처리는 "Position += PosistionIncrement"와 같은 반면에 언리얼은 "Position += Velocity * DeltaTime"과 같다. 이는 프레임 레이트(frame rate)에 따른 규모 가변성(scalability)을 극대화한다.
틱 연산을 처리하는 동안, 게임 상태는 코드가 수행됨에 따라 계속해서 변경된다. 게임 상태는 정확히 3가지만 변경할 수 있다:
액터(actor) 내부의 변수(variable)를 수정할 수 있다.
액터(actor)를 생성할 수 있다.
액터(actor)를 삭제할 수 있다.
위에 따르면, 서버의 게임 상태는 레벨 안의 모든 액터가 가진 변수들의 집합으로 완전하고 명료하게 정의할 수 있다. 서버의 게임 진행은 신뢰할 수 있기 때문에, 서버의 게임 상태를 항상 오직 하나의 진짜(true) 게임 상태로 볼 수 있다. 클라이언트 컴퓨터에 존재하는 게임 상태는 서버의 게임 상태로부터 나타날 수 있는 여러 종류의 오차 중의 하나로 생각할 수 있다. 클라이언트 컴퓨터 내부의 액터는 오브젝트 그 자체라기 보다는 임시로 대략 비슷하게 표현된 오브젝트이기 때문에, 프록시(proxies)로 간주해야 한다.
만일 네트워크 대역폭에 제한이 없다면, 네트워크 코드는 다음과 같이 매우 간단해 질 수 있을 것이다: 매 틱의 연산이 끝나면, 서버는 각 클라이언트에 모든 게임 상태를 보내고, 각 클라이언트는 항상 서버에서 일어난 것과 정확히 같은 게임 화면을 렌더링한다. 하지만, 인터넷의 현실은 모든 게임 상태를 업데이트하는 데 필요한 대역폭의 약 1%에 불과한 28.8K 모뎀이다. 미래에는 인터넷 연결이 보다 빠르겠지만, 대역폭은 게임에서 그래픽 품질이 향상되는 속도를 정의한 무어의 법칙(Moore's law)보다는 훨씬 낮은 속도로 성장한다. 그러므로, 현재뿐만 아니라 미래에도 모든 게임 상태를 업데이트하는 데 필요한 충분한 대역폭은 존재하지 않을 것이다.
그러므로, 네트워크 코드의 주 목표는 서버가 주어진 대역폭의 한도 내에서 웬만큼 맞아 떨어지는 게임 상태를 클라이언트에게 전달하는 데 있다. 그것으로 클라이언트는 공동의 현실에 가깝게 월드 화면을 렌더링할 수 있게 된다.
언리얼은 "서버와 클라이언트 간 공동의 현실(Shared reality)을 적당히 유사하게 처리하는 것(coordinating)"에 관한 일반적인 문제를 "복제"의 문제로 바라본다. 그것은 서로 근접한 공동의 현실을 구현하기 위해 클라이언트와 서버 사이에서 흐르는 데이터와 명령(commands)을 결정하는 문제이다.
언리얼에서 레벨은 매우 넓을 수 있고, 그 레벨에서 플레이어는 어떤 시점에 액터들의 작은 한 부분(fraction)만 볼 수 있다. 레벨에 존재하는 대부분의 액터는 보이지도, 들리지도 않고, 플레이어에게 영향을 미치지도 않는다. 서버가 한 클라이언트의 관점에서 판단할 때, 시야에 포함되거나 어떤 영향을 미칠 수 있는 범위에 있는 액터의 집합을 그 클라이언트의 의미있는 집합(relevant set)이라고 한다. 언리얼 네트워크 코드에서 중요한 대역폭 최적화는 서버가 클라이언트의 의미있는 집합에 포함된 액터들에 대해서만 정보를 전달하는(tell) 것이다.
언리얼은 다음과 같은 규칙을 적용해서, 의미있는 액터 집합을 결정한다.
ZoneInfo 클래스에 속한 액터이면, 의미있다.
액터가 static=true 이거나 bNoDelete=true 이면, 의미있다.
플레이어가 소유한 액터(Owner==Player)이면, 의미있다.
액터가 무기(Weapon)이고, 보이는 액터가 소유한 것이라면, 의미있다.
액터가 숨어있고(bHidden=true) 충돌하지 않으며(bBlockPlayers=false) 환경음도 갖지 않는다면(AmbientSound=false), 의미없다.
액터 위치와 플레이어 위치 사이에서의 시야(line-of-sight, 시선) 검사에 따라서 보이면, 의미있다.
액터가 2초에서 10초 사이의 값(정확한 숫자는 최적화에 의해 변함)보다 이전에 보였다면, 의미있다.
이러한 규칙은 플레이어에게 영향을 줄 수 있는 액터의 집합(정확하지는 않지만 매우 근접한)을 얻을 수 있게 설계되었다. 물론, 완벽하지 않다. 시야(line-of-sight) 검사는 많은 수의 액터들 사이에서 종종 잘못 탐지하는(false negative) 경우도 있다(우리는 이 문제를 해결하기 위해 스스호 학습하는 방법(heuristics)을 사용하지만). 환경음에 대한 사운드 오클루전(sound occlusion) 같은 것을 설명하지 못한다. 하지만, 이런 근사치는 인터넷의 특성으로 인한 지연시간, 패킷 손실과 같은 네트워크 환경에 내재된 오류가 압도한다(overwhelmed).
C++ 함수인 ULevel::GetRelevantActors에서 각 클라이언트에게 의미있는 액터 집합이 결정된다. 이 방식은, 또 다른 게임 플레이 규칙을 구현하고자 하는 개발자가 ULevel::GetRelevantActors 함수를 수정하거나, (더 좋은 방법으로) ULevel을 서브클래싱하거나 함수를 오버라이딩하는 방법으로 의미있는 집합에 대한 정의를 자유로이 수정할 수 있게 한다.
모뎀을 이용한 인터넷 접속 환경에서의 데쓰매치 게임은, 서버가 각 클라이언트에서 알고자 하는 게임 상태 정보를 모두 보내 주기에는 대역폭이 부족하다. 언리얼은 모든 액터에게 우선순위를 부여하고 게임 플레이에 있어서 "얼마나 중요한가"에 근거해서 대역폭에 해당하는 "몫(fair share)"을 분배하는 방식으로 로드 밸런싱을 구현한다.
각 액터는 NetPriority라는 부동소수형 변수를 갖는다. 이 값이 크고, 대역폭이 큰 액터는 상대적으로 많은 정보를 수신한다. 2.0의 우선순위(Priority)를 갖는 액터는 1.0의 우선순위를 갖는 액터보다 정확히 2배만큼 빈번하게 업데이트한다. 우선순위에서 문제가 되는 것 하나가 비율(ratio)이다; 우선순위 모두가 증가되면 분명 언리얼의 네트워크 성능은 향상될 수 없다. 일부 NetPriority 값은 퍼포먼스 튜닝(performance-tuning)에 의해 다음과 같이 설정했다:
봇: 8.0
이동하는 것: 7.0
발사체: 6.0
인질(Pawns): 4.0
장식용 생물체(물고기 같은): 2.0
장식물: 4.0
네트워크 코드는 서버와 클라이언트 사이에서 게임 상태에 관한 정보를 전달하기 위해 3가지 본질적(primitive)이고, 저수준(low-level)인 복제 작업에 바탕을 둔다.
액터 복제. 서버는 각 클라이언트의 "의미있는" 액터의 집합(클라이언트에게 보이거나 클라이언트의 시야나 즉각적인 움직임에 영향을 조금이라도 줄 수 있는 액터)을 구분해서, 각 액터에 대해 "복제된" 사본을 생성하고 유지하도록 알려준다. 서버는 항상 절대적인(authoritative) 액터의 상태를 가지고 있지만, 클라이언트는 각자 그 액터로부터 근사한 복제된 액터를 가져야 한다.
변수 복제. 클라이언트에게 의미있는 게임 상태를 표현하는 액터 변수는 "복제될" 수 있다. 즉, 서버 측의 변수의 값이 변경될 때마다, 서버는 변경된 값을 클라이언트에게 보내는 것이다.
함수 호출 복제. 네트워크 게임에서 서버에서 호출하는 함수는 로컬에서의 실행이 아닌, 원격 클라이언트로 전달할 수 있다. 반대로, 클라이언트에서 호출하는 함수도 서버로 전달할 수 있다.
구체적인 예를 들기위해, 네트워크 게임에 클라이언트로 접속한 경우를 생각해보자. 두 명의 적이 다가오고, 총을 쏘고, 그 총 소리가 들린다. 게임 상태는 클라이언트 컴퓨터가 아니라 서버 컴퓨터에서 유지되는데, 왜 그런 일들이 보이고 들릴 수 있을까?
서버가 적들이 클라이언트에 대한 "의미있다"고 인식(즉, 적들이 보인다)하고 그 액터들을 클라이언트에게 복제하고 있기 때문에 그 적들을 볼 수 있다. 그러므로, 클라이언트는 쫒아오는 그 두 플레이어에 대한 액터의 사본을 가지고 있는 것이다.
서버가 그들의 위치 변수를 클라이언트에게 복제하고 있기 때문에 쫒아오는 그 적들을 볼 수 있다. 그들의 애니메이션이 보이는 것도 서버가 애니메이션 변수를 복제하고 있기 때문이다. 다시 말해, 서버는 위치와 애니메이션 파라미터의 갱신된 값을, 초당 몇 번씩의 비율로, 끊임없이 전달한다.
서버가 ClientHearSound 함수를 클라이언트에게 복제하고 있기 때문에, 총 소리를 들을 수 있다. PlayerPawn(역주:클래스 이름)이 소리를 들을 때마다, 그 PlayerPawn에 대한 ClientHearSound 함수가 호출된다.
자, 이런 점에 있어서, 언리얼이 멀티플레이어 게임을 진행하는 저수준 메커니즘은 명확하다. 서버는 게임 상태를 갱신하고 게임에 영향을 미치는 모든 결정을 한다. 그리고, 서버는 적절한 액터, 적절한 변수를 클라이언트에 복제한다. 물론, 서버는 적절한 함수 호출도 클라이언트에 복제한다.
모든 액터가 복제될 필요는 없다는 점도 명확하다. 예를 들어, 한 액터가 레벨의 중간쯤 건너에 있다면, 그 액터에 대한 업데이트를 전송하는 대역폭을 낭비할 필요가 없다. 또한, 모든 변수들도 업데이트할 필요없다. 다른 예로, 서버가 인공지능을 결정하는 데 사용하는 변수는 클라이언트에게 보낼 필요없다. 즉, 클라이언트는 단지 그들을 표시하기 위한 변수, 애니메이션 변수, 물리관련 변수만 알면 된다. 또한, 서버에서 실행되는 대부분의 함수는 복제되지 않는다. 클라이언트가 보는 것과 듣는 것에 영향을 미치는 함수들만 복제하면 된다. 그래서, 이 모든 것에, 서버는 거대한 양의 데이터를 포함하지만, 클라이언트에게 중요한 것은 작은 한 부분(fraction)뿐이다. 그것은 플레이어의 눈과 귀, 또는 느낌에 영향을 미치는 것이다.
이리하여, "언리얼 엔진은 어떤 액터와 변수, 어떤 함수 호출이 복제될 필요가 있는지 어떻게 아는가?"라는 필연적인(logical) 질문이 생긴다.
대답은 이렇다. 액터에 관련된 스크립트를 작성하는 프로그래머가 그 스크립트 내에서 어떤 변수와 함수가 복제되어야 하는지 결정할 책임을 갖는다. 또한, 언리얼 엔진에게 어떤 조건에서 복제되어야 하는지 알려주기 위해서 스크립트에 "복제 기술문(statement)"이라고 불리는 코드를 작성할 책임도 갖는다. 실제적인 예로, 액터 클래스에 정의된 몇 가지를 고려해 보자.
위치 변수(벡터)는 액터의 위치를 가지고 있다. 서버는 위치 값을 유지할 책임이 있기 때문에, 클라이언트에게 그 정보를 보내야 한다. 그러므로, 복제 조건은 "내가 서버라면, 이 값을 복제하라"가 된다.
메시(Mesh) 변수(포인터)는 액터를 표현하기 위해 반드시 그려야 하는 메시를 가리킨다. 서버는 이 것을 클라이언트에 보내야 하지만, 액터가 메시로 표현될 때만 보낼 필요가 있다. 즉, 액터의 DrawType이 DT_Mesh일 때다. 그러므로, 복제 조건은 "내가 서버이고 DrawType이 DT_Mesh이면, 이 값을 복제하라"가 된다.
PlayerPawn 클래스에는 bFire나 bJump같이 키 또는 버튼의 눌림을 정의하는 불대수(boolean)가 여러 개 있다. 이 값들은 (입력이 일어나는) 클라이언트에서 생성되는데, 서버도 이 값들을 알아야 한다. 그러므로, 복제 조건은 "내가 클라이어언트라면, 이 값을 복제하라"가 된다.
PlayerPawn 클래스에는, 플레이어가 소리를 들을 수 있게 알려주는 ClientHearSound 함수가 있다. 이 함수는 서버에서 호출되지만, 당연히 그 사운드는 클라이언트에 존재하는 실제로 게임을 즐기고 있는 사람에게 들려야 한다. 그러므로, 복제 조건은 "내가 서버라면, 이 함수를 복제하라"가 된다.
이 예제들로부터, 많은 것이 확실해 졌다. 먼저, 복제할 필요가 있는 변수나 함수는 그에 맞는 "복제 조건"을 가져야 한다. 복제 조건이란 복제가 필요한가 그렇지 않은가에 의해 참(True) 또는 거짓(False)으로 나타낼 수 있는 표현식이다. 둘째, 이러한 복제 조건은 양방향(two-way)으로 필요하다: 서버는 변수나 함수를 클라이언트에게 복제할 수 있어야 하고, 반대로 클라이언트는 서버에게 복제할 수 있어야 한다. 셋째, "복제 조건"은 "내가 서버이고 DrawType이 DT_Mesh이면, 이 값을 복제하라"와 같이 복합적(complex)일 수 있다.
그러므로, 우리는 어떤 변수와 함수가 복제되어야 하는지를 결정하는 (복합적인) 조건을 표현할 수 있는 일반적(general-purpose)인 표현 방법이 필요하다. 이러한 조건을 표현하는 가장 적절한 방법은 무엇일까? 우리는 여러 방법을 모두 살펴보았는데, 최종적으로 UnrealScript가 복제 조건을 기술하는 완벽한 툴이 될 것이라고 결론내렸다. UnrealScript는 클래스와 변수, 그리고 코드를 작성하는데 있어서 이미 아주 강력한 언어이다.
UnrealScript에서 모든 클래스는 하나의 복제 기술문을 포함한다. 복제 기술문은 하나 이상의 복제 정의문(definition)를 가진다. 각 복제 정의문은 복제 조건(참 또는 거짓으로 나타나는)과 이 조건이 적용될 하나 이상의 변수나 함수의 목록으로 이루어져 있다.
클래스 내부의 복제 기술문은 그 클래스 내부에 정의된 변수와 함수만 참조한다(즉, 부모 클래스(superclass)에 정의된 함수에게는 적용되지 않고, 그 클래스로 부터 오버라이딩된 것을 참조하게 된다). 이 방식은, 액터가 DrawType이라는 변수를 가지고 있다면, 그 변수에 대한 복제 조건을 알고 싶을 때 어디를 봐야 하는지 알 수 있게 해준다: 그 조건은 반드시 그 액터 클래스 안에 존재한다.
복제 기술문을 포함하지 않는 클래스도 가능하다; 이 것은 클래스가 복제할 변수나 함수를 새로 정의하지 않는다는 것을 의미한다. 사실, 대부분의 클래스는 복제 기술문이 필요없다. 왜냐하면, 클라이언트에 영향을 미치는 "관심있는" 변수의 대부분은 액터 클래스 내부에 정의되어 있고, 파생된 클래스에 의해서만 변경되기 때문이다. 언리얼은 약 500개의 클래스를 가지고 있는데, 이 중 약 10개만 복제 기술문이 필요하다.
클래스 내부에 새로운 변수 또는 함수를 정의했지만 복제 정의문 목록에 넣지 않았다면, 그것은 결코 복제되지 않는다. 이것이 일반적이다; 대부분의 변수와 함수는 복제될 필요가 없다.
여기 복제 기술문을 작성한 UnrealScript의 문법 예제가 있다. 이것은 Pawn 클래스에서 가져왔다:
replication { // Variables the server should send to the client. reliable if( Role==ROLE_Authority ) Weapon; reliable if( Role==ROLE_Authority && bNetOwner ) PlayerName, Team, TeamName, bIsPlayer, CarriedDecoration, SelectedItem, GroundSpeed, WaterSpeed, AirSpeed, AccelRate, JumpZ, MaxStepHeight, bBehindView; unreliable if( Role==ROLE_Authority && bNetOwner && bIsPlayer && bNetInitial ) ViewRotation; unreliable if( Role==ROLE_Authority && bNetOwner ) Health, MoveTarget, Score; // Functions the server calls on the client side. reliable if( Role==ROLE_Authority && RemoteRole==ROLE_AutonomousProxy ) ClientDying, ClientReStart, ClientGameEnded, ClientSetRotation; unreliable if( Role==ROLE_Authority ) ClientHearSound, ClientMessage; reliable if( Role<ROLE_Authority ) NextItem, SwitchToBestWeapon, bExtra0, bExtra1, bExtra2, bExtra3; // Input sent from the client to the server. unreliable if( Role<ROLE_AutonomousProxy ) bZoom, bRun, bLook, bDuck, bSnapLevel, bStrafe; unreliable always if( Role<=ROLE_AutonomousProxy ) bFire, bAltFire; }
여기서 볼 수 있는 요점은:
복제 기술문은 "replication {}"로 둘러싸여 있다.
각 복제 정의문은 "reliable if (조건)" 또는 "unreliable if (조건)"으로 시작한다.
"unreliable" 키워드로 복제된 함수는 다른 사람에게 전달되는 것을 보장하지 않는다. 그리고, 다른 사람에게 전달된 경우에도, 적절하지 않은 상황이 될 수 있다. 신뢰성을 보장하지 않는 함수가 호출되는 것을 막을 수 있는 것은 패킷 손실과 대역폭 포화 상태뿐이다. 그러므로, 아주 대충 추측한 이상한 정보(Odd)를 감지할 수 있어야 한다. 그 결과가 네트워크의 종류에 따라 신뢰성의 편차가 크더라도, 아무것도 보장할 수 없다.
랜(LAN) 게임에서, 신뢰성을 보장하지 않는 데이터는 약 99%의 확률로 전달된다고 예상하고 있다. 하지만, 수백, 수천 가지의 요소를 복제해야 하는 게임의 경우, 신뢰성을 보장하지 않는 데이터 중 일부는 손실될 것이라는 점을 확실히 할 필요가 있다. 그러므로, 랜 환경만을 목표로 하더라도, 신뢰성을 보장하지 않는 데이터가 손실되어도 자연스럽게(gracefully) 처리할 수 있는 코드를 작성해야 한다.
전형적인 저 수준의 28.8K ISP 연결에서, 신뢰성을 보장하지 않는 데이터는 보통 90%에서 95%의 확률로 전달된다. 다시 말해, 매우 빈번한 손실이 존재한다.
신뢰성을 보장하거나 보장하지 않거나 사이에서 보다 나은 조절을 하려면, UnrealScript 내부의 복제 기술문을 점검하고, 그 데이터의 중요성(importance)과 우리가 결정한 신뢰도(reliability)를 비교하라.
변수에 대해서는 "reliable" 또는 "unreliable" 키워드는 무시된다. 변수는 패킷 손실이나 대역폭 포화 상태에서도 다른 사람에게 전달되는 것을 보장한다. 단, 변수 값의 변화는 전송한 순서와 같은 순서로 다른 사람에게 도달한다는 보장은 없다.
여기에서 복제 기술문의 문법에 대해서 모두 살펴 보았다. "Role==ROLE_Authority"나 "bNetOwner"같은 표현식의 의미에 대한 내용은 거의 다루지 않았는데, 이것은 다음 섹션에서 다루고 있다.
여기 클래스 스크립트 내부의 복제 조건을 나타내는 한 가지 간단한 예다:
replication { reliable if( Role==ROLE_Authority ) Weapon; }
이 복제 조건을 해석하면 "이 액터의 Role 변수 값이 ROLE_Authority와 같으면, 액터의 Weapon 변수는 이 액터를 의미있는 집합에 포함하고 있는 모든 클라이언트에게 신뢰성 있게 복제해야 한다."가 된다.
참 또는 거짓(즉, 불대수(boolean) 표현식) 값으로 나타낼 수 있는 어떤 표현식도 복제 조건이 될 수 있다. 그래서, Unrealscript에서 쓸 수 있는 어떤 표현식이라도 가능하다(do). 여기에는 변수 비교, 함수 호출, 그리고 !, &&, ||, ^^ 연산자를 이용한 조건절 조합같은 것들이 포함된다.
일반적으로 액터의 Role 변수는 이(local) 컴퓨터가 액터를 얼마나 컨트롤할 것인지 나타낸다. Role_Authority는 "이 컴퓨터는 서버라서 원격 액터에 대해 완전히 믿을만하다"는 의미이다. Role_SimulateProxy는 "이 컴퓨터는 클라이언트이고, 액터의 물리적인 행동을 시뮬레이션(예측)해야 한다"는 의미이다. Role_DumbProxy는 "이 컴퓨터는 클라이언트이고, 이 컴퓨터의 액터 사본은 어떤 시뮬레이션도 하지 말아야 한다"는 의미이다. Role은 이 후 섹션에서 좀 더 자세하게 설명할 것이고, 우선 간단하게 요약하면 다음과 같다:
if ( Role==ROLE_Authority ): "내가 서버이면, 클라이언트에게 이 것을 복제하라"의 의미.
if ( Role<ROLE_Authority ): "내가 클라이언트이면, 서버에게 이 것을 복제하라"의 의미.
다음 변수는 아주 유용하기 때문에 복제 기술문에서 매우 자주 사용된다.
bIsPlayer: 이 액터가 플레이이어인가 아닌가. 플레이어이면 참, 아니면 거짓.
bNetOwner: 이 액터가 복제 조건을 처리하는 클라이언트에게 속해있는가 아닌가. 예를 들어, 프레드(Fred)는 산탄총(Dispersion Pistol)을 가지고 있다고 하자. 그리고, 밥(Bob)은 무기가 없다. 산탄총을 프레드에게 복제할 때, 산탄총 액터의 bNetOwner 변수 값은 참이다(프레드가 그 무기를 소유했으므로). 밥에게 복제할 때는, 산탄총 액터의 bNetOwner 변수 값은 거짓이다(밥은 그 무기를 소유하지 않았으므로).
bNetInitial: 서버에서만 유효하다. 즉, Role==ROLE_Authority일 때. 이 액터가 클라이언트에게 처음으로 복제되는 것인지를 나타낸다. 이것은 Role==ROLE_SimulatedProxy인 클라이언트에게 유용한데, 서버가 액터의 위치와 속도를 단 한 번만 보내고, 그 다음에 클라이언트가 예측하는 것을 가능하게 하기 때문이다.
복제 조건문 가이드라인:
일반적으로 변수는 한쪽 방향으로만 복제되므로(클라이언트에서 서버, 또는 서버에서 클라이언트. 양방향은 절대 없음), 모든 복제 조건문은 Role 또는 RemoteRole 값의 비교부터 시작한다. 예를 들면, if( Role==ROLE_Authority ) 또는 if( RemoteRole<ROLE_SimulateProxy ). Role 또는 RemoteRole 값에 대한 비교가 존재하지 않는 복제 조건문은 아마도 잘못된 것이다.
네트워크 플레이에서는 서버가 복제 조건문을 엄청나게 자주 검사한다. 더 간단해질 수 없을 만큼, 가능한 간단하게 유지하라.
복제 조건문이 함수 호출을 할지라도 사용하지 마라. 왜냐하면, 함수 호출은 속도를 크게 떨어뜨리기 때문이다.
네트워크 코드는 예측할 수 없는 상황에서 복제 조건문을 언제라도 호출할 수 있기 때문에, 복제 조건문은 다른 것에 영향을 주어서는 안 된다. 예를 들어, if (Counter++ > 10) 와 같은 코드를 사용한다면..., 도대체 무슨 일이 벌어지는지 찾아낼 수 있기를 바란다...
매 틱(tick)이 끝나면 클라이언트와 서버는 각각의 의미있는 집합(relevant set)에 속한 모든 액터를 확인하고, 지난 업데이트와 비교해서 변경된 것이 있는지 알기 위해서 모든 복제된 변수를 검사한다. 이때, 변수를 전송할 필요가 있는지 알기 위해서 그 변수의 복제 조건문이 사용된다. 대역폭이 허락하는 한도 내에서 변수는 네트워크를 지나 다른 컴퓨터에 전송된다.
이렇게 클라이언트는 월드에서 일어난 "중요한" 이벤트 업데이트 정보를 수신한다. 물론, 그 이벤트는 클라이언트의 시야 안에 있거나 들을 수 있는 것이다. 변수 복제에서 기억해야할 중요한 포인트는 다음과 같다.
변수 복제는 틱(tick)이 끝난 후에만 일어난다. 그러므로 한 틱이 처리되는 동안에, 변수가 새 값으로 바뀌고 다시 원래 값으로 바뀐다면 복제가 일어나지 않는다. 그래서, 클라이언트는 틱이 끝난 후에만 서버에 있는 액터 변수의 상태를 알 수 있다. 즉, 한 틱이 처리되는 동안에 변수의 상태는 클라이언트가 알 수 없다.
변수는 이전 값과 비교해서 바뀌었을 때만 복제된다.
어떤 액터 변수는 클라이언트의 의미있는 집합에 포함될 때만 복제된다. 그러므로 클라이언트의 의미있는 집합에 포함되지 않는 액터의 변수는 정확한 값이 아닐 수 있다.
UnrealScript는 전역 변수의 개념이 없다. 그러므로 복제될 수 있는 변수는 액터에 속한 변수 뿐이다.
벡터 값과 회전 값: 대역폭을 효율적으로 사용하기 위해서, 언리얼은 벡터 값과 회전 값을 양자화(quantize)한다. 벡터를 전송하기 전에 X,Y,Z 요소를 16비트 정수로 변환해서, 소수점 이하 값이나 -32768에서 32767의 범위를 벗어나는 값은 없앤다. 벡터의 Pitch,Yaw,Roll 성분은 (Pitch>>8)&255의 형태의 바이트로 변환된다. 그러므로 벡터 값과 회전 값에 대해 주의를 기울어야 한다. 정말 완전히 정밀도가 필요한 경우라면, int나 float 변수를 개개의 성분으로 사용하라. (다른 자료형은 있는 그대로 전송한다.)
일반적인 구조체는 통째로 전송함으로써 복제할 수 있다. 구조체는 "전부 아니면 아무것도 아닌" 형태로 전송된다.
배열도 복제할 수 있다. 하지만 448 바이트보다 작아야 한다.
배열은 효율적으로 복제한다. 큰 배열 안에서 한 개의 요소만 바뀌었다면, 그 요소만 전송한다.
네트워크 게임에서 UnrealScript 함수가 호출될 때, 그 함수가 복제 조건문을 가진다면, 조건문을 검사한 뒤 다음과 같이 실행된다:
복제 조건문의 결과가 True 값이면, 함수 호출은 네트워크 연결를 통해 다른 사람에게 전송된다. 다시 말해, 함수의 이름, 모든 파라미터는 데이터 패킷 안에 채워지고 다른 컴퓨터에서 실행하기 위해 전송하게 된다. 이때, 함수는 즉시 리턴되고 다음 코드가 실행된다. 함수가 리턴 값을 가지는 형태로 선언되어 있으면, 리턴 값은 0이 된다(어떤 타입은 0과 동등한 값을 가진다. 예를 들면, 벡터는 0,0,0 값, 오브젝트는 None 값, 등). 그 외 아웃풋 파라미터의 값은 모두 아무런 변경없이 그대로 남아 있게 된다. 다시 말해, UnrealScript는 결코 함수 호출이 완료될 때까지 기다리지 않으므로, 데드락(deadlock)이 발생할 수 없다. 복제된 함수 호출은 원격 컴퓨터에서 실행할 수 있게 전송만하고, 로컬 코드 실행은 계속된다.
복제 조건문의 결과가 False 값이면 함수는 로컬 컴퓨터에서 실행된다.
변수 복제와는 달리, 액터의 함수 호출은 그 액터에 대한 소유권을 가진 플레이어에게만 복제할 수 있다. 그래서, 복제된 함수는 PlayerPawn 객체의 부분 집합(예를 들면, 각자 소유권을 가진 플레이어)이나 Inventory 객체의 부분 집합(예를 들면, 플레이어 누군가가 소유한 무기나 획득 아이템) 같은 곳에서만 유용하게 사용할 수 있다. 이 내용은, 함수 호출은 오직 하나의 액터의 소유권을 가진 플레이어에게만 복제할 수 있다는 얘기다. 즉, 멀티캐스트는 안 된다.
변수 복제와는 달리, 복제된 함수 호출은 발생한 즉시 원격 컴퓨터에게 전송되고, 대역폭이 부족하더라도 반드시 복제가 일어난다. 그래서, 너무 많이 복제 함수 호출을 만들면, 대역폭이 제한을 받게 될 수도 있다. 복제된 함수는 존재하는 대역폭이 변수 복제에 사용되기 위한 것인지 아닌지 검사하지 않고 막 써버린다. 그러므로, 함수 복제로 인한 전송량이 쇄도하면, 변수들은 거의 복제되지 않아서 화면에는 실제 액터가 업데이트한 결과 대로 나오지 않거나 매우 불규칙하게 변동이 심한 상태가 연출된다.
UnrealScript에는 전역 함수가 없다. 따라서, "복제된 전역 함수"의 개념도 없다. 함수 복제는 항상 특정 액터의 문맥(context)안에서 일어난다.
너무 많은 양의 함수 복제는 대역폭을 넘치게 할 수 있는 반면에(잔여 대여폭에 관계없이 항상 복제가 일어나므로), 변수 복제는 남은 대역폭에 따라서 처리량을 자동으로 제한하고 분배한다.
함수 호출은 UnrealScript가 실행되는 과정에서 실제로 그 함수가 호출될 때 복제가 되는 반면에, 변수는 스크립트 코드가 실행되지 않는 틱(tick)이 끝난 시점에 복제가 된다.
한 액터에 대한 함수 호출은 그 액터를 소유한 플레이어에게로만 복제할 수 있는 반면에, 한 액터의 변수는 그 액터를 의미있는 집합에 포함하고 있는 모든 클라이언트에게 복제할 수 있다.
대역폭을 효율적으로 사용하기 위해서, 언리얼은 벡터 값과 회전 값을 양자화(quantize)한다. 벡터를 전송하기 전에 X,Y,Z 요소를 16비트 정수로 변환해서, 소수점 이하 값이나 -32768에서 32767의 범위를 벗어나는 값은 없앤다. 벡터의 Pitch,Yaw,Roll 성분은 (Pitch>>8)&255의 형태의 바이트로 변환된다. 완전한 정확도를 유지하면서 벡터 값과 회전 값을 전송하려면, float나 int 자료형으로 개개의 성분을 전송하라.
액터 클래스는 ENetRole 열거형(enumeration)과 Role 그리고 RemoteRole 이렇게 두 개의 변수를 정의하고 있다. 다음과 같이:
// Net variables. enum ENetRole { ROLE_None, // Means the actor is not relevant in network play. ROLE_DumbProxy, // A dumb proxy. ROLE_SimulatedProxy, // A simulated proxy. ROLE_AutonomousProxy, // An autonomous proxy. ROLE_Authority, // The one authoritative version of the actor. }; var ENetRole Role; var(Networking) ENetRole RemoteRole;
Role과 RemoteRole 변수는 어떤 액터에 대한 정보를 가지고 있는 로컬 컴퓨터와 원격 컴퓨터에서 저마다 얼마나 많이 제어할지를 나타낸다.
Role==ROLE_DumbProxy는 임시 액터라는 뜻이다. 수동적인(approximate) 프록시는 어떤 물리 연산도 하지 않는다. 클라이언트에서는 이 연산을 하지 않는(dumb) 프록시를 그냥 내버려두고 서버가 새 위치 값이나 회전 값, 또는 애니메이션 정보를 복제해 줄 때만 움직이거나 갱신한다.
이 상황은 네트워크 클라이언트에서만 볼 수 있으며, 결코 네트워크 서버나 싱글 플레이어 게임을 위한 것은 아니다.
Role==ROLE_SimulatedProxy 역시 임시 액터라는 뜻이다. 수동적인(approximate) 프록시는 물리 연산과 애니메이션을 올바르게 보여줘야 한다. 클라이언트에서는 이 모조(Simulated) 프록시에 대해 기본적인 물리를 수행한다(선형 또는 중력을 고려한 움직임과 충돌). 하지만, 고수준 움직임에 대한 결정은 하지 않고, 그저 움직이기만 한다.
이 상황은 네트워크 클라이언트에서만 볼 수 있으며, 결코 네트워크 서버나 싱글 플레이어 게임을 위한 것은 아니다.
Role==ROLE_AutonomousProxy는 로컬 플레이어의 액터라는 뜻이다. 능동적인(Autonomous) 프록시는 클라이언트에서 (시뮬레이션이 아닌) 예측을 수행하는 특별한 로직을 갖는다.
이 상황은 네트워크 클라이언트에서만 볼 수 있으며, 결코 네트워크 서버나 싱글 플레이어 게임을 위한 것은 아니다.
Role==ROLE_Authority는 이 컴퓨터가 그 액터에 대해 완전하고 절대적인(authoritative) 제어권을 갖는다는 뜻이다.
싱글 플레이어 게임에서는 모든 액터에게 해당된다.
서버에서는 모든 액터에게 해당된다.
한 클라이언트에서, 게임에 영향력이 없는(gratuitous) 특수 이펙트 처럼 대역폭 사용량을 줄이기 위해서 로컬에서만 생성하는 액터에게도 해당된다.
서버에서 모든 액터는 Role==ROLE_Authority이고, RemoteRole은 여러 프록시 타입 중 하나가 된다. 클라이언트에서 Role과 RemoteRole은 항상 서버의 값과 정반대이다. 이는 Role과 RemoteRole의 의미를 떠올려 보았다면 예상할 수 있었을 것이다.
ENetRole 값의 의미는 대부분, Actor나 PlayerPawn같은 UnrealScript 클래스 내부의 복제 기술문(Replication Statement)이 정의하고 있다. 아래에 복제 기술문이 어떻게 여러가지 역할 유형(role) 값을 정의하고 있는지 보여주는 몇 가지 예가 있다:
서버는 Actor.AmbientSound 변수를 클라이언트로 전송한다. Actor 클래스 내부에서 다음과 같이 복제 조건문(Replication Definition)을 정의하고 있기 때문이다: reliable if( Role==ROLE_Authority ) AmbientSound;
서버는 Actor.AnimSequence 변수를 클라이언트에게 전송하지만, 메시(mesh)로 렌더링하고 있는 액터에게만 전송한다. Actor 클래수 내부에서 다음과 같이 복제 조건문을 정의하고 있기 때문이다: unreliable if( DrawType==DT_Mesh && (RemoteRole<=ROLE_SimulatedPRoxy) ) AnimSequence;
클라이언트는 Fire와 AltFire 함수를 서버에게 복제한다. PlayerPawn 클래스 내부의 이 복제 조건문은 다음과 같다: unreliable if( Role<ROLE_Authority ) Fire, AltFire;
서버는 프록시가 처음 생성될 때와 움직임이 있을 때 모든 프록시의 속도를 클라이언트에게 전송한다. Actor 클래스에서 다음과 같이 복제 조건을 정의하고 있기 때문이다: unreliable if( (RemoteRole==ROLE_SimulatedProxy && (bNetInitial || bSimulatedPawn)) || bIsMover ) Velocity;
모든 UnrealScript 클래스의 복제 기술문을 학습함으로써, 각 role의 내부 동작에 대해 이해할 수 있었을 것이다. 복제에 대한 처리에는 아주 약간의 "무대 뒤의 마법"이 있다. 저 수준의 C++ 레벨에서는, 엔진은 액터와 함수 호출, 그리고 변수를 복제하는 기본적은 매커니즘을 제공한다. 고 수준의 UnrealScript 레벨에서는, 여러 네트워크 역할 유형(role)의 의미가 어떤 변수와 함수 호출이 각자의 역할에 따라 어떻게 복제되어야 하는지 기술함으로써 정의할 수 있다. 따라서, 역할 유형(role)의 의미는 UnrealScript 내부에서 거의 스스로 정의한다. 물론 그 뒤에 있는, 조건에 따라 프록시의 물리와 애니메이션을 갱신해 주는 약간의 C++ 로직을 제외하면 말이다.
Visual C++의 "Find in Files"로 C++와 UnrealScript 파일에서 "ROLE_" 키워드로 검색한 다음 각자 자유롭게 알아 보면된다. 이 문서는 네트워크 역할 유형(role)이 어떻게 해석되는지에 대해 일반적인 이해를 제공하는 것이므로, 자세한 것은 소스 코드를 통해서만 확인할 수 있다.
클라이언트에서, 대부분의 액터는 "프록시"의 형태로 존재하는데, 이것은 서버에 의해 생성된 액터의 수동적인 복사본이라는 의미이다. 서버는 클라이언트가 게임을 플레이하는 동안 보이는 것에 대해 시각적, 청각적으로 무리없는 근사치를 제공하기 위해 이 프록시를 전송한다.
클라이언트에서 이러한 액터의 사본(proxy)은 종종 클라이언트 측에서 물리나 환경 요소에 영향을 받아서 움직인다. 그래서 언제라도 그에 관련된 함수들이 잠재적으로 호출될 수 있다. 예를 들어, 한 모조(Simulated) 프록시인 TarydiumShard 발사체는 연산을 하지 않는(Dumb) 프록시인 나무 액터에게 달려가서 부딪힐지도 모른다. 액터가 충돌할 때 엔진은 그 충돌에 대한 정보를 알려주기 위해 Touch 함수를 호출하려고 할 것이다. 상황(Context)에 따라서 클라이언트는 이러한 함수를 호출해야 할 수도 있고 무시할 수도 있다. 예를 들어, Skaarj의 Bump 함수는 클라이언트가 호출하지 말아야 한다. 게임 플레이 로직은 서버에서만 처리하면 되는데, Skaarj의 Bump 함수는 게임 플레이 로직을 수행하려고 하기 때문이다. 따라서, 클라이언트는 Skaarj의 Bump 함수를 호출하면 안 된다. 하지만, TarydiumShard 발사체의 Bump 함수는 호출해야 한다. 그렇지 않으면, 물리 연산이나 특수 효과가 연출되지 않기 때문이다.
프로그래머는 UnrealScript 함수를 선택적으로 "simulated" 키워드와 함께 선언할 수 있는데, 이는 프로그래머가 어떤 함수가 액터의 사본(Proxy)에서 반드시 실행되어야 하는지 섬세한(fine-grained) 제어를 할 수 있게 해준다. 액터의 사본(즉, Role<ROLE_Authority인 액터)은 "simulated" 키워드로 선언된 함수만 호출하고 나머지 모든 함수는 생략한다.
여기 전형적인 모조 함수의 예가 있다:
simulated function HitWall( vector HitNormal, actor Wall ) { SetPhysics(PHYS_None); MakeNoise(0.3); PlaySound(ImpactSound); PlayAnim('Hit'); }
이와 같이, "모조"는 "이 함수는 모든 액터의 사본(Proxy)이 항상 실행해야 한다"를 의미한다.
만약 언리얼이 순수한 클라이언트-서버 모델을 사용했었다면, 플레이어의 움직임은 매우 둔했을 것이다. 300 밀리초의 핑(ping) 지연을 갖는 접속자는 앞으로 가는 키를 눌러도 300 밀리초 동안에는 자신이 움직이는 모습을 볼 수 없다. 마우스를 왼쪽으로 밀어도 300 밀리초가 지나기 전에는 돌지 않을 것이다. 이러한 상황은 매우 실망스러울 수 있다.
언리얼은 클라이언트의 둔한 움직임을 해결하려고 퀘이크월드(QuakeWorld)에서 먼저 사용되었던 것과 비슷한 예측 체계(scheme)를 사용한다. 먼저, 플레이어 예측 체계는 UnrealScript안에서 전부 구현된다는 것을 기억하라. 이것은 네트워크 코드의 기능이 아니라 보다 고수준(high-level)인 PlayerPawn 클래스에 구현된 기능이다. 언리얼에서 사용된 클라이언트의 움직임을 예측하는 기능은 전적으로 네트워크 코드의 일반적인 복제 기능위에 구현된 계층이라고 할 수 있다.
PlayerPawn 스크립트를 살펴봄으로써 언리얼의 플레이어 예측 기능이 어떻게 작동하지는 정확히 알 수 있다. 관련된 코드는 다소 복합적이라서, 이해를 돕기 위해 간단하게 설명하겠다.
언리얼의 접근 방식은 락-스텝(lock-step) 예측/보정 알고리즘으로 잘 설명할 수 있다. 클라이언트는 입력(조이스틱, 마우스, 키보드)과 물리적인 힘(중력, 부력, 지역 속도)을 받아서, 자신의 움직임을 3D 가속 벡터로 표현한다. 클라이언트는 복제 함수 ServerMove를 통해, 가속 정보(Acceleration)를 입력에 관계된 여러 정보, 그리고 타임 스탬프(클라이언트에 있는 Level.TimeSeconds의 현재 값)와 함께 서버로 보낸다.
function ServerMove ( float TimeStamp, float AccelX, float AccelY, float AccelZ, float LocX, float LocY, float LocZ, byte MoveFlags, eDodgeDir DodgeMove, rotator Rot, int ViewPitch, int ViewYaw );
그 다음에 클라이언트는 로컬에서 똑같은 움직임을 수행하려고 MoveAutonomous 함수를 호출한다. 그리고 클라이언트는 이 정보를 SavedMove 클래스 내부의 움직임에 관한 정보를 기억하고 있는 링크드 리스트에 담게 된다. 여기서 알 수 있듯이, 클라이언트는 서버로부터 아무런 메시지를 돌려받지 않았다고 해도 싱글 플레이어 게임처럼 지연(lag)이 전혀 없는 듯이 돌아다닐 수 있을 것이다.
서버가 (네트워크를 통해 복제된) ServerMove 함수 호출에 대한 정보를 수신했을 때, 즉시 똑같은 움직임을 수행한다. 현재의 ServerMove 함수 호출에 대한 TimeStamp 정보에서 이전의 ServerMove 함수 호출에 대한 TimeStamp 정보를 계산해서 DeltaTime을 추론한다. 이런 식으로, 서버는 클라이언트와 똑같은 기본적인 이동을 수행한다. 하지만, 서버가 보는 것은 클라이언트의 정보와 약간 다를 것이다. 예를 들어, 몬스터가 뛰어 다니고 있다면, 클라이언트는 서버가 가진 위치 값과는 다른 위치 값을 가질 수 있다(클라이언트는 서버와 대략 근사한 정도만 동기화를 하고 있으니까). 그러므로, ServerMove 함수 호출의 결과로 얼마나 멀리 움직였는지에 대해 클라이언트와 서버는 서로 다른 정보를 가질 수 있다. 적어도, 서버는 절대적이고, 클라이언트의 위치를 결정할 완전한 책임이 있다. 한 번 서버가 클라이언트의 ServerMove 함수 호출을 수행하면, 서버는 다시 클라이언트의 ClientAdjustPosition 함수를 호출한다. 물론, 이 함수는 네트워크를 통해 클라이언트로 복제된 것이다:
function ClientAdjustPosition ( float TimeStamp, name newState, EPhysics newPhysics, float NewLocX, float NewLocY, float NewLocZ, float NewVelX, float NewVelY, float NewVelZ );
이제, 클라이언트가 ClientAdjustPosition 함수 호출을 요청받으면, 플레이어의 위치 정보에 대한 서버의 권한을 존중해서 처리해야 한다. 그래서, 클라이언트는 ClientAdjustPosition 함수 호출 정보에 있는 그대로 위치와 속도를 설정한다. 그렇지만, 서버가 ClientAdjustPosition 함수 호출을 통해서 지정한 위치 값은 과거의 어떤 시각에 있는 클라이언트의 실제 위치를 반영하고 있다. 하지만, 클라이언트는 현재 순간에 어디에 있어야 하는지를 예측하고 싶어 한다. 자, 이제 클라이언트는 SavedMove 링크드 리스트를 샅샅이 조사할 차례다. 먼저, ClientAdjustPosition 함수 호출에 표시된 TimeStamp 값 보다 이전의 정보들은 폐기된다. 그 다음, TimeStamp 값 보다 이후에 일어난 이동 정보들을 모두 돌면서 각각 MoveAutonomous 함수를 다시 실행한다.
이 방식에서, 클라이언트는 언제나 항상 자신의 핑(ping) 지연 시간에서 반 정도의 시간만큼 앞서서 서버가 뭐라고 알려줄 것인지를 예측한다. 그리고, 로컬에서의 움직임은 전혀 지연되지 않는다.
이러한 접근 방식은 순수하게 예측하는 모델이고, 이것은 클라이언트와 서버 양쪽 모두에게 최상의 선택이다. 서버는 모든 경우에서 완전하게 절대적인 존재가 될 수 있다. 클라이언트는 거의 대부분의 시간에서, 자신의 움직임을 시뮬레이션하는 것이 서버가 수행하는 클라이언트의 움직임과 매우 흡사하게 된다. 클라이언트의 위치는 드물게 보정될 뿐이다. 클라이언트의 위치는 플레이어가 로켓에 맞았다거나 적과 우연히 조우하는 경우 같이 아주 드문 경우에서만 보정될 필요가 있다.
UnrealScript의 GameInfo 클래스는 게임 규칙을 구현하고 있다. 서버(독립된 서버나 싱글 플레이어 모두)는 하나의 GameInfo 서브클래스(subclass, 파생된 클래스) 가지며, UnrealScript에서 Level.Game 같은 방법으로 접근할 수 있다. 언리얼의 각 게임 종류마다 특화된 GameInfo 서브클래스가 존재한다. 예를 들면, DeathmatchGame, SinglePlayer, TeamGame 같은 것이다.
네트워크 게임에 참여한 클라이언트는 GameInfo를 갖지 않는다. 즉, 클라이언트는 Level.Game==None 이란 말이다. 서버가 모든 게임 플레이 규칙을 구현하므로 클라이언트는 GameInfo를 갖지 않아야 한다. 게임 규칙이 무엇인지 알 필요도 없다.
GameInfo 클래스는 다양한 기능들을 포함하고 있다. 플레이어가 오고 가는 것을 인식하는 것, 사냥(kill)에 대한 점수를 주는 것, 무기가 리스폰할지 말지 결정하는 것, 등등 여러가지다. 여기서는 GameInfo 클래스의 함수 중에 네트워크 프로그래밍과 직접적으로 관련이 있는 것만 살펴보자.
event InitGame( string[120] Options, out string[80] Error );
서버(네트워크 플레이나 싱글 플레이어 모두)가 처음 시작할 때 호출된다. 이곳에서 URL 옵션을 분석해서 처리할 수 있다. 예를 들어, 서버가 "Unreal.exe MyLevel.unr?game=unreali.teamgame"로 시작했다면, Options 문자열은 "?game=unreali.teamgame"가 된다. Error 문자열에 뭔가가 입력되면, 게임은 치명적인 오류와 함께 종료된다.
event PreLogin( string[120] Options, out string[80] Error );
네트워크 클레이언트가 로그인하기 직전에 호출된다. 이곳에서 서버에게 플레이어를 거부할 기회를 갖는다. 플레이어의 패스워드를 검사하고, 플레이어 제한을 처리하는 등의 작업을 수행할 수 있다.
event playerpawn Login( string[32] Portal, string[120] Options, out string[80] Error, class<playerpawn> SpawnClass );
Login 함수는 PreLogin 함수가 성공한 다음에 항상 호출된다. 이곳이 Options 문자열을 사용해서 플레이어를 생성할 책임을 갖는다. 플레이어 생성이 성공하면, 생성된 PlayerPawn 액터를 반환해야 한다.
만약 Login 함수가 로그인이 실패했다는 의미의 None 값을 반환하면, Error 문자열에 상세한 오류 메시지를 입력해야 한다. 로그인에 실패하는 것은 최후의 수단으로서만 사용해야 한다. 로그인이 실패한다면, Login 함수 보다는 PreLogin 함수에서 실패하는 것이 더 효율적이다.
주어진 대역폭 내에서 시각적으로 중요한 요소의 양의 극대화하는 것이 목표이다. 대역폭의 한계는 런타임에 정해지므로, 멀티 플레이어 게임에서 사용되는 액터 스크립트를 작성할 때 대역폭을 최소한으로 사용하도록 유지하는 것이 목표이다. 언리얼의 스크립트에 쓰인 기법은 다음과 같다.
가능한한 ROLE_SimulatedProxy와 모조의(simulated) 움직임을 사용하라. 예를 들면, 언리얼에서 거의 모든 발사체는 ROLE_SimulatedProxy이다. 한 가지 예외는 플레이어를 조종(steer)할 수 있게 해주는 Razorjack 보조 무기(alt-fire) 블레이드이다. 이 때, 서버는 계속해서 위치 값을 갱신해서 클라이언트에게 전송해야 한다.
특수 효과 액터는 즉각 표현하기 위해 순수하게 클라이언트에서만 생성한다. 예를 들어, 많은 발사체는 클라이언트에서만 특수 효과를 생성하려고 모조의(simulated) HitWall 함수를 이용한다. 특수 효과는 게임 플레이에 영향을 미치기보다 단지 장식용이므로, 완전히 클라이언트에서만 처리해도 아무런 문제가 없다.
각 클래스의 NetPriority 기본값을 튜닝하라. 발사체와 플레이어는 높은 우선순위를 요구하지만, 순수하게 장식용인 특수 효과는 낮은 우선순위를 가져도 된다. 언리얼은 나름대로 고민해서 추측한 기본값을 제공하지만, 각 상황에 따라 튜닝함으로써 약간의 최적화를 할 수 있다.
액터가 처음으로 클라이언트에 복제될 때, 그 액터의 변수는 기본값으로 초기화된다. 이어서, 가장 최근의 값과 다른 값을 가지는 변수만 복제된다. 그러므로, 변수는 가능한한 자동으로 초기화되도록 클래스를 설계해야 한다. 예를 들어, 액터의 LightBrightness 변수는 항상 123 값을 가져야 한다면, 다음 두 가지 방법을 사용할 수 있다: (1) 클래스 설계에서 LightBrightness 변수의 기본값으로 123 값을 설정한다, 또는 (2) 액터의 BeginPlay 함수내에서 LightBrightness 변수의 값을 123 값으로 초기화한다. 여기서 첫 번째 방법이 더 효과적인데, LightBrightness 변수가 절대 복제되지 않을 것이기 때문이다. 두 번째 방법에서는 액터가 클라이언트의 의미있는 집합(relevant)에 포함될 때마다 LightBrightness 변수를 복제해야 한다.
네트워크 사용량을 모니터하려면, 다음 구문을 넣고 엔진을 컴파일하면 된다.
#define DO_SLOW_GUARD 1
이 방법은 네트워크 통계 데이터를 수집하게 한다. 그리고, Unreal.ini에서 Suppress[#]=DevNetTraffic 구문을 주석처리하라. 그러면, 시스템이 수신한 네트워크 데이터에 대한 통계 자료가 로그 파일에 남을 것이다. 바꾸어 말해서, 서버가 보낸 데이터를 살펴 보려면 클라이언트에다 이 방법을 사용을 하고, 클라이언트가 보낸 데이터를 살펴 보려면 서버에 이 방법을 사용하라. 로그 파일에 남는 정보는 매우 상세한(verbose) 형태이다. 복제된 모든 액터, 변수, 함수 호출의 요약된 자료(summary)와 함께 모든 패킷에 대한 타임 스탬프가 기록된다.
언리얼의 네트워크 지원은 C++ 클래스 UNetDriver에 기반한 플러그인 인터페이스를 통해 일반화되어 있다. 언리얼 엔진은 UNetDriver 클래스를 통해서 모든 네트워킹에 관련된 결정사항(issue)을 다룬다. 그리고, 동적으로 Unreal.ini에 명시된 적절한 네트워크 드라이버를 읽어 들인다. 기본값은 다음과 같다:
[Engine.Engine] NetworkDevice=IpDrv.TcpNetDriver
언리얼의 인터넷 드라이버는 TcpNetDriver라는 이름의 UDP 네트워크 드라이버이지만, UNetDriver 클래스에서 파생된 새로운 클래스를 만들어서 다른 종류의 네트워크를 지원하는 것도 물론(quite) 가능하다. TcpNetDriver 클래스의 소스를 참고하면 새로운 함수를 어떻게 구현해야 하는지 알 수 있다. 실제로 Maverick 소프트웨어 팀은 Mac 버전의 언리얼에 사용할 목적으로 AppleTalk 드라이버를 만들었다.
네트워크 드라이버는 연결을 만드는 것, 연결을 해제하는 것, 데이터를 보내는 것, 신뢰성이 없는 데이터를 수신하는 것에 대해 책임을 진다. 하지만, 데이터의 내용은 언리얼 와이어 프로토콜(Unreal Wire Protocol)에서 정의하고 있으며, 네트워크 드라이버 자체는 프로토콜의 정의에 대해서 전혀 알 수 없다. 그러므로, UNetDriver는 어떤 정보를 주고 받는지 모른채 그저 보내고 받는 것만 한다. 특성은 다음과 같다:
연결 지향. UNetDriver는 UNetConnection에서 파생된 클래스에 정의되어 있는 연결 리스트를 유지해야 한다. UNetDriver가 (UDP 같은) 비연결 프로토콜을 사용할 때는, 내부적으로 연결을 유지하고 타임아웃을 처리하는 등의 로직이 구현되어 있어야 한다.
UNetDriver는 스트림 형식이 아니라 패킷 형식이다. UNetDriver를 통해서 보내거나 받은 모든 데이터는 0에서 MAX_PACKET_SIZE 사이의 크기이며, 독립된(discrete) 패킷에 저장한다.
패킷은 신뢰성을 보장하지 않는다. 모든 신뢰성을 보장하는 패킷 송수신은 UNetDriver 수준에서 알 수 없는 고수준 계층에서 동작한다. 한 컴퓨터에서 다른 쪽으로 보낸 패킷은 도착하지 않을 수도 있고, 한 번 도착할 수도 있고, 여러번 반복해서 도착할 수도 있다. 그리고, 보낸 순서와 맞지 않거나 아주 늦게 도착할 수도 있다.
패킷은 절대 변조되지 않는다. 일단 패킷을 받았다면, 그 데이터는 보낸 패킷의 데이터와 일치한다.
언리얼은 신뢰성이 없고, 연결이 없는 표준 인터넷 프로토콜인 UDP를 사용한다. 게임 프레이에서 클라이언트와 서버 사이의 연결(Coordination)은 하나의 상수(변경되지 않는 UDP 주소의 짝)로 처리한다(UDP 주소는 클라이언트와 서버 모두 각각의 32비트 IP 주소와 16비트 포트 번호로 이루어져 있다). Unreal.ini 파일을 보면 포트 번호의 기본값을 알 수 있다.
이와 같이, 언리얼의 UDP 패킷은 대체(proxy)하기 매우 쉽고, 그 내용을 전혀 알 필요도 없이 대체하는 것이 가능하다.
언리얼 와이어 프로토콜(Unreal Wire Protocol)은 패킷의 내용을 비트의 스트림으로 정의하고 있다. 프로토콜은 변수나 함수 호출 복제, 액터 생성, 액터 파괴, 파일 전송, 패킷의 신뢰성 보장과 같은 여러 개념을 다룬다.
프로토콜은 몇 달 뒤에 효율성이고 신뢰성이 향상되는 주요 변화가 진행될 것이다. 그래서 지금 여기에 문서화하는 것은 쓸모없을 것 같다.
프로토콜은 Package Documentation에 기술된 언리얼 패키지 파일 포맷과 밀접하게 연관이 있다는 사실을 기억하라. 그래서 분석하거나 뭔가 유용한 것을 만드는 것은 매우 힘들 것 같다.
언리얼의 TcpLink와 UdpLink 스크립트는, 외부 프로그램과 통신할 수 있는 액터를 UnrealSript로 만드는 것을 가능하게 한다. 이는 언리얼을 새롭고 멋진 네트워킹 기능으로 확장하는 최고의 방법이다. 이 멋진 방법으로 할 수 있는 몇 가지 예를 보자:
다른 서버로 이벤트(플레이어가 버튼을 눌렀다거나 다른 구역으로 이동하는 등)를 전송하는 "이벤트 게이트웨이"를 작성한다.
서버를 마스터 서버와 연결하거나 통계 정보를 추적하는 등등의 서버 프로그램을 작성한다.
플레이어의 기록 정보나 인벤토리를 계속해서 유지하는 서버 프로그램을 작성한다. 예를 들면, Java/C++ 데이터베이스와 같은 종류의 "플레이어 계정 관리" 프로그램을 만들 수 있다. 이 프로그램은 플레이어의 이름과 비밀번호, 인벤토리 정보를 유지하게 된다. 그리고, TCP나 UDP를 통해서 계정 관리 모듈과 통신하는 UnrealScript 클래스를 만들 수 있으며, 이것을 통해 로그인을 시도하는 유저를 인증할 수도 있다. 아이디어를 좀 더 얻으려면 GameInfo.Login, TcpLink, 그리고 UdpLink를 살펴보라.
클라이언트 채팅 프로그램을 작성한다.
언리얼 네트워킹 아키텍처를 한 마디로 설명할 수 있는 단어는 "강력한(powerful)", "일반화된(general)", "복합적인(complex)", "숙달하기 힘든(hard to master)" 같은 것들이다. 아키텍처는 게임 개발 과정에서, 강력함과 단순함, 그리고 어떤 문제를 해결해 줄 실용적인 요구 사항들 사이의 조화를 이룬 지금의 해결책에 다다르기 위해서 무시무시한 양의 발전을 이룩했다.
프로그래밍 난이도 관점에서 볼 때 가장 이상적인 네트워킹 아키텍처는 순수한 클라이언트-서버 모델이다. 여기서 클라이언트는, 계산 능력이 없는 렌더링 터미널로 동작하면서 서버가 보내주는 처리 목록을 받아서 실행한다. 하지만, 이전에 논의했듯이, 네트워크 대역폭의 발전 속도와 컴퓨터 하드웨어의 발전 속도를 비교 분석한 자료를 보면, 실세계에서는 앞으로도 순수 클라이언트-서버 모델은 현실적으로 불가능하다.
이렇게, 일반적으로 클라이언트에서만 이루어지는 다양한 물리 연산이나 스크립트 처리 능력은 다음 세대의 네트워크 엔진과 동등한 수준이다. 언리얼은 오브젝트, 오브젝트 변수, 오브젝트 함수 호출을 복제하는 것에 기반해서 일반화된(generalized) 네트워킹 모델을 정의하고 있다. 이 모델은 특수한 시뮬레이션 모델마다 그에 특화된 코드를 작성하지 않고서도 다양한 시뮬레이션을 처리할 수 있다.
언리얼에서 쓰이는 광범위한 네트워킹 아키텍처는 이 세상에 하나 뿐이라는 것을 확신한다. 현재의 방향으로 결정하는 동안 조사했던 영역에는 데이터베이스 복제, 유닉스 RPC, CORBA 분산 객체 모델, 그리고 자바 RPC 같은 것들이 포함된다. 나의 총체적인 결론은, 비록 멋진 아이디어는 많지만 게임 개발자만큼 인터넷 기술의 한계를 극복(push)하려는 사람은 아무도 없다는 것이다. 실시간 3D와 같은 다른 영역의 연구도 비슷하다고 할 수 있다.
이 모델은 앞으로 여러 부분에 걸쳐 확장하고 개선할 여지가 있다. 멀티캐스트 복제 함수 호출을 추가하면 변수 복제 없이도 처리할 수 있는 시뮬레이션의 종류(amount)가 늘어난다. 접속자(peer) 프록시 모델을 확장하면, 백본 서버를 생성해서 NPC 오브젝트의 현실감있는 실시간 행위를 처리할 수 있다. 복제 오브젝트 모델은 UI(유저 인터페이스)나 채팅 시스템 같은 게임 엔진의 다른 영역으로도 확장할 수 있다. 이 아키텍처는 분산 구조를 유지하면서도 유저가 직접 수정 가능한 시스템인 울티마 온라인의 게임 월드와 같은 더 풍부하고(richer) 끊이지 않는(persistent) 게임 환경을 구현하기 위해 해결해야 할 많은 양의 과제가 남아있다. 다가올 몇 년 안에 네트워킹 분야는 게임 개발에서 매우 흥미로운 영역이 될 것이다.
- 팀 스위니