{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[Unreal Engine] Subsystem의 유연한 초기화

Subsystem의 초기값을 어떻게 유연하게 만들까요?

  Unreal Engine에서는 싱글턴(단일 객체)을 보장할 수 있는 Subsystem 클래스를 제공합니다. 각종 라이프 사이클에 따른 EngineSubsystem, EditorSubsystem, GameInstanceSubsystem, WorldSubsystem 등이 존재합니다.

  문제는 게임에서 활용되는 Subsystem들에 대한 초기화 방법을 제공하는데 있어 유저 친화적인 방법이 존재하지 않는다는 것인데요. 예를 하나 들어봅시다: 게임의 UI를 중앙집중적으로 관리하기 위해 UIManager라는 GameInstanceSubsystem 자식 클래스를 구현하였고, 모든 UI를 담을 최상위 부모 UI인 Root Layout이 User Widget Blueprint로 만들어져있다고 생각해봅시다. 따라서 UIManager는 게임 시작 시 해당 User Widget을 자신의 Root Layout으로서 Viewport에 추가하고 싶을 것입니다. 하지만, "어떤 User Widget이 Root Layout용으로 만들어진 User Widget Blueprint인가?"를 어떻게 결정해야 하는지에 대해 의문이 생길 수 있습니다. 


첫째, 그냥 하드코딩 하기.

static ConstructorHelpers::FClassFinder<UUserWidget> 
    RootLayoutClassFinder(TEXT("/Game/UI/MyRootLayoutAsset"));
RootLayoutClass = RootLayoutClassFinder.Class

  간단하게는, C++ 코드 상에서 경로를 하드코딩해 특정 경로에 있는 User Widget 에셋을 로드하여 Root Layout으로 사용하게끔 할 수도 있겠죠. 하지만 이것은 유연하지 않고, 에디터를 사용하는 입장에서 별로 명시적이지도 않습니다. (C++ 코드를 봐야하니까요) 어딘가에는 적어놓아야겠죠: '특정 행위를 하기 위해서는 에셋의 이름을 아무개로 짓거나, 어떤 DataTable의 값을 조작해야 합니다.' 따위로요.

  단순하고 강력하지만, 유연하지 않습니다.


둘째, GameInstance, Actor 등 다른 객체에 위임하기.

// GameInstance
UCLASS()
class UMyGameInstance : public UGameInstance
{
	GENERATED_BODY()

public:
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	TSubclassOf<UUserWidget> RootLayoutClass;

	...
};

// Subsystem
void UMySubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
	Super::Initialize(Collection);
	UMyGameInstance* GameInstance = Cast<UMyGameInstance>(GetGameInstance());
	RootLayoutClass = GameInstance->RootLayoutClass;
}

  한 가지 해결책으로는, GameInstance와 같이 게임 내내 전역적인 생명주기를 보장하는 클래스를 이용해서 정의하는 것입니다. GameInstance는 에디터 상에서 직접 정의할 수 있고, 해당 GameInstance 클래스가 UPROPERTY로 RootLayout이라는 이름의 UserWidget 에셋을 참조하게끔 하면, 에디터 사용자가 이를 수정해 RootLayout의 변경을 유연하게 대응할 수 있습니다. 각 GameInstanceSubsystem들은 게임 시작 시 GameInstance를 이용해 본인을 초기화하는 것이죠.

에디터 상에서 UPROPERTY를 명시적으로 직접 참조할 수 있습니다.

  만약 WorldSubsystem과 같은 경우, GameInstance 대신 Actor 등을 이용해서 해결할 수 있겠죠. 같은 생명주기를 공유하는 것이니까요.

  하지만 이 역시 문제가 있는데, 특히 GameInstance를 이용해서 유연한 에셋 참조를 다루는 것은 GameInstance가 너무 많은 책임을 갖게 한다는 점입니다. 당장은 UIManager의 Root Layout 하나지만, 이런 문제는 꽤나 흔하기에 게임 개발 과정에서 지속적으로 비슷한 요구사항이 발생할 수 있습니다.  수많은 Subsystem들의 초기값을 GameInstance 하나가 다 쥐고 있는 것은 별로 좋지 않습니다.


셋째, 내가 사용한 방법.

  Subsystem 클래스들은 본래 UCLASS로 생성하면, 알아서 엔진에서 해당 서브시스템을 각 생명주기에 맞게 초기화해줍니다. 하지만 이를 강제로 막을 수 있는 방법이 있는데, 바로 Abstract 키워드를 사용하는 것입니다. 이 경우 자식 객체로만 생성되어야 하므로, 언리얼 엔진에서는 이러한 Subsystem 클래스들을 무시하게 됩니다.

UCLASS(Abstract, Blueprintable)
class UDocumentManagerSubsystem : public UGameInstanceSubsystem
{
	GENERATED_BODY()

public:
	UPROPERTY(EditDefaultsOnly, Category = "UI")
	TSubclassOf<UCommonActivatableWidget> DocumentRevealedNotifyWidget;

	UPROPERTY(EditDefaultsOnly, Category = "Document")
	UDataTable* DocumentDataTable;

	UPROPERTY(EditDefaultsOnly, Category = "Document")
	UDataTable* DocumentCategoryDataTable;

	...
};

  추가적으로, 해당 클래스에 Blueprintable 키워드를 추가하면, 에디터 상에서 해당 Subsystem 클래스의 자식 블루프린트를 생성할 수 있습니다. 이를 이용하면, UPROPERTY로 정의된 값들을 미리 초기화할 수 있습니다. 따라서, 초기화가 필요한 Subsystem들을 각각의 블루프린트로 다루어 하나의 에셋처럼 간주할 수 있는 것입니다.

에디터 상에서 각 에셋을 명시적으로 참조할 수 있습니다.

  하지만 여기에는 문제가 있는데, 이렇게 블루프린트로 만들어진 Subsystem들은 마찬가지로 객체가 자동으로 생성되지 않는다는 점입니다. 따라서 이들을 직접 생성하고 초기화해주는 작업이 필요합니다. 이는 재밌게도 다시 Subsystem을 만들어서 해결할 수 있습니다. 블루프린트로 만들어진 Subsystem들을 생성하고 초기화해주는 Subsystem인 것이죠. 헤더는 다음과 같습니다:

UCLASS()
class UBlueprintedGISubsystemInitializer : public UGameInstanceSubsystem
{
	GENERATED_BODY()

public:
	virtual void Initialize(FSubsystemCollectionBase& Collection) override;
};

  어떤 Subsystem들을 초기화해주면 될까요? 모든 에셋들을 참조해서 알아서 그런 것들을 해결해줬으면 좋겠지만, 이런 구현에는 약간의 모험이 필요했습니다. 예를 들어 패키징 시 블루프린트화된 Subsystem들이 함께 포함되어야 하는데, 결국에는 이러한 블루프린트화된 Subsystem들이 어디선가 명시적인 에셋 참조로서 관리되어야 한다는 것을 의미합니다. 그렇지 않다면 불필요한 에셋이라고 판단하여 패키징 시 포함되지 않을 테니까요. C++ 코드 수준에서 마법같이 블루프린트화된 Subsystem들을 찾아 초기화해주는 것은, 위와 같은 문제 역시 함께 해결해줘야 한다는 의미입니다. 저는 그런 방법을 찾는 것보다는 명시적인 에셋 참조를 만들기로 하였습니다.

  언리얼 엔진에는 DeveloperSettings라는 클래스가 존재하는데, 이를 이용해서 게임에 필요한 각종 세팅을 관리할 수 있습니다. 저는 이런 DeveloperSettings의 자식 클래스를 생성하여, 각종 블루프린트화된 Subsystem들의 참조를 관리하고, 이 참조를 이용하여 게임 시작 시 Subsystem들을 초기화하도록 하였습니다.

// MyDeveloperSettings.h
UCLASS(config=Game, DefaultConfig)
class UMyDeveloperSettings : public UDeveloperSettings
{
	GENERATED_BODY()

public:
	// 각종 설정이 완료된 Blueprinted GameInstance Subsystems.
	UPROPERTY(EditAnywhere, config)
	TArray<TSubclassOf<UGameInstanceSubsystem>> BlueprintedGameInstanceSubsystems;
	
	// 각종 설정이 완료된 Blueprinted World Subsystems.
	UPROPERTY(EditAnywhere, config)
	TArray<TSubclassOf<UWorldSubsystem>> BlueprintedWorldSubsystems;
};

// BuleprintedGISubsystemInitializer.cpp
void UBlueprintedGISubsystemInitializer::Initialize(FSubsystemCollectionBase& Collection)
{
	Super::Initialize(Collection);

	const UMyDeveloperSettings* Settings = GetDefault<UMyDeveloperSettings>();
	for(auto Subsystem : Settings->BlueprintedGameInstanceSubsystems)
	{
		Collection.InitializeDependency(Subsystem);
	}
}

// WorldSubsystem도 비슷하므로 생략

  이렇게 만든 경우, 약간의 불편함이 추가됩니다: 1) Subsystem C++ 클래스를 구현하고, 2) 해당 C++ 클래스의 Blueprint를 만들고, 3) 엔진의 설정에서 해당 Blueprint 에셋을 추가해줘야 합니다. 하지만 이들은 어쨌거나 Subsystem 클래스가 최초로 추가될 때에만 부과되는 비용이며, 딱 한 번만 해주면 되는 것이므로 괜찮습니다. 이후에는 간단하게 에디터 상에서 블루프린트를 조작함으로써 유연함을 얻을 수 있죠.

Project Settings에서 각 Subsystem들을 명시적으로 이어줘야 합니다.

  하지만 이것은 어디까지나 UE 5.5에서까지만 동작함을 확인하였으며, Subsystem의 명세에 의존하는 것이 아닌 언리얼 엔진의 내부 직접적 구현에 의존하는 방법이므로 이후 버전에서 올바르게 동작함을 보장할 수 없음에 주의하세요.


참고 자료:

https://forums.unrealengine.com/t/configuring-subsystems-via-editor/134784/20

https://forums.unrealengine.com/t/how-to-reparent-a-blueprint-using-a-subsystem/1220379/3

댓글

이 블로그의 인기 게시물

[코딩의탑] 5층: 포탈(Portal), 더 나아가기

[코딩의탑] 4층: 툰 쉐이딩

[코딩의탑] 3층: 바다 렌더링