📝

【UE5/C++】MoviePlayerで固まらないロード画面を作る

に公開

前提

UEのバージョンは5.6.0

以下を参考にさせていただきました

https://suuta-blog.hatenablog.com/entry/2020/06/25/220707
https://historia.co.jp/archives/24846/

概要

ロード画面に、アニメーションするマテリアルを適用したUImageを置いてみたり、
UMGで作成したアニメーションをループ再生してみたりすると、
イイ感じのロード画面を作ることができました。最高にクールですね。

しかし、これらアニメーションの更新処理は通常、ゲームスレッドで実行されており、
重い処理や同期ロードなどでゲームスレッドが停止すると、簡単にカクカクになってしまいます。
試しにOpenLevelの直前にロード画面を表示してみました。

L_HeavyMapはBeginPlayで大量のアクターをスポーンするようにします。
大量にアセットを配置して同期ロードによるブロッキングを再現しても良いですが、
PIE上での確認ではキャッシュが効いたり、そもそも重たいアセットを用意する手間もあり、
今回は検証のために適当な重たい処理でブロッキングすることとします。

完全なブロッキングにより、せっかくのアニメーションが一切動かなくなってしまいました。
これはいただけませんね。

重い処理をフレームごとに分散させたり、
同期ロードを非同期ロードに置き換えたりすることで対応することもできそうですが、
MoviePlayerという機能を利用することでも簡単に対応できるらしいので、試してみます。

固まらないロード画面の実装

とりあえず動かしてみる

とりあえず、Build.csファイルにMoviePlayerモジュールへの依存を記載します。

PrivateDependencyModuleNames.AddRange(new string[] { "MoviePlayer" });

次に、MoviePlayerのセットアップを行います。
とりあえず重要なのは、LoadingWidgetというUUserWidgetなオブジェクトから、
UUserWidget::TakeWidget()でSlateという、Widgetの中身みたいなものを取り出し、
それをMoviePlayerにLoadingScreenのWidgetですよ、と渡してあげることです。
他にも色々パラメータがありますが、今回は

  • マップのロード処理が始まったら、勝手にロード画面を表示する
  • ロード処理が完了したら、勝手にロード画面を閉じる

という設定にしています。

if (IGameMoviePlayer* MoviePlayer = GetMoviePlayer())
{
	FLoadingScreenAttributes Attributes;
	Attributes.WidgetLoadingScreen = LoadingWidget->TakeWidget();
	Attributes.bWaitForManualStop = false;
	Attributes.bAutoCompleteWhenLoadingCompletes = true;
	MoviePlayer->SetupLoadingScreen(Attributes);
}

OpenLevelの前に、このセットアップ処理を呼び出しましょう。
今回はGameInstanceに上記処理を実装し、BPから呼び出す形にしてみました。

準備は整いました。長く険しい道のりでしたね。
それでは、満を持してOpenLevelしてみましょう。

無事にアニメーションしています。
実にクールな出来栄え...と言いたいところですが、レイアウトがちょっとおかしいですね。
さらに、UMGで作成したNOW LOADING...の明滅アニメーションも動いていないようです。

とりあえずそれっぽいことは出来ました。
ここからもう少し細かく内容を見ていきます。

どうしてレイアウトが変わった?

本来であれば画面解像度に応じて各要素が適切にリサイズされるはずですが、
それが行われておらず、元のサイズのまま表示されていることが原因のようです。

AIに聞いてみたところ、SDPIScalerというSWidgetでラップしてやると良いとのこと。
(Slate機能を利用するには、SlateSlateCoreのモジュール依存を追加する必要ありです)

const TSharedPtr<SWidget> ScaledWidget =
	SNew(SDPIScaler)
	.DPIScale(UWidgetLayoutLibrary::GetViewportScale(LoadingWidget))
	[
		LoadingWidget->TakeWidget()
	];
Attributes.WidgetLoadingScreen = ScaledWidget;

しかし所詮はAIの戯言、どうせ上手くいかないでしょう。

上手くいった。ごめんな、ありがとうAI。

どうやら、普段ウィジェットをAddToViewportして表示している際も、
エンジン内部で自動的にこのSDPIScalerというレイヤーが追加されているようです。
ウィジェットリフレクタで確認できました。いつもこんなことしてくれてたんやな、ありがとう。

どうしてNOW LOADING...は明滅しない?

先に書いておきますが、解決できていないです。
解決策をお持ちの方がいましたら教えてくださいね(懇願)

UMGで作成したアニメーションはUUserWidget::NativeTick()内で更新されます。
(正確にはUUMGSequenceTickManager::TickWidgetAnimations()あたり?)
つまり、同期ロード中はゲームスレッドで起動するTickが動作しないため、明滅しないのでは?
FLoadingScreenAttributesのメンバーを見直してみると、bAllowEngineTickなるものが。
完全にこれですね。

……。

ダメでした。

bAllowEngineTickは、FDefaultGameMoviePlayer::WaitWorMovieToFinish()の中で
GEngine->Tick()を呼ぶか、呼ばないかの分岐で利用されているフラグ値です。
FDefaultGameMoviePlayerは、GetMoviePlayer()でMoviePlayerが利用可能な場合に返されるオブジェクト)
そしてこのWaitForMovieToFinish()ですが、FEngineLoop::Tick()で呼ばれています。
FEngineLoop::Tick()はすべてのTick処理の母とも呼ぶべきメソッドなのでしょう。(たぶん)

ゲームスレッドが健全に動いている間は、FEngineLoop::Tick()によりWaitForMovieToFinish()が毎フレーム呼ばれます。
同期ロードなどによりゲームスレッドがブロッキングされた場合、当然ですがこの処理も停止します。
そしてこのWaitForMovieToFinish()ですが、内部にはwhileループが記述されており、
ローディング画面の表示中はこのループを延々と回るようになっています。
そのループ内でGEngine->Tick()を呼ぶか?が、bAllowEngineTickの正体なのでした。
つまり、ローディング画面の終了待機中にしかエンジンのTickは呼ばれません。
(そもそもGEngine->Tick()が呼ばれたところでアニメーションが動くのかは...?)

前述の通り未解決のままです...。
ループアニメーションにはUMGのアニメーションは利用せず、
フェードなどしたい場合はフェード処理後に同期ロード、ロード完了後にフェードイン
というような流れとするのが良いかなというのが現時点での落としどころになります。

おわりに

今回のような単純なケースでは簡単に導入できるうえ、ある程度期待通りの動作をしてくれました。
Slate特有の躓きポイントや、私のようにUEの並列処理まわりの知識が浅いとブラックボックスな領域が大きく、
上手くいかない部分の調査が難しいことなどが所感でした。

そんなに新しい機能というわけではないですが、
深掘りしていくとエンジンの知識も蓄えられそうなので、時間を見つけてチマチマ検証&追記していきたい所存です。

Discussion