* 주의 *

1. 해당 작업은 StandAlone에서만 테스트되었습니다.

2. 해당 작업은 임시 연동을 위해 작업되었으며, 타 환경 (네트워크 환경 등)에서는 정상 작동을 보장하지 않습니다.

3. 필요시, 코드를 적당히 수정해서 사용해주세요!

4. 해당 작업은 Unreal 5.3에서 수행되었습니다. 타 버전에서는 방법이 다를 수 있습니다.

 

 

 

이전에 썼던 AbilitySystem 관련 글이 있다.

https://locketgoma.tistory.com/80

 

짧막 팁 : AbilitySystem 관련 헤더를 불러오지 못할때

대충 이런 상황이다. AbilitySystemComponent 및 기타 관련 헤더를 사용해야하는데 못불러오는경우... 해당 문제는 모듈이 빠져있는 상황으로, 프로젝트 명칭으로 된 폴더에 있는 "(ProjectName).Build.cs"

locketgoma.tistory.com

 

https://locketgoma.tistory.com/81

 

(UnrealEngine 5.3 or Higher) EnhacedInput과 AbilitySystem 연동

* 주의 *1. 해당 작업은 StandAlone에서만 테스트되었습니다.2. 해당 작업은 임시 연동을 위해 작업되었으며, 타 환경 (네트워크 환경 등)에서는 정상 작동을 보장하지 않습니다.3. 필요시, 코드를 적

locketgoma.tistory.com

 

요건데...

 

사실 AbilitySystem에는 따라오는 항목들이 몇개 존재한다.

GameplayTag

GameplayAbility

GameplayEffect

 

그리고 이번에 설명할 GameplayCue...

 

그 외에도 몇개 있긴한데 주로 사용되는 신규 에셋(?) 들은 저 4개정도에서 정리된다.

 

나머지는 그냥 AbilitySystem Plugin을 추가하면 그만인데, GameplayCue는 선행작업이 좀 필요했다.

해당 내용을 검색을 해봐도, AI에게 물어봐도 시원하게 답을 주지 않았기에 직접 찾아보고 구현한 방법을 공유하고자 한다.

 


그래서 문제가 무엇인가

 

 

일단, 문제가 무엇인지부터 알아보자.

GameplayCue를 호출하기 위해서는 AbilitySystem이 추가될때 같이 추가되는 GameplayCueManager (이하 GCM이라 부름) 에 사용하고자 하는 GameplayCue들을 등록해주어야 한다.

 

이를 위해서는, AbilitySystemGlobal에 있는 GCM의 AddGameplayCueNotifyPath 라는 함수를 호출[1] 해주어야 하는데...

[1] (정확히는, 해당 함수를 호출해서, 경로에 있는 GameplayCue를 등록하게 해야한다)

 

...여기서 해당 함수를 호출하는 부분이 보이는가?

 

나는 아무리 눈을 씻고 찾아봐도 안보이더라

해서, 진짜 없나? 해서 뒤적대보니까 없는게 맞았다.

 

AbilitySystem 관련 샘플인 Lyra를 따라가자면, 해당 함수를 호출하는 Feature를 GameFeatureAction 항목에 추가하고, 경로를 지정해서 호출하게 해주어야 한다.

 


예시 확인

 

그럼 Lyra 프로젝트를 뜯어보는것으로 시작하자.

 

지금 진행중인 프로젝트의 버전과 동일한 엔진 버전을 가진 Lyra 프로젝트를 열고, 실행을 해보았다.

 

Lyra 프로젝트에 보면, 모든 게임피쳐 플러그인마다 해당 파일이 존재한다.

 

이 친구가 오늘의 핵심 되시겠다.

 

해당 파일을 열면, "액션" 탭에 여러 기능들이 포함되어있다.

 

여기서 이번에 추가할건 Add Gameplay Cue Path  라는 기능.

 

당연하게도 AbilitySystem만 세팅한 경우에는 저 항목이 존재하질 않는다.

 

 

 

해당 GameFeatureAction이 뭘 하는지, 어떻게 작동시키는지 살펴보자.

 

우선, 해당 기능은 2개의 클래스로 나뉜다.

 

1. GameFeatureAction으로 등록되는 클래스

2. GameFeatureAction에 의해 실제로 작동되는 클래스

 

왜 2개인지 까지는 이해를 못하겠는데, 일단 두개라고 하니까 둘다 구현해줘야한다.

 

"그냥 하나로 합치면 안되나요?"

 

라는 의문이 들 수 있는데, 시도해봤는데 안되더라.

 


문제점

 

이유는 다음과 같다.

 

우선, Lyra 프로젝트에서 ULyraGameFeature_AddGameplayCuePaths 라는 클래스를 보자.

여기서 중요한건 "OnGameFeatureRegistering" 라는 함수.

 

해당 함수가 GameFeatureSubsystem 에 의해 호출되어야 해당 GameFeatureAction이 작동하는 구조인데... 호출되어야 하는 함수의 파라미터가 예상되는 파라미터와 전혀 다르다.

 

위의 잘린 이미지만 보면 알 수 있듯이, 파라미터가 좀 긴데

 

누구세요?

서브시스템 코드를 보면, 저 길다란 파라미터를 가진 함수를 호출하는 함수 호출부가 존재하지 않는다.

 

그 말은 즉슨, 다른 방식으로 호출해야한다는 뜻.

 

해당 방식은 먼곳 갈 필요없이, 바로 위에 있는 CallbackObservers에서 호출된다.

Observer에 등록해두면, 해당 파라미터타입을 가진 클래스가 호출된다는것.

 

 

...뭔가 이상하지 않은가?

 

1. OnGameFeatureRegistering 라는 함수가 호출되어야 한다는건 알겠고

2. OnGameFeatureRegistering가 Observer에 의해 호출되어야 한다는건 알겠는데

3. 그럼 GameFeatureAction은 어떻게 만드는데?

 

놀랍게도 GameFeatureAction 클래스는 따로 만들어줘야한다.

 


구현하기

 

이제 차근차근 구현해보도록 하자.

 

1. 타 GameFeatureAction이나, Lyra 프로젝트를 참고해서 "AddGameplayCuePath"를 수행할 수 있는 GameFeatureAction 클래스를 생성한다.

 

UCLASS(MinimalAPI, meta = (DisplayName = "Add Gameplay Cue Path"))
class UGameFeatureAction_AddGameplayCuePath final : public UGameFeatureAction
{
	GENERATED_BODY()

public:

	UGameFeatureAction_AddGameplayCuePath();

	//~UObject interface
#if WITH_EDITOR
	virtual EDataValidationResult IsDataValid(class FDataValidationContext& Context) const override;
#endif
	//~End of UObject interface

	const TArray<FDirectoryPath>& GetDirectoryPathsToAdd() const { return DirectoryPathsToAdd; }

private:
	/** List of paths to register to the gameplay cue manager. These are relative tot he game content directory */
	UPROPERTY(EditAnywhere, Category = "Game Feature | Gameplay Cues", meta = (RelativeToGameContentDir, LongPackageName))
	TArray<FDirectoryPath> DirectoryPathsToAdd;
};

 

해당 클래스는 "껍데기" 에 해당하는 클래스라서, 구현도 크게 고민 안해도 된다.

 

그냥 참고한 프로젝트들이나 코드를 보면서 작성하면 되는데, 추가로 설명해주자면..

 

#include "GameFeatureAction_AddGameplayCuePath.h"

#if WITH_EDITOR
#include "Misc/DataValidation.h"
#endif

#include UE_INLINE_GENERATED_CPP_BY_NAME(GameFeatureAction_AddGameplayCuePath)

#define LOCTEXT_NAMESPACE "GameFeatures"

UGameFeatureAction_AddGameplayCuePath::UGameFeatureAction_AddGameplayCuePath()
{
	// Add a default path that is commonly used
	DirectoryPathsToAdd.Add(FDirectoryPath{ TEXT("/GameplayCues") });
}

#if WITH_EDITOR
EDataValidationResult UGameFeatureAction_AddGameplayCuePath::IsDataValid(FDataValidationContext& Context) const
{
	EDataValidationResult Result = Super::IsDataValid(Context);

	for (const FDirectoryPath& Directory : DirectoryPathsToAdd)
	{
		if (Directory.Path.IsEmpty())
		{
			const FText InvalidCuePathError = FText::Format(LOCTEXT("InvalidCuePathError", "'{0}' is not a valid path!"), FText::FromString(Directory.Path));
			Context.AddError(InvalidCuePathError);
			Result = CombineDataValidationResults(Result, EDataValidationResult::Invalid);
		}
	}

	return CombineDataValidationResults(Result, EDataValidationResult::Valid);
}
#endif	// WITH_EDITOR

#undef LOCTEXT_NAMESPACE

그냥 이게 끝이다.

 

별거 없다. 어차피 DataValid는 Editor 전용 코드기 때문에 가려질것이고, 기본 Path만 추가해주면 끝.

 

 

 

문제는 지금부터다.

 

2. 실제로 기능하는 GameFeatureAction을 만들어준다.

여기서 참고해야하는 클래스는 Lyra 프로젝트의 ULyraGameFeature_AddGameplayCuePaths 라는 클래스다.

 

근데 해당 클래스를 보면, UObject와 IGameFeatureStateChangeObserver 라는 인터페이스를 상속받는 형태의 클래스로 되어있다.

 

 

우선, 따라서 구현한다.

 

UCLASS()
class UCLGameFeature_AddGameplayCuePaths : public UObject, public IGameFeatureStateChangeObserver
{
	GENERATED_BODY()

public:
	UCLGameFeature_AddGameplayCuePaths();

public:
	virtual void OnGameFeatureRegistering(const UGameFeatureData* GameFeatureData, const FString& PluginName, const FString& PluginURL) override;
	virtual void OnGameFeatureUnregistering(const UGameFeatureData* GameFeatureData, const FString& PluginName, const FString& PluginURL) override;
};

 

여기엔 OnGameFeatureRegistering, OnGameFeatureUnregistering만 호출하면 된다.

 

그리고, 두 함수의 내용을 채워주면 되는데, Lyra 프로젝트에 구현된 부분을 보자.

 

void ULyraGameFeature_AddGameplayCuePaths::OnGameFeatureRegistering(const UGameFeatureData* GameFeatureData, const FString& PluginName, const FString& PluginURL)
{
	TRACE_CPUPROFILER_EVENT_SCOPE(ULyraGameFeature_AddGameplayCuePaths::OnGameFeatureRegistering);
	
	const FString PluginRootPath = TEXT("/") + PluginName;
	for (const UGameFeatureAction* Action : GameFeatureData->GetActions())
	{
		if (const UGameFeatureAction_AddGameplayCuePath* AddGameplayCueGFA = Cast<UGameFeatureAction_AddGameplayCuePath>(Action))
		{
			const TArray<FDirectoryPath>& DirsToAdd = AddGameplayCueGFA->GetDirectoryPathsToAdd();
			
			if (ULyraGameplayCueManager* GCM = ULyraGameplayCueManager::Get())
			{
				UGameplayCueSet* RuntimeGameplayCueSet = GCM->GetRuntimeCueSet();
				const int32 PreInitializeNumCues = RuntimeGameplayCueSet ? RuntimeGameplayCueSet->GameplayCueData.Num() : 0;

				for (const FDirectoryPath& Directory : DirsToAdd)
				{
					FString MutablePath = Directory.Path;
					UGameFeaturesSubsystem::FixPluginPackagePath(MutablePath, PluginRootPath, false);
					GCM->AddGameplayCueNotifyPath(MutablePath, /** bShouldRescanCueAssets = */ false);	
				}
				
				// Rebuild the runtime library with these new paths
				if (!DirsToAdd.IsEmpty())
				{
					GCM->InitializeRuntimeObjectLibrary();	
				}

				const int32 PostInitializeNumCues = RuntimeGameplayCueSet ? RuntimeGameplayCueSet->GameplayCueData.Num() : 0;
				if (PreInitializeNumCues != PostInitializeNumCues)
				{
					GCM->RefreshGameplayCuePrimaryAsset();
				}
			}
		}
	}
}

 

우선 코드 설명.

1. 현재 Action에 등록된 GameFeatureAction 중, UGameFeatureAction_AddGameplayCuePath 클래스가 있는지 찾는다.

2. 해당 클래스가 있다면, 해당 클래스 액터에서 파일을 추가할 디렉토리 정보를 가져온다.
(먼저 작성한 클래스에 있는 DirectoryPathsToAdd 값을 가져오게 된다)

3. 다음, GCM을 호출하여, 디렉토리에 있는 GameplayCue들을 GCM에 등록한다.

4. 마지막으로, Init 및 Refresh 과정을 수행한다.

 

...라는 그렇게 복잡하진 않은 구조이다.

 

호출부를 직접 다 구현할 필요는 없고, 일부 변경되는 함수들이 있어서 그것만 잘 확인해서 호출해주면 되겠다.

 

//바뀌는것들

//Lyra -> Native UE

//Lyra
UGameplayCueSet* RuntimeGameplayCueSet = GCM->GetRuntimeCueSet();

const int32 PostInitializeNumCues = RuntimeGameplayCueSet ? RuntimeGameplayCueSet->GameplayCueData.Num() : 0;
if (PreInitializeNumCues != PostInitializeNumCues)
{
	GCM->RefreshGameplayCuePrimaryAsset();
}


//Native Ue
UGameplayCueManager* GCM = UAbilitySystemGlobals::Get().GetGameplayCueManager();


//해당 함수는 기본 GameplayCueManager에 구현이 안되어있다.
//필요하면 추가해주어야 할것이다.

//const int32 PostInitializeNumCues = RuntimeGameplayCueSet ? RuntimeGameplayCueSet->GameplayCueData.Num() : 0;
//if (PreInitializeNumCues != PostInitializeNumCues)
//{
//	GCM->RefreshGameplayCuePrimaryAsset();
//}

 

 

자 이렇게 하면 짜잔! 하고 호출이....... 되면 좋겠지만!

 

당연히 안된다.

 

3. 신규 GameFeaturePolicy를 추가해준다.

 

이게 무슨소리인가? 싶은데 지금 우리는 "필요한 파라미터를 사용하는 함수가 존재하지 않아서, 해당 함수를 호출할 수 있도록 클래스를 2개로 나눠서 작업" 한 상태인다.

 

그렇다는것은, 두개의 클래스가 모두 정상적으로 호출될 수 있도록 해야 한다는 뜻.

 

혹시, 위에 있던 "Observer" 라는 존재가 기억나는가? 이제 Observer 를 추가하고, 등록해서 내가 원하는 GameFeatureAction이 등록될 수 있도록 해야한다.

 

방금까지 보고 있던 Lyra 프로젝트 파일의 윗부분을 보면, 해당 클래스가 있다.

UCLASS(MinimalAPI, Config = Game)
class ULyraGameFeaturePolicy : public UDefaultGameFeaturesProjectPolicies
{
	GENERATED_BODY()

public:
	LYRAGAME_API static ULyraGameFeaturePolicy& Get();

	ULyraGameFeaturePolicy(const FObjectInitializer& ObjectInitializer);

	//~UGameFeaturesProjectPolicies interface
	virtual void InitGameFeatureManager() override;
	virtual void ShutdownGameFeatureManager() override;
	virtual TArray<FPrimaryAssetId> GetPreloadAssetListForGameFeature(const UGameFeatureData* GameFeatureToLoad, bool bIncludeLoadedAssets = false) const override;
	virtual bool IsPluginAllowed(const FString& PluginURL) const override;
	virtual const TArray<FName> GetPreloadBundleStateForGameFeature() const override;
	virtual void GetGameFeatureLoadingMode(bool& bLoadClientData, bool& bLoadServerData) const override;
	//~End of UGameFeaturesProjectPolicies interface

private:
	UPROPERTY(Transient)
	TArray<TObjectPtr<UObject>> Observers;
};

 

이 클래스가 바로 3번째로 작성해야하는 클래스다.

우선, 각 클래스 함수의 내용이 수정될건은 없기에, 해당 클래스의 함수 구현부에 가서 내용을 전부 채워주면 된다.

 

이정도는 할수있을거라 믿음...

 

 

void ULyraGameFeaturePolicy::InitGameFeatureManager()
{	
	Observers.Add(NewObject<UCLGameFeature_AddGameplayCuePaths>());

	UGameFeaturesSubsystem& Subsystem = UGameFeaturesSubsystem::Get();
	for (UObject* Observer : Observers)
	{
		Subsystem.AddObserver(Observer);
	}

	Super::InitGameFeatureManager();
}

굳이 함수 설명을 하자면, 이 상황을 위한 함수인 Init 함수를 확인해보자.

 

GameFeatureSubsystem 싱글톤 오브젝트를 가져와서, 작업한 Observer를 SubsystemObserver에 추가한다.

 

이렇게 되면, 내가 추가한 클래스들이 Observer에 들어가면서, OnGameFeatureRegistering가 호출될때 Observer에 등록된 클래스도 같이 호출되는... 구조로 보인다.

 


등록 및 확인하기

 

사실 이정도까지 했으면 바로 짜잔! 하고 되면 좋겠지만, 한가지 과정이 남아있다.

 

우리는 지금 새로운 UDefaultGameFeaturesProjectPolicies 클래스를 만든 상황이다.

 

그렇다면, 해당 신규 Policy Class를 쓰도록 등록해줘야 하는데

 

이친구, 생각보다 숨겨져있다.

 

Edit->ProjectSetting에 가서 "GameFeature" 라고 검색을 해보자.

 

그럼 해당 위치에, Game Feature Policy Class를 바꿀 수 있게 되어있고, 해당 박스를 열었을때 내가 만든 클래스가 있으면 된다.

 

없으면? 뭔가 실수한거니까 잘 찾아보기.

 

잘 추가가 되었다면, 이제 GameFeatureData에 가서 추가한 Action이 있는지도 봐준다.

 

GameplayCue Path 라는 이름의 FeatrueAction이 추가되어있으면 끝.

 

이제 경로를 지정하고, 해당 경로에 GameplayCue를 몰아넣고 호출하면 된다.

 

 

 

캐릭터가 맞았을때 GameplayCue를 호출하게 해보았다.

 

잘되는것을 확인할 수 있다.

 


문제 확인부터 해결까지 생각보다 시간을 많이써서, 헷깔리지 않게 + 다른사람들은 도움받을수 있도록 글 작성해본다.

 

 

 

혹시 잘못된것이 있다면 알려주세요.

 

언리얼 엔진 워크플로우를 검색해보면 꼭 따라오는 이미지가 있다.

(워크플로우, 게임 플로우 등으로 검색하면 나온다)

 

바로 위의 Flow Chart인데, 에디터 / 스텐드얼론 (패키징) 에서 게임이 어떻게 실행되는지에 대해 간략히 설명해준다.

 

사실, 해당 과정들을 간략히만 알아도 큰 문제는 없었으나 연차가 얼추 붙으면서 좀 정리를 할 필요가 있을거같아 정리를 해둘까 한다.

 

 


해당 자료는 MS Copilot과 공식 문서 등을 참고하여 제작되었습니다.

틀린점이 있다면 지적바랍니다.


Editor 관점

Editor 관점은, 언리얼 에디터를 실행하고, PIE (Play In Editor)로 게임을 실행하기까지의 과정을 의미한다.

 

[1] UEditorEngine Initialize 단계

쉽게 설명해서, Editor를 구성하는 단계라고 볼 수 있다.

 

1. Editor Modules Initialize :

에디터에서 사용되는 모듈 초기화. 레벨 에디터, 블루프린트 편집기, 머티리얼 편집기 등 에디터 기능들이 포함됨.


2. 프로젝트 파일 열기:

사용자 프로젝트 파일을 로드하고, 필요한 에셋들을 초기화.


3. Config 파일 로드:

엔진 설정과 프로젝트 설정에 필요한 config 파일을 로드. DefaultEngine.ini 파일 등을 불러오는 과정.

 

4. Editor 세계 생성:

에디터에서 사용할 UWorld 인스턴스 생성

 

5. 플러그인 로드:

에디터에서 필요한 플러그인들을 로드.


6. 레벨 브라우저 및 콘텐츠 브라우저 초기화:

사용자가 에디터에서 접근할 수 있는 다양한 브라우저를 초기화.

 

7. 뷰포트 설정

에디터의 뷰포트를 초기화, 사용자에게 보여질 화면을 설정.


8. 리스너 설정:

에디터 이벤트에 대한 리스너들을 설정.
여기서 리스너는 키보드, 마우스 등의 입력장치 뿐만 아니라, "에디터의" UI 이벤트, 파일 IO시 발생하는 이벤트를 의미.


9. 게임 인스턴스 생성:

UGameInstance를 베이스로 하는 게임 인스턴스 클래스를 선택 후 인스턴스 생성

해당 인스턴스는 개발 중의 게임 상태를 관리할때 사용하는 게임 인스턴스임.

 

 

[2] UEngine Start 단계

게임 엔진 초기화가 끝나고, 실제로 시작하는 단계라고 볼 수 있다.

 

1. 엔진 초기화 확인:

모든 필수 모듈과 시스템이 올바르게 초기화되었는지 확인.

 

2. 로그 시스템 설정:

로그 및 디버그 시스템을 설정합니다. 로그 레벨 (Error, Warning 등)을 정의하는 단계.

 

3. 렌더링 초기화:

GPU 와 관련된 렌더링 시스템 / 리소스를 초기화하고, 렌더러를 설정.

렌더러, 쉐이더, 텍스쳐 및 모델을 설정하고 초기화하는 단계이기도 함.


4. 입력 시스템 초기화: 

입력 장치 및 매핑을 초기화. 입력 매핑을 설정하고, 입력 이벤트를 처리할 수 있도록 세팅.


5. 오디오 시스템 초기화: 

하드웨어 오디오 장치와 관련된 리소스 초기화, 사운드 파일 경로 설정 및 파일 로드.


6. 네트워크 시스템 초기화:

서버와 클라이언트 연결을 설정하고, 네트워크 프로토콜을 초기화하며, 온라인 세션을 관리.


7. 콘솔 명령어 등록:

디버깅과 테스트를 위한 콘솔 명령어를 등록. 엔진 내에서 다양한 명령어를 사용할 수 있도록 설정.

 

8. 게임 인스턴스 초기화:

이전 단계에서 생성된 게임 인스턴스 (PIE UGameInstnace) 를 초기화. 게임 인스턴스 내의 서브시스템 초기화, 각종 이벤트 리스너 바인딩 등을 수행

 

9. 월드 생성 및 초기화:

게임 내의 월드를 생성하고 초기화. 맵을 로드하고, 월드 내의 객체들을 스폰하며, 게임플레이를 위한 환경을 조성.

 

[3] PIE 활성화 단계

Create UGameInstance 및 Initialize PIE로 설명되어있으나, 구체적으로는 조금 다른듯 하다.

 

1. PIE 세션 설정: (Create UGameInstacne -> Initialize PIE 단계)

이미 생성된 UGameInstnace가 PIE 모드에서 실행 될 수 있도록 네트워크, 세션 설정등을 적용.

 

2. PIE 게임 인스턴스 시작: ( (UGameInstance) Init 단계 )

게임 인스턴스 (PIE UGameInstnace) 에 대한 추가적인 초기화 진행. 게임 데이터 및 리소스를 로드하거나 설정 적용.

UEngine Start에서 수행한것과 유사하나 PIE에 맞는 세팅이 추가적으로 진행되는것으로 보임.

 

3. (UEditorEngine) CreatePIEGameInstance:

이미 초기화된 UGameInstance를 PIE 모드로 전환하기 위한 설정을 하고, UEditorEngine에서 PIEGameInstance를 실행할 준비를 수행. PIE모드에서 실제 게임 플레이를 시뮬레이션하기 위한 추가 설정 및 조정이 이루어짐.

이 과정에서는 CreatePIEGameInstance (정확히는 UEditorEngine 클래스에서 PreCreatePIEInstances 함수가 호출되며 생성되는것으로 보임) 가 호출되어 PIE용 UGameInstance가 생성됨.

 

4. PIE 모드 월드 초기화: ( (UEditorEngine) StartPIEGameInstance 단계 )

레벨 로드, 객체 생성 등 기본적인 월드 구성을 수행

 


Standalone 관점 

Standalone 모드에서는 PIE가 아닌 독립적으로 실행되는 프로그램으로써 진행된다.

 

[1] 엔진 시작 단계:

해당 단계는 Start Engine -> (UGameEngine) Init 단계를 의미한다.

 

1. 엔진 시작:

Standalone 모드로 엔진을 시작. 엔진 기본 설정이 로드되고 초기 작업 수행.

 

2. 필수 모듈 로드:

RenderCore, InputCore 등 각종 모듈들이 로드 되는 과정

 

3. 기본 설정 초기화:

각종 Config 파일들 (DefaultEngine.ini 파일 등)을 불러오는 과정

 

4. 엔진 초기화 확인: (UGameEngine Init 단계)

필수적인 모듈과 시스템이 올바르게 초기화되었는지 확인.

 

5. 로그 시스템 설정:

로그 및 디버그 시스템을 설정합니다. 로그 레벨 (Error, Warning 등)을 정의하는 단계.

 

6. 렌더링 초기화:

GPU 와 관련된 렌더링 시스템 / 리소스를 초기화하고, 렌더러를 설정.

렌더러, 쉐이더, 텍스쳐 및 모델을 설정하고 초기화하는 단계이기도 함.


7. 입력 시스템 초기화: 

입력 장치 및 매핑을 초기화. 입력 매핑을 설정하고, 입력 이벤트를 처리할 수 있도록 세팅.


8. 오디오 시스템 초기화: 

하드웨어 오디오 장치와 관련된 리소스 초기화, 사운드 파일 경로 설정 및 파일 로드.


9. 네트워크 시스템 초기화:

서버와 클라이언트 연결을 설정하고, 네트워크 프로토콜을 초기화하며, 온라인 세션을 관리.


10. 콘솔 명령어 등록:

디버깅과 테스트를 위한 콘솔 명령어를 등록. 엔진 내에서 다양한 명령어를 사용할 수 있도록 설정.


[2] UGameInstance 준비 단계:

Create UGameInstance -> InitializeStandalone -> UGameInstance Init 단계를 의미

 

1. 게임 인스턴스 생성:

UGameInstance를 베이스로 하는 게임 인스턴스 클래스를 선택 후 인스턴스 생성.

메모리 할당 및 객체 초기화도 이루어짐

 

2. 독립 실행 모드 설정:

Standalone 모드에서 게임 인스턴스를 초기화하는 과정.

네트워크 설정, 게임 데이터 로드 등 독립 실행 모드에 필요한 추가 설정이 적용됨.

 

3. 서브시스템 초기화:

Standalone 모드에서 필요한 다양한 서브시스템을 초기화. 세이브 데이터 시스템, 네트워크 관리 시스템 등이 포함됨.

 

4. 게임 로직 초기화:

UGameInstance 내에서 기본적인 게임 로직을 초기화. 게임 상태 초기화, 플레이어 데이터 로드 등이 포함됨.

 

5. 이벤트 바인딩:

게임 인스턴스와 관련된 다양한 이벤트에 대한 리스너를 설정하고 바인딩.

게임 시작, 종료, 일시정지 등의 이벤트에 대한 리스너 및 처리기를 등록.

 

[3] 네트워크 준비 단계:

Create UOnlineSession and register delegates 과정을 의미. 선택사항.

 

1. 온라인 세션 생성 :

네트워크 플레이를 지원하는 경우, 새로운 온라인 세션을 생성하고 서버와 클라이언트 연결을 설정함.

2. 대리자(Delegate) 등록:
온라인 세션에서 발생하는 다양한 이벤트를 처리하기 위해 대리자(delegate)를 등록.

플레이어가 세션에 참가하거나 나갈 때 호출될 함수들을 설정함.

 

[4] UEngine Start 단계:

기본적으로 초기화 단계는 Init 단계와 유사하나, 일부 다른점이 있음.

 

1. 모듈 초기화:

Init 단계에서 수행한 과정과 유사함.

 

2. 게임 인스턴스 시작:

이전 단계에서 생성된 게임 인스턴스를 시작. 게임의 전반적인 상태를 관리하고, 게임 로직을 실행할 준비를 수행.

 

3. 월드 생성 및 초기화:

게임 내의 월드를 생성하고 초기화. 맵을 로드하고, 월드 내의 객체들을 스폰하며, 게임플레이를 위한 환경을 조성.

 


이후에는 둘다 동일하게 BeginPlay부터 시작하는 과정을 거친다.

해당 과정은 또 따로 정리할 필요가 있어 여기서는 설명하지 않는다.

최근 면접자리에서 짐벌락 관련 질문이 나온적이 있다.

 

정확히는, 내가 경력기술서에 작성한 내용 중

 

- 짐벌락 이슈에 대응하기 위해, 회전 정보 획득시 GetActorRotation이 아닌 GetComponentRotation 함수를 Root Component를 대상으로 호출하여 이용함.

 

라는 문장에 대한 질문이었는데, 내가 답변을 좀 에매모호하게 한거 같아서 정리를 좀 하고자 한다.

 

 


짐벌락?

 

짐벌락(Gimbal lock) 은 말 그대로 짐벌 (물체가 회전 가능하도록 중심축을 가진 물체)에 락이 걸린 상황을 의미한다.

 

보통, 3D 회전은 오일러 좌표계 (혹은 오일러 각 / Eular Angle)을 따르는데, 이때 3개의 축을 이용한다.

 

각각을 Roll, Pitch, Yaw로 표현하고, 보통은 각각의 수치가 X축 회전, Y축 회전, Z축 회전에 대응한다.

 

 

 

그래서 짐벌락은 언제, 왜 생기는가.

 

간단히 요약하는 짐벌락

 

 

간단히 요약하면, 축이 겹쳐서 발생하는 현상이다.

 

물체가 회전할때 명심해야 할 것이 있는데, 회전축이 회전할때는 다른 축의 회전에 영향을 받는다.

 

특히, 명심해야 하는 사항이 회전에는 순서가 있고, 순서에 따라 다른 결과값을 갖고온다는 점이다.

 

이러다보니 발생하는 문제가 바로 짐벌락. 임의의 축을 기준으로 회전했을때 회전한 축이 아닌 다른 축이 겹치는 현상이 발생하는데, 이렇게 되면 특정 축이 연산에 전혀 영향을 못주게 되면 문제가 발생한다.

 

예시로, X = 빨강, Y = 초록, Z = 파랑이라 생각해보자.

예를들어, X, Y축이 겹치는 현상이 발생했다면,  X축을 회전하던, Y축을 회전하던 결국 Z축을 기준으로 똑같이 빙빙 도는 결과가 발생한다.

 

짐벌락이 문제가 되는 경우는, 해당 상황과 같이 특정 축이 락이 걸려버린경우 해당 축의 연산값이 전혀 영향을 못 미치게 된다는것인데, 이로 인해 정상적인 회전을 보장할 수가 없게된다.

 

 

 

쿼터니언

 

선형대수학에서는 오일러 각을 이용할때 생기는 이러한 문제를 해결하기 위해 4개의 축을 이용하는 쿼터니언 이라는것을 이용하여 연산을 한다.

 

쿼터니언 표현식이 좀 많은데, 제일 많이 쓰는 식은

 

q = xi + yj + zk + w

 

의 식으로 기억하고 있다. 표현대로 각각 x축, y축, z축과.. 가상의 w축을 포함하여 4개의 축을 이용한다.

 

이를 이용해서 회전 처리를 할때 짐벌락이 걸리지 않도록 하는것인데... 쿼터니언 설명 자체는 다음에 필요하면 하도록 하겠다.

 

 


언리얼에서는

 

언리얼에서도 당연히 짐벌락을 막기 위해 많은 방법을 선택한다.

 

주로 사용되는 방식은 2가지인데,

 

첫번째. 회전시 쿼터니언을 사용한다. 

두번째. 회전값 표현시 축 분리를 한다.

 

 

우선 첫번째부터 설명하면, 언리얼에서 물체의 회전 및 위치, 스케일을 가지고 있는 "FTransform" 구조체를 뜯어보면 바로 답이 나온다.

 

protected:
	/** Rotation of this transformation, as a quaternion */
	TPersistentVectorRegisterType<T> Rotation;
	/** Translation of this transformation, as a vector */
	TPersistentVectorRegisterType<T> Translation;
	/** 3D scale (always applied in local space) as a vector */
	TPersistentVectorRegisterType<T> Scale3D;
public:
	/**
	 * The identity transformation (Rotation = TQuat<T>::Identity, Translation = TVector<T>::ZeroVector, Scale3D = (1,1,1))
	 */
	CORE_API static const TTransform<T> Identity;

그냥 대놓고 쿼터니언으로 변환해서 쓴다고 얘기를 한다.

 

언리얼에선 TQuat라는 쿼터니언 자료형이 있는데, 내부적으로는 해당 값을 사용하고, 오일러 좌표계로 표현되는 FRotator 값이 내부 함수에서 쿼터니언으로 변환되어 들어온다.

 

	FORCEINLINE explicit TTransform(const TRotator<T>& InRotation) 
	{
		TQuat<T> InQuatRotation = TQuat<T>::MakeFromRotator(InRotation);
		// Rotation = InRotation
		Rotation =  VectorLoadAligned( &InQuatRotation );
		// Translation = {0,0,0,0)
		Translation = GlobalVectorConstants::FloatZero;
		// Scale3D = {1,1,1,0);
		Scale3D = GlobalVectorConstants::Float1110;

		DiagnosticCheckNaN_All();
	}

 

 

두번째가 조금 설명하기 까다로운데,
우선 설명해야할것은, 쿼터니언 회전값은 해당 식만 보고는 이게 어떤 회전값을 가지고 있는지 바로 알기가 힘들다.
물론, FQuat와 FRotator를 변환해주는 함수가 없다는 뜻은 아니고, 쿼터니언 값만 가지고 원하는 오일러 각으로 변환하기가 쉽지 않다는 뜻이다. (물론, 지금은 시키면 할 수 있을거 같긴한데)

 

아무튼 그런 이유로, 언리얼 에디터에서는 회전값을 쿼터니언이 아닌 오일러 좌표계로 표현한다.

하지만, 오일러 좌표계를 이용하면 짐벌락을 피하기가 쉽지 않은데, 이를 "자동으로" 각도를 분할시키는 방식으로 해결하려고 했다.

 

무슨소리냐

 

예를들어, 멀쩡한 물체의 각도를 회전시키면

 

처음에는 0,0,0임

 

Y축 (Pitch)를 회전시키려고 하면

 

짜잔! 건들지도 않은 Yaw도 바뀌어버려요!

 

이처럼, 짐벌락을 피하기 위해 임의의 각을 연산하여 추가로 회전시키는 로직이 들어간다.

 

이러한 연산이 일반적인 회전 처리에서는 당연히 도움이 되지만...

회전이 들어간 이후 "인위적으로 회전값을 변경해야 하는 경우"에 문제가 발생했다.


그래서 뭐 한건데?


문제가 뭐였나면요...

 

 

위에서 말한것에서 이어서, 언리얼에서는 짐벌락을 피하기 위해 임의의 각 회전을 추가로 하는 경우가 있다고 했다.

하지만, 다음과 같은 경우에서 문제가 발생했었다.

 

 

예를들어, 특정 이동에서 X축값 (Roll)이 회전이 들어간 경우, Roll값을 0으로 만들고싶으면 Roll을 0으로 만드는 회전값 오버라이드를 하면 그만일것이다.

 

처음엔 당연히 그렇게 생각했다. 하지만 Roll을 회전 시킨 결과가 과연 Roll에만 있을까?

 

여기서 해당 회전 수치를 보장 할 수 없는 문제가 생겨버린다. 내가 임의의 축을 0으로 돌렸을때, 나머지 회전값은 내가 원하던 회전이 맞는가?

 

여기서 의문이 들 수 있다.

첫번째. "그냥 모든 축을 0으로 바꾸면 안됐나요?"

두번째. "내가 필요한 회전축을 제외하고 0으로 만들면 안되나요?"

 

둘다 답은 X다. 당연히 첫번째는 말도 안되고, 두번째의 경우, 처음 회전한 Yaw 값을 유지하는것이 목적이었는데
(언리얼에서 캐릭터를 조금이라도 만져보면 알겠지만, 서 있는 상태에서 회전은 Yaw값을 사용하여 움직인다)

 

임의의 축에서 회전이 발생 -> 짐벌락을 피하겠다고 언리얼에서 자체적으로 회전 처리 -> Yaw값에 손상 -> 나머지 회전값을 처리해도 Yaw값이 원하는 회전값이 아니게 되는 문제가 발생

 

이라는 문제가 발생해버리고 있었다.

 

 

 


해결방안

 

우선, Actor에서 회전값을 받아오는 GetActorRotation() 함수는 다음과 같은 구조로 작동한다.

template<class T>
static FORCEINLINE FRotator TemplateGetActorRotation(const T* RootComponent)
{
	return (RootComponent != nullptr) ? RootComponent->GetComponentRotation() : FRotator::ZeroRotator;
}

//---

/** Return rotation of the component, in world space */
FORCEINLINE FRotator GetComponentRotation() const
{
	return WorldRotationCache.NormalizedQuatToRotator(GetComponentTransform().GetRotation());
}

 

보통은 RootComponent의 회전값을 갖고와서 리턴해주는 방식인데, 당연히 대체로는 문제가 없다.

 

그런데 갖고오는 부분 어딘가에서 문제가 생기는지, RootComponent의 회전값과 다른 무언가를 갖고오는 현상이 있었는데, 이를 막기 위해 RootComponent에서 갖고오는 (정확히는 엔진 코멘트에 "해당 값은 RootComponent의 값과 동일함" 이라는 코멘트가 있는 부분이 있었다...) 방안을 선택했는데...

 

.....근데 분명 작업할때는 그런 코멘트를 본 기억이 있는데 (심지어 내부 문서에도 다 기록해놨다) 지금와서 찾으려니까 안보인다...???

 

그냥 해당 기록은 안쓰는거로 해야할거같다... 증거가 없으면 못쓰지요

 

사실 이제와서 해결하려면 그냥 쿼터니언 연산으로 해결하면 되긴 하는데 (...)

쿼터니언 연산으로 X축 성분, Y축 성분 제거하고 Z축 성분만 리턴해주면 될거다 아마도...

 

 


자료 출처

 

1.https://en.wikipedia.org/wiki/Euler_angles

2.https://en.wikipedia.org/wiki/Gimbal_lock

 

회사 및 과거 프로젝트가 유추될 수 있는 사항은 제외했습니다.

 


언리얼 리플렉션 시스템 (https://www.unrealengine.com/ko/blog/unreal-property-system-reflection)

  • 프로그램이 실행시간에 자기 자신을 조사할 수 있는 기능. (본인이 어떤 클래스인지 등을 알 수 있음)
  • 언리얼 엔진에서 C++ 클래스, 구조체, 함수, 멤버 변수, 열거형 관련 정보를 수집, 질의, 조작하는 별도의 시스템
  • 리플렉션 시스템에 등록시키고자 하는 변수 등에 UProperty 키워드 등을 부착시 Unreal Header Tool (UHT) 에 의해 리플렉션 시스템에 등록됨
    • UProperty 사용시, 언리얼 GC에 포함이 됨.
    • 각종 메타데이터들을 추가하여 해당 변수가 어떤 속성을 가졌는지를 알 수 있음.

 

 

 

언리얼 최적화 관련

  • 언리얼에서는 자체적인 GC를 사용함.
  • GC는 다음과 같이 작동함 :: 
    Unreachable 플래그를 모든 UObject에 부착 -> RootSet에 걸려있는 UObject에서 플래그 회수 -> 플래그 남은 UObject 기록 -> 리스트업 된 UObject Destroy (Begin/Finish) -> UObject의 소멸자 호출
  • GC의 타겟이 되지 않게 하기 위해서는 관련 오브젝트에 "AddToRoot" 혹은 "SetFlags(RF_MarkAsRootSet)" 플래그를 붙이면 됨.
  • 주로, 인게임에서 이미 소멸된 액터거나 Replicate 범위 밖 (NetCullDistance)에 존재하는 경우 타겟이 될 수 있음.

 

 

 

언리얼 리플리케이션 관련

  • 기본 개념 : 서버-클라간 동기화를 위한 개념
    • 서버->클라로는 호출한 / 소지한 (Autonomous) 클라이언트에게, 혹은 모든 클라이언트에게 전달 (Multicast) 가능
    • 클라->서버로는 클라이언트가 소지한 액터가 Autonomous일때만 서버로 전송가능 (이것도 RPC만 가능)
    • 변수 리플리케이션은 "원칙적으로는" 서버->클라만 가능함.
  • Role
    • Role은 Local Role / Remote Role 2개가 있음.
    • 보통 서버 입장에서 Local Role은 Authority인 경우가 대부분
    • 클라에서는 Remote는 Authority, Local이 다를 수 있음.
    • 비교
      • 클라 입장에서 Autonomous - "현재 클라이언트가 컨트롤 중인 플레이어"
      • 클라 입장에서 Simulated - "현재 클라이언트의 영향 외의 동기화 액터"
  • 기타
    • RPC는 단발성 리플리케이션에 주로 사용, 변수 리플리케이션은 영구적 리플리케이션에서 주로 사용함.

 

 

언리얼 포인터 관련


기본적으로 언리얼 스마트 포인터는 Object 포인터와 일반 포인터 (비 Object 포인터 = C++ 노멀 포인터)가 있음

  • 오브젝트 포인터 (Actor 클래스 등의 언리얼 오브젝트에서 사용. 언리얼 GC 타겟이 된다.)
    • TWeakObjectPtr - 순환참조 이슈 등을 대응하기 위한 오브젝트 포인터. 참조 대상 파괴시 nullptr로 세팅됨.
    • TObjectPtr - 64bit 기반 신규 포인터.
      원시포인터 (Normal Pointer) 와 유사하나, 다이나믹 해상도 (액터에 대한 동적 해상도 전환), 엑세스 트래킹 등의 신규 기능 추가.
      • 5.0 되면서 추가된 신규 포인터임. 원시 포인터를 TObjectPtr로 바꾸는걸 언리얼에서는 권장함.

  • 노멀 포인터
    • TUniquePtr - 일반적인 Unique Pointer와 동일. (하나의 포인터로 단 하나의 메모리만을 참조)
    • TSharedPtr - 일반적인 Shared Pointer와 동일
    • TWeakPtr - 일반적인 Weak Pointer와 동일 (Shared Pointer의 보조)
      • TWeakPtr은 TSharedPtr를 통해서만 복사 생성, 대입 연산이 가능하다.
      • TWeakPtr은 TSharedPtr를 참조하되, 레퍼런스 카운트를 증가시키지 않는다.
      • TWeakPtr을 통해 객체를 참조하려면 반드시 TSharedPtr로 변환하여 사용해야 한다.

 

 

 

* 주의 *

1. 해당 작업은 StandAlone에서만 테스트되었습니다.

2. 해당 작업은 임시 연동을 위해 작업되었으며, 타 환경 (네트워크 환경 등)에서는 정상 작동을 보장하지 않습니다.

3. 필요시, 코드를 적당히 수정해서 사용해주세요!

 

 

(아마 기억이 맞다면) 언리얼 5.3에 들어오면서 큰 변화가 몇가지 생겼다.

그중에 프로그래머 입장에서 유의미하게 볼것이 바로 EnhancedInput의 정식 사용 시작 및 AbilitySystem의 공식 플러그인 화 일것이다.

 

근데 웃긴게, 이 두가지가 하나는 정식기능, 다른 하나는 플러그인이라서 그런가 공식적으로 지원하는 연동 코드가 없다.....

 

물론, 진짜 하나도 없진 않다. 대신, LyraStarterGame에 포함되어있을뿐...

 

LyraStarterGame에서는 해당 기능을 HeroComponent라는 연동 컴포넌트와, EnhancedInput을 위한 LyraInputComponent를 만들어서 사용하는데, 해당 컴포넌트 및 Lyra 식 사용에 대한 분석은 다음에 하기로 하고, 우선은 조금 빠른 사용법을 익혀보도록 하자.

 

 

* 해당 코드의 일부는 EpicGames에서 제공하는 Lyra Starter Game에서 발췌했습니다.


우선, 이전 글을 참고하여 AbilitySystemComponent를 정상적으로 사용할 수 있는 상태를 기준으로 한다.

 

https://locketgoma.tistory.com/80

 

짧막 팁 : AbilitySystem 관련 헤더를 불러오지 못할때

대충 이런 상황이다. AbilitySystemComponent 및 기타 관련 헤더를 사용해야하는데 못불러오는경우... 해당 문제는 모듈이 빠져있는 상황으로, 프로젝트 명칭으로 된 폴더에 있는 "(ProjectName).Build.cs"

locketgoma.tistory.com

 

 

두가지를 연동하려면 우선, InputAction과 GameplayTag를 연동시켜둔 데이터파일이 필요하다.

해당 파일 타입을 Lyra에서는 "FLyraInputAction" 라는 struct를 사용하는 ULyraInputConfig라는 DataAsset를 만들어 사용한다.

 

일단, 해당 방법을 그대로 사용하자.

 

UDataAsset를 상속받는 신규 클래스를 만들고, 해당 파일에

 

USTRUCT(BlueprintType)
struct FInputAction		//이름은 적당히 바꿔서 쓰면 됩니다.
{
	GENERATED_BODY()

public:

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
	TObjectPtr<const UInputAction> InputAction = nullptr;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Meta = (Categories = "InputTag"))
	FGameplayTag InputTag;
};

해당 구조체를 추가, uproperty로 추가해주면 된다.

Lyra에서는 (EditDefaultsOnly, BlueprintReadOnly) 조건으로 사용중이나, 적당히 필요한대로 쓰시면 될것같다.

 

 

 

그 다음부터는 선택지가 갈리는데, EnhancedInputComponent 및 PanwComponent를 상속받아서 Lyra와 같이 InputComponent + HeroComponent를 만들어서 사용하거나, 간략하게 AbilitySystemComponent를 상속하여 구현해주는 방법 등이 있다.

 

여기서는 AbilitySystemComponent 을 상속하여 추가 함수를 만들어주는 방식으로 구현하고자 한다.

 

Lyra 방식은... 공부도 하실겸 Lyra 코드를 열어보시는걸 추천드립니다.

 

 

 

첫번째. UAbilitySystemComponent를 상속받은 컴포넌트를 추가한다.

두번째. 위에서 만든 DataAsset Class를 헤더에 추가한다.
(단순히 전방선언으로 사용하면 구조체의 사이즈를 알 수 없어 오류가 발생한다)

세번째. 다음과 같이 (혹은 적당히 수정하여) 헤더를 작성한다.

UCLASS()
class PROJECTCLOUD_API UCLAbilitySystemComponent : public UAbilitySystemComponent
{
	GENERATED_BODY()

public:
	//인풋 액션을 세팅하는 함수 (return bool인 이유는 오류 검출 용도)
	bool BindInputActions(const UCLAbilityInputConfig* InputConfig, UEnhancedInputComponent* EnhancedInputComponent);
	
	//인풋 액션을 호출하는 바인딩 함수
	void TryActiveAbilityFromInputAction(const FInputActionInstance& Value);

private:
	//인풋 액션 리스트
	TMap<TObjectPtr<const UInputAction>, FGameplayTag> InputactionList;
};

네번째. cpp 파일에 필요한 헤더들을 추가한다.
EnhancedInput관련, GameplayTag 관련 헤더를 추가해주면 된다.

다섯번째. 바인딩 코드 작성.

bool UCLAbilitySystemComponent::BindInputActions(const UCLAbilityInputConfig* InputConfig, UEnhancedInputComponent* EnhancedInputComponent)
{
	if (!ensure(EnhancedInputComponent))
	{
		UE_LOG(LogTemp, Error, TEXT("UCLAbilitySystemComponent :: EnhancedInputComponent is Null."));
		return false;
	}

	for (const FCLInputAction Action : InputConfig->AbilityInputActions)
	{
		InputactionList.Add(Action.InputAction, Action.InputTag);

		EnhancedInputComponent->BindAction(Action.InputAction, ETriggerEvent::Triggered, this, &UCLAbilitySystemComponent::TryActiveAbilityFromInputAction);
	}
	return true;
}

 

Input으로 받은 DataAsset에서 리스트를 가져와서 EnhancedInput에 바인드하고, 로직 검사를 위해 ASC에 추가한 Map에 등록해주는 로직이다.

 

여섯번째. 바인드 될 호출함수 작성

void UCLAbilitySystemComponent::TryActiveAbilityFromInputAction(const FInputActionInstance& Value)
{
	//1. 이벤트로 들어온 InputAction과 매치되는 Tag가 있는지 검사
	FGameplayTag* InputTag = InputactionList.Find(Value.GetSourceAction());
	
    //2. 매치되는 Tag가 있다면
	if (InputTag != nullptr)
	{
    	//3. Pay로드 작성 후
		FGameplayEventData TempPayload;
		TempPayload.EventTag = *InputTag;
		TempPayload.Instigator = GetOwner();
        
        //4. ASC에 포함된 HandleGameplaytEvent를 호출한다.
        HandleGameplayEvent(TempPayload.EventTag, &TempPayload);
	}
}

Payload값은 임의로 수정해도 된다. (단, EventTag 제외)

 

 

다음과 같이 작성하면 함수 준비는 끝났다.

 

이를 어떻게 사용하느냐...

 

사실 생각보다 별거 없다. EnhancedInput과 ASC를 사용하고자 한다면, 아마 여러가지 검색을 하면서 Player Pawn에다가 연동하는 작업도 수행했을것이다.

아니었다구요? 그렇다면..

 

	UEnhancedInputComponent* EnhancedInputComponent = GetComponentByClass<UEnhancedInputComponent>();

	if (ACLPlayerState * PS = Cast<ACLPlayerState>(GetPlayerState()))
	{
		PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this);
		PS->SetAbilitiesFromActionSet(AbilitySet);
		PS->GetAbilitySystemComponent()->BindInputActions(InputConfig, EnhancedInputComponent);
	}

다음과 같은 로직을 Player Pawn의 Beginplay 등의 Init 단계에서 호출하도록 추가해주면 된다.

 

당연한 얘기겠지만, 이미 EnhancedInput을 Player Pawn에 등록했어야 하고,
해당 로직의 경우에는 PlayerState에 ASC를 등록해서 사용했기에, 해당 과정도 거친 상태여야 한다.

(스텐드 얼론이어서 Pawn에 직접 ASC를 붙여서 사용한다면, 코드를 적당히 수정하면 된다.)

 

다음과 같이 등록하고, Player Pawn에 "InputConfig"  변수를 추가해주면 끝.

 

정상적으로 잘 따라왔다면,

 다음과 같은 InputConfig를 만들 수 있고,

해당 InputConfig를 추가할 수 있는 공간이 Player Pawn에 추가된다.

 

이제 자유롭게 InputAction과 InputTag를 부착하면, Player의 ASC에 부착된 GameplayAbility 중 Trigger Event로 호출되는 Ability를 사용할 수 있게 된다.

 

 


ASC 사용법은 저 말고도 다른 분들이 잘 설명해준 문서들이 많으니 참고 바랍니다...
EnhancedInput과 ASC 연동하는 방법은 어디에도 없길래 Lyra 코드 분석해가면서 간략화해서 만들어봤어요

 

틀린 내용에 대한 지적은 언제나 받습니다.

대충 이런 상황이다.

 

AbilitySystemComponent 및 기타 관련 헤더를 사용해야하는데 못불러오는경우...

 

해당 문제는 모듈이 빠져있는 상황으로, 프로젝트 명칭으로 된 폴더에 있는 "(ProjectName).Build.cs" 파일에 모듈을 추가해주면 된다.

 

	PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay" });

Build.cs 파일의 해당 위치에

 

 

	PublicDependencyModuleNames.AddRange(new string[] { "GameplayAbilities", "GameplayTags", "GameplayTasks", "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay" });

다음과 같이 어빌리티 시스템 관련을 추가해주면 됨.

 

 

어빌리티 시스템 관련을 모듈로 추가해준 뒤, 프로젝트 파일을 Re-Generate 해주면 완료.

 

 

 

 

참고 자료 : https://dev.epicgames.com/documentation/en-us/unreal-engine/gameplay-ability-system-for-unreal-engine?application_version=5.0

1. 스프라이트 셋에서 스프라이트 추출

스프라이트 셋을 불러온 뒤, 불러와진 텍스쳐파일에서 우클릭 -> 스프라이트 추출

 

추출 옵션은 적당히 선택하면 되나, 대체로 Grid를 사용하게 될것임..

정확한 너비를 모르겠다면, 셀 수 X,Y를 입력한 뒤 셀 너비 및 높이 옆의 돌아가기 버튼을 누르면 알아서 지정해준다.

(사실 내가 필요해서 정리함)

 

언리얼 입문자들이면 다 알것이다.

어느정도 배우기 시작하면 나타나는 "리플리케이션" (Replication) 이라는 개념을....

 

처음에는 굉장히 생소하고, 이게 뭐... 어쩌라는건지? 싶을때도 있는데

익숙해지고 나면 이거 없이는 개발을 어떻게하지? 싶은 순간도 와버리게 되는 마성의 개념인것같다 (글쓴이 주관임)

 

 

 

여튼,

 

언리얼 네트워크 시스템에 대해 찾아보려고 검색을 하면 다들 이 그림을 보게 될 것이다.

 

 

굉장히 예전부터 보이던 그림이라 어디가 원조인지 궁금하긴 했는데 2020년에도 쓰였다는거 말곤 잘 모르겠더라 (...)

 

 

여튼, 이 그림을 기준으로 정리를 해보도록 하자.

 

결국 리플리케이션 개념은 서버/클라 통신과 관련된 개념이다.

 

모든 데이터를 서버에 둘 수도 없고, 모든 데이터를 클라에만 둘 수도 없으며, 클라이언트에 필요는 하지만 서버에서만 관리해야하는 데이터가 있을수도 있고 등등... 온갖 이슈들이 존재하는데

이를 관계 정리한것이 리플리케이션 개념인것.

 

우선은, 언리얼 네트워크 시스템상에서 각 요소(=액터) 들은 크게 3가지로 나뉜다.

 

Server Only

Server/Client Multy

ClientOnly

 

Server Only는 게임 관리자에 해당하는 "GameMode"  가 포함

Server/Client Multy는 게임 통제 (점수 기록 등)을 담당하는 "GameState", 각 플레이어의 상태를 가지고 있는 "PlayerState", 플레이어의 이동 정보 등을 가지고 있어야하는 "PlayerController", 그리고...

"Pawn" (or Character)

생각해보면, 결국 서버/클라 무관하게 모든 캐릭터는 똑같이 보여야하니까 Pawn이 서버/클라 모두에 있는건 별로 이상한 것은 아닌것같다.

 

사실 네트워크 요소중 다른 사항들은 크게 신경쓰지 않아도 된다. (어차피 네트워크 통신 이슈가 발생하진 않으니까)

하지만, Server/Client Side 양측에 모두 존재하는 액터들이 제일 큰 문제인데

 

여기서도 몇가지 갈린다.

1. 서버가 무조건 우선권을 가짐

2. 모든 클라이언트가 소유하고 있어야 하는 경우

3. 지금 화면을 보고 있는 클라이언트의 정보만 있어야 하는 경우

 

간단히 설명하면 위의 3가지인데, 이를 좀 더 상세하게 풀어가면

1. "관리 권한이 존재"

2. "관리 권한은 없으나 소유권은 있음"

3. "관리 권한도 없고, 소유권도 없음"

 

이렇게 3가지 타입으로 구별이 가능하다.

이를, "Authority", "Autonomous", "Simulated" 이라고 표현한다.

 

디테일한 내용은 아래 공식문서를 참고바랍니다. ->

https://docs.unrealengine.com/4.27/ko/InteractiveExperiences/Networking/Actors/Roles/

 

액터 롤 및 리모트 롤

액터 오브젝트 리플리케이션의 여러가지 부분에 대한 것입니다.

docs.unrealengine.com

 

 

자세한 롤 관련 얘기는 다음 문서에서 설명하기로 하고, 여기서는 네트워크 관계도(?)에 대한 설명을 좀 더 하기로 하겠다.

결국, 리플리케이션이 필요한 객체라도 어떤 클라에선 없을수도 있고, 클라 서버 관계없이 다 있을수도 있고 이런 애들이 나타날 수 있는데, 이게 초반에는 좀 굉장히 헷깔린다. (실제로 일하다가도 좀 실수를 했음..)

 

해서, 정리를 하자면

 

액터 타입/명칭 배치 위치 클라이언트 배치 조건 각 클라이언트당 개수
GameMode Server Only None 0
GameState Server / Client All Clients 1
PlayerState Server / Client All Clients N
PlayerController Server / Client "Controlled Client Only" 1
Other Controller
(ex. AIController)
Basically Server Only "If you need" 0?
Player Pawn Server / Client All Clients N
HUD / UI Client Only Controlled Client Only 1

 

여기서 제일 골치아픈것이 "PlayerController"

내가 컨트롤중인 클라이언트에서는, 본인의 캐릭터에 연결되어있는 PlayerController를 제외하고, 타인의 PlayerController는 없다고 생각하면 된다.

...혹시나 동기화 필요하다고 PlayerController 갖고와서 하고 있으면 어라 이거 왜 안되지 하는 실수를 할 수 있을것이다..

 

그리고 추가, "이펙트는 동기화가 어떻게되나요" 할 수 있는데

이펙트는 재생 시점 / 재생 위치 / 재생 종류만 동기화가 되고, 직접적인 이펙트 랜더링은 클라이언트에서 각자 해결한다.

 

그야 그럴게, 같은곳에 같은 이펙트가 재생은 되어야하지만, 그게 어떤 모양으로 랜더링 될지는 각자 클라이언트가 판단할 문제니까...

 

 

 

 

 

예전에 작업하다가 좀 어처구니 없는 실수했던것이 기억나서 글로 남겨본다.

+ Recent posts