PavilionDV7の雑多なやつ

Qiitaから移行しました。UE4に関する記事から興味のあることまで色々書きます。

【UE4】HTN Plannerプラグインのサンプルを書きました

f:id:PavilionDV7:20210611162920p:plain

はじめに

UE4に標準搭載されているHTN PlannerプラグインのサンプルをGitHubの方にアップしました。

この記事では制作したサンプルプロジェクトの解説をしていきます。

プロジェクトの実行結果

プロジェクト

github.com

HTNとは

HTNについては過去にQiitaに投稿した記事内に書いたので、そちらを参照してください。

【UE4】HTNを実装してみた - Qiita

HTN Plannerプラグイン

HTN PlannerはUE4に標準搭載されているプラグインで文字通りHTNによるプランニングを提供するものです。

しかし残念ながら提供されるインターフェースは全てC++のみで、BP側から各機能にアクセスすることができません。そのためBP側とプラグインのやり取りする仕組みはユーザーが作る必要があります。

とは言っても試してみる程度であればやり取りの仕組みは全く複雑なものではなく、各機能をUFUNCTIONをつけた関数でラップしてあげれば、簡単にBP側からプラグインの各機能を呼び出すことができます。

プロジェクトの解説

C++

まずはC++の方から見ていきます。C++はHTN PlannerプラグインとBP側とやり取りするための仕組みを定義しています。

UBasicHTNBrainComponentクラス

World Stateの初期化

プランニング時に渡されるWorld Stateを設定するための機能はPopulateMinerWorldState関数でラップされBP側からアクセス出来るようにしています。

World Stateを構成する「項目名」と「値」を表す、2種類の整数型(uint8とint32)を持つFHTNWorldStateElem構造体の配列を受け取り、要素を直接World Stateにセットします。

void UBasicHTNBrainComponent::PopulateMinerWorldState(const TArray<FHTNWorldStateElem>& InitialWorldState)
{
    ~~ 省略 ~~

    for (const auto& InitialState : InitialWorldState)
        MinerWorldState.SetValueUnsafe(InitialState.WorldState, InitialState.Value);
}

FHTNWorldStateElemを構成するuint8型とint32型はそれぞれ「項目名」と「値」を表しています。

USTRUCT(BlueprintType)
struct FHTNWorldStateElem
{
    GENERATED_USTRUCT_BODY()

    UPROPERTY(BlueprintReadWrite)
        uint8 WorldState;
    UPROPERTY(BlueprintReadWrite)
        int32 Value;
};

項目名をuint8型とすることでユーザーが定義したEnumを利用することが出来ます。それによって項目名を「1」「2」と整数で指定するのではなく、「EMinerWorldState::HasOre」と指定することが可能になり読みやすくなっています。(uint8型 = Byte型 = Enumの要素の値) 値を表す方はint32型なので例えば「TRUE」「FALSE」を表したい場合は、それぞれ「1」「0」で指定する必要があります。(TRUE = 1、FALSE = 0)

f:id:PavilionDV7:20210611135050p:plain

HTNドメインの構築

HTNドメインの構築はBP側へのインターフェースを構築することが難しいため、C++側でのみ操作が可能になっています。

今回のプロジェクトで構築したHTNドメインは「鉱夫が指定数の鉱石を倉庫に置く」タスクを解くためのアクションシーケンスを求める内容となっています。これは 【UE4】HTNを実装してみた - Qiita と同様のものです。

ここのコードは長くなるので省略します。

今回構築したドメインを図に起こしてみると次のようになります。

f:id:PavilionDV7:20210611135045j:plain

プログラム上では少々複雑に見えたかもしれませんが、図に起こしてみるとシンプルな流れ図だと思います。それとどことなくBehavior Treeにも近い印象を受けます。

HTNドメイン構築時の注意点

  • SetRootNameは必須ではない

ドメインではFHTNBuilder_Domain::SetRootName()を使ってルートとなるタスク名を指定していますが、指定がなければ最初に追加されたタスクがルートとして解釈されます。

  • 必ず最後にFHTNBuilder_Domain::Compile()を呼ぶのを忘れないこと

  • ループする構造ではループを抜けた先のタスクも用意する

HTN Plannerにはプラン生成時に無限ループを検知する機能が無いため、条件を間違えば簡単に無限ループが発生してしまいます。 試しにUBasicHTNBrainComponent::PopulateMinerDomain()の次の部分をコメントアウトし、実行してみてください。無限ループが発生します。

         // どのMethodにも該当しない場合のタスク
            FHTNBuilder_Method& MethodsBuilder = CompositeTaskBuilder.AddMethod();
            MethodsBuilder.AddTask(TEXT("DoNothingTask"));

HTN PlannerではMethodによって分岐した先でプランに加えられるタスクが発見できなかった場合「ロールバック」という機能によって分岐前のWorld Stateを復元し、もう一方の分岐先へと探索を進めます。そこでもタスクが発見できなければ更にロールバックするのですが、復元されたWorld Stateは再び得られるタスクが無いタスクへと探索を進めてしまい、またしてもロールバックを実行します。これが繰り返されることで無限ループが発生します。

無限ループを避けるためには「ループをするようなドメインを書かない」ことが一番です。今回のドメインでは「鉱山から鉱石を取り出し、倉庫へ運ぶ動作」を3回繰り返すようなドメインとなっています。これを「鉱山から鉱石を取り出し、倉庫へ運ぶ」までをHTNプランニングによって一連のアクションシーケンスを求めて、実行します。全てのアクションを実行後、World Stateを更新し再びプランニングを実行します。これを3回繰り返せば同様の結果が得られるはずです。 つまりループをドメインの外へ出し、BP側でループの管理を行う感じです。

HTNプランニングの実行

FHTNPlanner::GeneratePlanはBP側からアクセス出来るようになっており、求まったアクションシーケンスを返します。

アクションシーケンスは配列になっており、先頭から順に実行するべきアクションが格納されています。アクションを表す要素はFHTNExecutableActionという構造体になっており「実行するアクション」を表すuint16型のActionID、「アクションのパラメータ」を表すint32型のParameterで構成されています。

どちらも整数型で表されるため「ActionIDの数値はどのアクションを指しているか」「Parameterの数値は何を表しているか」を解釈する仕組みはユーザーが作る必要があります。

このプロジェクトではActionIDとParameterをそれぞれuint8型へと変換し、BP側で適切なEnumへと変換することで「どのアクションか」「どのパラメータか」を解釈出来るようにしています。

BP側

求まったアクションシーケンスの実行

FHTNPlanner::GeneratePlanによって返されるデータは2つの整数型(uint8)を持つ構造体(FHTNResultAction)の配列になります。

FHTNResultActionが持つ整数型はそれぞれ「実行するアクション」と「アクションのパラメータ」を表しており、両者ともに適切なEnumへと変換することで、具体的な名前が判明します。

よってアクションシーケンスの実行は

  1. 配列からアクションを取り出す
  2. ActionIDとParameterを適切なEnumへ変換する
  3. それに準じた振る舞いを実行する

を繰り返すことになります。

おわりに

  • C++が必須なのが玉に瑕
  • しかし提供される機能は十二分
  • ドメイン構築も少々コツはいるもののC++の深い知識を持っている必要はないように思える

C++が必須なのが玉に瑕ですが簡単に触れるので、HTNによるプランニングというものを実感したい方是非触ってみてください。

GitHubに上げたプロジェクトの他にも

Plugins/AI/HTNPlanner/Source/HTNTestSuite/Private/HTNTest.cpp

にはHTNの各テストケースがまとまっているので「どういう場合にどういう動作をする」というのを把握するのに役立つと思います。エンジンソースを閲覧できる方は是非。

ここから独り言

すごい箇条書き

Behavior Tree との違い

  • Behavior Treeは反射型(条件反射によって行動が決まる)
  • HTNは非反射型(プランニングによって行動が決まる)

  • Behavior Treeの意思決定の生存期間は極短期間(その場で考え実行し、再び考え実行する)

  • HTNの意思決定の生存期間は中~長期間(状況が変化しない限り最初に求めたアクションシーケンスを最後まで実行する)

  • Behavior Treeは激しく状況が変わる環境では有利(その都度実行可能かをチェックするので状況変化に強い)

  • 状況の変化が穏やかな環境では不利(滅多に変化しない状況で実行可能かを都度チェックするので無駄になる)

  • HTNは激しく状況が変わる環境では不利(状況が変化するたびWorld Stateを現在の状況に合わせて初期化し再プランニングする必要があるため)

  • 状況の変化が穏やかな環境では有利(一度アクションシーケンスを求めれば順次取り出し実行するだけ)

HTNをいい感じに使う

  • HTNをBehavior Treeの代替とすることは非推奨()
  • Behavior Treeは非常に汎用性が高く柔軟なため超強い
  • HTNは今回のサンプルのような粒度ではなく、もっと大きな粒度でタスクを考えた方がいいかも
  • サンプルでは「倉庫に移動」と「鉱石を置く」が別々のタスクになっているが、これを1つのプリミティブタスク(例えば「倉庫に鉱石を置く」)としてまとめてしまって良い
    • 「倉庫に移動」アクションと「鉱石を置く」アクションはBehavior Treeで実行する感じ