[UE5 Multiplayer] 언리얼 멀티플레이어 게임에서 렉 없는 동기화를 위한 네트워크 시계 설계하기 | Implementing Lag-corrected Network Clock

seunghyun·2024년 5월 22일
0

Unreal Network Framework

목록 보기
1/3

결과 화면

결과 영상 유튜브 : https://youtu.be/meIXNXEuTQ4

발단

네트워크 시계는 게임의 현재 시간을 모든 클라이언트가 공유하여, 플레이 중인 모든 인스턴스가 동일한 시간 정보를 가짐으로써 게임 상태에 대한 일관성을 유지하는 데 필수적이다.

GetWorld()->GetTimeSeconds() 

위와 같은 코드를 서버와 클라이언트가 똑같이 작성해도, 서버와 클라이언트의 시간은 다르다. (Client Server Delta) 클라이언트는 서버가 게임을 시작한 후 어느 시점에 게임에 참가하기 때문이다.

언리얼 멀티플레이에서 네트워크 대역폭을 크게 절약하면서 정확한 타이머를 구현하는 방법으로 어떤 것이 좋을까?

A : Have clock functionality on GameMode, then replicate to GameState

또는

B : Have clock functionality on GameState, call it from GameMode

위 두 방법 중에 어떤 것이 좋을 지 고민 중이었다.

타이머 이야기는 아니지만,

저번 페어 프로그래밍 시간 때 고민했던 걸 떠올리면,
기본적으로, 게임 서버는 프로젝타일의 위치 같은 데이터를 네트워크를 통해 클라이언트에 지속적으로 전송하지 않고, 클라이언트는 서버로부터 받은 초기 데이터를 바탕으로 로컬에서 해당 객체를 시뮬레이션한다.

// pseudocode
void Update(float deltaSeconds)
{
    projectile.position += projectile.velocity * deltaSeconds;
    SendProjectileDataToClient(projectile.position);
}

하지만 이 방법은 렉(lag) 때문에 문제가 발생할 수 있다.

그래서 '네트워크 시계'라는 존재가 필요하다.

서버와 클라이언트가 동일한 '네트워크 시간'을 갖고 있으면, 서버가 데이터를 보낼 때의 시간을 타임스탬프로 기록하여 클라이언트에 전송하면, 클라이언트는 이를 바탕으로 정확한 위치를 계산할 수 있다.

그대로 직역하면 시간 도장 이라는 의미인데 말 그대로 특정한 시점에 도장을 찍는다고 보면 될 것 같다.
IT 에서는 일이 발생해서 컴퓨터에 기록된 시간을 의미하는데, 주로 어떤 일이 발생한 시간을 비교하거나 두 작업 사이에 어느정도의 시간이 경과되었는지를 알아내기 위해 사용한다.
참고) Java의 패키지에는 Timestamp라는 클래스가 존재한다.

(얼마 전 🔗스킬 쿨타임을 구현할 때도 타임스탬프를 사용했었다!)


요구사항

내 프로젝트가 팀 PvP 이다보니, '네트워크 시계'를 사용한 타이머가 생각보다 많이 필요하다.

캐릭터 선택 후 대기, 라운드 타임 체크, Transition 타이머, 등등..

이 때 공통되는 흐름을 만들면 어떨까

우선 구글링도 해보고, 엔진 코드도 찾아봐야겠다.


찾아보기!

구글링을 해보니 나와 같은 고민을 하신 분이 이미 계셨다.

  • Reddit: Clock/timer on GameMode or GameState?
    GameStateBase already replaces the ServerWorldTimeSeconds() at a set frequency so you can use that clock to sycnronize things.

  • Best Practices: Server Timer in Multiplayer Game
    Use the engine-built-in timer and do not create a custom one.
    이 글을 읽어보면 UE5 Docs: GetServerWorldTimeSecond() 라는 GameStateBase 클래스의 함수가 있다는 것을 알 수 있다.하지만 그렇게 정확하지는 않다고 한다.

    Unreal Engine 4 implements a synchronized network clock which can be used for just this kind of thing. You can get its value from AGameStateBase::GetServerWorldTimeSeconds(), and that value is replicated to all network clients, so in theory, everybody should have the same idea of what time it is.
    Unfortunately, Unreal’s network clock sync is… not all that accurate.

정확하지 않은 이유를 살펴보자!


엔진 코드 살펴보기

이제 엔진 코드를 살펴보면,

서버 시간 업데이트 주기

AGameStateBase 클래스에는 ReplicatedWorldTimeSeconds 라는 변수가 있으며, 이는 GetWorld()->GetTimeSeconds()의 결과를 매 5초마다 업데이트하여 네트워크를 통해 모든 클라이언트에게 전송된다.

// Default to every few seconds.
ServerWorldTimeSecondsUpdateFrequency = 0.1f;

void AGameStateBase::PostInitializeComponents()
{
    Super::PostInitializeComponents();

    UWorld* World = GetWorld();
    World->SetGameState(this);

    FTimerManager& TimerManager = GetWorldTimerManager();
    if (World->IsGameWorld() && GetLocalRole() == ROLE_Authority)
    {
        UpdateServerTimeSeconds();
        if (ServerWorldTimeSecondsUpdateFrequency > 0.f)
        {
            TimerManager.SetTimer(TimerHandle_UpdateServerTimeSeconds, this, &AGameStateBase::UpdateServerTimeSeconds, ServerWorldTimeSecondsUpdateFrequency, true);
        }
    }
}

void AGameStateBase::UpdateServerTimeSeconds()
{
    UWorld* World = GetWorld();
    if (World)
    {
        ReplicatedWorldTimeSecondsDouble = World->GetTimeSeconds();
    }
}

클라이언트에서 서버 시간 차이 계산 (클라이언트가 서버 시간과의 차이를 저장하는 부분)

클라이언트는 수신된 ReplicatedWorldTimeSeconds와 자체적인 GetWorld()->GetTimeSeconds()를 비교하여 ServerWorldTimeSecondsDelta라는 차이값을 저장한다.

void AGameStateBase::OnRep_ReplicatedWorldTimeSecondsDouble()
{
    UWorld* World = GetWorld();
    if (World)
    {
        const double ServerWorldTimeDelta = ReplicatedWorldTimeSecondsDouble - World->GetTimeSeconds();

        // Accumulate the computed server world delta
        SumServerWorldTimeSecondsDelta += ServerWorldTimeDelta;
        NumServerWorldTimeSecondsDeltas += 1;

        // Reset the accumulated values to ensure that we remain representative of the current delta
        if (NumServerWorldTimeSecondsDeltas > 250)
        {
            SumServerWorldTimeSecondsDelta /= NumServerWorldTimeSecondsDeltas;
            NumServerWorldTimeSecondsDeltas = 1;
        }

        double TargetWorldTimeSecondsDelta = SumServerWorldTimeSecondsDelta / NumServerWorldTimeSecondsDeltas;

        // Smoothly interpolate towards the new delta if we've already got one to avoid significant spikes
        if (ServerWorldTimeSecondsDelta == 0.0)
        {
            ServerWorldTimeSecondsDelta = TargetWorldTimeSecondsDelta;
        }
        else
        {
            ServerWorldTimeSecondsDelta += (TargetWorldTimeSecondsDelta - ServerWorldTimeSecondsDelta) * 0.5;
        }
    }
}

클라이언트가 서버 시간을 요청하는 RPC

클라이언트가 GetServerWorldTimeSeconds()를 호출할 때, 이 함수는 로컬 GetWorld()->GetTimeSeconds()ServerWorldTimeSecondsDelta를 더한 값을 반환한다. 이를 통해 서버 시간을 파악할 수 있다.

이러한 방식은 네트워크 대역폭을 절약하기 위해 서버 시간을 5초마다 한 번씩만 복제하고, 클라이언트가 서버로부터 마지막으로 들은 시간과의 차이를 기반으로 현재 서버 시간을 계산한다.

하지만! 이 방법은 완벽한 동기화를 보장하지 못하며, 실제 서버 시간과 클라이언트 시간 사이에는 시간 차이가 발생할 수 있다. 이는 네트워크 지연으로 인해 발생하는데, 서버에서 시간 정보를 전송하고 클라이언트가 이를 수신하는 사이에 시간이 지연될 수 있기 때문이다. 서버와 클라이언트 간의 시간 차이가 무작위로 발생할 수 있으며, 게임의 일관성을 해칠 수 있다.

double AGameStateBase::GetServerWorldTimeSeconds() const
{
    UWorld* World = GetWorld();
    if (World)
    {
        return World->GetTimeSeconds() + ServerWorldTimeSecondsDelta;
    }

    return 0.;
}

네트워크 시계 만들기 | Making the network clock accurate 🕑

참고한 사이트!

🔗Accurately syncing Unreal’s network clock

필요성

앞서 적은 것처럼, 렉이 발생할 수 있는 문제를 해결하기 위해서 네트워크 시계가 필요하다.

서버와 클라이언트가 동일한 '네트워크 시간'을 갖고 있으면, 서버가 데이터를 보낼 때의 시간을 타임스탬프로 기록하여 클라이언트에 전송하면, 클라이언트는 이를 바탕으로 정확한 위치를 계산할 수 있다.

방법 생각해보기

서버에서 클라이언트로 시간을 보내는 대신 클라이언트가 서버에 현재 시간을 요청 (RPC) 하고, 요청이 왕복 (RPC) 하는 데 걸린 시간 Round Trip Time을 구해야 한다. 즉, 메시지가 서버에 도달하고 곧바로 클라이언트로 돌아오는 데 걸리는 총 시간을 측정하여 서버 시간을 계산하는 방법이 있다.

이렇게 하면 한 번의 HandShake로 클라이언트와 서버의 시간을 거의 완벽하게 동기화할 수 있다.

  • (Client To Server Time) + (Server To Client Time) = (Round Trip Time; RTT)
  • Client -> Server : ServerRPC to get current Server Time, and Send ClientRequestTime
  • Server -> Client : ReportServerTime(Server Time of receipt), and Client Request Time
  • (Round Trip Time) = (Current Client Time) - (Client Request Time)
  • (Current Server Time) = (Server Time of receipt) + (1/2 RTT)
  • (Client-Sever Delta) = (Current Server Time) - (Current Client Time)

설계

우선 PlayerController가 시간 동기화를 담당할 것이다.

PlayerController 클래스 헤더 파일에 네트워크와 동기화된 서버 시간을 반환하는 함수 ServerRequestServerTime() 함수와, 서버 시간 요청과 응답을 처리하는 함수 ClientReportServerTime() 함수 이렇게 두 개의 네트워크 RPC가 선언한다. 서버 시간 요청은 클라이언트가 왕복 시간을 추적할 수 있도록 요청 시간을 포함하여 전송된다.

// PlayerController.h
public:

/** Returns the network-synced time from the server.
  * Corresponds to GetWorld()->GetTimeSeconds()
  * on the server. This doesn't actually make a network
  * request; it just returns the cached, locally-simulated
  * and lag-corrected ServerTime value which was synced
  * with the server at the time of this PlayerController's
  * last restart. */
virtual float GetServerTime() { return ServerTime; }

virtual void ReceivedPlayer() override;

protected:

/** Reports the current server time to clients in response
  * to ServerRequestServerTime */
UFUNCTION(Client, Reliable)
void ClientReportServerTime(
    float requestWorldTime, // 요청이 발송된 시간
    float serverTime
);

// 클라이언트는 왕복 시간을 계산하여 서버 시간을 조정한다.
/** Requests current server time so accurate lag
  * compensation can be performed in ClientReportServerTime
  * based on the round-trip duration */
UFUNCTION(Server, Reliable, WithValidation)
void ServerRequestServerTime(
    APlayerController* requester,
    float requestWorldTime
);

float ServerTime = 0.0f;

PlayerController 클래스의 .cpp 파일에서는 이 RPC들을 구현하고 있으며, 클라이언트는 왕복 시간 (RPC 도달 시간) 을 계산하여 서버 시간을 조정한다.

// PlayerController.cpp

// 서버가 해야 할 일은 클라이언트의 요청 시간과 함께 자신의 현재 시간을 다시 보내는 것뿐.
// 그리고 다시 ClientRPC를 호출한다.
void APlayerController::ServerRequestServerTime_Implementation(
    APlayerController* requester,
    float requestWorldTime
)
{
    float serverTime = GetWorld()->GetGameState()->
        GetServerWorldTimeSeconds();
    ClientReportServerTime(requestWorldTime, serverTime);
}

bool APlayerController::ServerRequestServerTime_Validate(
    APlayerController* requester,
    float requestWorldTime
)
{
    return true;
}

// 클라이언트가 이 RPC를 수신하면 이제 왕복 시간을 계산할 수 있다.
// 클라이언트의 현재 시간(serverTime)에서 클라이언트 요청 시간(requestWorldTime)을 뺄 수 있다.  
// 이렇게 하면 클라이언트가 서버에 RPC를 보낸 후 얼마나 시간이 흘렀는지 알 수 있다.
// 그리고 클라이언트에서 서버의 현재 시간(adjustedTime)을 계산한다.
void APlayerController::ClientReportServerTime_Implementation(
    float requestWorldTime,
    float serverTime /*서버가 클라이언트 요청을 수신한 시간*/
)
{
    // Apply the round-trip request time to the server's         
    // reported time to get the up-to-date server time
    float roundTripTime = GetWorld()->GetTimeSeconds() - 
        requestWorldTime;
    // 왕복 시간의 절반을 더한다. 즉, 서버가 정보를 전송하는 데 걸리는 시간까지 고려
    float adjustedTime = serverTime + (roundTripTime * 0.5f); 
    ServerTime = adjustedTime;
}

플레이어가 유효한 네트워크 연결을 가지고 있을 때 가장 먼저 네트워크 시계 요청을 추가하는 ReceivedPlayer() 함수를 오버라이드한다. 서버에서 시간을 얻을 수 있는 가장 빠른 함수이다.

void APlayerController::ReceivedPlayer()
{
    Super::ReceivedPlayer();

    if(IsLocalController())
    {
        ServerRequestServerTime(
            this,
            GetWorld()->GetTimeSeconds() // 현재 시간
        );
    }
}

이제 사용자 정의 GameState 클래스에서는 네트워크 시계에 접근하는 방법을 제공하는 GetServerWorldTimeSeconds() 함수를 오버라이드해보자. 이 함수는 첫 번째 로컬 PlayerController의 서버 시간을 반환하며, PlayerController가 없는 경우 로컬 월드의 시간을 반환한다. 왜냐하면, PlayerController는 ServerRPC 호출이 가능한 locally-owned network connections이 있기 때문 !!

float ACustomGameState::GetServerWorldTimeSeconds() const
{
    if(APlayerController* pc = GetGameInstance()->
        GetFirstLocalPlayerController(GetWorld())
    )
    {
        return pc->GetServerTime();
    }
    else
    {
        return GetWorld()->GetTimeSeconds();
    }
}
// 이 구현에서는 Super::를 호출하지 않는다! 우리는 오래되고 부정확한 동작을 원하지 않는다!

주의사항

이 구현은 Unreal 엔진의 기본 방식보다 훨씬 정확한 네트워크 시계 동기화를 제공할텐데, 왕복 시간을 절반으로 나누어 시간을 보정하는 방법에는 아주 작은 오류 가능성이 있다. 이는 왕복 시간이 정확히 50/50으로 나뉘지 않았을 경우에 발생할 수 있다.


🔗 Reference

0개의 댓글

관련 채용 정보