언리얼 멀티플레이 이해하기

MoOrY·2023년 11월 7일
0

언리얼 엔진

목록 보기
37/41

https://youtu.be/JOJP0CvpB8w

언리얼은 빌트인 된 멀티플레이 기능을 가지고 있다.
멀티플레이에선 여러개의 클라이언트가 동시에 실행되는데, 각 클라이언트가 서로 대화할 수 있고, 일관적인 하나의 세계를 공유해야 한다.

언리얼 네트워크 모델에서는, 플레이어는 서버에 접속하고, 서버는 권위있는 월드를 보유한다. 서버에서 무언가 변경되면, 복제Replicate라는 프로세스로 해당 변경 사항을 다른 클라이언트에 반영한다.

언리얼 복제 시스템 덕분에 소켓, 직렬화 등 복잡한 부분을 신경쓰지 않고 멀티플레이 게임을 쉽게 개발할 수 있다.


Net Mode

ENetMode UWorld::GetNetMode() const;

Net Mode는 월드(UWorld)의 프로퍼티이며, 다음 네가지 값 중 하나를 가진다.

NM_Standalone
NM_DedicatedServer
NM_ListenServer
NM_Client

다음과 같은 기준으로 넷 모드를 구별할 수 있다.

  1. 게임을 플레이할 수 있는가? (GameInstance에 LocalPlayer이 있고, 플레이어의 입력을 처리하며 월드를 렌더링하는가?)
  2. 서버인가? (GameInstance에 GameMode 액터가 포함된, 권위있는 버전의 월드 복사본을 가지고 있는가?)
  3. 클라이언트에 대해 열려있는가? (자신이 서버이고, 다른 클라이언트가 자신에게 참여할 수 있는가?)

해당 기준에 대해 4가지 넷 모드는 각각 다음과 같다.

NM_Standalone 		// 1, 2, x
NM_DedicatedServer 	// x, 2, 3
NM_ListenServer 	// 1, 2, 3
NM_Client 			// 1, x, x

게임을 시작하면 게임의 생명 주기와 함께하는 게임 인스턴스 객체를 얻게 된다. 이후, 게임은 서버 주소 혹은 맵 이름의 주소 정보를 담은 URL을 찾고, 이를 이용하여 맵을 로드하여 월드(UWorld)를 생성한다.

월드의 NetMode는 게임 인스턴스가 시작된 방식에 따라 달라지는데, 이는 다음과 같다.

  • NM_Standalone
    게임 인스턴스가 로컬로 맵을 로드한 경우.
    게임 인스턴스는 서버이자 클라이언트지만, 단일 구성으로 실행되므로, 다른 클라이언트가 참여할 수 없다.
    URL은 맵 이름 혹은 경로

  • NM_DedicatedServer
    LocalPlayer나 뷰포트가 없는 게임의 인스턴스
    플레이어가 클라이언트로 연결할 수 있는 서버 전용 콘솔 응용 프로그램

  • NM_ListenServer
    맵을 로컬로 연결하지만, 경로에 ?Listen 옵션을 추가하여 로드한 경우
    StandAlone과 동일하지만, 다른 클라이언트가 참여할 수 있다.
    URL은 맵 경로?Listen

  • NM_Client
    게임 인스턴스가 원격 서버에 연결된 경우.
    URL은 아이피 주소


복제 시스템의 기반

언리얼의 서버와 모든 클라이언트는 각각의 게임 인스턴스와 월드를 가진다. 이 각각의 클라이언트를 일관되게 만들기 위해 복제를 통해 동기화하는데, 이를 실현하기 위해 복제 시스템은 UNetDriver, UNetConnection, UChannel이라는 세가지 중요한 클래스에 의존한다.

데디 케이트 서버와 두개의 클라이언트가 있다고 가정해보자. 세 프로세스 각각에는 엔진 객체(UGameEngine)가 있다.
서버를 부팅하면 NetDriver가 생성되고, 원격 프로세스의 메세지 수신이 시작된다.(InitListen() 함수 호출)
클라이언트를 부팅하면 NetDriver가 생성되어 서버에 연결 요청을 보낸다.(InitConnect() 함수 호출)

서버와 클라이언트의 NetDriver가 연결되면, NetDriver 내의 NetConnection이 설정된다.
서버에는 연결된 각 클라이언트마다 하나씩 NetConnection을 가지고, (클라이언트가 3개면 NetConnection도 3개)
클라이언트서버에 대한 연결을 나타내는 하나의 NetConnection을 가진다.

각각의 NetConnection은 다양한 채널을 가지는데, 일반적으로 UControlChannelUVoiceChannel, 그리고 복제되는 모든 액터당 하나씩 가지게 되는 ActorChannel이 있다.
이는 복제가 엑터 단에서 이루어진다는 복제 시스템의 주요 특성을 보여준다.

액터를 모든 프로세스에 대해 동기화하기 위해, 해당 액터를 복제할 수 있도록 구성한다.(bReplicates = true)
복제할 수 있는 액터가 특정 플레이어와 연관된 것으로 간주되면,(AActor::IsNetRelevantFor() == true)
서버는 해당 플레이어의 NetConnection에서 ActorChannel을 열고, 서버와 클라이언트는 해당 채널을 통해 해당 액터에 대한 정보를 교환한다.

액터 복제

액터가 클라이언트에 복제되는 경우, 세가지 중요한 일이 발생할 수 있다.

Lifetime

액터의 수명이 서버와 클라이언트 간에 동기화되어 유지된다. 서버에서 복제되는 액터를 스폰하면 클라이언트에서도 생성되고, 액터가 서버에서 소멸하면, 클라이언트에서도 자동으로 소멸한다.

프로퍼티 복제

액터에 복제 플래그가 지정된 멤버 변수가 있는 경우, 해당 변수가 서버에서 변경되면 새로운 값이 클라이언트에 전파된다.

RPC

https://docs.unrealengine.com/4.27/ko/InteractiveExperiences/Networking/Actors/RPCs/
원격 프로시저 호출로, 다음 세가지 타입이 있다.

  • Multicast RPC
    서버에서 해당 함수를 호출하면 서버는 해당 액터가 현재 복제되고 있는 모든 클라이언트에 메세지를 보내, 클라이언트가 해당 함수를 호출하게 한다.

  • Server RPC
    서버에서 해당 클라이언트에게 함수를 호출하도록 한다.

  • Client RPC
    클라이언트에서 서버에게 해당 함수를 호출하도록 한다.

소유권(Ownership)

모든 액터는 소유자Owner로 지정된 다른 액터를 가질 수 있다.
일반적으로 생성 시 소유자를 지정하지만,(UWorld::SpawnActor() 호출 시, 해당 함수의 파라미터 지정) 런타임에 지정할 수도 있다.(AActor::SetOwner())

플레이어 컨트롤러는 소유권과 관련하여 특히 중요한데, 일반적으로 각각의 UNetConnection은 플레이어를 나타내며(UNetConnection is UPlayer), 플레이어가 완전히 로그인 되면, 이와 연결된 플레이어 컨트롤러를 갖게 된다.
서버의 관점에서 보면, UNetConnection은 해당 플레이어 컨트롤러를 소유하고, 더 나아가 플레이어 컨트롤러로 추적할 수 있는 모든 액터를 소유한다.

예를 들어, 플레이어 컨트롤러는 APlayerState와 APawn객체를 자동으로 소유하는데, 만약 플레이어 컨트롤러가 소유하는 캐릭터가 무기를 소환하고, 서버가 해당 무기가 누구 소유인지 알고 싶다고 가정하면, 서버는 무기 액터의 소유자 참조 체인을 따라가며 플레이어 컨트롤러를 찾고, 최종적으로 플레이어 컨트롤러의 소유자인 UNetConnection 객체를 찾게 되고, 어느 Client Connection에 속해 있는지 알아내게 된다.

액터 복제 플래그 지정

액터를 복제하도록 등록하려면, 생성자에서 bReplicates = true를 지정해 주거나, AACtor::SetReplicates 함수를 호출하여 런타임에 복제를 켜거나 끌 수 있다.
bReplicates = true가 되면, 해당 액터가 복제에 적합해 지고, 서버는 해당 액터를 클라이언트에 복제하기 위해, 접속한 모든 플레이어의 NetConnection에서 해당 액터를 위한 새로운 ActorChannel을 열게 된다.

연관성Relevancy

https://docs.unrealengine.com/5.0/ko/actor-relevancy-and-priority-in-unreal-engine/

액터의 연관성에 따라, 어떤 연결이 언제 일어날지 결정된다.
액터가 복제에 적합할 때, 서버의 UNetDriver 객체는 해당 액터가 각각의 클라이언트와 연관이 있는지 확인한다.(AACtor::IsNetRelevantFor())
일부 액터는 bAlwaysRelevant == true인데, 복제 자격이 있는 한, 모든 클라이언트에 복제된다는 의미이다. 예를들어 GameState나 PlayerState는 bAlwaysRelevant == true이다.

소유권은 연관성에 있어 중요한 역할을 하는데, 특정 플레이어가 소유하거나 선동하는 액터는 항상 해당 클라이언트와 연관된 것으로 간주된다. (해당 액터가 어떤 플레이어에게 소유되는지는 AActor::GetNetOwner로 구할 수 있다.)
플레이어 컨트롤러와 같은 일부 액터는 소유자에게만 연관되도록 구성되어 있으므로, 소유자가 아닌 다른 클라이언트엔 복제되지 않는다.

소유자로부터 연관성을 상속하도록 액터를 구성할 수도 있다. (bNetUseOwnerRelevancy = true)

연관성 기본 동작 규칙

한 액터가 이러한 특수 플래그 중 어느 것도 설정되지 않고, 클라이언트가 해당 액터를 소유하지 않은 경우, 기본 동작은 연관성을 해당 클라이언트의 플레이어와의 거리를 기반으로 정한다.액터에서 플레이어까지의 제곱 거리가 NetCullDistanced보다 작으면 액터는 해당 플레이어와 연관이 있다.

거대한 레벨에서 한 플레이어는 해당 레벨의 작은 일부분만 볼 수 있는데, 레벨에 있는 다른 액터는 대부분 보이지도, 플레이어에게 중대한 영향을 끼치지 않는다. 따라서 서버가 보이는 것으로, 또는 클라이언트에 영향을 끼칠 수 있다고 여기는 액터 세트는 그 클라이언트에 대해 연관성이 있는 액터 세트라 여기게 된다.
언리얼 네트워크 코드의 대역폭 최적화는, 서버가 클라이언트에게 연관성이 있는 액터 세트에 대해서만 알려주는 것으로 이루어진다.
이것이 연관성을 설정하는 기본 동작 규칙이 거리에 따라 이루어지는 이유이다.

언리얼이 플레이어에 대해 연관성이 있는 액터 세트를 결정하는 데 사용하는 규칙은 다음과 같다.
이 검사는 순서대로 검사하며, AActor::IsNetRelevantFor에서 수행한다.

  1. 액터가 bAlwaysRelevant == true (항상 연관성이 있)거나, Pawn 이거나, 폰이 노이즈나 대미지같은 동작의 Instigator (유발자)인 경우, 연관성이 있다.

  2. 액터가 bNetUseOwnerRelevancy == true (네트 오너 연관성 사용)이고 Owner (오너)가 있는 경우, 오너의 연관성을 사용한다.

  3. 액터가 bOnlyRelevantToOwner == true (오너에게만 연관성이 있)고 첫 번째 검사를 통과하지 못한 경우, 연관성이 없다

  4. 액터가 다른 액터의 스켈레톤에 붙어있으면, 그 연관성은 조상의 연관성에 의해 결정된다.

  5. 액터가 숨겨져있고 (bHidden == true) 루트 컴포넌트가 충돌이 비활성화 되어 있으면 액터는 연관성이 없다.
    (루트 컴포넌트가 없으면, AActor::IsNetRelevantFor() 는 경고를 출력하고 액터에 bAlwaysRelevant=true 설정을 할지 물어본다)

  6. AGameNetworkManager (게임 네트워크 매니저)가 거리 기반 연관성을 사용하도록 설정되어 있고, 액터가 네트 컬 디스턴스보다 가깝다면 연관성이 있다

기본 동작 규칙은 모든 액터 클래스에 대해 IsNetRelevantFor 함수를 재정의함으로써 변경할 수 있다.

업데이트 빈도와 우선순위 Update Frequency & Priority

액터가 복제되면, 업데이트 빈도와 우선순위에 따라 서버가 접속한 클라이언트에 업데이트를 보내는 빈도가 결정된다.
NetUpdateFrequency를 설정하면 서버가 액터를 확인하고, 변동된 사항이 있을 경우, 클라이언트에 새 데이터를 보내는 초당 횟수가 결정된다.
실제 네트워크는 대기 시간이 매우 가변적이고, 대역폭이 제한 요소가 될 수 있으므로, 액터가 초당 60회 업데이트 되도록 설정되어 있어도, 반대편에선 항상 초당 60회 미만으로 작동될 수 없다는 점에 주의

서버의 NetDriver은 대역폭 포화를 완화하기 위해 몇 가지 간단한 로드 밸런싱을 사용한다. NetDriver가 작업할 수 있는 대역폭이 한정되어 있으므로, 우선 순위에 따라 관련 액터들을 정렬한 다음, 사용 가능한 대역폭을 모두 사용할 때까지 업데이트를 실행한다.
플레이어와 가까운 액터거나, 한동안 업데이트 되지 않은 액터에는 더 높은 우선순위 가중치가 부여되므로, 모든 액터는 결국에는 우선 순위의 맨 앞으로 작업하게 된다.

액터의 NetPriority 변수를 설정하면, 해당 가중치에 추가 값이 적용된다. 만약 NetPriority를 5.0으로 설정하면, 보통보다 5배 더 자주 업데이트 받게 된다.

RPC

앞서 언급한 정기적으로 업데이트되고, 대역폭이 제한되는 네트워크 업데이트 프로세스는 대부분의 복제되는 프로퍼티에 적용된다. 하지만 우선순위와 빈도를 무시하고, 즉시 전송하여 적용하고 싶은 메시지가 있는 경우를 처리하기 위해 RPC가 존재한다.

https://docs.unrealengine.com/4.27/ko/InteractiveExperiences/Networking/Actors/RPCs/

  • Server RPC
    서버에서 해당 클라이언트에게 함수를 호출하도록 한다.

  • Client RPC
    클라이언트에서 서버에게 해당 함수를 호출하도록 한다.

  • Multicast RPC
    서버에서 해당 함수를 호출하면 서버는 해당 액터가 현재 복제되고 있는 모든 클라이언트에 메세지를 보내, 클라이언트가 해당 함수를 호출하게 한다.
    서버와 클라이언트RPC와 달리, 멀티캐스트RPC는 연관성의 영향을 받는다. 소유하지 않은 클라이언트는 액터에 대한 공개 채널을 갖고 있지 않을 수 있기 때문, 이 경우 연관성이 없는 클라이언트에선 멀티캐스트 RPC가 호출되지 않고, 이는 지속적인 상태변화를 클라이언트에 복제하기 위해 멀티캐스트RPC에 의존해서는 안되는 이유이다.

신뢰할 수 있음 플래그

Reliable 플래그를 넣어줌으로써, 신뢰할 수 있는 RPC 지정이 가능하다.

  • 신뢰할 수 없는 RPC는 대역폭이 포화된 경우, 메시지가 반드시 도착하거나, 순서대로 도착한다는 보장이 없다.
  • 신뢰할 수 있는 RPC는 도착하는것이 보장되며, 단일 액터 내에서는 호출된 순서대로 도착하는 것이 보장된다.

해당 함수가 중요한 함수라면 이러한 안정성이 필요하지만, 과도하게 사용하면 대역폭 포화가 악화되고 패킷 손실 시 병목 현상이 발생할 수 있음에 주의

_Implementation

RPC함수의 구현부의 함수 이름에는 _Implementation을 붙여줘야 한다. _Implementation이 붙은 함수 구현부는 실제로 메시지가 도착한 클라이언트 혹은 서버에서 실행되는 내용이고, _Implementation이 붙지 않고, 로컬에서 호출하는 함수는 네트워크를 통해 적절한 메시지를 보내는 자동 생성된 썽크이다.

_WithValidation

서버 RPC에 UFUNCTION 내부에 WithValidation 플래그를 지정할 수 있는데, 이 경우, 동일한 인수를 사용하고, 뒤에 _WithValidation를 붙인 함수를 생성할 수 있다. 해당 함수는 해당 값을 신뢰할 수 있는지 여부를 나타내는 부울을 반환한다. 이를 통해 클라이언트에서 전송된 데이터를 사용하는 경우, 치트를 감지할 수 있다. 서버 RPC가 이 검사에 실패하면, 해당 RPC를 보낸 클라이언트가 게임에서 추방된다는 점을 명심


RPC는 즉시 전송되며 신뢰할 수 있으고, 서버 RPC는 클라이언트의 값을 서버로 가져올 수 있는 유일한 방법이므로 매우 유용하다.
하지만 대체로 RPC는 일반적으로 캐릭터 무브먼트 등, 우선순위가 높고, 시간이 중요한 네트워크 코드용으로 예약되어 있다.
그 밖에 모든 경우엔 가능한 프로퍼티 레플리케이션에 의존해야 한다.

프로퍼티 레플리케이션

언리얼 복제 시스템의 핵심이며, 로드 밸런싱 및 우선순위 지정 기능을 통해 훨씬 더 확장성이 뛰어나다.

서버에서 복제되는 프로퍼티를 변경하면, 모든 클라이언트가 결국에는 서버와 동기화 될 것이라 기대할 수 있다.
플레이어가 너무 멀리 떨어져 있는 상태에서, 서버에서 액터가 변경되면, 변경사항은 업데이트 되지 않고 유지되다가, 액터가 해당 클라이언트와 다시 관련되면, 업데이트 된 값을 받게 된다.

프로퍼티 레플리케이션은 어떠한 경우에도 업데이트 빈도와 대역폭 제한을 준수한다.
서버에서 단일 프레임마다 복제되는 프로퍼티를 변경해도, 클라이언트는 업데이트 빈도에 따라, 업데이트될 때마다 가장 최근 값을 가져온다. 서버가 모든 단일 프레임마다 변경된 값을 보낼 의무가 없다는 뜻.

GetLifetimeReplicatedProps()

프로퍼티를 복제하고싶으면, UPROPERTY에 Replicate 플래그를 지정해주면 된다. 이후 복제하는 프로퍼티를 가진 모든 액터는 GetLifetimeReplicatedProps()함수를 오버라이드하고, DOREPLIFETIME(액터 포인터, 복제되는 프로퍼티 변수) 매크로로 복제하고자 등록하면 된다.
DOREPLIFETIME_CONDITION()으로 복제 조건을 지정할 수도 있는데, 예를 들어 이 액터를 소유하는 클라이언트에만 복제하고 싶다면, COND_OwnerOnly를 기입하면 된다.

RepNotify

복제되는 프로퍼티가 업데이트 될 때 일부 함수성을 실행해야 한다면 유용하다.
ReplicatedUsing=함수이름 플래그를 지정하여, RepNotify 함수를 등록하면, 복제 업데이트로 인해 값이 변경될 때마다 해당 함수가 클라이언트에서 호출된다.

주의
블루프린트에서는, 서버에서 복제되는 프로퍼티를 변경해도 RepNotify 함수가 자동으로 호출되지만, C++에서 RepNotify는 오직 클라이언트에서만 호출된다. 만약 해당 함수를 서버에서도 호출하고 싶다면, 명시적으로 해당 RepNotify함수를 호출해주자.

오소리티와 네트워크 롤Authority&Role

enum ENetRole
{
    ROLE_None,
    ROLE_SimulatedProxy,
    ROLE_AutonomousProxy,
    ROLE_Authority,
    ROLE_MAX,
}

대부분의 경우, 이 액터에 대한 권한이 나에게 있는지를 고려하면 된다.(HasAuthority() == true)
만약 HasAuthority() == true일 경우, 현재 해당 코드는 스탠드 얼론으로 실행되는 싱글 플레이거나, 서버이거나, 해당 액터가 클라이언트에만 존재하는 것이다.
HasAuthority() == false일 경우, 해당 코드는 클라이언트에서 실행되고, 액터는 서버에서 복제된 것이다. 이 경우, 클라이언트 액터 복제본은, 서버의 권위있는 버전의 프록시이다. 대부분의 경우, SimulatedProxy이지만, AutonomousProxy는 플레이어에 대해 이야기할 때만 적용된다.

플레이어 컨트롤러와 해당 플레이어 컨트롤러가 컨트롤하는 폰은, 해당 플레이어 컨트롤러를 소유한 클라이언트에 복제될 때 AutonomousProxy이다. 소유하지 않는 다른 모든 클라이언트에선 SimulatedProxy로 복제된다. AutonomousProxy는 클라이언트가 완전한 권한을 가지지 못하더라도, 액터의 움직임과 행동을 직접적으로 제어한다는 것을 의미한다.

IsLocallyControlled

폰이 로컬에 의해 제어받는 경우, 해당 플레이어는 IsLocallyControlled = true이다. 만약 IsLocallyControlled == false일 경우, 원격 클라이언트의 플레이어다.

멀티플레이 개발 팁

멀티플레이 게임을 만든다는 것은, 클래스의 멤버 함수의 기능만 생각하는 것이 아니라, 해당 코드가 실행되는 위치와, 데이터가 네트워크를 통해 서로 다른 시간에 흐르는 방식도 고려해야 한다. 추가로 고려해야 하는 고민거리가 하나 더 생기는 셈.
이를 보다 쉽게 배우기 위해 몇 가지 팁을 첨부한다.

  1. 액터가 복제되더라도, 해당 액터의 모든 부분이 네트워크와 관련될 필요가 없다.
    서버에서 동기화 되어야 하는 중요한 부분만 처리하고, 게임에 큰 영향을 주지 않는 다른 모든 것은 해당 클라이언트에게 맡겨버리자
  2. 대부분의 게임 코드는 멀티플레이용으로 설계된 언리얼 게임 프레임워크를 기반으로 구축된다. 자신만의 기능을 추가하기 위해 이러한 클래스의 멤버 함수를 재정의하려는 경우, 해당 함수가 서버에서만 실행되도록 설계되었는지, 혹은 클라, 혹은 모든 곳에서 실행되도록 설계되었는지 알고 싶을 것이다.
    어설션을 사용하면 코드가 어디에서 실행되도록 설계되었는지 명확하게 알 수 있다.
    • checkf(HasAuthority(), TEXT("This Code Should only run on the server!")
    • checkf(!HasAuthority(), TEXT("This Code Should only run on the client!")
    • checkf(IsLocallyControlled(), TEXT("Pawn must be locally controlled!")
      또한, 이러한 함수에 접두사로 Auth등을 붙여주는 규칙을 사용한다면 유용할 것이다.
  3. 심도깊은 이해를 원한다면 엔진 코드를 보자
    NetDriver.h에는 복제 시스템 작동 방식에 대한 유용한 정보들을
    NetWorkDriver.cpp에는 유용한 콘솔 변수와 구현을 확인할 수 있다.
    NetDriver은 엔진 수준의 기본 클래스일 뿐, 게임 네트워크 드라이버에 사용되는 실제 구현은 IPNetDriver, IPConnection과 함께 OnlineSubsystemUnils 플러그인에서 구현된다.
profile
필기용 블로그입니다.

0개의 댓글