1. 로그를 통한 흐름 분석
1 - 1. 로그인 흐름 분석
GameModeBase와 PlayerController에서 로그를 찍어보면 아래와 같이 정리해볼 수 있다.
- 맨 처음 네모는 서버에서만 생성되는 GameMode 로직이다.
- 아래 네모는 Client01번의 PlayerController가 서버에서 생성되고, Client로 복제되는 과정이다.
- 마지막 네모는 Client02번이 생성 및 복제되는 과정이다.
1 - 2. NetConnection 관련 로그 추가
NetConnection에 존재하는 ClientConnection과 ServerConnection 관련 로그를 찍어보자
- ClientConnection
- 서버가 가지고 있는 Connection이므로, GameMode에서 로그를 작성해야 한다.
- 참고)
- ClientConnection은 Login 관련 함수에서 확인하면 좋다고 한다.
- 그 이유는 Login 관련 함수는 클라이언트가 로그인 할 때 마다 호출되기 때문이다.
- 로그 코드
void ADXGameModeBase::PostLogin(APlayerController* NewPlayer)
{
DX_LOG_NET(LogDXNet, Log, TEXT("Begin"));
Super::PostLogin(NewPlayer);
UNetDriver* NetDriver = GetNetDriver();
if (IsValid(NetDriver))
{
if (NetDriver->ClientConnections.Num() == 0)
{
DX_LOG_NET(LogDXNet, Log, TEXT("There is no ClientConnection"));
}
else
{
for (const auto& ClientConnection : NetDriver->ClientConnections)
{
if (IsValid(ClientConnection))
{
DX_LOG_NET(LogDXNet, Log, TEXT("ClientConnection Name: %s"), *ClientConnection->GetName());
}
}
}
}
else
{
DX_LOG_NET(LogDXNet, Log, TEXT("There is no NetDriver"));
}
DX_LOG_NET(LogDXNet, Log, TEXT("End"));
}
- ServerConnection
- 클라이언트가 가지고 있는 커넥션 이기 때문에, PlayerController에서 작성해야 한다.
- 참고)
- 서버 커넥션을 확인하기 가장 적합한 함수가 PostNetInit 함수라고 한다.
- 그 이유는 해당 함수가 액터의 네트워크 관련 속성이 모두 초기화된 후 호출되는 함수이기 때문이다.
- 추가)
- IsLocalPlayerController() 를 통해서, 이 컨트롤러가 Local에 존재하는 컨트롤러인지 확인한다.
- 이유는 Controller는 서버에도 있기 때문이다.
- 코드
void ADXPlayerController::PostNetInit()
{
DX_LOG_NET(LogDXNet, Log, TEXT("Begin"));
Super::PostNetInit();
if (IsLocalPlayerController())
{
UNetDriver* NetDriver = GetNetDriver();
if (IsValid(NetDriver))
{
UNetConnection* ServerConnection = NetDriver->ServerConnection;
if (IsValid(ServerConnection))
{
DX_LOG_NET(LogDXNet, Log, TEXT("ServerConnection Name : %s"), * ServerConnection->GetName());
}
else
{
DX_LOG_NET(LogDXNet, Log, TEXT("There is no serverconnection"));
}
}
}
DX_LOG_NET(LogDXNet, Log, TEXT("End"));
}
실행 결과
- 서버 커넥션은 하나 클라이언트 커넥션은 두 개인 것을 확인할 수 있다.
1 - 3. 참고 (PreLogin 접속 막기)
- PreLogin함수 매개변수인 ErrorMessage에 문자열을 넣어주면 연결이 거부된다.
- 결과적으로 NetMode 스탠드얼론으로 플레이된다.
void ADXGameModeBase::PreLogin(const FString& Options, const FString& Address, const FUniqueNetIdRepl& UniqueId, FString& ErrorMessage)
{
DX_LOG_NET(LogDXNet, Log, TEXT("Begin"));
Super::PreLogin(Options, Address, UniqueId, ErrorMessage);
//ErrorMessage = TEXT("Server is full. try again");
DX_LOG_NET(LogDXNet, Log, TEXT("End"));
}
1 - 4. 초기화를 위한 게임 시작 이벤트 함수 정리
- PreLogin()
- 클라이언트의 접속 요청을 수락할지 거절할지 처리하는 함수
- 서버의 역할하는 PC 경우 호출하지 않는다.
- Login()
- 접속을 허용한 클라이언트에 대응하는 플레이어 컨트롤러를 만드는 함수
- APlayerController를 return 한다.
- PostLogin()
- 클라이언트가 서버에 성공적으로 접속한 후 호출
- 플레이어 입장을 위해 플레이어에 필요한 기본 설정을 하는 함수
- StartPlay()
- 게임의 시작을 지시하는 함수
- 게임의 시작과 로그인은 다르다
- ex) 로비에 접속해서 대기하는 사람의 경우는 로그인만 된 것이지 게임을 시작하는 것은 아니다
- BeginPlay()
- 게임모드의 StartPlay 함수를 통해 게임이 시작될 때 모든 액터에서 호출되는 함수
참고) BeginPlay의 설명에서 게임모드의 StartPlay함수를 통해서 호출된다고하는데, 어떻게 액터(클라이언트 쪽)들은 이 신호를 알고 호출이 가능할까?
- 클라이언트에는 게임모드가 없기 때문에 클라이언트에서 게임모드의 StartPlay 함수를 받아올 방법이 없어 보인다.
- 그러나 서버의 "게임 스테이트"는 각 클라이언트에게 복제되기 때문에, 게임모드가 게임 스테이트에게 명령을 내리고 게임 스테이트가 각 클라이언트에게 알려주고 BeginPlay를 호출하게 된다.
1 - 5. GameState와 BeginPlay
- GameModeBase에서 StartPlay() 함수를 호출 (서버) 하면
- GameStateBase의 HandleBeginPlay() 함수를 호출한다.
엔진 코드를 통해 BeginPlay() 흐름 살펴보기
// GameStateBase.cpp
void AGameStateBase::HandleBeginPlay()
{
bReplicatedHasBegunPlay = true; // 클라 모든 액터의 BeginPlay() 함수가 호출되게끔 함
GetWorldSettings()->NotifyBeginPlay(); // 서버 모든 액터의 BeginPlay() 함수가 호출되게끔 함
...
}
// WorldSettings.cpp
void AWorldSettings::NotifyBeginPlay()
{
...
if (!World->bBegunPlay)
{
for (FActorIterator It(World); It; ++It)
{
...
It->DispatchBeginPlay(bFromLevelLoad);
}
...
}
}
// Actor.cpp
void AActor::DispatchBeginPlay(bool bFromLevelStreaming)
{
...
if (World)
{
...
BeginPlay();
...
}
}
// GameStateBase.cpp
void AGameStateBase::OnRep_ReplicatedHasBegunPlay()
{
//HandleBeginPlay에서 세팅한 값이 true이며, 서버 아닌 경우
if (bReplicatedHasBegunPlay && GetLocalRole() != ROLE_Authority)
{
GetWorldSettings()->NotifyBeginPlay();
...
}
}
1 - 6. 게임 시작 관련 이벤트 함수
- PostInitializeComponents()
- 액터에 부착된 컴포넌트들이 모두 준비된 상태에서 호출
- PostNetInit()
- 서버에서 액터의 변경된 세팅이 완료된 후 클라이언트에서 호출되는 함수
- 멀티플레이에서
- 액터의BeginPlay()가 호출되기 위해서는
- 해당 액터에 대한 서버에서의 처리가 완료되어야 한다.
- 결론) PostNetInit()이 호출된 후 StartPlay() 에 의해 BeginPlay() 가 호출된다.
- 위에서 ServerConnection 출력을 위해 사용했던 함수
- BeginPlay()
- 게임플레이에 필요한 액터의 초기화 로직 담당
2. NetConnection
클라이언트가 서버에 접속 -> NetConnection 생성 -> PlayerController 소유 -> Pawn 소유
언리얼 네트워크의 데이터 단위
- NetConnection
- 멀티플레이 관련 데이터들이 드나드는 통로
- 서버는 클라이언트 갯수만큼 "클라이언트커넥션"을 가짐
- 클라이언트는 "서버 커넥션" 하나를 가짐
- Channel
- NetConnection을 좀 더 세분화 시킨 것
- ActorChannel : Actor Replication 과 관련이 있다.
- ControlChannel
- VoiceChannle
- Packet
- 네트워크에서 사용하는 통상적인 데이터 단위
- Bunch
- 언리얼에서 사용하는 특별한 패킷 단위
- 예시) 하나의 RPC, 하나의 Property Replication
NetConnection의 쓰임새
- 어떤 플레이어(캐릭터)가 다른 PC의 화면에서도 보이게 하기 위해서는 NetConnection에 소유된 플레이어(캐릭터)여야 한다.
- 그 이유는 NetConnection이 소유하는 Controller에 빙의된 Pawn(캐릭터)라면 다른 PC로 복제가 되기 때문
- 플레이어 컨트롤러(NetConnection 소유) 통신 가능 -> 빙의된 pawn 통신 가능 -> pawn이 소유하는 액터 통신 가능
- Relevancy
- 어떤 클라이언트가 특정 액터의 정보를 업데이트 받을지 말지 결정할 때 NetConnection 필요
- RPC
- RPC를 실행할지 무시할지 결정할 때 NetConnection 필요
NetConnection 관련 함수 살펴보기
- AActor::GetNetConnection 함수
- Owner가 있어야 NetConnection을 가져올 수 있음
- APawn::GetNetConnection() 함수
- 컨트롤러가 있어야 NetConnection을 가져올 수 있음
- 없으면, AActor::GetNetConnection 실행 (하나 상위 클래스의 함수 실행)
- APlayerController::GetNetConnection 함수
- Player가 있어야 NetConnection 리턴
// AActor.cpp
UNetConnection* AActor::GetNetConnection() const
{
return Owner ? Owner->GetNetConnection() : nullptr;
}
//APawn.cpp
class UNetConnection* APawn::GetNetConnection() const
{
// if have a controller, it has the net connection
if ( Controller )
{
return Controller->GetNetConnection();
}
return Super::GetNetConnection();
}
//APlayerController.cpp
UNetConnection* APlayerController::GetNetConnection() const
{
// A controller without a player has no "owner"
return (Player != NULL) ? NetConnection : NULL;
}
3. Role
3 - 1. Role 기본 개념 다시 정리
기본적으로 클라이언트-서버 모델에서는 서버에 있는 액터만이 신뢰된다.
- 신뢰되는 액터는 Authority
- 복제된 액터는 Proxy
Role이 필요한 이유는 "액터의 특성"을 알기 위해서이다.
- 이 액터가 서버에 존재하는지
- 클라이언트에 존재하는지
- 복제되었는지 (복제된 후 어떤 상태인지)
- 등등
Role은 Remote Role과 Local Role로 나뉜다. 나누는 이유는 아래와 같다.
- 서버에서 스폰한 A라는 캐릭터와 서버에 접속한 캐릭터 B를 구별할 수 없다.
- 두 경우 모두 서버의 입장에서는 Authority이기 때문에 구별이 불가능하다. (Local Role)
- 그러나 A의 경우는 Remote Role은 None이고, B의 경우 Remote Role이 Autonomous Proxy이므로 구별 가능하다.
3 - 2. 액터 Role을 파악하는 함수
- HasAuthority()
- 로컬 롤이 Authority인지를 return 해준다.
- IsLocalController(), IsLocallyControlled()
- 해당 액터가 컨트롤이 가능한지 체크한다.
- HasAuthority()가 false이고 위의 두 함수가 true인 경우 Autonomous Proxy로 판별할 수 있다.
- 사용 예시
- 게임모드에서는 HasAuthority() 사용할 필요 없다.
- UI 같은 경우 IsLocalPlayerController 체크를 해서 클라이언트에만 출력되도록 해준다.
4. Replication
4 - 1. Replication 기본 개념 정리
특정 클라이언트에서 생성된 정보를 다른 클라이언트로 복제하는 작업을 리플리케이션이라고 한다.
서버-클라이언트 구조에 있어서는 서버에서 클라이언트로 복제되는 것이 규칙이다.
리플리케이션에는 두가지 방식이 존재한다.
- Property Replication
- 자동으로 값이 바뀌는 방식
- 단, 서버에서 클라이언트로의 방향만 존재한다.
- RPC
- 직접적으로 값을 바꾸는 방식
- 서버 -> 클라, 클라 -> 서버 둘 다 가능하다.
4 - 2. NetLocalOnClient
레벨에 존재하는 모든 액터의 정보를 서버가 모든 클라이언트에게 복제하는 것은 비효율적이다.
- 고정적으로 배치되는 액터(레벨 디자인된 액터) 의 경우 NetLocalOnclient 속성을 true로 지정하여 클라이언트가 스스로 스폰하도록 할 수 있다.
- 단, 동적으로 생성하는 액터는 사용하면 안된다. (Character, Pawn 등)
NetLoadOnClient 속성을 false로 하면 아래와 같이 cube가 보이지 않는다.
4 - 3. Property Replication
회전하는 물체를 만들어보자
- RotationYaw를 Replicated되는 속성으로 만들고 Tick을 돌려서 해당 정보를 클라이언트로 계속 가져오도록 하였다.
- 서버에서 값을 계산한 다음 replicated된 값을 통해 클라이언트에서 회전을 해주는 방식
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "DXCube.generated.h"
class USceneComponent;
class UStaticMeshComponent;
class FLifetimeProperty;
UCLASS()
class SCC_DEDICATEDX_API ADXCube : public AActor
{
GENERATED_BODY()
public:
ADXCube();
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
virtual void Tick(float Deltaseconds) override;
protected:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TObjectPtr<USceneComponent> SceneRoot;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TObjectPtr<UStaticMeshComponent> Mesh;
UPROPERTY(Replicated)
float ServerRotationYaw;
float RotationSpeed;
};
#include "Gimmick/DXCube.h"
#include "Net/UnrealNetwork.h"
ADXCube::ADXCube()
:ServerRotationYaw(0.f)
,RotationSpeed(30.f)
{
PrimaryActorTick.bCanEverTick = true;
bReplicates = true;
SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("SceneRoot"));
SetRootComponent(SceneRoot);
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
Mesh->SetupAttachment(SceneRoot);
Mesh->SetRelativeLocation(FVector(-50.f, -50.f, 50.f));
}
void ADXCube::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ThisClass, ServerRotationYaw);
}
void ADXCube::Tick(float Deltaseconds)
{
Super::Tick(Deltaseconds);
if (HasAuthority())
{
AddActorLocalRotation(FRotator(0.f, RotationSpeed * Deltaseconds, 0.f));
ServerRotationYaw = RootComponent->GetComponentRotation().Yaw;
}
else
{
SetActorRotation(FRotator(0.f, ServerRotationYaw, 0.f));
}
}
이 방식으로 구현해도 동작은 제대로 하지만 Tick을 계속 돌면서 복제된 값을 가져와야 하기에 오버헤드가 크다
오버헤드를 줄이기 위해, 리플리케이트된 값이 수정되면 call 되는 함수가 존재한다. -> OnRep_ 접두사 붙인 함수
OnRep_ 함수
- ReplicatingUsing 키워드와 OnRep_ 접두사를 붙여서 아래와 같이 수정할 수 있다.
- 이 함수의 특징은 서버에서는 클라이언트에서만 호출된다는 점이다.
- 지금은 큰 차이가 없다고 느껴질 수 있지만, 만약 서버 회전값 수정을 Tick에서 하지 않는다면 오버헤드 차이가 클 것이다.
//DXCube.h
UCLASS()
class SCC_DEDICATEDX_API ADXCube : public AActor
{
GENERATED_BODY()
public:
UFUNCTION()
void OnRep_ServerRotationYaw();
protected:
...
UPROPERTY(ReplicatedUsing = OnRep_ServerRotationYaw)
float ServerRotationYaw;
};
//DXCube.cpp
void ADXCube::Tick(float Deltaseconds)
{
Super::Tick(Deltaseconds);
if (HasAuthority())
{
AddActorLocalRotation(FRotator(0.f, RotationSpeed * Deltaseconds, 0.f));
ServerRotationYaw = RootComponent->GetComponentRotation().Yaw;
}
else
{
//SetActorRotation(FRotator(0.f, ServerRotationYaw, 0.f));
}
}
void ADXCube::OnRep_ServerRotationYaw()
{
SetActorRotation(FRotator(0.f, ServerRotationYaw, 0.f));
}
4 - 4. Frequency
Frequency란 액터 리플리케이션 빈도를 뜻한다.
NetUpdateFrequency
- 액터 리플리케이션 빈도의 최대치 (1초에 몇번의 리플리케이션을 시도할지)
- 기본값은 100이다.
- 이 값은 단순히 최대치 일 뿐 보장하지는 않는다.
- 만약 빈도보다 서버의 성능이 낮으면, 서버의 성능으로 복제된다.
- 그래서 서버의 성능이 중요하며, 데디케이티드 서버의 경우가 더 좋은 성능을 낸다(그래픽적 요소 없으니까)
- 주요 액터의 빈도값
- Actor, Pawn, PlayerController : 100
- GameState : 10
- PlayerState : 1
결국 Frequency의 요점은, 서버의 네트워크 부하를 줄이는 것에 있다.
- 네트워크 부하를 줄이는 방법
- 기존의 방식은 매 틱마다 서버의 위치가 변경되고, 그 값이 리플리케이션 하여 클라이언트로 보내진다.
- 개선된 방식은 NetUpdateFrequency를 줄이고, 받아온 값을 토대로 클라이언트에서 보간하는 방법이 있다.
- 코드
- 값을 업데이트할 주기(NetUpdatePeriod) 설정 후
- 리플리케이션 된 순간부터 변수(Acc..)에 Deltaseconds를 더한 값을 저장하여
- 다음 리플리케이션 되기 전까지 존재하는 중간 값들을 보간을 통해서 찾아낸다.
- 리플리케이션 된 순간의 정보를 가지고, NetUpdateFrequency를 통해 비율을 계산한다.
- 이후 다시 리플리케이션 되면 0부터 다시 시작한다.
변수 두개 추가
float NetUpdatePeriod;
float AccDeltaSecondSinceReplicated = 0.f;
ADXCube::ADXCube()
{
...
//NetUpdateFrequency 설정
const static float CubeActorNetUpdateFrequency = 1.f;
SetNetUpdateFrequency(CubeActorNetUpdateFrequency);
NetUpdatePeriod = CubeActorNetUpdateFrequency / GetNetUpdateFrequency();
}
void ADXCube::Tick(float Deltaseconds)
{
Super::Tick(Deltaseconds);
if (HasAuthority())
{
...
}
else
{
if (NetUpdatePeriod < KINDA_SMALL_NUMBER)
{
return;
}
AccDeltaSecondSinceReplicated += Deltaseconds;
const float LerpRatio = FMath::Clamp(AccDeltaSecondSinceReplicated / NetUpdateFrequency, 0.f, 1.f);
const float NextServerRotationYaw = ServerRotationYaw + RotationSpeed * NetUpdatePeriod;
const float EstimatedClientRotationYaw = FMath::Lerp(ServerRotationYaw, NextServerRotationYaw, LerpRatio);
SetActorRotation(FRotator(0.f, EstimatedClientRotationYaw, 0.f));
}
}
void ADXCube::OnRep_ServerRotationYaw()
{
SetActorRotation(FRotator(0.f, ServerRotationYaw, 0.f));
AccDeltaSecondSinceReplicated = 0.f;
}
'Unreal Engine' 카테고리의 다른 글
Unreal Engine - 데디케이티드 서버 4 (RPC) (0) | 2025.04.07 |
---|---|
Unreal Engine - 데디케이티드 서버 3 (Property Replication) (0) | 2025.04.06 |
Unreal Engine - Save Game (0) | 2025.04.02 |
Unreal Engine - 야구게임(데디케이티드 서버) (0) | 2025.03.29 |
Unreal Engine - 데디케이티드 서버 개념 및 실습 (0) | 2025.03.27 |