[UE5] Unreal EngineにおけるCoroutine
こんにちは。エンジニアの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]。
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]。
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
で適切なヘッダがインクルードされるようにします。
#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型です。
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 (後述)を生成して、コルーチンの呼び出し元へ返します。 |
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はコルーチンを制御するコルーチンハンドルをラップしたクラスです。FGenerator
はFPromise
によってカスタマイズされたコルーチンを制御できる coroutine_handle<FPromise> をラップします。FGenerator
はFPromise
からのみ生成できます。FPromise
はfinal_suspend
関数で中断するため、デストラクタ内でコルーチンハンドルをdestroy
関数によって明示的に破棄します。FPromise
はget_return_object
関数でFGenerator
オブジェクトを生成し、コルーチンの呼び出し元で受け取ります。FGenerator
はResume
関数でコルーチンを再開できます。
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 でアダプトする必要があります。
template <class... ArgTypes>
struct co::coroutine_traits<FGenerator, ArgTypes...>
{
using promise_type = FPromise;
};
上例ではcoroutine_traits
を使用しました。こちらの手法では既存の型を使ってコルーチンのPromise型を指定できます。例えば、上例のFGenerator
をTOptional<FGenerator>
のように指定することも可能です[8]。
3.3. コルーチンを使用する
作成したコルーチンは以下のように使用します。FGenerator
はデフォルトコンストラクタを削除しているため、メンバ変数で保持するときはTOptional
でFGenerator
を括ります。
#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();
};
#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
ざっと実装を確認しましたが......イマイチ想定される使い方がわかりませんでした。
単純な中断・復帰処理であれば以下のように記述できます。
~~省略~~
#include "Experimental/Coroutine/Coroutine.h"
UCLASS()
class COTEST500_API ACoActor : public AActor
{
~~省略~~
TOptional<TCoroFrame<int32>> CoTask;
TCoroFrame<int32> CoroutineFrame();
};
~~省略~~
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コルーチン関連
- コルーチン cpprefjp
- Coroutine(C++20) cppreference.com
- From Algorithms to Coroutines in C++ Microsoft learn
- Raymond Chen氏のコルーチン関連記事
- C++20 Coroutineの規格案
コンパイラサポート関連
UnrealEngine統合関連
- Unreal Engine 5.1 リリースノート
- UE5Coro Github
- ACE Team Coroutines Plugin for Unreal Engine Github
- SquidTasks Github
- コルーチン boostjp
コルーチン実装関連
- C++コルーチン拡張メモ Qiita
- C++ でコルーチン (async/await 準備編) Qiita
- 【C++】C++のコルーチンを気軽に試してみる logicalbeat
- 20分くらいでわかった気分になれるC++20コルーチン Docswell
- My tutorial and take on C++20 coroutines
特に重要なC++20コルーチンの挙動関連
- C++ coroutines: The lifetime of objects involved in the coroutine function
- Can C++20 coroutines be copied?
-
記事内でClangに言及するときは主にAndroid向けクックに用いられるコンパイラを想定しています。Clangは他のプラットフォームでも使われますが、ツールチェイン, バージョン, 環境などにより詳細な挙動が異なる可能性がある点はご了承ください ↩︎
-
例えば、「キャラクターを指定位置へ移動させる」処理は、複数フレームにまたがって目標位置までの位置変更を行うタスクと捉えられます ↩︎
-
C#で例えると、
IEnumerable
がサポートされたのではなく、IEnumerable
を実装するための機能がサポートされた、というイメージです ↩︎ -
Editor.Target.csも同様 ↩︎
-
CppStandardVersion.Cpp20
もありますが、こちらはClangのバージョンによってはエラーとなったのでLatestを使用しています。LatestがCpp20を指していないプラットフォームの場合、正しく動作しない可能性があります ↩︎ -
コルーチンをコピーしたとき、それがDeep Copy(コルーチン本体の内部状態ごとのコピー?)なのか、Shallow Copy(コルーチン制御機能のコピー?)なのか曖昧ということです ↩︎
-
ここで指定した型とPromise型のget_return_objectで戻す型を一致させる必要があります ↩︎
Discussion