이 문서에는 언리얼 엔진을 이용하면서 개인적으로 경험한 것들을 정리한다.
(문서의 내용은 결정된 내용이 아니고, 지속적으로 개선될 것이다.)
언리얼 엔진 C++ 개발의 기본적인 프로그래밍 컨벤션은 다음 문서를 참고한다.
docs.unrealengine.com/en-US/ProductionPipelines/DevelopmentSetup/CodingStandard/index.html
Coding Standard
Standards and conventions used by Epic Games in the Unreal Engine 4 codebase.
docs.unrealengine.com
게임 내 커스텀 Enum 데이터 타입들을 모아놓을 클래스 파일을 생성한다.
언리얼 작업중에는 직접 제작한 다양한 열거형 타입들을 사용하게 될 것이다.
이 데이터 타입들 중에서는 특정 클래스에 귀속되어야하는 타입도 존재하겠지만, 글로벌하게 사용되는 공통 데이터 타입들도 분명 존재한다. 그리고 구현 단계에서는 그 경계를 명확히 지정하기 어렵기 때문에 playground로서의 클래스파일이 하나 필요하다.
ex)
// 빈 클래스
class DataTypes
{
public:
DataTypes();
~DataTypes();
};
UENUM(BlueprintType)
enum class EEnemyAIType : uint8
{
Tank UMETA(DisplayName = "Tank"),
Healer UMETA(DisplayName = "Healer"),
Ranged UMETA(DisplayName = "Ranged"),
Rusher UMETA(DisplayName = "Rusher"),
Disabler UMETA(DisplayName = "Disabler")
};
UENUM(BlueprintType)
enum class EGameState : uint8
{
MainMenu UMETA(DisplayName = "MainMenu"),
LevelSelectionMenu UMETA(DisplayName = "LevelSelectionMenu"),
LoadingScreen UMETA(DisplayName = "LoadingScreen"),
LoadOutMenu UMETA(DisplayName = "LoadOutMenu"),
InGameHUD UMETA(DisplayName = "InGameHUD"),
MissionSummary UMETA(DisplayName = "MissionSummary"),
Unassigned UMETA(DisplayName = "Unassigned"),
};
// ...
게임 내 커스텀 Struct 데이터 타입들을 모아놓을 클래스 파일을 생성한다.
열거형과 마찬가지의 이유로 구조체도 따로 모아놓을 클래스 파일이 필요하다.
class CustomStructs
{
public:
CustomStructs();
~CustomStructs();
};
USTRUCT(BlueprintType)
struct FBossAIData
{
GENERATED_BODY()
public:
FBossAIData()
:
AIType(EEnemyAIType::Tank)
{}
FBossAIData(EEnemyAIType InAIType, FName InDataTableRowName)
:
AIType(InAIType),
DataTableRowName(InDataTableRowName)
{}
UPROPERTY(EditAnywhere, BlueprintReadWrite)
EEnemyAIType AIType;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FName DataTableRowName;
};
// ...
게임 내 유틸리티 함수를 모아놓을 클래스를 생성한다.
언리얼에서 작업을 진행하게 되면, 굉장히 많은 기능들과 방대한 코드 속에서 그때그때 예제를 만들며 많은 시간을 낭비하게 된다. 시간을 좀 더 효율적으로 활용하기 위해 제작해본 예제들을 기능 단위로 유틸리티 함수에 쌓아두어야 할 것이다.
유틸리티 함수는 일반화된 작업들을 수행하는 함수들로, 모두 static함수로 구성되어 있다.
화면에 직선이나 구체를 그리는 디버깅용 함수, Enum을 문자열로 변환하는 함수, 템플릿 함수 등이 이 클래스의 함수로서 들어가게 될 것이다.
ex)
class GameUtils
{
public:
GameUtils() {}
~GameUtils() {}
public:
// logs
static void PrintHitResult(const FHitResult& InHitResult);
static bool IsValid(const UObjectBase* const InObject);
static FString GetDebugName(const UObject* const InObject);
static void DrawPoint(UWorld* InWorld, FVector InLocation, FColor InColor = FColor::Red);
static void DrawArrow(UWorld* InWorld, FVector InStart, FVector InEnd);
template<typename Type>
static Type GetRandomArrayElement(const TArray<Type>& InArray)
{
int Index = FMath::RandRange(0, InArray.Num() - 1);
return InArray[Index];
}
template<typename TEnum>
static FORCEINLINE FString GetEnumValueAsString(const FString& InEnumName, TEnum InEnumValue)
{
const UEnum* EnumPtr = FindObject<UEnum>(ANY_PACKAGE, *InEnumName, true);
if (!EnumPtr) return FString("Invalid");
return EnumPtr->GetNameByValue((int64)InEnumValue).ToString();
}
// ...
};
GameInstance의 역할
ㅇ
상황에 맞는 객체 포인터 사용 기준
언리얼 엔진에서 C++로 개발을 하는 과정에서 기본적으로 객체 레퍼런스를 위해 포인터를 사용한다.
하지만 똑같이 포인터를 사용하더라도 상황에 맞게 명확하게 구분지어줄 필요가 있다.
다음과 같은 코드가 있다고 가정해보자.
UCLASS()
class ATowerBase
{
// ...
USphereComponent* RangeSphere;
UActorComponent* ActiveAbilityComponent;
AActor* CurrentTarget;
// ...
};
TowerBase 객체는 적을 공격하기 위한 범위를 표현하기 위해 구체 형태의 출돌영역 RangeSphere를 갖는다.
그리고 현재 활성화된 능력에 대한 기능을 다루기 위해 ActiveAbilityComponent를 갖는다.
마지막으로 RangeSphere의 영역에 들어온 감지된 적을 참조하기 위해 CurrentTarget을 갖는다.
RangeSphere는 현재 객체의 컴포넌트로 붙는 객체이다.
TowerBase를 구성하는 필수요소이며, 생성/소멸 과정을 TowerBase의 생성/소멸 과정과 함께한다.
TowerBase는 RangeSphere를 완전히 소유하고 있기 때문에 RangeSphere는 nullptr이 되어서는 안된다.
또한, 이 컴포넌트는 중간에 교체되거나, 사라지지 않는다.
이러한 RangeSphere의 특성들을 고려했을 때, 언리얼 내에서 어떤 형태로 포인터를 이용해야 할까?
1. UPROPERTY 세팅
기본적으로 모든 UObject는 가비지컬렉션 대상이 된다. 자신을 참조하는 다른 객체가 없다면, 언제든지 사라질 수 있는 상태에 놓여있다. RangeSphere도 UObject이기 때문에, GC로부터 방어되도록 하기 위해 RangeSphere를 UPROPERTY로 지정해줄 필요가 있다.
(RegisterComponent를 하면 GC방어가 되지 않느냐고 알고 있는 경우가 많다. 하지만 RegisterComponent는 Component로 하여금 World의 자원을 사용하도록 하기위함이지, GC방어를 위함이 아니다.)
RangeSphere는 TowerBase의 구성품으로서, 이 객체가 존재하지 않으면 TowerBase객체도 의미가 없다.
언리얼 엔진에서는 이런 수준의 객체는 굳이 게임타임에 생성시킬 필요 없이, CDO 타임에 미리 생성해놓고 나중에 로드하여 사용하는 방법을 이용한다.
RangeSphere = CreateDefaultSubobject<USphereComponent>("RangeSphere");
또한 RangeSphere는 TowerBase의 구성품으로서 Blueprint 설계 수준에서도 세팅이 가능하면 좋을 것이다.
그러므로 UPROPERTY로 세팅해준다.
그리고 RangeSphere의 소유권을 TowerBase가 갖고 있기 때문에 RangeSphere의 소멸도 TowerBase가 책임져야 할 것이다. 모든 UObject들은 가비지 컬렉션 대상이므로, 레퍼런스만 제거된다면 언리얼 내에서는 소멸도 자동으로 이루어질 것이다. 현재는 TowerBase가 소멸하면, Register된 컴포넌트들은 제거될 것이고, TowerBase에서는 파괴 시 RangeSphere만 nullptr로 세팅해주면 될 것이다. (사실 nullptr로 세팅하지 않더라도 결국에는 제거되겠지만, 미리 레퍼런스를 풀어주는것이 가비지 컬렉터 입장에서 유리하다.)
void ATowerBase::EndPlay(EEndPlayReason::Type EndReason)
{
RangeSphere = nullptr;
Super::EndPlay(EndReason);
}
다음은 ActiveAbilityComponent이다.
ActiveAbilityComponent는 상황에 따라 생성/제거/변경 되는 컴포넌트이다.
TowerBase가 소멸될때 남아있는 객체는 함께 소멸되지만,
TowerBase가 생성될 때 반드시 같이 생성된다고 보장할 수는 없다.
또한 중간에 다른 객체로 교체될 수 있기 때문에, 기존 ActiveAbilityComponent는 제거되고 새로운 ActiveAbilityComponent가 생성되는 시나리오도 충분히 가능하다.
UCLASS()
class ATowerBase
{
// ...
UPROPERTY(EditDefaultsOnly, Category = "Components")
USphereComponent* RangeSphere;
UPROPERTY()
UActorComponent* ActiveAbilityComponent;
TWeakObjectPtr<AActor> CurrentTarget;
// ...
};
변하지 않을 문자열을 사용하는 경우 FString이 아닌 FName을 이용한다.
문자열을 key로 하는 상황이 이에 해당될 것이다.
FName은 FString과 달리 문자열 조작이 불가능하지만, 반면에 문자열 탐색에 있어 최적화된 성능을 보여준다.
TMap<FName, UObject*> Objects;
언리얼에서 사용될 소스 파일들은(.cpp) Private 폴더에 넣고, 헤더 파일들은 Public 폴더에 넣는다.
언리얼에서는 자체적인 헤더 포함 방법이 존재하기 때문에 Private이나 Public 폴더 내부에 존재하는 코드들은 쉽게 포함시킬 수 있다.
예를 들어 Test/ 디렉토리 하위에 MyObject.h 라는 헤더가 있을 때, 다른 파일에서 아래처럼 포함시켜야 하는 파일들을
#include "../Test/MyObjects.h"
다음과 같이 포함시킬 수 있다.
#include "Test/MyObjects.h"
로그를 쉽게 탐색하기 위해 로그 카테고리를 정의한다.
헤더 파일에 로그 카테고리를 선언한다.
DECLARE_LOG_CATEGORY_EXTERN(LogGenScene, Log, All);
소스 파일에 로그 카테고리를 정의한다.
DEFINE_LOG_CATEGORY(LogGenScene);
다음과 같이 사용한다.
UE_LOG(LogGenScene, Log, TEXT("Stop"));
비동기 작업을 처리할 때 유용한 방법
가끔 개발을 하다보면 게임 스레드에서 처리하기에 부담이 되는 작업들이 있다. 예를 들면 사이즈가 큰 에셋, 파일을 런타임에 로드해야하는 경우나 네트워크를 통해 데이터를 받기까지 Block해야하는 경우가 있을 것이다. 이러한 상황에서 사용되는 방법이 비동기 함수를 이용하는 것인데 언리얼에서는 AsyncTask라는 쉽고 간편한 함수를 제공해주고 있다.
다음과 같이 원하는 작업을 비동기로 처리할 수 있다.
int32 GlobalCounter = 0;
AsyncTask(ENamedThreads::AnyThread, []()
{
// This code will run asynchronously, without freezing the game thread
GlobalCounter ++;
});
하지만 콜백이 호출되는 지점도 비동기 루틴 내부에서 처리되므로 race condition 문제가 발생할 수 있다.
다음과 같이 데이터를 보호할 수 있다.
int32 GlobalCounter = 0;
FCriticalSection CounterCriticalSection;
AsyncTask(ENamedThreads::AnyThread, []()
{
FScopeLock Lock(&CounterCriticalSection);
GlobalCounter ++;
});
Lock-Free 하게 설계할수는 없을까?
다음 예제를 보자.
첫번째 AsyncTask는 GameThread의 프리징 없이 동작된다. 람다 내부의 코드는 다른 스레드에서 동작할 것이다. 두번째 AsyncTask는 다른 스레드의 프리징 없이 동작된다. 람다 내부의 코드는 GameThread에서 동작할 것이다. 그러므로 이 코드를 활용하면 Lock 없이 데이터 동기화를 할 수 있다.
AsyncTask(ENamedThreads::AnyThread, []()
{
GlobalCounter++;
uint32 ThreadId = FPlatformTLS::GetCurrentThreadId();
FString ThreadName = FThreadManager::Get().GetThreadName(ThreadId);
UE_LOG(LogTemp, Warning, TEXT("ThreadName : %s Counter updated: %d"), *ThreadName, GlobalCounter);
AsyncTask(ENamedThreads::GameThread, []()
{
GlobalCounter++;
uint32 ThreadId = FPlatformTLS::GetCurrentThreadId();
FString ThreadName = FThreadManager::Get().GetThreadName(ThreadId);
UE_LOG(LogTemp, Warning, TEXT("ThreadName : %s Counter updated: %d"), *ThreadName, GlobalCounter);
});
});
테스트 결과
LogTemp: Warning: ThreadName : GameThread Counter updated: 1530
LogTemp: Warning: ThreadName : Foreground Worker #0 Counter updated: 1531
LogTemp: Warning: ThreadName : GameThread Counter updated: 1532
LogTemp: Warning: ThreadName : Foreground Worker #1 Counter updated: 1533
LogTemp: Warning: ThreadName : GameThread Counter updated: 1534
LogTemp: Warning: ThreadName : Foreground Worker #1 Counter updated: 1535
LogTemp: Warning: ThreadName : GameThread Counter updated: 1536
LogTemp: Warning: ThreadName : Foreground Worker #1 Counter updated: 1537
LogTemp: Warning: ThreadName : GameThread Counter updated: 1538
LogTemp: Warning: ThreadName : Foreground Worker #1 Counter updated: 1539
LogTemp: Warning: ThreadName : GameThread Counter updated: 1540
UObject를 상속받은 객체를 설계할 때 주의점 (GC)
객체를 설계하다보면 UObject를 상속하여 설계해야하는 상황이 생긴다.
//..
UCLASS()
class YOURPROJECT_API UCustomObject : public UObject
{
GENERATED_BODY()
public:
//..
};
UObject를 이용하면 UObject의 여러가지 기능들을 사용할 수 있어서 좋은 부분도 있지만, GC에 대해 제대로 이해하지 못한다면 혼란스러운 경험을 하게 될 수 있다. 먼저 모든 UObject 기본적으로 GC의 대상이고 언제든지 갑작스럽게 객체가 삭제될 수 있다는 점을 알아야한다. 다만 GC는 UPROPERTY로 레퍼런싱 되거나 RootSet에 추가된 예외적인 UObject들에 대해서는 삭제를 진행하지 않기 때문에 이를 활용하여 GC로부터 보호될 수 있는 형태로 설계해야한다.
다른 객체를 통해 완전히 소유되는 형태로 설계된 UObject들은 보통 안전하다. 생성된 후 소유권이 어디로든 이전될 수 있는 성질의 객체로 설계되는 경우에 이 GC문제를 겪게 될 가능성이 크다.
다음 예제를 보자.
이 객체는 애니메이션을 저장하는 에셋으로 활용되기 위해 설계한 객체이다.
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "AnimationData.generated.h"
struct FCustomAnimationData
{
UPROPERTY(EditAnywhere, Category = "Animation")
TArray<FTransform> KeyFrames;
};
UCLASS()
class YOURPROJECT_API UAnimationData : public UObject
{
GENERATED_BODY()
public:
void SetAnimationData(const FCustomAnimationData& InAnimationData) { AnimationData = InAnimationData; }
private:
FCustomAnimationData AnimationData;
};
이 애니메이션 객체를 비동기 세팅하는 상황을 가정해보자.
다음 객체는 비동기 로딩을 돕기 위한 매니저 객체이다.
header
#include "CoreMinimal.h"
class FAnimationLoader
{
public:
void LoadCustomAnimationDataAsync(
UAnimationData* AnimationData,
const FString& AnimationAssetPath,
FOnAnimationDataUpdatedDelegate OnAnimationDataUpdated);
};
cpp
void FAnimationLoader::LoadCustomAnimationDataAsync(
UAnimationData* AnimationData,
const FString& AnimationAssetPath,
FOnAnimationDataUpdatedDelegate OnAnimationDataUpdated)
{
// Use AsyncTask to load the custom animation data asynchronously
AsyncTask(ENamedThreads::AnyThread, [AnimationData, AnimationAssetPath, OnAnimationDataUpdated]()
{
FSoftObjectPath AssetPath(AnimationAssetPath);
// Load the custom animation data asynchronously
FCustomAnimationData* LoadedCustomData = AssetPath.TryLoad<FCustomAnimationData>();
// Check if the data loaded successfully
if (LoadedCustomData)
{
// Set the loaded custom animation data in the UAnimationData object
AnimationData->SetAnimationData(*LoadedCustomData);
// Execute the callback on the game thread using AsyncTask
AsyncTask(ENamedThreads::GameThread, [OnAnimationDataUpdated]()
{
OnAnimationDataUpdated.ExecuteIfBound();
});
}
});
}
이제 이 코드를 이용하는 부분이다.
// Get or create an instance of UAnimationData
UAnimationData* MyAnimationData = NewObject<UAnimationData>();
// Define a callback function to handle when the data is updated
FOnAnimationDataUpdatedDelegate OnAnimationDataUpdatedCallback;
OnAnimationDataUpdatedCallback.BindLambda([]() {
// Animation data is updated asynchronously
});
// Load and set the custom animation data asynchronously (FAnimationLoader)
AnimationLoader->LoadCustomAnimationDataAsync(MyAnimationData, "/Game/Path/To/YourCustomAnimationData.YourCustomAnimationData", OnAnimationDataUpdatedCallback);
이 예제에서 UObject로 설계된 UAnimationData 객체가 위험한 구간은 어디일까?
마지막 예제 코드에서 UAnimation을 생성한 후, 비동기로 로드하는 구간 사이에 MyAnimationData를 누군가 레퍼런싱하고있지 않는다면 GC의 동작에 따라 사라질 수 있다. 이처럼 에셋 데이터를 사용하는 경우에는 에셋의 소유권이 여기저기로 옮겨다니게 되고, 그 과정에서 레퍼런싱이 비는 순간이 오게 된다.
그렇다면 이 문제를 어떻게 해결할 수 있을까?
가장 쉬운 방법은 레퍼런싱만 관리하는 관리자 객체를 두고 모든 생성된 UObject 객체를 레퍼런싱하도록 하는 것이다. 다만 이 경우 레퍼런스를 어느 타이밍에 누가 해제해주어야 할지 잘 결정해야할 것이다.
위 예제와 같은 상황에서는 누군가에게 레퍼런싱 직전까지 UObject를 생성하지 않는 것도 방법이다. 당연한 얘기일수도 있지만 생성되지 않았다면 파괴될일도 없다. 즉, 미리 생성시켜놓고 Async함수에서 세팅하는것이 아니라, 애초에 모든 데이터가 준비되었을 때 생성시켜서 반환하는 것이다. 물론 반환된 에셋은 바로 레퍼런싱해주어야 할것이다.
모듈은 매번 빌드되지 않도록 한다.
언리얼 c++을 빌드하는 경우 빌드 시간이 오래 걸린다. 이는 언리얼에서 프로젝트를 빌드할 때, 다른 플러그인도 빌드하기 때문이다. 보통 .uproject 파일은 다음과 같이 구성되어 있다.
{
"FileVersion": 3,
"EngineAssociation": "4.26",
"Category": "",
"Description": "An awesome game project with multiple modules.",
"Modules": [
{
"Name": "MyAwesomeGame",
"Type": "Runtime",
"LoadingPhase": "Default"
},
{
"Name": "MyAwesomeGameEditor",
"Type": "Editor",
"LoadingPhase": "Default"
},
{
"Name": "MyAwesomeGameTest",
"Type": "Developer",
"LoadingPhase": "PostEngineInit"
}
],
"Plugins": [
{
"Name": "Paper2D",
"Enabled": true
},
{
"Name": "MyCustomPlugin",
"Enabled": false
},
{
"Name": "OnlineSubsystemSteam",
"Enabled": true,
"SupportedTargetPlatforms": [ "Win64" ]
}
],
"TargetPlatforms": [
"WindowsNoEditor",
"WindowsEditor"
]
}
여기에서 매번 다시 빌드될 필요 없는 플러그인의 경우 Enabled를 false로 둔다면 빌드과정과 로딩 과정에서 제외시킬 수 있다. 만약 플러그인이 아닌, 모듈을 매번 빌드하지 않도록 하려면 어떻게 해야할까?
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
해당 모듈의 Build.cs에서 다음 코드를 추가하면 된다. 이는 Precompiled Header를 사용할지에 대한 코드이다.
PCH (Precompiled Header) 는 헤더 파일을 미리 컴파일해두고 여러 소스 파일에서 재사용하는 방식으로, 컴파일 시간을 줄이기 위해 사용되는데, 언리얼 모듈에서도 Build.cs에서 이 기능을 지원한다.
모듈에 Blueprintable 객체를 사용하는 경우
새로 만든 모듈에서 다음과 같은 BP 구조체를 생성한다고 가정하자.
USTRUCT(BlueprintType)
struct LLM_API FChatMessage
{
GENERATED_BODY()
public:
UPROPERTY(BlueprintReadWrite, Category = "ChatGPT")
FString role;
UPROPERTY(BlueprintReadWrite, Category = "ChatGPT")
FString content;
};
이 구조체를 생성하려면 .generated 헤더가 필요하다. 이와 같은 .generated 파일을 생성하려면 CoreUObject 모듈이 포함되어야 한다. 반대로 .generated 헤더가 있는데 Blueprint 관련 매크로가 붙은 객체게 파일에 없으면 에러가 난다.
모듈 포함 시, Public/Private Dependency ModuleNames 의 차이와 언제 써야하는지?
언리얼에서는 모든 코드 단위를 모듈이라는 단위로 관리한다. 그리고 각 모듈에서는 다른 모듈을 임포트할 수 있다.
언리얼에서는 Build.cs에 Public/Private Dependency ModuleNames에 임포트할 모듈 이름을 추가하는 방식으로 모듈 임포팅 기능을 지원한다.
하지만, 여기서 궁금해할 수 있는 부분은 Public과 Private이 어떤 차이인지, 언제 Public을 쓰고 언제 Private을 써야 하는지이다.
이를 알기 위해서는 Public/Private 임포트 방식의 차이에 대해 알아야한다.
먼저 Public 모듈 임포트는 임포트할 대상 모듈의 헤더까지 외부(header)로 노출시켜야 하는 경우 사용한다.
아래 코드와 같이 내가 만든 모듈의 헤더 코드에서 다른 모듈의 헤더를 이용하는 경우 Public 모듈 임포트를 해야한다. (그렇지 않는 경우 빌드 에러 발생)
// MyModule의 SomeComponent.h
#include "GameplayCore/Public/Utilities.h"
다음으로 Private 모듈 임포트는 임포트할 대상 모듈의 헤더가 현재 모듈의 내부(cpp)에서만 노출되는 경우 사용한다.
// MyModule의 SomeComponent.cpp
#include "EnemySystem/Private/EnemyUtils.h"
'게임 엔진 > Unreal' 카테고리의 다른 글
[Unreal] [Networking] TCP Socket을 이용한 Client 개발 (0) | 2022.08.27 |
---|---|
[Unreal] [Example] Editor에 Asset 생성 및 저장 (7) | 2021.08.10 |
[Unreal] 언리얼 플러그인과 모듈 (0) | 2021.03.24 |
[Unreal] Android 디버깅 방법들 (0) | 2021.03.18 |
[Unreal] 언리얼로 Google Play 결제 시스템 이용하기 (3) | 2021.03.04 |