関連ブログ
- [UE4][UE5]開発環境の容量を少しでも減らす 2024.08.14UE
- [UE5] PushModel型のReplicationを使い、ネットワーク最適化を図る 2024.05.29UE
- [UE5]マテリアルでメッシュをスケールする方法 2024.01.17UE
CATEGORY
2017.01.27UE4UE/ C++
今回はちょっとプログラム寄りのお話です。
非同期処理の話になりますが、概念を理解していて実装だけを知りたい人は前半部分は読み飛ばして下さい。
ゲームやインタラクティブコンテンツではリアルタイムで状況が変化していきます。
それらはプレイヤーの入力によって変わる事も多く、そういったものは事前に処理を行う事ができないため、その時々で更新処理を行う必要があります。
ゲームの場合だとモニターの再描画間隔(リフレッシュレート)に合わせて秒間60回もの画面更新を行うため、1回の描画にかけられる時間は 16.6ms 程度です。
この「単位時間辺りの画面更新回数」をフレームレートと呼び、「秒間に何回更新できるか」を fps(frames per second)で表現します。
秒間で60回画面更新を行う場合は 60fps と表現できます。
また、最近流行りのVRでは 90fps が必要です。
つまりは1回の描画にかけられる時間はたった 11ms 程度ということになります。
ではプレイ中に何か重い処理を実行してしまって、画面の描画更新が間に合わなくなったらどうなるでしょうか。
その場合は画面が更新されず、ゲームが一瞬止まったように見えます。
これがいわゆる “処理落ち” と呼ばれるものです。
処理落ちはプレイヤーの体験に影響を与え、没入感を削いでしまいます。
ゲームが面白いというのが一番大事な事ですが、どれだけ面白くても画面が何度もカクついてしまうゲームはプレイヤーも遊びたくなくなります。
各種ハードウェアは別々の処理を同時に実行する仕組みを持っています。
1つの処理の実行単位のことをスレッドと呼びますが、ゲームで画面描画処理を実行するスレッドをメインスレッドと呼びます。
メインスレッドはリフレッシュレートに合わせて60秒に1回ゲームループを実行します。
先程、「画面の描画更新が間に合わなくなったら」と書きましたが、これは「メインスレッドの処理が間に合わなくなったら」に言い換えることができます。
メインスレッドが止まらないようにすることは良質なゲーム体験に繋がります。
ではどうしても重い処理を行いたい場合はどうすればいいのでしょうか。
その場合は処理をメインスレッドでやらなければいいのです。
スレッドはあくまで一つの処理の実行単位であり、ハードウェアが許す限りはメインスレッドとは別のスレッドを利用することが可能です。
このように処理の実行を呼び出すだけで、処理の完了を待たずに次の処理を始めることを 非同期処理 と言います。
ではこれを実際にUE4で行うにはどうすればよいのかをご説明します。
ひとまず重くなりそうな処理を作ってみます。
1 2 3 4 5 6 |
static void ExecSample() { // なんか重い処理 TArray NumberStrings; for (int32 Number = 0; Number & lt; 10000; ++Number) { NumberStrings.AddUnique(FText::AsNumber(Number).ToString()); } // 終了通知 if (GEngine) { GEngine->AddOnScreenDebugMessage(INDEX_NONE, 2.0f, FColor::Red, TEXT("Finish")); } |
特に意味もない処理ですが、これは重そうです。
これを以下の様にして普通にメインスレッドから実行するとどうなるでしょうか。
1 2 3 4 |
void AParallelTestGameModeBase::ExecSampleBlocking() { ExecSample(); } |
結果はコチラ。
こちらは「stat UnitGraph」コマンドで処理負荷を可視化したものですが、重い処理の実行タイミングでグラフに大きな変化が見られます。
今回は重い処理の実行タイミングで 53.7ms もの時間がかかってしまったようです。
60fps を目指すと1フレームに 16.6ms しか使えないので大きくオーバーしています。
もちろんこの処理を実行したタイミングでは画面が急に止まったように見えてしまいます。
これでは問題があるので、この重い処理を別スレッドで実行してみます。
UE4には自前でスレッドを用意しなくても、必要になった時に手軽に別スレッドで処理を実行できる FAsyncTask という仕組みがあります。
FAsyncTask に実行したい処理(タスク)を指定しつつ実行リクエストを呼び出すことで、指定されたタスクが別スレッドで実行されます。
今回は FAsyncTask と同等の機能を持ちつつ、タスクの完了時に自動で削除される FAutoDeleteAsyncTask を使って重い処理を実行してみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class FAsyncExecTask : public FNonAbandonableTask { friend class FAutoDeleteAsyncTask; public: FAsyncExecTask(TFunction<void()> InWork) : Work(InWork) { } void DoWork() { // コンストラクタで指定された関数を実行 Work(); } FORCEINLINE TStatId GetStatId() const { RETURN_QUICK_DECLARE_CYCLE_STAT(FAsyncExecTask, STATGROUP_ThreadPoolAsyncTasks); } private: TFunction<void()> Work; }; |
非同期実行タスクを定義したので、これをメインスレッドから呼び出してみます。
1 2 3 4 5 |
void AParallelTestGameModeBase::ExecSampleParallel() { auto AsyncTask = new FAutoDeleteAsyncTask(ExecSample); AsyncTask - > StartBackgroundTask(); } |
結果はコチラ。
同じ重い処理を実行したにも関わらず、全く処理落ちが発生していません。
このように重くなって処理落ちを発生させてしまう可能性がある処理に関しては、別スレッドで行った方が良いです。
これだけ聞くととても使いやすいように思えますが、複数のスレッドを利用するプログラム(マルチスレッドプログラム)は注意深く利用する必要があります。
例えば複数のスレッドから同時に同じリソース(とあるクラスのメンバ変数など)にアクセスした場合、競合が発生して意図しない動作となってしまう可能性があります。
そのため特に何も考えずに処理を組んでしまうとクラッシュの原因になったりもします。(例えば別スレッドでアクターのTransform情報を書き換えようものなら高頻度でクラッシュします)
このあたりは排他処理を組むことでできることが増えるのですが、その辺りはまた別の機会でご紹介しようと思います。