💻

オレオレSubsytem管理システムを作る

2024/12/03に公開

Unreal Engine (UE) Advent Calendar 2024、シリーズ1、3日目の記事です。

趣味で開発しているVoicevoxEngineForUEプラグインで、VOICEVOXの機能の一つであるマルチエンジン機能がSubsystemで実現できそうだと思い、独自のSubsystemを管理するクラスを作ってみました。
https://github.com/YuukiOgino/VoicevoxEngineForUE

VoicevoxEngineForUEプラグインのソースコードは結構膨大になっているので、今回の解説用にサンプルコードを作成しました。
https://github.com/YuukiOgino/MySubsystemSample

ここから以下に記載していることは、上記サンプルコードを見ていただければ大体わかると思いますので、コードがあれば十分な方は先を読まなくて大丈夫です。

前提条件

  • Unreal C++オンリーです。ある程度C++が分かる人向けの記事です。
  • UE5.0~5.5で検証を行いました。
  • UE4.27以下は内容が違う可能性が高いです。
  • UE5.6以上で大幅に変わる可能性が高いです。

開発環境

  • Windows11
  • Unreal Engine 5.0~5.5
  • JetBrains Rider 2024.3

Subsystemについて

公式ドキュメントに記載された説明文を引用します。

Unreal Engine (UE) のサブシステムは、管理されたライフタイムを持つ自動的にインスタンス化されたクラスです。このクラスを利用すると、拡張ポイントを簡単に使用できるため、プログラマはエンジン クラスの変更または上書きするといった複雑な作業を回避しつつ、ブループリントや Python を公開できます。

少し硬い文章なので大雑把に書くと、UnrealC++やBlueprintで使える超便利なシングルトンパターンみたいな仕組み、です。

初めてSubystemを利用する場合は、猫でも分かるUE4.22から入ったSubsystem【第4回 UE4何でも勉強会 in 東京 2020】が分かりやすいので、このスライドを一読してください。

Subsystemを使う利点

公式ドキュメントから再度引用します。

  • プログラミング時間が短縮される。
  • エンジン クラスのオーバーライド回避に役立つ。
  • ただでさえ入り組んだクラスにさらに API を追加しなくてすむ。
  • ユーザー フレンドリなタイプのノードを通じてブループリントにアクセスできる。
  • エディタ スクリプティングやテスト コードの記述のために Python スクリプトにアクセスできる。
  • コードベースのモジュール性と一貫性を実現する。

サブシステムは、プラグインを作成する際に特に役立ちます。プラグインを機能させるのに必要なコードに関する指示を用意する必要がないからです。ユーザーはプラグインをゲームに追加するだけでよく、デベロッパーはプラグインがインスタンス化され、初期化されるタイミングが正確にわかります。その結果、デベロッパーは API と UE4 で提供されている機能の使用方法に注力できます。

Subsystemが実装されたことにより、プロジェクトでは一つしか持てないGameInstanceを事実上複数持つことが可能になったり、スコア管理、HP管理などなど機能ごとのManagerクラスが作りやすくなりました。
また、初期化、破棄、寿命管理は既存のSubsystemを継承する場合に限り、エンジン側が勝手にやってくれるので、新規クラスやUObject等でManagerクラスを作るよりは実装コストが削減されているのが便利です。

このSubsystemをある程度良い感じに監視、管理してくれるのがSubsystemCollectionです。

SubsystemCollection

USubsystemを継承したクラスをベースクラスとして渡すことで、ベースクラスを継承したSubsystemクラス全てを自動でインスタンス生成して管理するクラスです。

クラスは以下の通りです。

  • 基底クラス
    • FSubsystemCollectionBase
  • 派生クラス
    • FSubsystemCollection
    • FObjectSubsystemCollection(5.1から追加)

5.1以上からは大半のSubsystemCollectionは、新規実装されたFObjectSubsystemCollectionを継承するように変更されており、FSubsystemCollectionを使用しているのはFAudioSubsystemCollectionのみになっています。
上記理由から、FSubsystemCollectionは将来的に非推奨になる可能性がありそうです。

FSubsystemCollectionを用いてSubsystemの仕組みを自作する

通常は既存のGameInstanceSubsystem等のSubsystemを利用すれば問題ないですが、実装内容次第では担当プログラマー以外は参照してほしくないSubsystemが必要になることがあります。

GameInstanceSubsystem、EngineSubsystem等の既存クラスはそれぞれUGameInstance、GEngineに実装されたSubsystemCollectionから利用されており、条件を満たせばどこでも参照できるのはメリットでもありますが、担当プログラマー以外から好き勝手触られた結果、とんでもない結果になってしまうSubsystemも参照できてしまうデメリットがあります。
また、既存のSubsystem取得関数はテンプレート関数のため、参照時に明確にクラスを指定する必要があり、SubsystemCollectionで管理しているSubsystem全てに参照したい場合は使用することができません。

このように明確に独自管理したいSubsytemがある、また既存のSubsystemでは出来ないことを実現するためにSubsystemCollectionを保持するクラスを作成します。

しかし、やりすぎるとブラックボックスになって担当プログラマーが居なくなってしまうと誰も修正できなくなるため、ほどほどに、かつ明確に管理したいSubsystemを設計するようにしましょう。

SubsystemCollectionを持つUObject派生クラスを作成

SubsystemCollectionを変数として持つUObject派生クラスを作成します。
以下の3パターンになるかと思います。

  1. 派生クラスでSubsystemを管理する場合
    • 5.0
      FSubsystemCollection
    • 5.1以上
      FObjectSubsystemCollection
  2. ある程度は自作したい場合
    FSubsystemCollectionBase
  3. 一から自作したい場合(完全独自のSubsystem)
    新規クラスを作成

今回作成したサンプルコードは1と2に関してのみです。
3はエンジンソースコードを理解する必要があるのと検証の時間が無かったため、最低限の推測で書いています。

SubsystemCollection使用方法

FSubsystemCollectionBaseの派生クラスを用いてSubsystemを管理する場合、以下のタイミングで指定のAPIを実行する必要があります。

  1. Subsystem使用開始
    • Subsystemを呼び出す前に必ずSubsystemCollection.Initialize()を実行
  2. Subsystem破棄
    • Subsystemを管理するクラスを破棄した場合、必ずSubsystemCollection.Deinitialize()を実行
  3. AddReferencedObjectsを追加(5.1以降)
    • SubsystemCollectionをガーベジコレクションの対象から外すために必ず追加してください。
    • FSubsystemCollectionを利用する場合はAddReferencedObjectsは追加不要です。
      • 必要であればTUniqueObjを利用してください。
  4. Subsystemを参照する関数を追加

サンプルコードは以下の通りです。

#pragma once

#include "CoreMinimal.h"
#include "Subsystems/MyAbstractSubsystem.h"
#include "MyObject.generated.h"

UCLASS()
class MYPLUGIN_API UMyObject : public UObject
{
	GENERATED_BODY()
	
public:
	
	UMyAbstractSubsystem* GetSubsystemBase(TSubclassOf<UMyAbstractSubsystem> SubsystemClass) const
	{
		return SubsystemCollection.GetSubsystem<UMyAbstractSubsystem>(SubsystemClass);
	}
	
	template <typename TSubsystemClass>
	TSubsystemClass* GetSubsystem() const
	{
		return SubsystemCollection.GetSubsystem<TSubsystemClass>(TSubsystemClass::StaticClass());
	}
	
	template <typename TSubsystemClass>
	static FORCEINLINE TSubsystemClass* GetSubsystem(const UMyObject* GameInstance)
	{
		if (GameInstance)
		{
			return GameInstance->GetSubsystem<TSubsystemClass>();
		}
		return nullptr;
	}
	
	template <typename TSubsystemClass>
	const TArray<TSubsystemClass*>& GetSubsystemArray() const
	{
		return SubsystemCollection.GetSubsystemArray<TSubsystemClass>(TSubsystemClass::StaticClass());
	}

	void Initialize();

	void Shutdown();

	static void AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector);
	
private:
#if (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION == 0)
	FSubsystemCollection<UMyAbstractSubsystem> SubsystemCollection;
#else
	FObjectSubsystemCollection<UMyAbstractSubsystem> SubsystemCollection;
#endif
};
#include "MyObject.h"

void UMyObject::Initialize()
{
#if (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION == 0)
	SubsystemCollection.Initialize(this);
#else
	if (!SubsystemCollection.IsInitialized())
	{
		SubsystemCollection.Initialize(this);
	}
#endif
}

void UMyObject::Shutdown()
{
	SubsystemCollection.Deinitialize();
}

void UMyObject::AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)
{
	UMyObject* This = CastChecked<UMyObject>(InThis);
#if (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION == 0)
	This->SubsystemCollection.AddReferencedObjects(Collector);
#else
	This->SubsystemCollection.AddReferencedObjects(This, Collector);
#endif
	UObject::AddReferencedObjects(This, Collector);
}

Subsystem使用開始

Subsystemを呼び出す前に必ず一度SubsystemCollection.Initialize()を実行してください。
Initializeが呼び出されることにより、SubsystemCollectionに登録したベースクラスの派生を検索し、自動でインスタンスを生成します。

void UMyObject::Initialize()
{
#if (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION == 0)
	SubsystemCollection.Initialize(this);
#else
	if (!SubsystemCollection.IsInitialized())
	{
		SubsystemCollection.Initialize(this);
	}
#endif
}

SubsystemCollectionのInitialize実行タイミングについて

SubsystemCollectionはUObjectの派生クラスであれば追加可能なため、SubsystemにSubsystemCollectionを持たせる、というようなこともできます。

ただし、SubsystemのInitializeが呼ばれている時点では、モジュールを全てロードしきれてないため、このタイミングでSubsystemCollection.Initialize()を実行するとメモリ参照エラーが発生する場合があります。

そのため、SubsystemCollection.Initialize()は全てのモジュールのロードが終わったタイミングで通知されるOnAllModuleLoadingPhasesComplete以降で実行しましょう。

以下はSubsystem内でSubsystemCollection.Initialize()を実行させるようにしたサンプルコードです。

void FVoicevoxNativeCoreModule::StartupModule()
{
	FCoreDelegates::OnAllModuleLoadingPhasesComplete.AddLambda([]
	{
    	GEngine->GetEngineSubsystem<UVoicevoxCoreSubsystem>()->NativeInitialize();
	});
}
void UVoicevoxCoreSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
	Super::Initialize(Collection);

	const UClass* NativeClass = UVoicevoxNativeObject::StaticClass();
	NativeInstance = NewObject<UVoicevoxNativeObject>(this, NativeClass);
}

void UVoicevoxCoreSubsystem::NativeInitialize() const
{
	NativeInstance->Init();
}
void UVoicevoxNativeObject::Init()
{
#if (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION == 0)
	VoicevoxSubsystemCollection.Initialize(this);
#else
	if (!VoicevoxSubsystemCollection.IsInitialized())
	{
		VoicevoxSubsystemCollection.Initialize(this);
	}
#endif
	const UClass* BaseType = UVoicevoxNativeCoreSubsystem::StaticClass();
	GetDerivedClasses(BaseType, SubsystemClasses, true);
}

Subsystem破棄

何かしらの理由でSubsystemCollectionを管理しているオブジェクトを破棄する場合、SubsystemCollection.Deinitialize()を実行して生成したインスタンスを破棄します。

void UMyObject::Shutdown()
{
	SubsystemCollection.Deinitialize();
}

AddReferencedObjectsを追加

FObjectSubsystemCollection、もしくはUE5.1以上FSubsystemCollectionBaseを継承した派生クラス、かつFGCObjectを継承しない場合はAddReferencedObjectsを追加する必要があります。

void UMyObject::AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)
{
	UMyObject* This = CastChecked<UMyObject>(InThis);
	This->SubsystemCollection.AddReferencedObjects(This, Collector);
	UObject::AddReferencedObjects(This, Collector);
}

Subsystemを参照する関数を追加

GetSubsystem等のSubsystemCollectionから目的のSubsystemを取得する関数を追加します。
既存のSubsystemは以下のように、SubsystemCollection.GetSubsystemを参照するテンプレート関数になっています。

template <typename TSubsystemClass>
TSubsystemClass* GetSubsystem() const
{
    return SubsystemCollection.GetSubsystem<TSubsystemClass>(TSubsystemClass::StaticClass());
}

中身を見るとprotectedのGetSubsystemInternalを使用しているので、FSubsystemCollectionBaseから派生クラスを新規作成した場合は、以下のようにUClassを直接渡せる関数からSubsystemを参照するクラスを作ることが可能です。

USubsystem* GetSubsystem(UClass* SubsystemClass) const
{
    return GetSubsystemInternal(SubsystemClass);
}

// 使用例
void UMyCustomObject::ShowLogSampleText()
{
	TArray<UClass*> SubsystemClasses;
	const UClass* BaseType = UMyAbstractSubsystem::StaticClass();
	GetDerivedClasses(BaseType, SubsystemClasses, true);

	for (const auto Element : SubsystemClasses)
	{
		const auto Subsystem = static_cast<UMyAbstractSubsystem*>(SubsystemCollection.GetSubsystem(Element));
		Subsystem->ShowLogSampleText();
	}
}

VOICEVOXのマルチエンジン機能は上記のようにベースクラスの派生クラスのみ取得することができたため、UEで実装することができました。

一から自作したい場合(完全独自のSubsystem)

ここからは色々な都合により、UEのAPIは最低限しか使用できない人向けです。
Subsystemがそもそもどうやって初期化しているかに関しては、エンジンソースコードのSubsystemCollection.cppを読めば大体の仕組みは理解できると思います。

流石に全部を引用することはできないため、最低限これだけ実装すればオリジナルSubsystemCollectionが出来る...んじゃないかなー、と思うことを書きます。

  1. Subsystemの配列、もしくはMapを定義
    • 今回はエンジンソースコードと同じマップ管理とします。
TMap<UClass*, USubsystem*> SubsystemMap;
  1. GetDerivedClassesを使って、ベースクラスの派生クラス全てを取得する(超重要)
UClass* BaseType = UMyAbstractSubsystem::StaticClass();
TArray<UClass*> SubsystemClasses;
GetDerivedClasses(BaseType, SubsystemClasses, true);
  1. 派生クラスのリストからNewObject、管理用のMapもしくは配列に追加、SubsystemのInitializeを実行
for (UClass* SubsystemClass : SubsystemClasses)
{
    USubsystem* Subsystem = NewObject<USubsystem>(Outer, SubsystemClass);
    SubsystemMap.Add(SubsystemClass, Subsystem);
    Subsystem->Initialize(*this);
}
  1. 破棄時は、SubsystemのDeinitializeを実行後、管理用のMapもしくは配列を初期化
for (auto Iter = SubsystemMap.CreateIterator(); Iter; ++Iter)
{
    UClass* KeyClass = Iter.Key();
    USubsystem* Subsystem = Iter.Value();
    if (Subsystem != nullptr && Subsystem->GetClass() == KeyClass)
    {
        Subsystem->Deinitialize();
    }
}
SubsystemMap.Empty();

これで最低限のオレオレSubsystemが完成すると思います。

Subsystemクラスを取得するのに大事なのはGetDerivedClassesを利用することです。

GetDerivedClassesは指定したクラスから派生したクラスの配列を取得することが可能です。
第三引数のbRecursiveをtrueにすることで、派生クラスの派生クラスも再帰的に取得できます。
派生クラスのみ使用したい場合はfalseを設定してください。

参考資料

今回の記事を書くのに参考にした資料一覧です。

公式ドキュメント

プログラミング サブシステム

猫でも分かるUE4.22から入ったSubsystem【第4回 UE4何でも勉強会 in 東京 2020】

目指せ脱UE4初心者!?知ってると開発が楽になる便利機能を紹介 - DataAsset, Subsystem, GameplayAbility編 -

[UE4] Subsystem, GameplayAbilityに関する講演で使用したC++コードについて

ユーザー記事

【UE4】Subsystem, GAS, DataAssetを活用して実装した会話システムについて雑多に書いてみた

作成したクラスに専用のSubSystemを搭載する

【UE5】Subsystem(サブシステム)を使ってみよう!

[UE5] SubSystemを使いつつ、Blueprint側でロジックを実装する

How to Recreate a GameInstanceSubsystem After GC

Unreal Engine 4メモ:デリゲートをprivateで書く
→内容はデリゲートですが、今回のサブシステム利用方法を考えるきっかけになったので載せます

Discussion