タスクシステム
Contents
タスクシステム#
MGLにはアプリケーション側のゲームロジックを簡潔に実装するための機能として、独自のタスクシステムが用意されています。この機能はゲーム中に同時に存在し得る要素(プレイヤーや敵、UI描画など)を小分けにして実装し、それを登録することでゲーム中に同時に存在させるための仕組みです。
MGLのタスクシステムが備えている機能や特徴を簡潔に述べると、次のようなものがあります。
IDによる厳格な実行順序の保証
弱参照による安全なタスク参照
イベントによるタスクへの通知処理
実行ステージのカスタマイズ
スレッドプールを用いた並列実行
本項では、これらの機能の具体的な利用方法についてを解説します。
概要#
タスクシステムとは?#
まず初めに、タスクシステムの基本的な概念についてを説明します。
タスクシステムはゲームの実装に必要な処理を小さく分割し、それを登録することによって一定周期で更新処理を呼び出すための仕組みです。分割した小さな処理単位を「タスク」と呼び、これはそのタスク固有の処理と、その実行に必要なワークデータの組み合わせです。C++においては、タスクは1つのクラスオブジェクトとして表現可能です。
タスクは同種のものを複数登録可能です。例えば、敵キャラクターを画面に3体配置するとした場合、タスクシステムでは敵タスクの生成を3回行うことで実現します。もちろん、生成時に与えるパラメータを変化させることによって、それら3体にバリエーションを与えることも可能です。そして、もしその敵を倒したとしたら、タスクシステムから削除することによって画面から退場してもらいます。
このように、ゲームに必要な機能をタスクとして分解し、それらを必要に応じて生成・削除を繰り返すことによってゲームの進行を実現する仕組みがタスクシステムとなります。
MGLのタスクシステム#
一口にタスクシステムと言っても多種多様な実装方法があり、それに伴い機能も様々です。ここでは、MGLのタスクシステムのもう少し詳しい仕様についてを解説します。
まず、MGLのタスクシステムの実行を簡略化した図を次に示します。
タスクシステムは内部にノードリストと呼ばれるコンテナを複数保持しており、各々のノードリストは処理単位であるタスクノードを複数登録可能です。タスクシステムの実行が行われた際、全てのノードリストは自身が管理しているタスクノードを順に実行します。通常はこれをフレームごとに1度実行し、登録されている全てのタスクノードが毎フレーム呼び出されるようになります。
ノードリストはタスクシステム内部で管理されているため、アプリケーション側からは意識する必要はありません。アプリケーションにとって重要な項目は、図中の赤枠で示してある「タスクID」と「タスクノード」の2つになります。
タスクシステムを用いたゲームの開発では、実装したい機能ごとにタスクIDとタスクノードを作成し、それを量産していくことで進めていきます。その具体的な方法は後述しますが、両者を簡単に説明すると次の通りです。
- タスクID
タスクIDはタスクノードの種類を表す一意の識別子です。
この値はアプリケーション側で作るゲームに合わせて個別に定義する必要があります。例えば、プレイヤーキャラクター用のタスク、敵キャラクター用のタスク、ステータス表示用のタスクの3種類を定義する場合、これら3つの値を個別に定義するのはアプリケーション側の役目です。
タスクIDは実行順序にも関わっています。MGLのタスクシステムは実行順序に厳格であり、このIDが小さいタスクほど先に実行されます。また、タスクを並列に実行する場合であっても、その制御はこのタスクID単位で行うことになります。
- タスクノード
タスクノードは実際の処理を行うオブジェクトであり、MGL::Task::Nodeを継承したクラスとして実装します。
フレームごとの更新処理や描画処理はこのタスクノードに記述していくことになります。タスクシステムを用いたゲームの開発では、このタスクノードを作成していくことがゲームロジックの実装のメインとなるはずです。
タスクノードには先述のタスクIDが必須であり、両者の関係は基本的には一対となります。MGLは同じタスクIDを持ったタスクノードを同種とみなし、同じノードリストへと格納されます。いくつかの例外的なテクニックこそありますが、通常は1つのタスクノード(継承先クラス)に対して1つのタスクIDを割り当てるようにしてください。
MGLのタスクシステムは実行制御に特化し、多くの機能を持たないという特徴があります。このような仕組みはしばしば「古典的タスクシステム」とも呼ばれています。一方で、MGLのカスタマイズ性重視はタスクシステムにおいても例外ではなく、タスクノードに機能を追加することは可能な作りになっています。その具体的な方法についてはタスクノードのカスタマイズにて解説しています。
基本的な使い方#
各種定義の準備#
タスクシステムはゲームの開発に必須な機能ではなく、必要な定義も作るゲームによって大きく異なります。そのため、MGLの初期化が完了した時点ではまだ使用できる状態にありません。タスクシステムを有効化するためには、先にそのゲーム向けの定義を準備し、それを元にアプリケーション側から初期化処理を呼び出す必要があります。
作るゲームに合わせて定義する必要のある要素は次の3つです。
タスクID
タスクイベントID
タスクノードの基底クラス
このうち、タスクノードの基底クラスについてはデフォルトタスクノードと呼ばれる標準的な定義がMGL側に用意されています。多くのゲームではこのクラスを使い回すことで必要十分を満たせますが、もし不足があればタスクノードのカスタマイズを参考に自前で作成することもできます。
タスクイベントIDは外部から不特定多数のタスクに対して通知を行う際に定義するものです。その詳細についてはイベント通知にて後述します。
本項ではまずデフォルトタスクノードを用いた使用方法を解説し、後にタスクノードのカスタマイズ方法についてを解説します。
タスクシステムを扱うための第一歩は、作るゲーム向けの定義を行うことです。ここでは例としてtask.h
というヘッダファイルを作成し、そこに関連定義を記述します。定義に必要な最低限の記述は次の通りです。
- タスクシステム関連の定義ヘッダ
task.h
#include <mgl/mgl.h> namespace YourApp { //! タスクID enum class TaskID : MGL::Task::Identifier { // ここにタスクIDを定義 }; //! タスクイベント enum class TaskEvent : MGL::Task::EventIdentifier { // ここにタスクイベントIDを定義 }; //! タスクノード using TaskNode = MGL::Task::DefaultTaskNode<TaskID, TaskEvent>; //! タスクの最大数 constexpr size_t kTaskCapacity = 1024; }
タスクIDとタスクイベントの定義は最初は空で問題ありません。これらは必要に応じて随時追加していくことになります。
ヒント
タスクIDやタスクイベントの型に指定しているMGL::Task::IdentifierやMGL::Task::EventIdentifierはそれぞれuint32_t
型のエイリアスです。各々のIDはこの型で表現可能な定数であればどのような型でも問題ありませんが、例のように強い型付けが可能なスコープ付き列挙型で定義することを推奨します。
using
でエイリアスとして宣言しているTaskNode
型はMGL側に用意されているデフォルトタスクノードの別名です。このクラスはタスクIDとイベントIDの型を引数に取るテンプレートクラスであるため、ここにゲームごとに異なる両者を指定します。後にタスクノードを実装する際にはこのTaskNode
を継承した派生クラスとして実装していくことになります。
kTaskCapacity
で定義している定数はタスクの最大数です。この定数はタスクシステム初期化時に、同時に存在できるタスクの上限数として指定します。具体的な値は作るゲームに合わせて調整してください。
初期化と実行#
タスクシステムに関する定義の用意ができたら、それを用いてタスクシステムの初期化を行います。
タスクシステムの初期化はMGL自身の初期化の完了後であればどのタイミングでも問題ありませんが、通常はアプリケーションデリゲートの初期化関数内で行います。ここでは、導入方法のアプリケーションデリゲートの作成で作成したアプリケーションデリゲートを例に解説を進めます。
アプリケーションデリゲートapp_main.cc
に対し、先述の定義を用いたタスクシステムの初期化と実行を追記する例を次に示します。
- app_main.cc
#include "app_main.h" #include "task.h" // タスクシステム関連の定義ヘッダ namespace YourApp { /* ------------------------------------------------------------------------- */ /*! * \brief コンストラクタ */ /* ------------------------------------------------------------------------- */ Application::Application() noexcept { } /* ------------------------------------------------------------------------- */ /*! * \brief 初期化処理 * \retval true 成功 * \retval false 失敗 */ /* ------------------------------------------------------------------------- */ bool Application::OnInitialize() noexcept { // タスクシステムの初期化 if (!MGL::Task::Initialize( kTaskCapacity, TaskNode::GetInitializeDescriptor())) { return false; } return true; } /* ------------------------------------------------------------------------- */ /*! * \brief フレーム更新処理 */ /* ------------------------------------------------------------------------- */ void Application::OnFrameUpdate() noexcept { MGL::Render::Renderer2D renderer; // 青色で画面をクリア renderer.Clear(MGL::kColorBlue); // タスクの実行 MGL::Task::Execute(); } /* ------------------------------------------------------------------------- */ /*! * \brief 終了処理 */ /* ------------------------------------------------------------------------- */ void Application::OnExit() noexcept { } } // namespace YourApp
先頭ではまずタスクシステム関連の定義ヘッダをインクルードし、初期化関数内にタスクシステムの初期化処理が、フレーム更新処理内にタスクシステムの実行処理が追記されています。各々の詳細は次の通りです。
- タスクシステムの初期化
/* ------------------------------------------------------------------------- */ /*! * \brief 初期化処理 * \retval true 成功 * \retval false 失敗 */ /* ------------------------------------------------------------------------- */ bool Application::OnInitialize() noexcept { // タスクシステムの初期化 if (!MGL::Task::Initialize( kTaskCapacity, TaskNode::GetInitializeDescriptor())) { return false; } return true; }
ここではタスクシステムの初期化関数であるMGL::Task::Initializeを呼び出しています。第1引数にはタスクの最大数を、第2引数には初期化記述子を指定します。
初期化記述子はタスクノードの動作に関する定義を行うための構造体MGL::Task::InitializeDescriptorです。デフォルトタスクノードを使用する場合、MGL::Task::DefaultTaskNode::GetInitializeDescriptorを呼び出すことで対応した初期化記述子を取得できます。もしタスクノードを自前で用意する場合、この記述子も併せて用意する必要があります。
タスクシステムの初期化に失敗した場合、関数の戻り値に
false
が返ります。ここではアプリケーションの初期化に失敗したものとして扱っています。- タスクシステムの実行
/* ------------------------------------------------------------------------- */ /*! * \brief フレーム更新処理 */ /* ------------------------------------------------------------------------- */ void Application::OnFrameUpdate() noexcept { MGL::Render::Renderer2D renderer; // 青色で画面をクリア renderer.Clear(MGL::kColorBlue); // タスクの実行 MGL::Task::Execute(); }
フレーム更新処理内では画面のクリアを行った後、MGL::Task::Executeを呼び出してタスクシステムを実行しています。
この関数の実行により登録されているタスクノードの実行が行われます。これをフレームごとに行うことにより、登録タスクの更新処理がフレーム単位で実行されるという仕組みです。
これら2つの関数を呼び出すだけでタスクシステムの準備は完了です。あとはタスクノードを実装し、それを登録することでタスクの周期的な実行が開始されます。
タスクノードの実装(1):空のタスク#
先述の通り、タスクノードはTaskNode
クラスを継承した派生クラスとして実装します。
まずは最も簡単な例として、何もしないタスクEmptyTask
を作成します。このタスクは文字通り何もしない、ただ存在するだけのタスクです。
タスクノードを実装する際に最初に行う事は、これから作るタスクに対応したタスクIDの定義です。まず、task.h
にて準備したタスクIDに対応する要素を追加します。
task.h
//! タスクID enum class TaskID : MGL::Task::Identifier { Empty, //!< 空のタスク };
次に、このIDに対応したタスクノードを作成します。ここではempty_task.h
にEmptyTask
という名前で作成します。
empty_task.h
何もしない空のタスクノード#include "task.h" namespace YourApp { //! 空のタスク class EmptyTask : public TaskNode { public: //! タスクIDの定義 static constexpr auto kTaskIdentifier = TaskID::Empty; // コンストラクタ constexpr EmptyTask() noexcept : TaskNode(kTaskIdentifier) // タスクIDはここで設定 { } private: // 初期化処理 void OnInitialize() noexcept override { } // 更新処理 void OnUpdate() noexcept override { } // 描画処理 void OnRender() noexcept override { } // イベント受信処理 void OnEvent(TaskEvent event, void *argument) noexcept override { } }; } // namespace YourApp
タスクIDはまずstatic
かつpublic
なメンバ定数kTaskIdentifier
という名前で定義し、それを使用するようにします。これは必須ではありませんが、そのクラスに紐付いたタスクIDをkTaskIdentifier
という名前で定義することで、一部の機能を利用する際に記述を省略できるようになるためです。
定義したタスクIDはコンストラクタで親クラスであるTaskNode
のコンストラクタ引数に渡してください。こうすることで、このタスクノードとタスクIDの紐付けが行われます。
プライベートな関数として、OnInitialize()
、OnUpdate()
、OnRender()
、OnEvent()
の4つを宣言・定義しています。これらは全て適切なタイミングで呼び出されるコールバック関数です。
OnInitialize()
はタスクノードが生成された直後に一度だけ呼び出される関数です。呼び出されるタイミングはコンストラクタの呼び出しの直後となりますが、もし何らかの理由で初期化処理をコンストラクタに含めたくない場合はこちらを利用してください。この関数は必須ではないため省略可能です。
OnUpdate()
とOnRender()
は、それぞれフレームごとの更新処理と描画処理の際に呼び出されるコールバック関数です。これらの関数はMGL::Task::Executeが呼び出された際、まずは登録されている全てのタスクのOnUpdate()
が呼び出され、その後に全てのタスクのOnRender()
が呼び出されます。この2つの関数の定義は必須となります。
ヒント
1回のフレーム更新処理で用途の異なる2種類の呼び出しを行うことは、実行ステージと呼ばれる仕組みによって実現しています。デフォルトタスクノードはこの2種類のみを扱いますが、タスクノードをカスタムすることでコールバック関数の用途や順序を任意のものに変更できます。詳細はタスクノードのカスタマイズの実行ステージにて後述します。
OnEvent
は外部からのイベント通知処理が行われた際に呼び出される関数です。この用途についてはイベント通知にて後述します。この関数は必須ではなく省略可能です。
各々の継承元関数の仕様は次のAPIリファレンスを参照してください。
タスクノードの実装(2):プレイヤータスク#
タスクノードの実装例としてもう1つ、簡易的なキャラクター移動を行うプレイヤータスクの実装例を示しましょう。このタスクは上下左右に入力した方向へと移動し、その場所にキャラクターに見立てた赤い矩形を表示するタスクです。
まず、対応するタスクID Player
を追加します。
task.h
にタスクIDPlayer
を追加//! タスクID enum class TaskID : MGL::Task::Identifier { Empty, //!< 空のタスク Player, //!< プレイヤー };
次に、タスクノードのクラスを宣言するヘッダファイルを作成します。ここではこのファイルをplayer_task.h
とします。
player_task.h
#include "task.h" namespace YourApp { //! プレイヤータスク class PlayerTask : public TaskNode { public: //! タスクIDの定義 static constexpr auto kTaskIdentifier = TaskID::Player; // コンストラクタ constexpr PlayerTask(const MGL::Vector2 &position, float speed) noexcept : TaskNode(kTaskIdentifier) , _position(position) , _speed(speed) private: // 更新処理 void OnUpdate() noexcept override; // 描画処理 void OnRender() noexcept override; MGL::Vector2 _position; // プレイヤーの座標 float _speed; // 移動速度 }; } // namespace YourApp
今回は初期化処理とイベント通知は使用しないため、OnInitialize()
とOnEvent()
の宣言は省略しています。
プレイヤーを任意の方向に移動させるためには、その座標を保持する変数が必要です。ここではメンバ変数として、プレイヤーの座標を表す_position
と移動速度を表す_speed
を宣言しています。また、これらの変数は外部から初期値を指定できるよう、コンストラクタの引数から受けた値で初期化するようにしています。
ヘッダを準備できたら、今度は実装のためのソースコードを作成します。ここではファイル名をplayer_task.cc
とします。
player_task.cc
#include "player_task.h" namespace YourApp { /* ------------------------------------------------------------------------- */ /*! * \brief 更新処理 */ /* ------------------------------------------------------------------------- */ void PlayerTask::OnUpdate() noexcept { MGL::Input::Keyboard keyboard; // 左右移動 if (keyboard.IsPressing(MGL::Input::Keycode::Left)) { _position.x -= _speed; // 左移動 } else if (keyboard.IsPressing(MGL::Input::Keycode::Right)) { _position.x += _speed; // 右移動 } // 上下移動 if (keyboard.IsPressing(MGL::Input::Keycode::Up)) { _position.y -= _speed; // 上移動 } else if (keyboard.IsPressing(MGL::Input::Keycode::Down)) { _position.y += _speed; // 下移動 } } /* ------------------------------------------------------------------------- */ /*! * \brief 描画処理 */ /* ------------------------------------------------------------------------- */ void PlayerTask::OnRender() noexcept { MGL::Render::Renderer2D renderer; // プレイヤーとして赤い矩形を表示 renderer.DrawRectangle( MGL::Rectangle(_position.x, _position.y, 16.0f, 16.0f), MGL::kColorRed); } } // namespace YourApp
OnUpdate()
ではMGL::Input::Keyboard::IsPressingを用いてキーの入力状態をチェックし、その結果に応じて座標_position
を変更しています。
OnRender()
ではOnUpdate()
で更新した使用し、MGL::Render::Renderer2D::DrawRectangleを用いて赤い矩形をプレイヤーとして表示しています。
これらの具体的な処理内容についてはユーザー入力や2D描画に記載されていますので参考にしてください。
タスクの生成#
タスクノードを実行するにはMGL::Task::Createを用いてタスクの生成し、タスクシステムへと登録する必要があります。通常、起動時にいくつかの初期タスクを生成し、そこから先はタスクが他のタスクを生成したり、不要なタスクを削除したりを繰り返すことによってゲームが展開されていくことになります。
先述した空のタスクとプレイヤータスクをアプリケーション起動時に生成する例を次に示します。
/* ------------------------------------------------------------------------- */
/*!
* \brief 初期化処理
* \retval true 成功
* \retval false 失敗
*/
/* ------------------------------------------------------------------------- */
bool Application::OnInitialize() noexcept
{
// タスクシステムの初期化
if (!MGL::Task::Initialize(
kTaskCapacity,
TaskNode::GetInitializeDescriptor()))
{
return false;
}
// 空のタスクを生成
MGL::Task::Create<EmptyTask>();
// プレイヤータスクを生成
MGL::Task::Create<PlayerTask>(MGL::Vector2(0.0f, 0.0f), 7.0f);
return true;
}
MGL::Task::Createはテンプレート関数であり、テンプレート引数として生成したタスクノードの型を指定します。
関数の引数にはコンストラクタに渡したい値を指定します。プレイヤータスクは第1引数に初期位置を、第2引数に移動速度を指定することで、その内容をそのままコンストラクタへと渡しています。
MGL::Task::Createは戻り値として、生成したタスクノードの弱参照クラスMGL::Task::WeakNodeを返します。この戻り値は生成したタスクが有効である間はそのタスクノードを返し、削除などで無効化された場合はnullptr
を返すクラスです。この弱参照についてはタスクの参照にて後述します。
- より細かいタスク生成の仕様
タスクはタスクシステム初期化後であれば基本的にいつでも生成可能です。それはMGL::Task::Execute実行期間中はもちろん、異なるスレッドからの生成にも対応しています。
ただし、生成直後のタスクはスタンバイ状態となっており、そこからアクティブ状態へと遷移するタイミングはMGL::Task::Executeが各々のタスクの実行を行う直前となります。すなわち、タスクがタスクを生成した場合、そのタスクの更新が開始されるタイミングは次のフレームからとなります。
タスクの削除#
生成済みのタスクを削除する方法は複数あります。
- (1) 自身または特定のタスクの削除
タスクノードの基底クラスにあるMGL::Task::Node::Killを呼び出します。
// Aキーを押したら自身を削除する例 void EmptyTask::OnUpdate() noexcept { MGL::Task::Keyboard keyboard; if (keyboard.IsTriggerd(MGL::Task::Keycode::KeyA)) { Kill(); } }
- (2) 一括削除
MGL::Task::KillにタスクIDを指定することで、特定の種類のタスクを一括で削除できます。この方法は敵弾やエフェクトなど、同じ種類のタスクが多数生成される場合に有効です。
// 敵弾タスク (TaskID::EnemyBullet と仮定)を一括で削除する例 MGL::Task::Kill(TaskID::EnemyBullet);
MGL::Task::Killの引数を省略した場合、登録されている全てのタスクに対して削除要求を行います。こちらはシーンの切り替え時などに有効です。
// タスクの一括削除 MGL::Task::Kill();
一括削除を行う際に、例外的に削除したくないタスクが存在する場合もあります。その際には後述の常駐レベルを設定することにより、一括削除の対象に含めるか否かの制御が可能となっています。
ヒント
削除要求を受けてから実際に削除されるまでの間に演出を挟みたい場合など、タスクによってはすぐに消えて欲しくない場合もあります。その際にはここで挙げた削除方法ではなく、イベント通知を経由した削除要求を検討してください。
タスクシステムの機能#
タスクの参照#
あるタスクが別のタスクを参照するための機能として、タスクノードへの弱参照クラスMGL::Task::WeakNodeが用意されています。このクラスは参照先のタスクが有効であればそのタスクノードを返し、削除済みなどで無効となっている場合はnullptr
を返す機能を備えています。
タスクの生成を行うMGL::Task::Createは、その戻り値として生成したタスクノードの弱参照を返します。
auto weakNode = MGL::Task::Create<EmptyTask>();
この弱参照の参照先が有効か否かを知りたい場合、MGL::Task::WeakNode::IsValidまたはbool
型へのキャストで判別できます。
if (weakNode.IsValid()) // if (weakNode) と書いてもOK
{
// 参照先が有効な場合はここに到達
}
この弱参照から参照先のタスクノードを取得したい場合、MGL::Task::WeakNode::Getに変換先の型を指定して取得します。
auto task = weakNode.Get<EmptyTask>();
if (task != nullptr)
{
// ここに到達していれば task を EmptyTask 型として参照可能
}
MGL::Task::WeakNode::Getは参照先のノードを取得するための関数です。この関数に変換先の型を指定することで、参照先がその型と一致していることをチェックしたうえで参照先のアドレスを返します。もし異なるタスクノードを参照していたり、参照先が既に削除されている場合はnullptr
を返します。
なお、この例ではテンプレート引数の第2引数を省略しています。第2引数には本来は変換先のタスクノードに紐付けられたタスクIDを指定しますが、変換先の型が自身のタスクIDを表すメンバ定数kTaskIdentifier
を保持している場合は省略可能となっています。もしkTaskIdentifier
が定義されていない場合は、次のように直接タスクIDを指定する必要があります。
auto task = weakNode.Get<EmptyTask, MGL::Task::Identifier(TaskID::Empty)>();
if (task != nullptr)
{
// ここに到達していれば task を EmptyTask 型として参照可能
}
このような冗長な記述を避けるため、タスクノードのクラスにはkTaskIdentifier
を定義しておくことを推奨します。
注釈
ここで示したように、MGLのタスクシステムはタスクノードの型とタスクIDが強く紐付けられている事を期待した作りになっています。これは、実行時型情報を持たない環境でもタスクノードのキャストを実現するための制限です。
不正なノードキャストを発生させないために、同じタスクIDを複数のタスクノードの型に紐付けることは避けてください。なお、逆に同じタスクノードの型に異なるタスクIDを割り当てることについては問題はありません。
タスクの検索#
タスクシステムはタスクID単位での検索機能を備えています。この機能を用いることで、生成時に弱参照ノードを保持していなかったタスクノードに対しても参照が可能となります。
タスクの検索を行うにはMGL::Task::Findを使用します。この関数は引数としてタスクIDを渡すことで、現在登録されている該当タスクの配列を返します。
auto emptyTasks = MGL::Task::Find(TaskID::Empty);
タスクの戻り値はMGL::STL::vectorに収められた弱参照ノードMGL::Task::WeakNode型となっています。
// EmptyTaskを検索
auto emptyTasks = MGL::Task::Find(TaskID::Empty);
// 検索結果を表示
MGL_TRACE("検索結果: %zu個発見", emptyTasks.size());
// 見つかった全てのタスクノードにアクセス
for (auto weakNode : emptyTasks)
{
if (auto emptyTask = weakNode.Get<EmptyTask>(); emptyTask != nullptr)
{
// ここで emptyTask にアクセス可能
}
}
イベント通知#
外部からタスクへ情報を伝達するもう1つの手段として、イベント通知の仕組みも用意されています。この機能は種類を跨ぐ複数のタスクに情報を伝える際に有効です。
イベント通知を行うためには、まずはタスクIDと同様にアプリケーション毎のイベントIDの定義を行う必要があります。デフォルトタスクノードを用いる場合は、基本的な使い方のtask.h
にて宣言してあるタスクイベントの列挙型にイベントの種類を表す要素を追加してください。例として、タスクの削除要求を行うイベント通知を追加する場合は次のようになります。
//! タスクイベント
enum class TaskEvent : MGL::Task::EventIdentifier
{
RemoveRequests, //!< 削除要求
};
ヒント
タスクの削除そのものはタスクの削除で解説した方法で行えますが、削除要求を受けてから実際に削除されるまでの間に何らかの処理を行いたい場合などに、このような削除要求イベントは有用です。
このイベントを通知する方法は3種類あります。
- 特定のタスクに対して通知を行う場合
1つのタスクに対してイベント通知を行いたい場合、MGL::Task::Node::NotifyEventまたはMGL::Task::WeakNode::NotifyEventを使用します。
// 弱参照ノードを経由して呼び出した場合、参照先が有効であれば通知を行う weakNode.NotifyEvent(EventID::RemoveRequests); // ノードに対して直接通知も可能 if (auto node = weakNode.Get(); node != nullptr) { node.NotifyEvent(EventID::RemoveRequests); }
弱参照ノードに対してイベント通知を行った場合、その参照先が有効である場合にのみイベント通知を行います。もし参照先が無効だった場合は失敗となり、戻り値に
false
が返ります。- 特定の種類のタスク全体に通知を行う場合
特定の種類のタスク全体にイベント通知を行いたい場合、MGL::Task::NotifyEventの引数にタスクIDを指定して呼び出します。
// アクティブな EmptyTask 全体に RemoveRequests イベントを通知 MGL::Task::NotifyEvent(TaskID::EmptyTask, TaskEvent::RemoveRequests);
この呼び出しによって、指定したタスクIDを持つアクティブなタスク全てにイベント通知が行き渡ります。複数同時に存在するタスクに対して、一括で何らかの通知を行う際に有用です。
- 登録されている全てのタスクに通知を行う場合
現在アクティブな全てのタスクにイベント通知を行いたい場合、MGL::Task::NotifyEventにタスクIDを指定せずに呼び出します。
// 全てのタスクに RemoveRequests イベントを通知 MGL::Task::NotifyEvent(TaskEvent::RemoveRequests);
これらのいずれかの方法でイベント通知を行うことで、デフォルトタスクノードであればMGL::Task::DefaultTaskNode::OnEventが、自前でタスクノードの基底クラスを継承した場合はMGL::Task::Node::OnReceiveTaskEventが呼び出されます。
例として、タスクノードの実装(1):空のタスクで作成した空のタスクノードに先述の削除要求イベントを対応させる例を次に示します。
empty_task.h
#include "task.h" namespace YourApp { //! 空のタスク class EmptyTask : public TaskNode { public: //! タスクIDの定義 static constexpr auto kTaskIdentifier = TaskID::Empty; // コンストラクタ constexpr EmptyTask() noexcept : TaskNode(kTaskIdentifier) // タスクIDはここで設定 { } private: // 初期化処理 void OnInitialize() noexcept override { } // 更新処理 void OnUpdate() noexcept override { } // 描画処理 void OnRender() noexcept override { } // イベント受信処理 void OnEvent(TaskEvent event, void *argument) noexcept override { // イベントIDごとの分岐 switch (event) { // 削除要求イベント case TaskEvent::RemoveRequests: Kill(); // このタスクを削除 break; } } }; } // namespace YourApp
OnEvent()
の引数event
には、通知の際に指定したイベントIDが渡されています。この変数によって分岐を行うことで、外部からの通知に対するアクションを各々のタスク側で処理できるようになります。
イベント通知には引数を渡すことも可能です。例として、シーンの切り替えの際に、その情報を各タスクに通知するイベントを次に示します。
task.h
の各種定義//! タスクイベント enum class TaskEvent : MGL::Task::EventIdentifier { RemoveRequests, //!< 削除要求 SceneChange, //!< シーンの変更 }; //! シーン変更イベントの際に引数として渡す構造体 struct SceneChangeEventArgs { // Note: SceneID は別途定義済みとする SceneID nextScene; //!< 次のシーン SceneID prevScene; //!< 前のシーン };
- イベント通知側の処理
// 引数として渡す構造体を初期化 (変数はあるものと仮定) SceneChangeEventArgs args; args.nextScene = nextScene; args.prevScene = currentScene; // シーン変更イベントの通知 MGL::Task::NotifyEvent(TaskEvent::SceneChange, &args);
- イベント受信側の処理
// イベント受信処理 void OnEvent(TaskEvent event, void *argument) noexcept override { // イベントIDごとの分岐 switch (event) { // 削除要求イベント case TaskEvent::RemoveRequests: Kill(); // このタスクを削除 break; // シーン変更イベント case TaskEvent::SceneChange: { auto *sceneInfo = static_cast<SceneChangeEventArgs *>(argument); // ここにシーン変更時の処理を記述 } break; } }
ヒント
ここではvoid *
型の引数をstatic_cast
で変換していますが、タスクノードの基底クラス側をカスタマイズすることでもう少し安全な扱いが可能です。独自タスクノードの実装例ではその一例を解説しています。
常駐レベル#
タスクの削除で解説した通り、外部からのタスクに対する削除要求は、タスクID単位または全体に対して一括で行えます。一方、タスクの処理内容によっては例外的に対象外にした場合もあります。例えば、ゲーム全体を通してバックグラウンドで動作するタスクや、デバッグ用途のタスクなどです。このような問題を解決するために、各々のタスクには常駐レベルが設定できるようになっています。
タスクの常駐レベルを設定するにはMGL::Task::Node::SetResideLevelを使用します。この関数の引数にはMGL::Task::ResideLevelで表される値を指定し、値が大きいほど削除されにくい事を表しています。
// 常駐レベルを Middle (中) に設定
SetResideLevel(MGL::Task::ResideLevel::Middle);
設定した常駐レベルはMGL::Task::Killが呼び出された際に評価されます。この関数の引数には削除対象となる常駐レベルが指定可能であり、各々のタスクの常駐レベルが引数で指定した値以下の場合に削除を実行します。もし常駐レベルの指定を省略して呼び出した場合、いずれかの常駐レベルが設定されたタスクは全て削除対象外となります。
// 全てのタスクを削除
MGL::Task::Kill(); // 引数を省略した場合、常駐レベルが設定されているタスクは削除しない
// 常駐レベル Middle (中) 以下のタスクを削除
MGL::Task::Kill(MGL::Task::ResideLevel::Middle);
常駐レベルの評価を含めた削除は弱参照ノードに対しても行えます。
// 弱参照ノードが有効、かつ常駐レベルが設定されていない場合に削除
weakNode.Kill();
// 弱参照ノードが有効、かつ常駐レベル Middle (中) 以下だった場合に削除
weakNode.Kill(MGL::Task::ResideLevel::Middle);
一方、自身のタスクを削除するMGL::Task::Node::Killは常駐レベルの評価を行わず、常に削除を実行します。したがって、この関数に常駐レベルを指定する引数はありません。
void EmptyTask::OnUpdate() noexcept
{
MGL::Input::Keyboard keyboard;
// Escキーを押したら削除
if (keyboard.IsTriggerd(MGL::Input::Keycode::Escape))
{
Kill(); // 自身の削除は常駐レベルに関わらず実行される
}
}
この仕様はややこしく思われるかもしれませんが、外部からの削除要求は誤って削除されてしまう事が深刻であるのに対し、内部から自分自身を削除する場合は誤って残り続ける事の方が深刻であるためです。
この仕様の副作用として、外部から直接MGL::Task::Node::Killを呼び出すことで、常駐レベルを無視した削除が行えてしまう点には注意が必要です。次のように外部からタスクノードを直接参照して削除する方法は推奨されません。
// 常駐レベルを無視した削除の例
// この削除方法は推奨されず、weakNode.Kill(); とする方法が好ましい。
if (auto node = weakNode.Get(); node != nullptr)
{
node->Kill();
}
タスクノードのカスタマイズ#
カスタマイズの概要#
これまでの解説では、MGL側に用意されているデフォルトタスクノードを基底クラスとして使用してきました。この方法でも多くのゲームをカバーできるように作られていますが、基底クラスを自前で実装することで、より作成するゲームに合わせた仕様に作り変えることも可能となっています。
MGLのタスクシステムは、MGL::Task::Nodeを継承したクラスであればどのようなタスクも登録可能です。しかし、正しく動作させるためにはあらかじめ実行ステージを定義しておき、タスクノード側もそれに合わせた動作を行う必要があります。したがって、この2つの準備だけが、自前のタスクノードを扱うための必要最低限の作業となります。
実行ステージに関する処理を除けば、他に必須となる実装項目はありません。そこから先の追加機能は利用者に委ねられています。作るゲームに特化した機能を持たせたり、汎用コンポーネントの仕組みを導入してより高機能にしたり等、望むがままにカスタマイズを楽しんでください。
実行ステージ#
タスクノードをカスタマイズするには、まず実行ステージという概念を知っておく必要があります。
実行ステージは1回の実行で呼び出される処理を複数に分割する仕組みです。例えば、デフォルトタスクノードでは1回の更新(MGL::Task::Executeの呼び出し)において、タスクノードの更新処理と描画処理の2つのコールバック関数が呼び出されます。また、先に全てのタスクの更新処理が呼び出され、その後に同じ順序で描画処理が呼び出されます。この仕組みは更新用と描画用の2つの実行ステージを登録することで実現しています。
用途に応じて更新処理を分割し、その呼び出し順序を細かく制御したい状況は複数考えられます。例えば、物理エンジンの更新や3Dモデルの姿勢制御を行う場合などにおいて、その他の更新処理はこれらの前後の2つに分割したくなるでしょう。また、MGLのタスクシステムには並列実行可能な実行ステージも指定できるため、これを活用したパフォーマンスチューニングも可能となっています。このように、MGLのタスクシステムにおける柔軟性の要となっている仕組みがこの実行ステージです。
実行ステージの設定はMGL::Task::StageSettings構造体に格納します。この構造体が持つパラメータは次の通りです。
- MGL::Task::ExecuteStage
stage
実行ステージを識別するための値を0から31の間で設定します。
この値は、タスクノード側の実行コールバック関数MGL::Task::Node::OnExecuteの引数に渡されます。各々のタスクノードはこの値を参照し、それがどの種類の更新処理なのかを判別して処理を分岐させます。
この値は識別のみに使用され、実行順序を表すものではない事に注意してください。実行順序は後述の配列の順序によって確定します。
- MGL::Task::ExecuteMode
mode
このステージの実行に関わるモードの指定です。指定可能な実行モードの詳細は次の通りです。
NormalUpdate
: 通常更新一般的な更新処理を行う実行ステージに指定するモードです。描画以外の処理を行い、かつ後述の並列実行を行わない場合はこのモードを指定してください。
ParallelizableUpdate
: 並列実行可能な更新描画以外の用途向けという点では先述の
NormalUpdate
と同じですが、このモードではタスクの並列実行が可能となります。並列処理用のスレッドプールが生成されていない、または個別のタスクに並列実行の許可を与えていない場合はNormalUpdate
と同じ動作となります。並列実行の詳細は「タスクの並列実行」の節にて後述します。RenderUpdate
: 描画処理のための更新このモードの用途は描画処理に特化しています。このモードが指定された実行ステージでは描画に関する処理のみを行ってください。逆に、他のモードの実行ステージで描画処理を行うことは推奨されません。
この構造体には可変長配列MGL::Task::StageSettingsArrayが定義されており、通常はこちらを用いて設定情報を格納します。各ステージの実行順序は、この配列に格納した順によって決定されます。
これらの具体的な設定例については次の初期化記述子とタスクシステムの初期化にて解説します。
初期化記述子とタスクシステムの初期化#
初期化記述子はタスクシステムの初期化に使用する情報です。MGL::Task::InitializeDescriptorの構造体で表され、先述の実行ステージの設定情報もここに含まれます。
初期化記述子は持つパラメータは次の通りです。
- MGL::Task::StageSettingsArray
stageSettings
: 実行ステージ情報 実行ステージの設定を格納した配列です。このパラメータの内容は実行ステージにて先述しています。
- int32_t
parallelExecuteCount
並列実行を行う場合、同時に実行可能な最大数をここに指定します。タスクシステムはこの値を元に内部にスレッドプールを構築し、それを用いて非同期実行を行います。
並列実行を行わない場合は
1
以下の値を指定してください。その場合はスレッドプールの構築を行わず、実行モードのParallelizableUpdate
はNormalUpdate
と同等に扱われます。
この構造体を初期化する例を次に示します。ここでは例として、並列実行を行わない更新処理と描画処理の2つの実行ステージに設定します。
//! 実行ステージ
enum class Stage
{
Update, //!< 更新処理
Render //!< 描画処理
};
/* ------------------------------------------------------------------------- */
/*!
* \brief 初期化記述子の生成
* \return 生成した初期化記述子
*/
/* ------------------------------------------------------------------------- */
InitializeDescriptor MakeInitializeDescriptor() noexcept
{
using namespace MGL::Task;
InitializeDescriptor descriptor;
// 実行ステージ設定
descriptor.stageSettings =
{
{ExecuteStage(Stage::Update), ExecuteMode::NormalUpdate}, // 更新処理
{ExecuteStage(Stage::Render), ExecuteMode::RenderUpdate}, // 描画処理
};
// 並列実行の最大数の設定
descriptor.parallelExecuteCount = 0; // 0を設定して無効化
return descriptor;
}
実行ステージの実行順序は配列の格納順によって設定されるため、この場合は更新処理→描画処理の順に実行されます。
この例の関数を用いてタスクシステムを初期化する例は次のようになります。
constexpr size_t kTaskCapacity = 1024; //!< 登録可能なタスクの数
// タスクシステムの初期化
MGL::Task::Initialize(
kTaskCapacity, // 登録可能なタスクの数
MakeInitializeDescriptor()); // 初期化記述子
MGL::Task::Initializeの第1引数には登録可能なタスクの数を指定し、第2引数に関数が生成した初期化記述子を指定しています。
独自タスクノードの実装例#
既に何度か述べている通り、タスクノードはMGL::Task::Nodeを継承したクラスであり、タスクシステムは登録されたノードに対して実行ステージ設定に基づいた更新処理の呼び出しを行います。すなわち、実行ステージ設定が用意できれば、残る作業はそれに対応する更新処理の呼び出しを記述するだけです。
ここでは例として、更新処理と描画処理の2つの実行ステージを持ったタスクノードの実装例を紹介します。
タスクIDやイベントIDの定義は各種定義の準備にて準備した定義をそのまま流用します。ただし、using TaskNode = ...
の部分は干渉するため削除してください。
ヒント
独自のタスクノードを用いる場合、ここで紹介する実装例を元に改変して使用することを検討してください。この内容は必要最低限の機能しか実装されておらず、テンプレートとして使用するのに最適です。
ここでは先に実装例の全文を示し、細かい部分を個別に解説することにしましょう。まずはソースコード全体の例です。
/* ------------------------------------------------------------------------- */
/*!
* \file task_node.h
* \brief カスタムタスクノード
* \date Since: March 3, 2024. 2:23:13 JST.
* \author Acerola
*/
/* ------------------------------------------------------------------------- */
#ifndef INCGUARD_TASK_NODE_H_1709400193
#define INCGUARD_TASK_NODE_H_1709400193
#include "task.h"
namespace YourApp
{
//! カスタムタスクノード
class TaskNode : public MGL::Task::Node
{
private:
//! 実行ステージ
enum class Stage : uint8_t
{
Update, //!< 更新処理
Render, //!< 描画処理
};
public:
/* ------------------------------------------------------------------------- */
/*!
* \brief このタスク用の初期化記述子を取得
* \return 初期化記述子
*/
/* ------------------------------------------------------------------------- */
static MGL::Task::InitializeDescriptor GetInitializeDescriptor() noexcept
{
using namespace MGL::Task;
InitializeDescriptor descriptor;
// 実行ステージ設定
descriptor.stageSettings =
{
{ExecuteStage(Stage::Update), ExecuteMode::ParallelizableUpdate},
{ExecuteStage(Stage::Render), ExecuteMode::RenderUpdate},
};
// 並列実行の最大数
descriptor.parallelExecuteCount = 0;
return descriptor;
}
/* ------------------------------------------------------------------------- */
/*!
* \brief コンストラクタ
* \param[in] identifier タスクのID
*/
/* ------------------------------------------------------------------------- */
constexpr TaskNode(TaskID identifier) noexcept
: MGL::Task::Node(MGL::Task::Identifier(identifier))
{
}
/* ------------------------------------------------------------------------- */
/*!
* \brief デストラクタ
*/
/* ------------------------------------------------------------------------- */
virtual ~TaskNode() noexcept = default;
protected:
/* ------------------------------------------------------------------------- */
/*!
* \brief 更新処理
*/
/* ------------------------------------------------------------------------- */
virtual void OnUpdate() noexcept {}
/* ------------------------------------------------------------------------- */
/*!
* \brief 描画処理
*/
/* ------------------------------------------------------------------------- */
virtual void OnRender() noexcept {}
/* ------------------------------------------------------------------------- */
/*!
* \brief イベント受信処理
* \param[in] event 発生したイベントの種類
* \param[in] argument イベントの引数
*/
/* ------------------------------------------------------------------------- */
virtual void OnEvent(TaskEvent event, void *argument) noexcept
{
(void)event;
(void)argument;
}
private:
/* ------------------------------------------------------------------------- */
/*!
* \brief 実行時の処理
* \param[in] stage 実行ステージ
*/
/* ------------------------------------------------------------------------- */
void OnExecute(MGL::Task::ExecuteStage stage) noexcept final
{
switch (Stage{stage})
{
// 更新処理
case Stage::Update:
OnUpdate();
break;
// 描画処理
case Stage::Render:
OnRender();
break;
}
}
/* ------------------------------------------------------------------------- */
/*!
* \brief タスクのイベント受信処理
* \param[in] eventID イベントID
* \param[in] argument 引数
*/
/* ------------------------------------------------------------------------- */
void OnReceiveTaskEvent(
MGL::Task::EventIdentifier eventID,
void *argument) noexcept final
{
OnEvent(TaskEvent{eventID}, argument);
}
};
} // namespace YourApp
#endif // INCGUARD_TASK_NODE_H_1709400193
// vim: et ts=4 sw=4 sts=4
この実装内容を分解し、個別に解説していきましょう。
- 実行ステージと初期化記述子の定義
private: //! 実行ステージ enum class Stage : uint8_t { Update, //!< 更新処理 Render, //!< 描画処理 }; public: /* ------------------------------------------------------------------------- */ /*! * \brief このタスク用の初期化記述子を取得 * \return 初期化記述子 */ /* ------------------------------------------------------------------------- */ static MGL::Task::InitializeDescriptor GetInitializeDescriptor() noexcept { using namespace MGL::Task; InitializeDescriptor descriptor; // 実行ステージ設定 descriptor.stageSettings = { {ExecuteStage(Stage::Update), ExecuteMode::NormalUpdate}, {ExecuteStage(Stage::Render), ExecuteMode::RenderUpdate}, }; // 並列実行の最大数 descriptor.parallelExecuteCount = 0; return descriptor; }
ここでの実装内容は初期化記述子とタスクシステムの初期化での定義内容とほぼ同等です。異なる点は、初期化記述子を生成する関数がタスクノードの
static
メンバ関数として作成されている事のみです。こうすることで、この記述子とタスクノードの関連性を明確にしています。- コンストラクタ
/* ------------------------------------------------------------------------- */ /*! * \brief コンストラクタ * \param[in] identifier タスクのID */ /* ------------------------------------------------------------------------- */ constexpr TaskNode(TaskID identifier) noexcept : MGL::Task::Node(MGL::Task::Identifier(identifier)) { }
コンストラクタではタスクIDを引数で受け、それをそのまま基底クラスであるMGL::Task::Nodeのコンストラクタへと渡しています。ここで重要なのは基底クラスに
MGL::Task::Identifier(identifier)
としてIDを渡している事です。こうすることにより、タスクIDの定義に強い型付けを持つスコープ付き列挙型を扱えるようにしています。- 継承先で実装する仮想関数
protected: /* ------------------------------------------------------------------------- */ /*! * \brief 更新処理 */ /* ------------------------------------------------------------------------- */ virtual void OnUpdate() noexcept {} /* ------------------------------------------------------------------------- */ /*! * \brief 描画処理 */ /* ------------------------------------------------------------------------- */ virtual void OnRender() noexcept {} /* ------------------------------------------------------------------------- */ /*! * \brief イベント受信処理 * \param[in] event 発生したイベントの種類 * \param[in] argument イベントの引数 */ /* ------------------------------------------------------------------------- */ virtual void OnEvent(TaskEvent event, void *argument) noexcept { (void)event; (void)argument; }
protected
で宣言している3つの関数は、継承先で実装するための仮想関数です。これら3つの関数は必ず必要になるとは限らないため、純粋仮想関数ではなくデフォルト実装を用意しています。作るゲームに合わせたカスタマイズを行う場合、イベント受信処理についてはまだ改善の余地があります。詳細は呼び出し側にて解説します。
- タスク実行時のコールバック処理
private: /* ------------------------------------------------------------------------- */ /*! * \brief 実行時の処理 * \param[in] stage 実行ステージ */ /* ------------------------------------------------------------------------- */ void OnExecute(MGL::Task::ExecuteStage stage) noexcept final { switch (Stage{stage}) { // 更新処理 case Stage::Update: OnUpdate(); break; // 描画処理 case Stage::Render: OnRender(); break; } }
タスクシステムの要となる、タスク実行時のコールバック関数が呼ばれた際の処理です。この関数はMGL::Task::Node::OnExecuteで宣言された純粋仮想関数です。
この関数はMGL::Task::Executeを呼び出した際に、登録した実行ステージの数だけ呼び出されます。引数の
stage
には現在の実行ステージが渡されています。これを元に対応した関数を呼び出すことが、この関数の主な役割となります。重要な注意点として、並列実行中はこの関数は異なるスレッドから同時に呼び出される可能性があります。関数内では必ず実行ステージを参照し、並列実行を行うステージではスレッドセーフでない処理を呼び出さないようにしてください。
- イベント受信時のコールバック処理
/* ------------------------------------------------------------------------- */ /*! * \brief タスクのイベント受信処理 * \param[in] eventID イベントID * \param[in] argument 引数 */ /* ------------------------------------------------------------------------- */ void OnReceiveTaskEvent( MGL::Task::EventIdentifier eventID, void *argument) noexcept final { OnEvent(TaskEvent{eventID}, argument); }
イベント受信時にはMGL::Task::Node::OnReceiveTaskEventで宣言された関数が呼び出されます。この関数が行っている事は、MGLが内部で扱うイベント型MGL::Task::EventIdentifierをアプリケーションが扱う
TaskEvent
型に変換してOnEvent
を呼び出しているだけです。先述の通り、作るゲームに合わせたカスタマイズを行う場合、この呼び出しにはまだ改善の余地があります。
TaskEvent
の要素が明確である場合、継承先でその分岐を行うよりも、このクラス内で対応した関数を呼び出したほうがスマートです。例として、イベント通知で解説した2つのイベント通知を処理する場合は次のようになるでしょう。
protected: /* ------------------------------------------------------------------------- */ /*! * \brief 削除イベント受信時の処理 */ /* ------------------------------------------------------------------------- */ virtual void OnKillEvent() noexcept {}; /* ------------------------------------------------------------------------- */ /*! * \brief シーン変更時の処理 */ /* ------------------------------------------------------------------------- */ virtual void OnChangeScene(const SceneChangeEventArgs &sceneInfo) noexcept { (void)sceneInfo; }; private: /* ------------------------------------------------------------------------- */ /*! * \brief タスクのイベント受信処理 * \param[in] eventID イベントID * \param[in] argument 引数 */ /* ------------------------------------------------------------------------- */ void OnReceiveTaskEvent( MGL::Task::EventIdentifier eventID, void *argument) noexcept final { // イベントIDごとの分岐 switch (TaskEvent{event}) { // 削除要求イベント case TaskEvent::RemoveRequests: OnKillEvent(); break; // シーン変更イベント case TaskEvent::SceneChange: if (auto *sceneInfo = static_cast<SceneChangeEventArgs *>(argument); sceneInfo != nullptr) { OnChangeScene(*sceneInfo); } break; } }
この場合、継承先ではイベントIDによる分岐を行う必要が無いだけでなく、扱いを誤りやすい
void *
型の引数も透過的に扱えるようになります。ただし、汎用性はやや劣る事に加え、イベントの種類が増えるにつれて仮想関数が増え、クラスの肥大化の要因にもなる点には注意が必要です。
タスクの並列実行#
注意
現時点ではタスクの並列実行は試験段階の機能にあります。不具合のリスクや仕様変更の可能性が他の機能よりも高い点にご注意ください。
並列実行の概要#
今日のプロセッサの多くは同時に実行可能な複数のコアを搭載しており、パフォーマンスチューニングにおいてもこれを活用した処理の並列化が避けて通れない時代となりました。しかし、既存のプログラムを後から並列化することは困難な場合が多い一方、尚早な最適化は設計上の問題やパフォーマンスの悪化を招くリスクもあります。これらの問題は、ゲーム開発におけるアプリケーションレベルの最適化の大きな課題となっています。
MGLのタスクシステムはこの点を鑑みて、既存のゲームに対して後から並列化しやすいように設計されています。
タスクの並列実行の機能を簡単に述べると、並列実行可能な実行ステージにおいて、その機能を有効化したタスクの更新処理を非同期に行います。すなわち、通常はあるタスクの実行が完了してから次のタスクへ処理を移すところを、並列実行中は処理の完了を待たずに次のタスクへ処理を移します。
並列実行中のタスクは実行ステージの最後に必ず完了を待ち合わせる他、特定のタスクIDを実行する前に完了を待つバリア同期機能の備えています。この機能はタスクの実行期間を明瞭にし、各々のタスクに同期処理を実装せずとも並列実行ステージ中に他のタスクを参照できるようになります。
諸注意#
- 並列実行の必要性について
並列実行は処理を分散させることでパフォーマンスを大きく改善することを目的としていますが、必ずしも効果が得られるとは限りません。処理を分散し安全な同期を行うコストは決して低くはなく、それを上回る効果が得られる場合にのみ改善が見込めます。
もしゲームが十分に高速であり、パフォーマンスに不足を感じていない場合、並列実行の対応は必要ありません。この機能はいざという時の奥の手として捉えてください。
- スレッドローカルストレージの扱いについて
タスクシステムは内部に並列実行用のスレッドプールを構築し、それを利用してタスクを並列に実行します。この割り当ては毎回固定ではなく、空いているスレッドを逐次割り当てて実行します。
タスクがスレッドローカルストレージを使用する場合はこの点にご注意ください。ある時点での更新処理が、別のタイミングでは(例えば次のフレームなどでは)異なるスレッドで動作する可能性があるためです。
なお、並列実行を行わないタスクに関しては、常にMGL::Task::Executeを呼び出したスレッドで更新処理が呼び出されます。
並列実行の準備#
タスクを並列に実行するためには、3つの条件を満たす必要があります。実行ステージが並列実行可能なモードである事、スレッドプールが構築されている事、タスクが並列実行を行う設定になっている事の3点です。タスクの設定に関しては次の各タスクの並列実行設定にて解説するため、ここではその準備段階についてを解説します。
並列実行の準備は、デフォルトタスクノードを使用しているか、独自に用意したタスクノードを扱っているかによって変わります。
デフォルトタスクノードを使用している場合、準備は至って簡単です。初期化記述子を取得するMGL::Task::DefaultTaskNode::GetInitializeDescriptorの引数に2
より大きな値を指定し、それをMGL::Task::Initializeへと渡してください。
初期化と実行で示した例を並列実行に対応させる場合は次のようになります。
/* ------------------------------------------------------------------------- */
/*!
* \brief 初期化処理
* \retval true 成功
* \retval false 失敗
*/
/* ------------------------------------------------------------------------- */
bool Application::OnInitialize() noexcept
{
//! 並列実行可能な最大数
constexpr int32_t kParallelExecuteCount = 4;
// タスクシステムの初期化
if (!MGL::Task::Initialize(
kTaskCapacity,
TaskNode::GetInitializeDescriptor(kParallelExecuteCount))) // ここで指定
{
return false;
}
return true;
}
ここでは4
という値を最大数として指定しています。これにより、タスクの更新処理であるMGL::Task::DefaultTaskNode::OnUpdateは同時に最大4つ実行できるようになります。
独自のタスクノードを並列実行に対応させる場合、MGL::Task::InitializeDescriptorにその設定を記述します。初期化記述子とタスクシステムの初期化で示した例を並列実行に対応させる場合は次のようになります。
//! 実行ステージ
enum class Stage
{
Update, //!< 更新処理
Render //!< 描画処理
};
/* ------------------------------------------------------------------------- */
/*!
* \brief 初期化記述子の生成
* \param[in] parallelExecuteCount 並列実行の最大数
* \return 生成した初期化記述子
*/
/* ------------------------------------------------------------------------- */
InitializeDescriptor MakeInitializeDescriptor(int32_t parallelExecuteCount = 0) noexcept
{
using namespace MGL::Task;
InitializeDescriptor descriptor;
// 実行ステージ設定
descriptor.stageSettings =
{
// 並列実行可能な更新処理
{ExecuteStage(Stage::Update), ExecuteMode::ParallelizableUpdate},
// 描画処理
{ExecuteStage(Stage::Render), ExecuteMode::RenderUpdate},
};
// 並列実行の最大数の設定
descriptor.parallelExecuteCount = parallelExecuteCount;
return descriptor;
}
実行ステージのモード (MGL::Task::ExecuteMode) にParallelizableUpdate
を指定することで、そのステージは並列実行が可能となります。並列実行の最大数はMGL::Task::InitializeDescriptorのパラメータparallelExecuteCount
に設定します。この値は外部から変更できるよう、引数で受けた値を設定しています。
ヒント
並列実行の最大数に1
以下の値を設定した場合、スレッドプールの構築は行われません。この場合、実行モードのParallelizableUpdate
はNormalUpdate
と同等に扱われます。検証目的などで並列実行の有効・無効を切り替えたい場合などは、この最大数で制御すると便利です。
- 並列実行の最大数について
タスクシステムは最大数に基づいた数のスレッドプールの構築を試みますが、生成可能なスレッドの数はシステムによって異なります。また、同時に実行可能なスレッドの数も、そのコンピュータが搭載しているプロセッサによって異なります。したがって、ここで設定した数が必ずしも同時実行可能な数となる保証はない点にご注意ください。
最大値に設定すべき値としての大まかな目安は、搭載しているプロセッサがサポートしているスレッド数と同じ、または少し少なめの数が無難な値です。もちろん、この目安は環境や処理内容によっても大きく変動します。最終的な値の決定は計測と検証を重ねたうえで判断するしかありません。
なお、C++においては
std::thread::hardware_concurrency
によって、現在のハードウェアがサポートしているスレッドの数を得られます。ただし、このAPIには正確なスレッド数を返す保証はない点にご注意ください。
各タスクの並列実行設定#
並列実行可能なステージでスレッドプールが構築されていたとしても、新規に生成されたタスクはデフォルトでは並列に動作しません。これを有効にするためにはMGL::Task::Node::SetAsynchronousを呼び出し、明示的に有効にする必要があります。
タスクの初期化中に並列実行を有効化する例を次に示します。
/* ------------------------------------------------------------------------- */
/*!
* \brief 初期化処理
*/
/* ------------------------------------------------------------------------- */
void OnInitialize() noexcept override
{
// 並列実行を有効化
SetAsynchronous(true);
}
この設定により、このタスクの並列実行ステージ中の更新処理は非同期に行われます。
並列実行ステージの最後には、非同期に動作する全てのタスクの実行が完了するまで待ち合わせを行います。したがって、更新処理にどれだけ時間が掛ったとしても、その完了を待たずに次の実行ステージに移ることはありません。
場合によっては、実行ステージの最後よりも早い段階で完了待ちを行いたい事もあります。例えば、タスクBがタスクAを参照したく、それまでにタスクAの処理が完了していて欲しい場合などです。このような問題を解決するための機能として、タスクIDによるバリア同期機能も備わっています。
バリア同期を有効化するには、MGL::Task::Node::SetAsynchronousの第2引数に完了待ちを行いたいタスクIDを指定するだけです。
/* ------------------------------------------------------------------------- */
/*!
* \brief 初期化処理
*/
/* ------------------------------------------------------------------------- */
void OnInitialize() noexcept override
{
// 並列実行を有効化(バリアID付き)
SetAsynchronous(true, TaskID::TaskB);
}
この例ではTaskID::TaskB
をバリアIDとして指定しています。これにより、もしタスクBの実行が開始されるタイミングで未完了のタスクAが存在している場合、それが完了するまで待ち合わせを行います。
注釈
MGLのタスクシステムの特徴の一つである、実行順序に厳格であるという性質は並列実行中も例外ではありません。並列実行は完了を待たずに次のタスクの実行を開始しますが、開始順序そのものは優先順位を無視することはありません。この性質は既存のタスクを後から並列化する際に、計画の立てやすさに大きく貢献するはずです。