🎄

[UE5] Unreal EngineにおけるCoroutine

2022/12/04に公開

こんにちは。エンジニアのunwithererです。
これは Unreal Engine (UE) Advent Calendar 2022 4日目の記事です。
この記事ではUE5で実験的機能として導入が進んでいるコルーチンについて紹介します。

1. 使用環境

  • Unreal Editor : 5.0.3
  • Microsoft Visual Studio 2022 : 17.3.6
    • MSVC (Win64) : 14.33.31629
    • Clang (Android) [1]: 9.0.9

執筆にあたり作成したサンプルプロジェクトは ここ にアップロードしました。

2. コルーチンとは

コルーチン は、一時停止した処理を途中から再開できる関数の一種です。

このような機能は「完了までに時間がかかるタスク」を可読性を維持したまま記述可能で、特に複数フレームにまたがる処理が多いゲーム開発でも有用な機能です[2]。例えばC#には「反復子メソッド」と呼ばれるコルーチンと同じ振る舞いの機能がありますが、Unityではこれがコルーチンとして活用されてきました。

2.1. Unreal Engineにおけるコルーチン

Unreal Engineではプログラムの実装に C++Blueprint を用います。Blueprintにコルーチンに相当する機能はありませんが、以下のような実装で近い振る舞いを実現できます。しかし、Blueprintは豊富なLatentノードによって複数フレームにまたがる処理の記述は容易なため、あえてコルーチンを使う必要はないかもしれません。

2.2. C++20のコルーチン

一方のC++ですが、UE4ではコルーチンに相当する機能はサポートされていませんでした。しかし、2020年に追加された新しいC++の規格「C++20」で間接的に コルーチン が使えるようになりました。これに合わせて、UE5でもコルーチンに関する以下のサポートが始まっています。

  • UnrealBuildToolに実験的コルーチンサポートが追加(UE5.0)
  • Coreモジュールに実験的コルーチンライブラリが追加(UE5.1)

ただし、C++20のコルーチンは、C#における IEnumerable のようなアプリ実装者向けコルーチンサポートではなく、主に関数をコルーチンへ拡張するための低レベルコルーチンライブラリを実装するための機能です[3]
現状、コルーチン機能のコンパイラサポートは以下のような状況になっています。まだ完全には対応していないものの、最新のUE5では十分に動作を試すことができます。

コンパイラ 対応状況
MSVC 対応済み?
Clang 部分的対応

3. コルーチンの作成

3.1. 前準備

UE5でコルーチンを試すなら、UnrealBuildToolのコルーチンサポートを利用することを推奨します。まず.Target.csにbEnableCppCoroutinesForEvaluation = true;を追加します[4]

.Target.cs
using UnrealBuildTool;
using System.Collections.Generic;
public class CoTest500Target : TargetRules
{
	public CoTest500Target( TargetInfo Target) : base(Target)
	{
		Type = TargetType.Game;
		DefaultBuildSettings = BuildSettingsVersion.V2;
		ExtraModuleNames.AddRange( new string[] { "CoTest500" } );
		
		bEnableCppCoroutinesForEvaluation = true;
	}
}

次に、コルーチンを使用するモジュールの.Build.csにCppStandard = CppStandardVersion.Latest;を追加します[5]

.Build.cs
using UnrealBuildTool;
public class CoTest500 : ModuleRules
{
	public CoTest500(ReadOnlyTargetRules Target) : base(Target)
	{
		PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
		PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore" });
		PrivateDependencyModuleNames.AddRange(new string[] {  });
		
		CppStandard = CppStandardVersion.Latest;
	}
}

上記は、コルーチンを使用するためのコンパイラフラグを設定するための設定です。UE4では上記の代わりにAdditionalCompilerArgumentsでコンパイラごとに以下のコンパイラフラグを指定しても同じです。

コンパイラ コンパイラフラグ
MSVC /await:strict-std=c++20 または /std:c++latest
Clang -fcoroutines-ts-std=c++20 または -std=c++2b

3.2. ジェネレーターの作成

C++におけるコルーチンは、co_await, co_yield, co_returnキーワードのいずれかを定義に含む関数をコルーチンとして扱います。C++20はそのようなコルーチンに対するカスタマイズポイントを提供し、関数をコルーチン化するための機能を実装できます。詳細な仕様は cpprefjp 様の解説が参考になります。
今回は、中断・再開のみ可能な簡素なジェネレータークラスを作成します。
機能の実装にはcoroutineヘッダを使用します。Clangのバージョンによってはexperimentalを付ける必要があるため、__has_includeで適切なヘッダがインクルードされるようにします。

Generator.h
#if __has_include(<coroutine>)
	#include <coroutine>
	namespace co = std;
#else
	#include <experimental/coroutine>
	namespace co = std::experimental;
#endif

3.2.1. Awaitable

Awaitable型はコルーチンを中断するときの挙動を決めます。coroutineヘッダには以下の2つのAwaitable型が用意されています。

Awaitable型 中断時の挙動
suspend_always 常に中断する
suspend_never 常に中断しない

単に中断したいならsuspend_alwaysを使えばよいですが、今回は中断判定をカスタマイズしたAwaitable型を作成してみます。Awaitable型のもつawait_ready関数の戻り値がfalseのとき、コルーチンは中断します。下例は「指定した整数が3の倍数または3がつく」ときのみコルーチンが中断するAwaitable型です。

Generator.h
struct FAwaiter
{
	const int32 n;
	// コルーチンの中断判定 falseで中断する
	constexpr bool await_ready() const
	{
		// 3の倍数か3のつく数で中断する
		return !(n % 3 == 0 || FString::FromInt(n).Contains(TEXT("3")));
	}
	// await_readyで中断したときに評価される
	// この関数の戻り値がcoroutine_handle型なら、それをresumeする
	// この関数の戻り値がbool型の場合、この関数の評価結果がfalseならコルーチンが再開する
	// それ以外は単に関数が評価される ここでは何もしない
	constexpr void await_suspend(co::coroutine_handle<>) const noexcept {}
	// await_readyで中断しなかったときや
	// コルーチン再開時に co_await 式の評価結果を返す ここでは何も返さない
	constexpr void await_resume() const noexcept {}
};

3.2.2. Promise型

Promise型はコルーチン本体の挙動をカスタマイズします。以下の2つの関数が重要です。

関数 詳細
await_transform co_await式の挙動をカスタマイズします。下例では、co_awaitのオペランドとして受け取った整数をFAwaiterに渡し、コルーチンを中断するか決定します。
get_return_object このPromise型を用いるコルーチンを呼び出したときの戻り値を指定します。多くの場合、コルーチンを制御するコルーチンハンドルをラップしたクラスを返します。ここではFGenerator(後述)を生成して、コルーチンの呼び出し元へ返します。
Generator.h
struct FPromise
{
	// コルーチン初回呼び出し時に実行され、Awaitable型で中断挙動を決定する
	// ここでは常に中断する
	constexpr co::suspend_always initial_suspend() const { return {}; }
	// コルーチン終了時に実行され、Awaitable型で中断挙動を決定する
	// 中断した場合はdestroy関数で明示的にコルーチンを破棄しなければならない
	// ここでは常に中断する
	constexpr co::suspend_always final_suspend() const noexcept { return {}; }
	// co_returnで終了時 または コルーチン終端到達時に実行される
	constexpr void return_void() const noexcept {}
	// 例外発生時に実行される ここではterminate()で終了する
	void unhandled_exception() { std::terminate(); }
	// co_awaitに指定した整数をFAwaiterに渡し、中断可能か判定し、可能なら中断する
	constexpr FAwaiter await_transform(const int32 inValue) { return { inValue }; }

	// コルーチン初回呼び出し時の戻り値を指定する
	// ここでは戻り値としてFPromiseからジェネレーターを生成する
	FGenerator get_return_object()
	{
		return FGenerator(*this);
	}
};

3.2.3. Generator

Generatorはコルーチンを制御するコルーチンハンドルをラップしたクラスです。FGeneratorFPromiseによってカスタマイズされたコルーチンを制御できる coroutine_handle<FPromise> をラップします。FGeneratorFPromiseからのみ生成できます。FPromisefinal_suspend関数で中断するため、デストラクタ内でコルーチンハンドルをdestroy関数によって明示的に破棄します。FPromiseget_return_object関数でFGeneratorオブジェクトを生成し、コルーチンの呼び出し元で受け取ります。FGeneratorResume関数でコルーチンを再開できます。

Generator.h
class FGenerator
{
	friend FPromise;

	using FCoroutineHandle = co::coroutine_handle<FPromise>;
	FCoroutineHandle Handle; // FPromise型のコルーチンを制御するコルーチンハンドル
private:
	// PromiseからGeneratorを生成
	explicit FGenerator(FPromise& inPromise) :
		Handle(FCoroutineHandle::from_promise(inPromise)){}

	// デフォルト・コピーコンストラクタ, コピー代入演算子の削除
	FGenerator() = delete;
	FGenerator(const FGenerator&) = delete;
	FGenerator& operator = (const FGenerator&) = delete;
public:
	// ムーブコンストラクタ, ムーブ代入演算子はムーブ元を削除する
	FGenerator(FGenerator&& inGenerator) :
		Handle(std::exchange(inGenerator.Handle, nullptr)){}
	FGenerator& operator = (FGenerator&& inGenerator)
	{
		Handle = std::exchange(inGenerator.Handle, nullptr);
		return *this;
	}
	~FGenerator()
	{
		if (Handle)
			Handle.destroy();
	}
	void Resume()
	{
		if (Handle && !Handle.done())
			Handle.resume();
	}
};

上例はGeneratorのコピーを禁止し、ムーブのみ許可します。Generatorのようなクラスをコピーしてよいかは諸説[6]ありますが、ここでは以下の理由で禁止しました。

  • コルーチンをコピーする操作がセンシティブである
  • コルーチン利用者にはGeneratorのコピー挙動が曖昧[7]に見えてしまう

3.2.4. promise_typeのアダプト

コルーチンをカスタマイズするPromise型はR::promise_typeという形式になるようにpromise_typeにPromise型を指定するか、std::coroutine_traits でアダプトする必要があります。

Generator.h
template <class... ArgTypes>
struct co::coroutine_traits<FGenerator, ArgTypes...>
{
	using promise_type = FPromise;
};

上例ではcoroutine_traitsを使用しました。こちらの手法では既存の型を使ってコルーチンのPromise型を指定できます。例えば、上例のFGeneratorTOptional<FGenerator>のように指定することも可能です[8]

3.3. コルーチンを使用する

作成したコルーチンは以下のように使用します。FGeneratorはデフォルトコンストラクタを削除しているため、メンバ変数で保持するときはTOptionalFGeneratorを括ります。

CoActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Generator.h"
#include "CoActor.generated.h"

UCLASS()
class COTEST500_API ACoActor : public AActor
{
	GENERATED_BODY()
public:	
	ACoActor();

protected:
	virtual void BeginPlay() override;

public:	
	virtual void Tick(float DeltaTime) override;

	TOptional<FGenerator> CoGenerator;
	FGenerator TestCoroutine();
};
CoActor.cpp
#include "CoActor.h"
	
ACoActor::ACoActor()
{
	PrimaryActorTick.bCanEverTick = true;
}
void ACoActor::BeginPlay()
{
	Super::BeginPlay();
	CoGenerator = TestCoroutine();
}
void ACoActor::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	CoGenerator.GetValue().Resume();
}
FGenerator ACoActor::TestCoroutine()
{
	co_await 1;
	co_await 2;
	co_await 3; // suspend!
	co_await 4;
	co_await 5;
	co_await 6; // suspend!
	co_await 7;
	co_await 8;
	co_await 9; // suspend!
	co_await 10;
	co_await 11;
	co_await 12; // suspend!
	co_await 13; // suspend!
	co_await 14;
	co_await 15; // suspend!
}

上例の処理にブレークポイントを張ると、co_awaitに指定した整数が3の倍数または3がつく数字で処理が中断していることがわかります。またTickで実行されるResume関数によって、中断したコルーチンを途中から再開できていることも確認できます。

4. コルーチンライブラリ

4.1. UE5.1組み込みコルーチン

UE5.1で以下のディレクトリにC++20コルーチンを用いたコルーチンライブラリが追加されました。
Engine\Source\Runtime\Core\Public\Experimental\Coroutine
ざっと実装を確認しましたが......イマイチ想定される使い方がわかりませんでした。
単純な中断・復帰処理であれば以下のように記述できます。

CoActor.h
~~省略~~
#include "Experimental/Coroutine/Coroutine.h"

UCLASS()
class COTEST500_API ACoActor : public AActor
{
	~~省略~~
	TOptional<TCoroFrame<int32>> CoTask;
	TCoroFrame<int32> CoroutineFrame();
};
CoActor.cpp
~~省略~~
void ACoActor::BeginPlay()
{
	Super::BeginPlay();
	CoTask = CoroutineFrame();
}
void ACoActor::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	CoTask.GetValue().CoroutineHandle.resume();
}
TCoroFrame<int32> ACoActor::CoroutineFrame()
{
	co_await suspend_always{};
	co_await suspend_always{};
	co_await suspend_always{};
	co_return 5;
}

4.2. 非公式のUE5統合

ライブラリ 詳細
UE5Coro おそらく、現状最もアクティブなC++20ベースのコルーチンプラグインです。Blueprintとの連携も可能なようです。
ACE Team Coroutines Plugin for Unreal Engine C++20のコルーチンを使用しないコルーチン実装です。コンパイラサポートが必要ないため、多くのプラットフォームで使用できますが、コルーチンを記述する構文が複雑です。
Boost Boostでもコルーチンは使えるため、Unreal Engine内にあるBoostライブラリを使う方法も考えられます。ライセンス的に使用可能なのか詳しくないため、使用する際は権利周りにご留意ください。

5. まとめ

Unreal Engineにおけるコルーチンの動向や実装例を紹介しました。C++20コルーチンを使うため、コンパイラサポートがないプラットフォームでの利用は難しいですが、今後徐々にサポートが進むと考えています。

6. 参考資料

コルーチンの概念関連

C++20コルーチン関連

コンパイラサポート関連

UnrealEngine統合関連

コルーチン実装関連

特に重要なC++20コルーチンの挙動関連

脚注
  1. 記事内でClangに言及するときは主にAndroid向けクックに用いられるコンパイラを想定しています。Clangは他のプラットフォームでも使われますが、ツールチェイン, バージョン, 環境などにより詳細な挙動が異なる可能性がある点はご了承ください ↩︎

  2. 例えば、「キャラクターを指定位置へ移動させる」処理は、複数フレームにまたがって目標位置までの位置変更を行うタスクと捉えられます ↩︎

  3. C#で例えると、IEnumerableがサポートされたのではなく、IEnumerableを実装するための機能がサポートされた、というイメージです ↩︎

  4. Editor.Target.csも同様 ↩︎

  5. CppStandardVersion.Cpp20もありますが、こちらはClangのバージョンによってはエラーとなったのでLatestを使用しています。LatestがCpp20を指していないプラットフォームの場合、正しく動作しない可能性があります ↩︎

  6. これによると、コピー自体は禁止されていないようです。 ↩︎

  7. コルーチンをコピーしたとき、それがDeep Copy(コルーチン本体の内部状態ごとのコピー?)なのか、Shallow Copy(コルーチン制御機能のコピー?)なのか曖昧ということです ↩︎

  8. ここで指定した型とPromise型のget_return_objectで戻す型を一致させる必要があります ↩︎

Discussion