Blog

ENGINEERING NOTE

[Unreal Engine] Tick 함수 최적화

해당 포스팅은 Stuart Butler , Tom Oliver의 Game Development Patterns with Unreal Engine 5를 바탕으로 작성한 내용입니다.위와 같이 적을 색적하기 위한 감시 타워 오브젝트를 만들었다.해당 코드로 실행을 해도 목표로

Unreal Engine
해당 포스팅은 Stuart Butler , Tom Oliver의 Game Development Patterns with Unreal Engine 5를 바탕으로 작성한 내용입니다.

Tick 함수를 최적화해보자.

위와 같이 적을 색적하기 위한 감시 타워 오브젝트를 만들었다.

cpp
// GuardTower.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "GuardTower_CH5_1.generated.h"

class UArrowComponent;
class USpotLightComponent;
UCLASS()
class RTS_AI_API AGuardTower_CH5_1 : public AActor
{
	GENERATED_BODY()

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
	TObjectPtr<UStaticMeshComponent> _TowerMesh;
	
	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
	TObjectPtr<USceneComponent> _LightPivot;

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
	TObjectPtr<UStaticMeshComponent> _LightMesh;

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
	TObjectPtr<USpotLightComponent> _SpotLight;

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
	TObjectPtr<UArrowComponent> _Arrow;

public:	
	// Sets default values for this actor's properties
	AGuardTower_CH5_1();

	virtual void Tick(float DeltaTime) override;

protected:
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	bool _RotateForward;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	bool _EnemySpotted;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	float _DetectionRange;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	float _DetectionRadius;
};
cpp
// GuardTower.cpp
#include "GuardTower_CH5_1.h"

#include "Components/ArrowComponent.h"
#include "Components/SpotLightComponent.h"
#include "GameFramework/Character.h"
#include "Kismet/KismetSystemLibrary.h"

AGuardTower_CH5_1::AGuardTower_CH5_1()
{
	PrimaryActorTick.bCanEverTick = true;
	_RotateForward = true;
	_EnemySpotted = false;
	_DetectionRange = 4000.f;
	_DetectionRadius = 250.f;

	_TowerMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("TowerMesh"));
	RootComponent = _TowerMesh;
	
	_LightPivot = CreateDefaultSubobject<USceneComponent>(TEXT("LightPivot"));
	_LightPivot->SetupAttachment(_TowerMesh);
	
	_LightMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("LightMesh"));
	_LightMesh->SetupAttachment(_LightPivot);
	
	_SpotLight = CreateDefaultSubobject<USpotLightComponent>(TEXT("SpotLight"));
	_SpotLight->SetupAttachment(_LightMesh);
	
	_Arrow = CreateDefaultSubobject<UArrowComponent>(TEXT("Arrow"));
	_Arrow->SetupAttachment(_LightMesh);
}

void AGuardTower_CH5_1::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	
	// 시작 위치와 끝 위치 계산 (전방으로 DetectionRange만큼)
	FVector startLocation = _Arrow->GetComponentLocation();
	FVector endLocation = _Arrow->GetComponentLocation() + (_Arrow->GetForwardVector() * _DetectionRange);

	// 구체(스피어) 트레이스 결과 저장용
	FHitResult hit;
	TArray<AActor*> ActorsToIgnore;

	// 스피어 트레이스 실행
	UKismetSystemLibrary::SphereTraceSingle(
		GetWorld(), 
		startLocation, 
		endLocation, 
		_DetectionRadius,
		UEngineTypes::ConvertToTraceType(ECC_Visibility), 
		false,
		ActorsToIgnore, 
		EDrawDebugTrace::ForOneFrame, 
		hit, 
		true
	);

	// 히트된 액터를 캐릭터로 캐스팅해 적 탐지 여부 확인
	ACharacter* otherCasted = Cast<ACharacter>(hit.GetActor());
	_EnemySpotted = (otherCasted != nullptr);

	// 적이 없을 때만 라이트를 좌우로 회전
	if(!_EnemySpotted)
	{
		if(_RotateForward)
		{
			// 오른쪽(또는 지정 방향)으로 회전
			_LightPivot->AddLocalRotation(FRotator(0.0, 0.2, 0.0));

			// Yaw가 40도에 거의 도달하면 회전 방향 반전
			if(FMath::IsNearlyEqual(_LightPivot->GetRelativeRotation().Yaw, 40.f))
			{
				_RotateForward = false;
			}
		}
		else
		{
			// 반대 방향으로 회전
			_LightPivot->AddLocalRotation(FRotator(0.0, -0.2, 0.0));

			// Yaw가 -40도에 거의 도달하면 회전 방향 반전
			if(FMath::IsNearlyEqual(_LightPivot->GetRelativeRotation().Yaw, -40.f))
			{
				_RotateForward = true;
			}
		}
	}
}

해당 코드로 실행을 해도 목표로 하는 동작은 정상적으로 수행된다. 하지만 이러한 코드는 잠재적으로 여러가지 문제점을 내포하고 있다.

1. 모든 로직이 Tick 안에 존재

  • 스피어 트레이스, 라이트 회전, 적 탐지 모두 Tick에서 수행
  • 매 프레임 불필요한 연산 발생 → 성능 저하 가능

2. 매 프레임 Getter/Vector 연산 반복

  • _Arrow->GetComponentLocation() 같은 함수가 매 프레임 반복 호출됨
  • 불필요한 함수 호출과 Vector 연산 발생

3. 형변환(Cast) 연산 반복

  • ACharacter* otherCasted = Cast<ACharacter>(hit.GetActor());
  • 매 Tick마다 수행 → 캐릭터가 많으면 비용 증가

4. 회전 로직의 하드코딩

  • Yaw 제한값(-40, 40)과 회전량(0.2f) 하드코딩

5. Gated Polling 미흡

  • 적이 감지되지 않은 상태에서도 매 Tick 라이트 회전 계속 수행
  • 적 발견 시에도 트레이스나 회전 체크가 Tick에서 계속 이루어짐

최적화 방안

1. Tick 의존 제거 → 이벤트 기반

  • SphereComponent의 Overlap 이벤트(OnSphereOverlapBegin, OnSphereOverlapEnd)를 활용해 적 감지
  • 적이 들어왔을 때만 LineTrace 수행 → 불필요한 매 프레임 검사 제거 (Gated Polling 적용)
cpp
// GuardTower.h
UFUNCTION()
void OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool FromSweep, const FHitResult& SweepResult);

UFUNCTION()
void OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);
cpp
// GuardTower.cpp
_Sphere = CreateDefaultSubobject<USphereComponent>(TEXT("Sphere"));
_Sphere->SetupAttachment(_LightMesh);
_Sphere->OnComponentBeginOverlap.AddDynamic(this, &AGuardTower_CH5_3::OnSphereOverlapBegin);
_Sphere->OnComponentEndOverlap.AddDynamic(this, &AGuardTower_CH5_3::OnSphereOverlapEnd);

...

void AGuardTower_CH5_3::OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool FromSweep, const FHitResult& SweepResult)
{
	if(_EnemyUnit != nullptr) return;

	_EnemyUnit = Cast<ACharacter>(OtherActor);
	if(_EnemyUnit == nullptr) return;
	
	// 필요할 때만 LineTrace 수행
	FHitResult hit(ForceInit);
	FVector start = _Arrow->GetComponentLocation();
	FVector end = _EnemyUnit->GetActorLocation();
	TArray<AActor*> ActorsToIgnore;
	ActorsToIgnore.Add(_EnemyUnit);

	if(UKismetSystemLibrary::LineTraceSingle(GetWorld(), start, end,
		UEngineTypes::ConvertToTraceType(ECC_Visibility), false,
		ActorsToIgnore, EDrawDebugTrace::ForDuration, hit, true, FLinearColor::Red,
		FLinearColor::Green, 0.5f))
	{
		if(hit.GetActor() != OtherActor) return;
	}
	
	_EnemySpotted = true;
	StopRotation();
}

2. 라인 트레이스 최소화

  • _Arrow->GetComponentLocation() 같은 Getter 호출을 이벤트 발생 시점으로 제한
  • 매 Tick마다 불필요한 연산 제거
cpp
FVector start = _Arrow->GetComponentLocation();
FVector end = _EnemyUnit->GetActorLocation();
TArray<AActor*> ActorsToIgnore;
ActorsToIgnore.Add(_EnemyUnit);

UKismetSystemLibrary::LineTraceSingle(GetWorld(), start, end,
	UEngineTypes::ConvertToTraceType(ECC_Visibility), false,
	ActorsToIgnore, EDrawDebugTrace::ForDuration, hit, true, FLinearColor::Red,
	FLinearColor::Green, 0.5f);

3. 회전 로직 개선(Timeline)

  • Timeline + CurveFloat를 사용해 프레임 독립적 회전 구현
  • FRotator::SetRelativeRotation(FMath::Lerp(-40.f, 40.f, val))로 부드러운 회전
  • 하드코딩 값은 Curve에서 관리 가능
cpp
// GuardTower.h
// Timeline 관련 Delegate
FOnTimelineFloat onTimeline_Update;
FOnTimelineEventStatic onTimeline_Finished;

// Timeline 처리 함수
UFUNCTION()
void Handle_RotateLight_Update(float val);

UFUNCTION()
void Handle_RotateLight_Finished();

// Timeline 제어
void StartRotation();
void StopRotation();

// Timeline 컴포넌트
UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
TObjectPtr<UTimelineComponent> T_RotateLight;

// CurveFloat
UPROPERTY(EditAnywhere)
UCurveFloat* _Curve;
cpp
// GuardTower.cpp
AGuardTower_CH5_3::AGuardTower_CH5_3()
{
	...
	
	T_RotateLight = CreateDefaultSubobject<UTimelineComponent>(TEXT("T_RotateLight"));
	onTimeline_Update.BindUFunction(this, FName("Handle_RotateLight_Update"));
	onTimeline_Finished.BindUFunction(this, FName("Handle_RotateLight_Finished"));
}

void AGuardTower_CH5_3::BeginPlay()
{
	Super::BeginPlay();

	if(_Curve == nullptr) { return;}
	T_RotateLight->AddInterpFloat(_Curve, onTimeline_Update, FName("Alpha"));
	T_RotateLight->SetTimelineFinishedFunc(onTimeline_Finished);
	T_RotateLight->SetLooping(false);
	T_RotateLight->SetIgnoreTimeDilation(true);
	StartRotation();
}

void AGuardTower_CH5_3::Handle_RotateLight_Update(float val)
{
	// Curve 기반 Lerp로 부드럽게 회전
	_LightPivot->SetRelativeRotation(
		FRotator(0.f, FMath::Lerp(-40.f, 40.f, val), 0.f));
}

void AGuardTower_CH5_3::Handle_RotateLight_Finished()
{
	// 회전 방향 토글
	_RotateForward = !_RotateForward;
	StartRotation();
}

void AGuardTower_CH5_3::StartRotation()
{
	if(_RotateForward)
		T_RotateLight->Play();
	else
		T_RotateLight->Reverse();
}

void AGuardTower_CH5_3::StopRotation()
{
	T_RotateLight->Stop();
}

4. Gated Polling 적용

  • _EnemySpotted 값에 따라 Timeline 회전 제어
  • 적 발견 시 Timeline 중지 → 불필요한 반복 제거
  • 적이 사라지면 Timeline 다시 재생
cpp
void AGuardTower_CH5_3::OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
	if(_EnemyUnit != OtherActor) return;
	
	_EnemySpotted = false;
	_EnemyUnit = nullptr;
	StartRotation(); // 적이 사라지면 다시 회전 시작
}

5. 로직 함수 분리

  • 최종적으로 회전 처리, Timeline 제어, 적 감지 처리 등을 Tick함수에서 개별 함수로 분리
  • 코드 가독성 및 유지보수성 향상
cpp
// 회전 처리
void AGuardTower_CH5_3::Handle_RotateLight_Update(float val);

// 회전 종료 후 방향 토글
void AGuardTower_CH5_3::Handle_RotateLight_Finished();

// Timeline 제어
void AGuardTower_CH5_3::StartRotation();
void AGuardTower_CH5_3::StopRotation();

// 적 감지 처리
void AGuardTower_CH5_3::OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool FromSweep, const FHitResult& SweepResult);

void AGuardTower_CH5_3::OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);

최종 코드 전체

cpp
// GuardTower.h 개선 버전
// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "Components/SphereComponent.h"
#include "Components/TimelineComponent.h"
#include "GameFramework/Actor.h"
#include "GuardTower_CH5_3.generated.h"

class ACharacter;
class UArrowComponent;
class USpotLightComponent;
UCLASS()
class RTS_AI_API AGuardTower_CH5_3 : public AActor
{
	GENERATED_BODY()

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
	TObjectPtr<UStaticMeshComponent> _TowerMesh;
	
	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
	TObjectPtr<USceneComponent> _LightPivot;

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
	TObjectPtr<UStaticMeshComponent> _LightMesh;

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
	TObjectPtr<USpotLightComponent> _SpotLight;

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
	TObjectPtr<UArrowComponent> _Arrow;

	UPROPERTY(VisibleAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = true))
	TObjectPtr<USphereComponent> _Sphere;

	FOnTimelineFloat onTimeline_Update;
	FOnTimelineEventStatic onTimeline_Finished;
	UFUNCTION()
	void Handle_RotateLight_Update(float val);
	UFUNCTION()
	void Handle_RotateLight_Finished();
	void StartRotation();
	void StopRotation();
	
public:
	AGuardTower_CH5_3();

protected:
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	bool _RotateForward;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	bool _EnemySpotted;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	float _DetectionRange;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	float _DetectionRadius;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	TObjectPtr<ACharacter> _EnemyUnit;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	TObjectPtr<UTimelineComponent> T_RotateLight;

	UPROPERTY(EditAnywhere)
	UCurveFloat* _Curve;
	
	virtual void BeginPlay() override;

	UFUNCTION()
	void OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool FromSweep, const FHitResult& SweepResult);

	UFUNCTION()
	void OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);
};
cpp
// GuardTower.cpp 개선 버전
#include "GuardTower_CH5_3.h"

#include "Components/ArrowComponent.h"
#include "Components/SpotLightComponent.h"
#include "GameFramework/Character.h"
#include "Kismet/KismetSystemLibrary.h"

AGuardTower_CH5_3::AGuardTower_CH5_3()
{
	PrimaryActorTick.bCanEverTick = true;
	_RotateForward = true;
	_EnemySpotted = false;
	_DetectionRange = 4000.f;
	_DetectionRadius = 250.f;

	_TowerMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("TowerMesh"));
	RootComponent = _TowerMesh;
	
	_LightPivot = CreateDefaultSubobject<USceneComponent>(TEXT("LightPivot"));
	_LightPivot->SetupAttachment(_TowerMesh);
	
	_LightMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("LightMesh"));
	_LightMesh->SetupAttachment(_LightPivot);
	
	_SpotLight = CreateDefaultSubobject<USpotLightComponent>(TEXT("SpotLight"));
	_SpotLight->SetupAttachment(_LightMesh);
	
	_Arrow = CreateDefaultSubobject<UArrowComponent>(TEXT("Arrow"));
	_Arrow->SetupAttachment(_LightMesh);

	_Sphere = CreateDefaultSubobject<USphereComponent>(TEXT("Sphere"));
	_Sphere->SetupAttachment(_LightMesh);
	_Sphere->OnComponentBeginOverlap.AddDynamic(this, &AGuardTower_CH5_3::OnSphereOverlapBegin);
	_Sphere->OnComponentEndOverlap.AddDynamic(this, &AGuardTower_CH5_3::OnSphereOverlapEnd);
	
	T_RotateLight = CreateDefaultSubobject<UTimelineComponent>(TEXT("T_RotateLight"));
	onTimeline_Update.BindUFunction(this, FName("Handle_RotateLight_Update"));
	onTimeline_Finished.BindUFunction(this, FName("Handle_RotateLight_Finished"));
}

void AGuardTower_CH5_3::BeginPlay()
{
	Super::BeginPlay();

	if(_Curve == nullptr) { return;}
	T_RotateLight->AddInterpFloat(_Curve, onTimeline_Update, FName("Alpha"));
	T_RotateLight->SetTimelineFinishedFunc(onTimeline_Finished);
	T_RotateLight->SetLooping(false);
	T_RotateLight->SetIgnoreTimeDilation(true);
	StartRotation();
}

void AGuardTower_CH5_3::Handle_RotateLight_Update(float val)
{
	_LightPivot->SetRelativeRotation(
		FRotator(0.f, FMath::Lerp(-40.f, 40.f, val), 0.f));
}

void AGuardTower_CH5_3::Handle_RotateLight_Finished()
{
	_RotateForward = !_RotateForward;
	StartRotation();
}

void AGuardTower_CH5_3::StartRotation()
{
	if(_RotateForward)
	{
		T_RotateLight->Play();
	}
	else
	{
		T_RotateLight->Reverse();
	}
}

void AGuardTower_CH5_3::StopRotation()
{
	T_RotateLight->Stop();
}

void AGuardTower_CH5_3::OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool FromSweep, const FHitResult& SweepResult)
{
	if(_EnemyUnit != nullptr) {return;}

	_EnemyUnit = Cast<ACharacter>(OtherActor);
	if(_EnemyUnit == nullptr) {return;}
	
	FHitResult hit(ForceInit);
	FVector start = _Arrow->GetComponentLocation();
	FVector end = _EnemyUnit->GetActorLocation();
	TArray<AActor*> ActorsToIgnore;
	ActorsToIgnore.Add(_EnemyUnit);
	if(UKismetSystemLibrary::LineTraceSingle(GetWorld(), start, end,
		UEngineTypes::ConvertToTraceType(ECC_Visibility), false,
		ActorsToIgnore, EDrawDebugTrace::ForDuration, hit, true, FLinearColor::Red,
		FLinearColor::Green, 0.5f))
	{
		if(hit.GetActor() != OtherActor) return;
	}
	
	_EnemySpotted = true;
	StopRotation();
}

void AGuardTower_CH5_3::OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
	if(_EnemyUnit != OtherActor) {return;}
	
	_EnemySpotted = false;
	_EnemyUnit = nullptr;
	StartRotation();
}

요약 비교

항목수정 전수정 후
적 감지매 Tick 스피어 트레이스SphereComponent Overlap 이벤트 + LineTrace
라이트 회전Tick에서 AddLocalRotation, 하드코딩Timeline + CurveFloat로 프레임 독립적 회전
Gated Polling미적용, 항상 검사적 발견 여부에 따라 회전/트레이스 제어
성능매 프레임 불필요 연산이벤트 발생 시에만 최소 연산
가독성Tick에 모든 로직 포함함수 분리, 역할 별 모듈화