카테고리 없음

UE5 게임 시간 시스템 설계

Tsukino Ren 2026. 5. 21. 23:43

목차

  1. 왜 리팩터링이 필요했나
  2. SetTime: 증분 → 절대값
  3. UTickableWorldSubsystem으로 자동 시간 흐름
  4. 레벨 전환 시 시간 유지 (GameInstance 연동)
  5. FTimeOfDay 구조체 + ETimeOfDay Enum
  6. Dynamic Multicast Delegate로 블루프린트 바인딩
  7. 전체 구조 정리
  8. 추후 확장 포인트

1. 왜 리팩터링이 필요했나

처음 만든 TimeSubsystem은 동작은 했지만 구조적인 문제가 있었다.

항목 기존 문제
SetTime() 증분값으로 AddActorLocalRotation 매 호출마다 각도 누적 → 시간 지날수록 조명 각도 틀어짐
자동 흐름 없음 외부에서 계속 SetTime 호출해야 함
레벨 전환 고려 없음 WorldSubsystem은 레벨 전환 시 소멸 → 시간 초기화
델리게이트 DECLARE_MULTICAST_DELEGATE 블루프린트에서 바인딩 불가

2. SetTime: 증분 → 절대값

기존 코드는 AddActorLocalRotation으로 매 호출마다 각도를 더했다. 호출 빈도나 DeltaTime 오차가 쌓이면 시간이 지날수록 조명 각도가 틀어진다.

// 기존 - 호출할 때마다 각도 누적
double Pitch = Time / 86400.f * 360;
CachedDirectionalLight->AddActorLocalRotation(FRotator(Pitch, 0, 0));
// 변경 - 절대 각도로 항상 정확하게 세팅
void UTimeSubsystem::ApplyTimeToLight(float TimeInSeconds)
{
    if (!CachedDirectionalLight.IsValid()) return;

    // 정오(12시)에 Pitch -90도 기준으로 오프셋 계산
    const double Pitch = (TimeInSeconds / 86400.0 * 360.0) - 270.0;
    CachedDirectionalLight->SetActorRotation(FRotator(Pitch, 0.0, 0.0));
}

SetActorRotation은 항상 절대 각도로 세팅하기 때문에 누적 없이 정확한 위치가 보장된다.

오프셋 계산:

  • 정오(12시) = 43200초 → 43200 / 86400 * 360 = 180도
  • 실제 정오 Pitch = -90도
  • 오프셋 = -90 - 180 = -270

3. UTickableWorldSubsystem으로 자동 시간 흐름

시간이 자동으로 흐르게 하려면 Tick이 필요하다. UWorldSubsystem은 Tick을 지원하지 않으므로 UTickableWorldSubsystem으로 변경한다.

// UTickableWorldSubsystem 사용 시 GetStatId() 반드시 구현해야 함
virtual TStatId GetStatId() const override
{
    RETURN_QUICK_DECLARE_CYCLE_STAT(UTimeSubsystem, STATGROUP_Tickables);
}
void UTimeSubsystem::Tick(float DeltaTime)
{
    // FMath::Fmod로 86400초(24시간) 넘으면 0으로 순환
    CurrentTimeSeconds = FMath::Fmod(CurrentTimeSeconds + DeltaTime * TimeScale, 86400.f);

    ApplyTimeToLight(CurrentTimeSeconds);
    SaveToGameInstance();
    TimeChanged.Broadcast(CurrentTimeSeconds / 86400.f * 360.0, GetCurrentTimeData());
}

TimeScale = 60이면 실제 1초에 게임 1분이 흐른다.


4. 레벨 전환 시 시간 유지 (GameInstance 연동)

UWorldSubsystem은 레벨 전환 시 소멸하고 새 레벨에서 새로 생성된다. 시간 값을 유지하려면 레벨과 무관하게 살아있는 GameInstance에 저장해야 한다.

// MyGameInstance.h에 변수 추가
UPROPERTY()
float SavedTimeSeconds = 43200.f; // 기본값: 낮 12시
void UTimeSubsystem::SaveToGameInstance()
{
    if (auto* GI = Cast<UMyGameInstance>(GetWorld()->GetGameInstance()))
        GI->SavedTimeSeconds = CurrentTimeSeconds;
}

void UTimeSubsystem::LoadFromGameInstance()
{
    if (auto* GI = Cast<UMyGameInstance>(GetWorld()->GetGameInstance()))
        CurrentTimeSeconds = GI->SavedTimeSeconds;
}

// OnWorldBeginPlay에서 복원 후 조명 즉시 적용
void UTimeSubsystem::OnWorldBeginPlay(UWorld& InWorld)
{
    Super::OnWorldBeginPlay(InWorld);
    // ... 디렉셔널라이트 캐싱 ...
    LoadFromGameInstance();
    ApplyTimeToLight(CurrentTimeSeconds);
}

5. FTimeOfDay 구조체 + ETimeOfDay Enum

Pitch 수치만 브로드캐스트하면 받는 쪽에서 매번 계산해야 하고 시간대 판단 로직이 여러 곳에 흩어진다.

UENUM(BlueprintType)
enum class ETimeOfDay : uint8
{
    Dawn,       // 새벽  0~6h
    Day,        // 낮    6~17h
    Evening,    // 저녁  17~20h
    Night,      // 밤    20~24h
};

USTRUCT(BlueprintType)
struct FTimeOfDay
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadOnly) int32 Hour   = 0;
    UPROPERTY(BlueprintReadOnly) int32 Minute = 0;
    UPROPERTY(BlueprintReadOnly) int32 Second = 0;
    UPROPERTY(BlueprintReadOnly) ETimeOfDay Period = ETimeOfDay::Day;
};

이제 LightManager는 Pitch 수치 직접 비교 대신 Enum으로 판단한다.

// 기존 - 매직 넘버 직접 비교
const bool bIsNight = Pitch >= 0.f && Pitch <= 180.f;

// 변경 - Enum으로 명확하게
const bool bIsNight = (TimeData.Period == ETimeOfDay::Night ||
                       TimeData.Period == ETimeOfDay::Dawn);

6. Dynamic Multicast Delegate로 블루프린트 바인딩

블루프린트에서 이벤트를 바인딩하려면 DECLARE_DYNAMIC_MULTICAST_DELEGATE를 써야 한다. 일반 MULTICAST는 C++ 전용이다.

// 기존 - C++ 전용
DECLARE_MULTICAST_DELEGATE_OneParam(FTimeChanged, double)

// 변경 - 블루프린트 바인딩 가능, 파라미터 이름 반드시 명시
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FTimeChanged, double, Pitch, FTimeOfDay, TimeData);

// UPROPERTY 없으면 블루프린트에서 못 씀
UPROPERTY(BlueprintAssignable, Category = "Time")
FTimeChanged TimeChanged;

C++에서 바인딩할 때는 AddUObject 대신 AddDynamic 사용:

// 함수에 UFUNCTION() 없으면 AddDynamic 못 씀
UFUNCTION()
void UpdateLights(double Pitch, FTimeOfDay TimeData);

// BeginPlay
GetWorld()->GetSubsystem<UTimeSubsystem>()->TimeChanged.AddDynamic(
    this, &ALightManager::UpdateLights);

7. 전체 구조 정리

UMyGameInstance
└── SavedTimeSeconds  ← 레벨 전환 시 시간 보존

UTimeSubsystem (UTickableWorldSubsystem)
├── OnWorldBeginPlay → LoadFromGameInstance + 조명 즉시 적용
├── Tick → CurrentTimeSeconds += DeltaTime * TimeScale
│        → ApplyTimeToLight (절대 각도)
│        → SaveToGameInstance
│        → TimeChanged.Broadcast (Pitch, FTimeOfDay)
├── SetTime(float) → 외부에서 강제 세팅
└── GetCurrentTimeData() → FTimeOfDay { Hour, Minute, Second, Period }

TimeChanged 구독자
├── ALightManager → ETimeOfDay로 가로등 ON/OFF
└── HUD Blueprint → Hour:Minute:Second 텍스트 표시

8. 추후 확장 포인트

Sky Atmosphere / Exponential Height Fog 연동
TimeChanged에 구독자로 추가하기만 하면 된다. 시간대별로 안개 밀도, 하늘 색상을 Curve로 제어할 수 있다.

ETimeOfDay 전환 이벤트 분리
현재는 매 Tick마다 브로드캐스트하지만, 시간대가 바뀌는 순간에만 발생하는 OnTimeOfDayChanged 델리게이트를 추가하면 BGM 전환이나 파티클 스폰에 활용할 수 있다.

TimeScale 동적 변경
컷신 중 빠르게 돌리거나 특정 구간에서 시간을 멈추는 기능. TimeScale = 0.f만 해도 Tick에서 시간이 멈춘다.

시간대 경계값을 DataTable로 분리
현재 CalcTimeOfDay()의 경계값(0, 6, 17, 20)이 하드코딩되어 있다. DataTable로 빼면 디자이너가 직접 조정할 수 있다.

Save Game 연동
GameInstance의 SavedTimeSecondsUSaveGame 오브젝트에 직렬화하면 게임 종료 후에도 시간이 유지된다.