
글에 사용된 모든 그림과 내용은 직접 만들고 작성한 것입니다.
언리얼 엔진의 멀티플레이 게임에서 Property를 어떻게 Replication 하는지, 서버와 클라이언트가 클래스 내부에 있는 Property를 Replicate하는 방법은 무엇인지에 대에 학습한 내용을 정리하기 위함
- Property Replication은 매크로로 정의되고, AGameState에서 MatchState를 가져와 비교한다
- 액터에서는 GetLifetimeReplicatedProps를 사용해 함수를 만들어서 프로퍼티를 Replication되게끔 지정할 수 있다.
https://dev.epicgames.com/documentation/en-us/unreal-engine/replicate-actor-properties-in-unreal-engine
https://dev.epicgames.com/documentation/ko-kr/unreal-engine/property-replication?application_version=4.27
https://dev.epicgames.com/documentation/en-us/unreal-engine/replicating-variables-in-blueprints?application_version=4.27
공식 홈페이지에서는 Property Replication을 다음처럼 소개하고 있습니다.
"서버는 리플리케이트된 프로퍼티의 값이 변할 때마다 각 클라이언트에 업데이트를 전송하며, 클라이언트는 액터의 로컬 버전에 적용합니다. 이 업데이트는 서버에서만 받으며, 클라이언트는 프로퍼티 업데이트를 서버나 다른 클라이언트로 절대 전송하지 않습니다.액터 프로퍼티 리플리케이션은 신뢰성입니다."
신뢰성을 기반으로 값이 업데이트되며 정수 프로퍼티가 빠른 속도로 1에서 10으로 1씩 증가했다면, 클라이언트에서 최종적으로 10이라는 것을 알 수 는 있지만 중간에 5였다는 사실을 알 수 있다고 보장할 수 없습니다.
즉 이 Property Replication을 키는 순간, 서버에서 어떤 처리가 끝나고 이를 클라이언트에서 감지할때 어떻게 감지할지 잘 생각해야합니다.
예시 코드는 다음과 같습니다.
UPROPERTY에서 Replicated 지정자를 추가해주고, GetLifetimeReplicatedProps 함수에서 DOREPLIFETIME 매크로를 불러 해당 변수를 Replication에 등록해줍니다.
// 헤더파일 부분
virtual void GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const override;
UPROPERTY(Replicated)
int32 ReplicatedInt;
// cpp 파일 부분
void AItemActor::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AItemActor, ReplicatedInt);
}
ReplicatedUsing으로 함수의 이름을 전달해줍니다. OnRep_Function를 전달합니다.
virtual void GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const override;
UPROPERTY(ReplicatedUsing="OnRep_Function")
int32 ReplicatedInt;
UFUNCTION(Client, Reliable)
void OnRep_Function();
void AItemActor::OnRep_Function_Implementation()
UE_LOG(LogTemp, Log, TEXT("ReplicatedInt: %d"), ReplicatedInt);
}
여기서 저 DOREPLIFETIME 매크로를 들어가보면 이렇게 선언이 되어있습니다.
#define DOREPLIFETIME(c,v) DOREPLIFETIME_WITH_PARAMS(c,v,FDoRepLifetimeParams())
Net/UnrealNetwork.h에서 이 매크로를 사용하는 함수를 따라가보면 다음처럼 선언이 되어 있습니다. OutLifetimeProps에 유효성을 검사해서 네트워크 복제할 속성을 추가해주고 있습니다. COND_NetGroup도 검사하고 있습니다.
// 헤더파일 부분
/**
* Struct containing various parameters that can be passed to DOREPLIFETIME_WITH_PARAMS to control
* how variables are replicated.
*/
struct FDoRepLifetimeParams
{
/** Replication Condition. The property will only be replicated to connections where this condition is met. */
ELifetimeCondition Condition = COND_None;
/**
* RepNotify Condition. The property will only trigger a RepNotify if this condition is met, and has been
* properly set up to handle RepNotifies.
*/
ELifetimeRepNotifyCondition RepNotifyCondition = REPNOTIFY_OnChanged;
/** Whether or not this property uses Push Model. See PushModel.h */
bool bIsPushBased = false;
#if UE_WITH_IRIS
/** Function to create and register a ReplicationFragment for the property */
UE::Net::CreateAndRegisterReplicationFragmentFunc CreateAndRegisterReplicationFragmentFunction = nullptr;
#endif
// CPP 파일 부분
void RegisterReplicatedLifetimeProperty(
const NetworkingPrivate::FRepPropertyDescriptor& PropertyDescriptor,
TArray<FLifetimeProperty>& OutLifetimeProps,
const FDoRepLifetimeParams& Params)
{
checkf(Params.Condition != COND_NetGroup, TEXT("Invalid lifetime condition for %s. COND_NetGroup can only be used with registered subobjects"), PropertyDescriptor.PropertyName);
for (int32 i = 0; i < PropertyDescriptor.ArrayDim; i++)
{
const uint16 RepIndex = PropertyDescriptor.RepIndex + i;
FLifetimeProperty* RegisteredPropertyPtr = OutLifetimeProps.FindByPredicate([&RepIndex](const FLifetimeProperty& Var) { return Var.RepIndex == RepIndex; });
FLifetimeProperty LifetimeProp(RepIndex, Params.Condition, Params.RepNotifyCondition, Params.bIsPushBased);
#if UE_WITH_IRIS
LifetimeProp.CreateAndRegisterReplicationFragmentFunction = Params.CreateAndRegisterReplicationFragmentFunction;
#endif
if (RegisteredPropertyPtr)
{
// Disabled properties can be re-enabled via DOREPLIFETIME
if (RegisteredPropertyPtr->Condition == COND_Never)
{
// Copy the new conditions since disabling a property doesn't set other conditions.
(*RegisteredPropertyPtr) = LifetimeProp;
}
else
{
// Conditions should be identical when calling DOREPLIFETIME twice on the same variable.
checkf((*RegisteredPropertyPtr) == LifetimeProp, TEXT("Property %s was registered twice with different conditions (old:%d) (new:%d)"), PropertyDescriptor.PropertyName, RegisteredPropertyPtr->Condition, Params.Condition);
}
}
else
{
OutLifetimeProps.Add(LifetimeProp);
}
}
}
};

다음의 그림을 보면 서버와 클라이언트가 공유하고 있는 클래스가 다음처럼 있는데, PlayerState는 어떻게 서버, 클라이언트가 모두 알고 있는 걸까요? GameStateBase.h를 살펴보면 다음과 같은 변수가 있습니다. 주석을 보면 서버, 클라이언트 모두가 유지된다고 나와있습니다.
/** Array of all PlayerStates, maintained on both server and clients (PlayerStates are always relevant) */
UPROPERTY(Transient, BlueprintReadOnly, Category=GameState)
TArray<TObjectPtr<APlayerState>> PlayerArray;
그리고 이 PlayerArray를 사용하는 함수를 찾아보면 이렇게 두 개가 있습니다. 이렇게 두 함수로 PlayerArray를 관리하고 있습니다.
/** Add PlayerState to the PlayerArray */
ENGINE_API virtual void AddPlayerState(APlayerState* PlayerState);
/** Remove PlayerState from the PlayerArray. */
ENGINE_API virtual void RemovePlayerState(APlayerState* PlayerState);
// GameStateBase.cpp
void AGameStateBase::AddPlayerState(APlayerState* PlayerState)
{
// Determine whether it should go in the active or inactive list
if (!PlayerState->IsInactive())
{
// make sure no duplicates
PlayerArray.AddUnique(PlayerState);
}
}
void AGameStateBase::RemovePlayerState(APlayerState* PlayerState)
{
for (int32 i=0; i<PlayerArray.Num(); i++)
{
if (PlayerArray[i] == PlayerState)
{
PlayerArray.RemoveAt(i,1);
return;
}
}
}
그리고 위의 두 함수를 사용하는 다른 함수를 찾아보면 PostInitializeComponents()와 Destroyed()를 발견할 수 있습니다. 이는 APlayerState의 PostInitializeComponents()와 Destroyed() 부분이고, 월드내에 있는 게임 스테이트를 찾아서 AGameStateBase 내부의 함수를 호출하고 있습니다.
void APlayerState::PostInitializeComponents()
{
Super::PostInitializeComponents();
UWorld* World = GetWorld();
AGameStateBase* GameStateBase = World->GetGameState();
// register this PlayerState with the game state
if (GameStateBase != nullptr )
{
GameStateBase->AddPlayerState(this);
}
if (GetLocalRole() < ROLE_Authority)
{
return;
}
AController* OwningController = GetOwningController();
if (OwningController != nullptr)
{
SetIsABot(Cast<APlayerController>(OwningController) == nullptr);
}
if (GameStateBase)
{
SetStartTime(GameStateBase->GetPlayerStartTime(OwningController));
}
}
여기서 보면 캐릭터 Controller를 하나씩 돌면서 ClientReceiveLocalizedMessage를 주는걸 확인해볼 수 있습니다.
void APlayerState::Destroyed()
{
UWorld* World = GetWorld();
if (World->GetGameState() != nullptr)
{
World->GetGameState()->RemovePlayerState(this);
}
if( ShouldBroadCastWelcomeMessage(true) )
{
for (FConstPlayerControllerIterator Iterator = World->GetPlayerControllerIterator(); Iterator; ++Iterator)
{
APlayerController* PlayerController = Iterator->Get();
if( PlayerController )
{
PlayerController->ClientReceiveLocalizedMessage( EngineMessageClass, 4, this);
}
}
}
// Remove the player from the online session
UnregisterPlayerWithSession();
Super::Destroyed();
}
정리하자면 플레이어 스테이트가 레플리케이트 되어서 생성될때와 제거될때에 PlayerArray에 자기가 스스로 추가되거나, 제거되는 방식으로 관리되고 있습니다.
💡 다음의 코드는 컨트롤러에서 버튼을 클릭해 RPC_Click으로 Actor를 전달하는 로직입니다. 근데 이 로직으로 게임을 실행해보면 RPC_Click을 실행한 클라이언트가 서버와의 연결이 끊기게 됩니다. 왜 이런 현상이 일어날까요? 원인은 무엇이고 어떻게 해결할 수 있을까요? 정답은 생각보다 간단합니다. 한번 생각해보시면 좋을 듯 합니다.
void AMyPlayerController::RPC_Click(AButtonActor* ButtonActor)
{
if (HasAuthority())
{
ButtonActor->ClickButton();
NetMulticast_ClickButton(ButtonActor);
}
else
{
Server_ClickButton(ButtonActor);
}
}
void AMyPlayerController::NetMulticast_ClickButton_Implementation(AButtonActor* ButtonActor)
{
ButtonActor->ClickButton();
}
bool AMyPlayerController::NetMulticast_ClickButton_Validate(AButtonActor* ButtonActor)
{
return !HasAuthority() && ButtonActor;
}
void AMyPlayerController::Server_ClickButton_Implementation(AButtonActor* ButtonActor)
{
ButtonActor->ClickButton();
NetMulticast_ClickButton(ButtonActor);
}
bool AMyPlayerController::Server_ClickButton_Validate(AButtonActor* ButtonActor)
{
return HasAuthority() && ButtonActor;
}
virtual void GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const override;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Button")
TArray<TObjectPtr<AActor>> m_aTargetActors;
void AMyButtonActor::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMyButtonActor, m_aTargetActors);
}
void AMyButtonActor::ClickButton()
{
MoveData.EventTimeline->PlayFromStart();
OnButtonClicked.Broadcast();
}
void AMyButtonActor::HandleClick()
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Green, FString::Printf(TEXT("HandleCLick!")));
}