언리얼에서의 성능 최적화

김혁·2025년 8월 16일

챌린지

목록 보기
6/14

Tick

Tick의 문제

  • Tick : 매 프레임마다 실행되는 함수
  • 60FPS = 초당 60번 실행 -> 16.67ms
  • 30FPS = 초당 30번 실행

액터 1개의 Tick 함수가 0.05ms가 걸린다고 한다면, 액터가 1000개 있으면 50ms나 걸린다.

Tick 오버헤드 확인 방법

1. stat game

  • 게임 진행 시(혹은 `키), 터미널에 stat game 입력
  • Frame : 16.67ms -> Game : 10ms 넘기면 문제

2. 직접 측정

  • Tick 함수를 사용하는 의심되는 곳에서 직접 측정
void AMyActor::Tick(float DeltaTime)
{
    double StartTime = FPlatformTime::Seconds();

    DoHeavyWork();

    double ElapsedTime = FPlatformTime::Seconds() - StartTime;
    if (ElapsedTime > 0.001)  // 1ms 이상
    {
        UE_LOG(LogTemp, Warning, TEXT("무거운 Tick: %.3f ms"),
               ElapsedTime * 1000);
    }
}

Tick 최적화 해결책

1. 실행 간격 조절

  • 필요에 따라 Tick을 실행하는 주기를 조절
  • 권장 주기
    • 매 프레임: 입력, 카메라, 애니메이션
    • 0.05초: 근접 전투
    • 0.1초: AI, 거리 체크
    • 0.5초: 미니맵, 원거리 적
void AEnemy::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    TimeSinceLastUpdate += DeltaTime;

    if (TimeSinceLastUpdate >= 0.1f)  // 0.1초마다
    {
        TimeSinceLastUpdate = 0.0f;
        CheckPlayerDistance();
        UpdateAIState();
    }

    UpdateAnimation();  // 애니메이션은 매 프레임
}

2. 중복 계산 제거 - 중요

  • 월드를 가져오거나, 월드에서 액터를 가져오거나, 액터의 데이터를 받아올 때 중복해서 받아오면 그에 대한 비용이 많이 든다.
  • 실시간으로 받아야 하는데, 여러 번 쓰는 경우는 한 번 받아서 변수로 저장해놓고 사용
  • Tick마다 바뀌지 않고, 액터를 부르는 것처럼 큰 비용이 드는 동작은 매번 하지 말고 액터를 캐싱해놓기
void AEnemyManager::Tick(float DeltaTime)
{
    if (!CachedPlayer)
        CachedPlayer = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0);

    FVector PlayerLocation = CachedPlayer->GetActorLocation();  // 1번만

    for (AEnemy* Enemy : AllEnemies)
    {
        float Distance = FVector::Dist(Enemy->GetActorLocation(), PlayerLocation);
        Enemy->SetPlayerInfo(PlayerLocation, Distance);
    }
}

3. 거리별 차등 업데이트

  • 거리별로 주기를 다르게 둬서 차등으로 업데이트하게끔 하기
void AEnemyManager::UpdateEnemies()
{
    for (AEnemy* Enemy : AllEnemies)
    {
        float Distance = FVector::Dist(Enemy->GetActorLocation(), PlayerLocation);

        if (Distance < 500.0f)       // 근거리
            Enemy->SetUpdateRate(0.033f);  // 30FPS
        else if (Distance < 1500.0f) // 중거리
            Enemy->SetUpdateRate(0.1f);    // 10FPS
        else if (Distance < 3000.0f) // 원거리
            Enemy->SetUpdateRate(0.5f);    // 2FPS
        else                          // 초원거리
            Enemy->SetUpdateRate(1.0f);    // 1FPS
    }
}

4. 매니저 패턴

  • 업데이트 주기를 관리하는 매니저 클래스를 생성
  • 액터를 거리 같은 기준점을 두고 주기적으로 일괄적으로 업데이트
  • 액터를 개별적으로 Tick을 몇 백 개 돌리는 것보다 매니저 하나에서 돌리는 것이 효율적
class AEnemyManager : public AActor
{
private:
    float FastUpdateTimer = 0.0f;    // 0.05초 주기
    float NormalUpdateTimer = 0.0f;  // 0.1초 주기
    float SlowUpdateTimer = 0.0f;    // 0.5초 주기

    TArray<AEnemy*> CloseEnemies;    // 500m 이내
    TArray<AEnemy*> MediumEnemies;   // 500-1500m
    TArray<AEnemy*> FarEnemies;      // 1500m+
};

void AEnemyManager::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    FastUpdateTimer += DeltaTime;
    NormalUpdateTimer += DeltaTime;
    SlowUpdateTimer += DeltaTime;

    if (FastUpdateTimer >= 0.05f)  // 근거리
    {
        FastUpdateTimer = 0.0f;
        UpdateCloseEnemies();
    }

    if (NormalUpdateTimer >= 0.1f)  // 중거리
    {
        NormalUpdateTimer = 0.0f;
        UpdateMediumEnemies();
    }

    if (SlowUpdateTimer >= 0.5f)  // 원거리
    {
        SlowUpdateTimer = 0.0f;
        UpdateFarEnemies();
        ReclassifyEnemies();  // 거리별 재분류
    }
}

Tick Group

: Tick 함수는 Tick Group에 따라 순차적으로 실행된다. 물리 계산 이전에 이루어져야 할 Tick과 물리 계산 이후에, 화면 출력 해줄 때 이루어져야 할 Tick이 다르기 때문이다.

Tick Group 실행 순서

[프레임 시작]
     ↓
TG_PrePhysics (물리 전) - 입력, 이동 명령
     ↓
TG_DuringPhysics (물리 엔진 계산)
     ↓
TG_PostPhysics (물리 후) - AI, 충돌 처리 (default)
     ↓
TG_PostUpdateWork (마지막) - UI, 카메라
     ↓
[프레임 끝]

설정 방법

// 플레이어 입력 - 물리 전
AMyPlayerController::AMyPlayerController()
{
    PrimaryActorTick.bCanEverTick = true;
    PrimaryActorTick.TickGroup = TG_PrePhysics;
}

// 적 AI - 물리 후 (기본값)
AEnemy::AEnemy()
{
    PrimaryActorTick.bCanEverTick = true;
    PrimaryActorTick.TickGroup = TG_PostPhysics;
}

// 카메라 - 마지막
AFollowCamera::AFollowCamera()
{
    PrimaryActorTick.bCanEverTick = true;
    PrimaryActorTick.TickGroup = TG_PostUpdateWork;
}

Time Manager

Timer

  • 지정한 주기를 기준으로 함수를 실행시켜준다.
  • Tick보다 CPU 부담이 적다.
  • Handle로 관리하기 때문에 주의가 필요하다.

Timer Handle의 안전한 관리법

  • 핸들을 재사용하거나, 더이상 사용하지 않아서 해제할 때 초기화를 해줘야 한다.
  • 객체가 제거될 때도 핸들을 초기화하지 않는다면 핸들은 TimerHandleManager가 별도로 관리하기 때문에 삭제되지 않고, 메모리를 잡아먹고 떠돌고 다닐 수 있다.
void AEnemy::StopDistanceCheck()
{
    if (DistanceCheckTimer.IsValid())
    {
        GetWorld()->GetTimerManager().ClearTimer(DistanceCheckTimer);
        DistanceCheckTimer.Invalidate(); // 핸들 초기화
    }
}

더 안전한 방법

void AEnemy::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    GetWorld()->GetTimerManager().ClearAllTimersForObject(this);
    Super::EndPlay(EndPlayReason);
}

Lambda vs Delegate

Lambda

  • 짧고 간단한 작업에 적합
  • 하지만, 캡처를 사용할 때 타이머 핸들이 캡처를 계속 가지고 있기 때문에 핸들 초기화를 하지 않는다면 의도치 않은 객체가 남아있을 수도 있다.
GetWorld()->GetTimerManager().SetTimer(
    AttackTimer,
    [this]() // 람다 캡처
    {
        if (ACharacter* Player = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0))
        {
            Player->TakeDamage(AttackDamage);
        }
    },
    1.0f,
    false
);

안전하게 쓰는 방법

  • 객체 파괴될 때 모든 타이머 정리
  • TWeakObjectPtr 쓰기 : 약한 참조이기 때문에 캡처를 계속 가지고 있지 않고, 캡처를 해제
TWeakObjectPtr<AEnemy> WeakThis = this;
GetWorld()->GetTimerManager().SetTimer(
    AttackTimer,
    [WeakThis]()
    {
        if (WeakThis.IsValid())
        {
            WeakThis->PerformAttack();
        }
    },
    2.0f,
    false
);

Delegate

  • 명확하고 유지보수에 유리
  • 하지만 람다에 비하면 복잡하고, 함수를 만들어야 함
void AEnemy::BeginPlay()
{
    GetWorld()->GetTimerManager().SetTimer(
        DistanceCheckTimer,
        this,
        &AEnemy::OnDistanceCheckTimer,
        0.1f,
        true
    );
}

void AEnemy::OnDistanceCheckTimer()
{
    CheckDistanceToPlayer();
    UpdateAIState();
}

Event 기반 아키텍처

Polling -> Event

Polling

  • 매 프레임마다 특정 조건을 확인
  • 조건에 맞는다면 특정 함수 실행
  • 간단하지만, 성능상 매우 안 좋음
void AEnemy::Tick(float DeltaTime)
{
    if (Player && Player->GetHealth() <= 0)
    {
        StopChasing();
        PlayVictoryAnimation();
    }
}

Event

  • 특정 이벤트가 발생하면 이를 구독자에게 알림
  • 불필요한 Tick 제거
  • 코드 구조가 좋아지고, 성능상 매우 좋음
  • 추적이 어렵긴 함
void APlayer::TakeDamage(float Damage)
{
    Health -= Damage;
    
    if (Health <= 0)
    {
        OnPlayerDied.Broadcast(); // 이벤트 발생!
    }
}

void AEnemy::BeginPlay()
{
    if (APlayer* Player = GetPlayer())
    {
    	// 이벤트 구독
        Player->OnPlayerDied.AddUObject(this, &AEnemy::OnPlayerDeath);
    }
}

void AEnemy::OnPlayerDeath()
{
    StopChasing();
    PlayVictoryAnimation();
}

Event Bus 패턴

  • 이벤트 관리하는 Bus 클래스를 생성해서 관련 이벤트를 관리하는 패턴
  • 새로운 이벤트를 처리할 때, 기존 이벤트의 수정 없이 새로운 구독자를 등록함으로써 해결 가능
  • 구독과 게시 과정을 추상화하여 간단하게 처리 가능

주의사항

  • 메모리 누수 : 구독하고 있는 객체가 파괴될 때, 이벤트 구독을 해제해야 한다.
  • 순환 참조 : 이벤트 처리 중 또 다른 이벤트를 바로 발행하면 무한 루프에 돌 수 있다.

Event Bus 예시

UCLASS()
class UGameEventBus : public UObject
{
    GENERATED_BODY()
    
public:
    static UGameEventBus* GetInstance();

    // Enemy 관련 이벤트들
    DECLARE_MULTICAST_DELEGATE_OneParam(FOnEnemyKilled, AEnemy*);
    DECLARE_MULTICAST_DELEGATE_TwoParams(FOnEnemyDamaged, AEnemy*, float);
    DECLARE_MULTICAST_DELEGATE_OneParam(FOnEnemySpawned, AEnemy*);
    
    FOnEnemyKilled OnEnemyKilled;
    FOnEnemyDamaged OnEnemyDamaged;
    FOnEnemySpawned OnEnemySpawned;

private:
    static UGameEventBus* Instance;
};

이벤트 구독 및 실행 예시

void AEnemy::Die()
{
	// 이벤트 발생
    UGameEventBus::GetInstance()->OnEnemyKilled.Broadcast(this);
    Destroy();
}

void AEnemy::BeginPlay()
{
	// 이벤트 구독
    UGameEventBus::GetInstance()->OnEnemyKilled.AddUObject(this, &AEnemy::OnOtherEnemyKilled);
}

void AEnemy::OnOtherEnemyKilled(AEnemy* DeadEnemy)
{
    // 동료가 죽으면 경계 레벨 상승
    if (FVector::Dist(GetActorLocation(), DeadEnemy->GetActorLocation()) < 1000.0f)
    {
        AlertLevel = FMath::Min(AlertLevel + 1, MaxAlertLevel);
    }
}

비동기 처리와 스레드

언리얼의 멀티스레드 구조와 역할

주요 스레드

  • Game Thread -> 게임 핵심 기능 담당
    • 게임 로직 처리
    • 입력 처리
    • UI 업데이트
    • UObject 생성/삭제
  • Render Thread -> 그래픽 담당
    • 드로우 콜 준비
    • 머터리얼 처리
    • 렌더 커맨드 생성
  • RHI Thread -> GPU 담당
    • DirectX/Vulkan 명령 변환
    • GPU에 실제 명령 전달
  • Audio Thread -> 사운드 담당
    • 오디오 믹싱
    • 3D 사운드 계산
  • Worker Thread -> 잡일 담당 (CPU 코어 수만큼)
    • 물리 계산
    • AI 경로 탐색
    • 파일 로딩
    • 기타 백그라운드 작업

-> 스레드별로 할 수 있는 작업이 구분되어 있기 때문에 이를 다르게 명령할 시에 크래시 발생
-> Worker Thread의 일은 모든 스레드에서 가능한 작업

언리얼 비동기 실행 도구

1. Async()

  • 간단한 백그라운드 작업을 할 때 사용
  • 실행 옵션
    • EAsyncExecution::Thread : 새 스레드 생성
    • EAsyncExecution::ThreadPool : 기존 워커 풀 사용 (일반적)
    • EAsyncExecution::ThreadIfForkSafe : 조건부 스레드
    • EAsyncExecution::TaskGraph : 태스크 그래프 사용
    • EAsyncExecution::TaskGraphMainThread : 게임 스레드로 예약
// Enemy 거리순 정렬
Async(EAsyncExecution::ThreadPool, [this]()
{
	SortEnemiesByDistance();
});

2. AsyncTask()

  • 특정 스레드 지정해서 사용
// Enemy 처치 후 UI 업데이트 (Game Thread 전용)
AsyncTask(ENamedThreads::GameThread, [this]()
{
    EnemyCountWidget->SetText(FText::AsNumber(--RemainingEnemies));
});

// Enemy AI 계산을 백그라운드로
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this]()
{
    CalculateAllEnemyNextActions();
});

3. UE::Tasks

  • UE 5.1부터 도입된 경량 태스크 기반의 비동기 처리 시스템
  • 작은 단위의 태스크들을 스케줄링해서 실행 -> 스레드풀을 활용

핵심 개념

  • Task : 실행할 작은 코드 블록
  • Task Graph : 여러 태스크가 의존성을 가지며 연결된 구조
  • 스케줄링
    • 병렬 실행 (스레드풀에서 자동 실행)
    • 직렬 실행 (의존성 보장)
    • 특정 스레드에서 실행

장점

  • 가벼움 : 작은 단위 작업을 쉽게 병렬화 가능
  • 의존성 관리 : Task 간의 순서를 보장할 수 있음
  • GameThread 연동 : 안전하게 게임 로직과 연계 가능
  • 멀티코어 활용 : CPU 자원을 최대한 효율적으로 사용

예시

#include "Tasks/Task.h"

// Enemy 스폰 → 초기화 → AI 설정 체이닝
UE::Tasks::Launch(UE_SOURCE_LOCATION, 
    []() { return LoadEnemyData(); })
    .Then(UE_SOURCE_LOCATION, 
    [](auto Data) { return InitializeEnemies(Data); })
    .Then(UE_SOURCE_LOCATION, 
    [](auto Enemies) { SetupEnemyAI(Enemies); });

// Enemy 200마리 병렬 처리
TArray<UE::Tasks::FTask> EnemyTasks;
for (int i = 0; i < 200; ++i)
{
    EnemyTasks.Add(UE::Tasks::Launch(UE_SOURCE_LOCATION, [i]()
    {
        return ProcessEnemyAI(i);
    }));
}

UE::Tasks::WaitAll(EnemyTasks);


출처: 팀스파르타 내일배움캠프
profile
게임 개발자를 향해..

0개의 댓글