[UnrealC++] ゲームウィンドウ内にSlate Widgetを埋め込む

はじめに

前回は、一般的なGUIを外部ウィンドウにまとめることで、主に非ゲーム的な用途を想定した、UE4アプリケーションの作成方法について紹介しました。今回は、外部ウィンドウにGUIをまとめるのではなく、ゲームウィンドウ内にGUIを埋め込むことで、一般的なGUIを持つUE4アプリケーションを作成する方法の一つを紹介します。なお、記事内での「一般的なGUIアプリケーション」とは、WPFやQtといったGUIフレームワークによって作成された、例えば図1のようなものを想定しています。
図1 一般的なGUIをもつアプリケーションの例

SWidget直接挿入による埋め込み

ゲームウィンドウ内にGUIを埋め込む方法の一つとして、ゲームウィンドウを構築しているSWidgetに対し、埋め込みたいSWidgetを子ウィジェットとして挿入する方法が考えられます。図2は、PIEでゲームを開始し、現れたゲームウィンドウに対してWidget Reflectorを適用した結果です。SViewportに至るまでのツリー構造の中に、挿入可能なSVerticalBoxなどがいくつか確認できます。
図2 New Editor Window(PIE)に対してWidget Reflectorをかけた結果

ルートであるSWindowやGameViewport(SViewport)はGEngine経由でアクセスすることができるため、挿入可能なSVerticalBoxなどは容易に取得することができ、実際に埋め込みたいSWidgetの直接挿入も実現することができます(できました)。
しかし、この方法には問題があります。ゲーム開始モード(Selected Viewport, New Editor Window (PIE), Standalone, ......)によって、ゲームウィンドウを構築するウィジェット構成が異なるためです。図3はSelected Viewportでゲームを開始した場合の、Widget Reflectorの結果です。
図3 Selected Viewportに対してWidget Reflectorをかけた結果

図2と比べると、SViewportに至るまでの構成がまったく異なります。これを解決するためには、ゲーム開始モードごとのウィジェット構成を考慮する必要があります。それ以外にも、フルスクリーン時にクラッシュしたり、UE4Editor自体にSWidgetを埋め込んでしまうとPIE終了後も残り続けてしまう、などの問題があります。
このような問題が山積みとなっており、埋め込みたいSWidgetを直接挿入するのは、実質的に不可能と考えられます。

UMGによる埋め込み

GUIを埋め込むもう一つの方法は、NativeWidgetHostを使い、UMGによってGUIを構築する方法です。以前、NativeWidgetHostでカラーピッカーの作り方を紹介しましたが、同様の手法を使うことで、コードでしか扱えないあらゆるSlate WidgetをUMGで扱うことができます。
しかし、このようなGUIをUMGで作っても「ゲーム画面にゲーム用のGUIを上から貼り付けているゲーム」のようなものができてしまい、図1のような非ゲーム的なものにはなりません。そこで、ゲーム用のGUIを持つアプリケーションと、一般的なGUIを持つアプリケーションの大きな違いは「ViewportにGUIが重なっているかどうか」だと私は考えることにしました。例えば図1の右部にはViewport(マップを写している部分)がありますが、これは左部にあるリストや上部のメニューの上に重なっていません。
UE4を一般的なGUIを持つアプリケーションのように作るために、アプローチを少し変えます。GUIを「ゲームウィンドウ内に埋め込む」のではなく、「埋め込むための領域を確保した場所に配置する」やり方によって実現する方法を紹介します。

実装

SplitscreenInfoによる埋め込み領域の確保

UE4にはローカルマルチプレイヤーのための機能として、画面分割があります。この機能をうまく使うと、ウィンドウ上の任意の矩形領域にゲーム画面を描画することができます。これを利用して描画部分をずらし、ウィンドウ上に何もない領域=埋め込みのための領域を作成します。今回は、BlueprintFunctionLibraryで作成します。.hを次のようにしました。
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "BPFL_SplitScreen.generated.h"
UCLASS()
class BLOGTEST416_API UBPFL_SplitScreen : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = SplitScreen)
static void SetSplitScreenInfo(const float newSizeX, const float newSizeY, const float originX, const float originY);
};
次に、.cppを次のようにしました。
#include "BPFL_SplitScreen.h"
#include "Engine.h"
void UBPFL_SplitScreen::SetSplitScreenInfo(const float newSizeX, const float newSizeY, const float originX, const float originY)
{
GEngine->GameViewport->SplitscreenInfo[0].PlayerData[0] = FPerPlayerSplitscreenData(newSizeX, newSizeY, originX, originY);
}
.cpp6行目のSplitScreenInfoには、ローカルマルチプレイヤー数に応じて、どのように画面分割を行うかを決めるプリセットが格納されています。SplitscreenInfo[0]は、1人プレイ用のプリセットが格納されており、この中にあるPlayerData[0]は、画面いっぱいにゲーム画面を描画するよう指定されています。これを変更すると、ウィンドウサイズ比に応じて、どの位置に、どの位の大きさでゲーム画面を描画するかを変更することができます。
実際に変更するとどうなるか確認してみます。レベルブループリントで図4のようにします。
図4 SetSplitScreenInfoを使用する

これを実行すると、図5のようになります。
図5 SetSplitScreenInfo実行結果

左側に何も描画されない部分が現れました。ゲーム画面が描画される部分が右に少しずれて、その横幅も狭まりました。図4で指定している0.75や0.25といった値は、図6のような左上原点を(0, 0)として、右下を(1, 1)とする座標系をもとにした値になります。すなわち、NewSizeXを0.75に指定すると、ウィンドウ横幅の4分の3の大きさの横幅をもつゲーム画面の描画領域を、OrizinXに0.25を指定することで、ウィンドウ横幅のちょうど4分の1だけ右にずらすことができます。
図6 スクリーン座標系

これで、GUIを配置するための領域を確保できました。この状態でGUIとなるUserWidgetをUMGで作り、AddToViewportで追加すると、図7のようになります。AddToViewportでは、画面分割とは関係なくウィジェットを配置できるため、配置するために確保した領域上にも表示することができます。
図7 AddToViewportでUserWidgetを配置する

余談ですが、AddToPlayerScreenで追加すると図8のように、画面分割を考慮した配置を行います。
図8 AddToPlayerScreenでUserWidgetを配置する

埋め込み領域にSlate Widgetの配置

埋め込み領域を確保できた時点で、後はUMGなどでGUIを作ることが可能ですが、せっかくなのでSlate Widgetをそれらしく配置してみます。この方法で配置した場合、GUIはOpenLevelなどでリセットされることがないので、場合によっては有用かもしれません。
基本的な手順は、前回と同様に、GameInstanceで初期化する方法とします。Build.csなどの細かな実装部分は省略しますので、詳しくは前回の記事を参照してください。
まず、.hを次のようにしました。
#pragma once
#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "MyGameInstance.generated.h"
UCLASS()
class BLOGTEST416B_API UMyGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
virtual void Init();
UFUNCTION(BlueprintImplementableEvent, Category = "MyGameInstance")
void OnClicked();
};
次に、.cppを次のようにしました。
#include "MyGameInstance.h"
#include "TimerManager.h"
#include "SlateApplication.h"
#include "Engine/GameViewportClient.h"
#include "Widgets/Layout/SConstraintCanvas.h"
#include "Widgets/Layout/SDPIScaler.h"
#define LOCTEXT_NAMESPACE "MyGameInstance"
void UMyGameInstance::Init()
{
Super::Init();
GetTimerManager().SetTimerForNextTick(FTimerDelegate::CreateLambda([this]()
{
// Set Split Screen
GetGameViewportClient()->SplitscreenInfo[0].PlayerData[0] = FPerPlayerSplitscreenData(0.75f, 1.0f, 0.25f, 0.0f);
// Embed Slate
GetGameViewportClient()->AddViewportWidgetContent(
SNew(SConstraintCanvas)
// Add Input Canceler
+ SConstraintCanvas::Slot()
.Anchors(FAnchors(0.0f, 0.0f, 0.25f, 1.0f))
.Offset(FMargin(0.0f, 0.0f, 0.0f, 0.0f))
[
SNew(SButton)
]
// Add Background Image
+ SConstraintCanvas::Slot()
.Anchors(FAnchors(0.0f, 0.0f, 0.25f, 1.0f))
.Offset(FMargin(0.0f, 0.0f, 0.0f, 0.0f))
[
FSlateApplication::Get().MakeImage(
TAttribute<const FSlateBrush*>::Create(TFunction<const FSlateBrush*()>([]() { return &FCoreStyle::Get().GetWidgetStyle<FWindowStyle>("Window").ChildBackgroundBrush; })),
TAttribute<FSlateColor>::Create(TFunction<FSlateColor()>([]() { return FCoreStyle::Get().GetWidgetStyle<FWindowStyle>("Window").BackgroundColor; })),
TAttribute<EVisibility>::Create(TFunction<EVisibility()>([]() { return EVisibility::SelfHitTestInvisible; }))
)
]
// Add Main Content
+ SConstraintCanvas::Slot()
.Anchors(FAnchors(0.0f, 0.0f, 0.25f, 1.0f))
.Offset(FMargin(0.0f, 0.0f, 0.0f, 0.0f))
[
SNew(SDPIScaler)
.DPIScale(1.5f)
[
SNew(SBox)
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(SButton)
.Text(LOCTEXT("CreateButton", "Push Me!"))
.OnClicked_Lambda([this]()
{
OnClicked();
return FReply::Handled();
})
]
]
]
);
}));
}
#undef LOCTEXT_NAMESPACE
コードの解説をします。

17行目:SplitScreenInfoの設定

画面分割を設定しています。これにより、図5のような画面になり、左側にGUIを配置するためのスペースが現れます。

20行目:AddViewportWidgetContent

Slate WidgetをViewport上に配置します。これは図7のように、UserWidgeetをAddToViewportでゲーム画面に配置するのとほぼ同じ操作です。
ここでは、Viewportサイズの比率に応じてウィジェットの配置や大きさを変更できるSConstraintCanvasをルートに使います。スロットの.Anchorsや.Offsetを25, 26行目のような値にすれば、確保した領域に収まるように子ウィジェットを配置できます。

23~29行目:カーソル入力阻害の追加

実は、確保した領域の上でマウスを動かすと、ゲーム内でのプレイヤーカメラが動いてしまいます。これはViewportの位置と大きさが変更されていないために、カーソル入力に反応して起こります。それほど問題になりませんが、せっかくですので、確保した領域上でのカーソル入力を無効化します。詳しくはここで解説していますが、SButtonを配置することで、カーソル入力を無効化できます。

31~41行目:背景の追加

確保した領域は、真っ黒の状態で味気ないので、UE4デフォルトの背景を追加します。デフォルトの背景も、かなり黒色に近いグレー色なのであまり意味が無いのですが、もし自分で背景を用意した場合(例えば、白い背景?)は、ここの実装を変更します。

43~64行目:メインコンテンツの追加

ここでは、メインとなるコンテンツを追加します。デフォルトの状態では何故か表示が小さすぎるので、SDPIScalerを使って、1.5倍に拡大しました。51~62行目は、前回の記事で作ったものを一部流用しています。

これで実装は完了しました。後は、GameInstanceをプロジェクトに設定して実行すると、図9のようになります。今回も導入部だけの紹介でしたが、それらしいものを作ることができたと思います。
図9 実行結果

おわりに

当初は、Slate Widgetをウィンドウ内に直接挿入する方法で実装しようとしましたが、これは上手くいきませんでした。一方で代替手法は、描画部分の位置や大きさをどのように変更するかといった「見た目」の問題に終始していますが、この方法が最も安定していて作り込みやすいという結論に至りました。これではUMGで似たような見た目になるよう作ったものと大差無い気もしますが、「それらしいものを作る」ための一助にはなると考えています(あるいは、ならない……)。


この記事は次のバージョンで作成されました。
Unreal Editor(4.16.2-3514769+++UE4+Release-4.16)
Microsoft Visual Studio Community 2017 Version 14.0.25431.01

コメント