[UMG+Slate]プリミティブを描画するウィジェットを作成する

この記事はUE4 Advent Calender 2017の24日目の記事です。
今回紹介する内容を応用すると、このような変なウィジェットを作ることができます。
このウィジェットの具体的な実装方針については記事内で触れません。代わりに、完成したプロジェクトをダウンロードできるようにしたので、そちらを参照いただければ幸いです。※実装が悪く、パフォーマンスが非常に悪いので、実装方法の参考程度に留めておいてください。

はじめに

記事内で表記する用語を統一します。グラフィックスプログラミングにおける「プリミティブ」とは、頂点を元に描画される線やポリゴン等の図形を指しますが、この記事の中ではDrawBoxノードやDrawTextノード、MakeBox関数やMakeSpline関数(ノード、関数の詳細は後ほど)によって描画されるものに対して呼称するものとします。
また、本記事で用いるFSlateDrawElementクラスは、UE4のバージョンによる変更が多いクラスです。記事では、執筆時点で最新のUE4.18を用いることとします。

プリミティブを描画する

まず、図1のように、プリミティブを描画するウィジェットを作成します。コンテンツブラウザ上で右クリックして、コンテキストメニューの[UserInterface][WidgetBlueprint]を順にクリックします。新しく作られたCustomUserWidgetの名前を、ここでは「UI_PaintTest」とします。
図1 プリミティブを描画するウィジェットの作成

次に、UI_PaintTestを開き、デザイナータブからグラフタブへ移動します。Functionsの横にある「Override」をクリックするとオーバライド可能な関数の一覧が表示されるので、OnPaintを選びます。
図2 OnPaint関数をオーバーライドする

OnPaint関数内では、引数となるContextを受け取ってDraw~関数を呼び出すことで、プリミティブを描画することができます。今回は矩形を描画するためのDrawBoxを紹介します。図3のように実装しました。Brush引数にはSlateBrushAssetを設定する必要があるので、一旦コンテンツブラウザに戻ります。
図3 OnPaint関数を実装する

図4のように、コンテンツブラウザ上で右クリックして、コンテキストメニューの[UserInterface][SlateBrush]を順にクリックします。その後、新しく作られたSlateBrushAssetの名前を、ここでは「SBA_PaintBox」とします。
図4 SlateBrushAssetを作成する

次に、SBA_PaintBoxを設定します。図5のようにImageの部分にS_Actorテクスチャを入れ、その他は変更しません。
図5 SlateBrushAssetを設定する

SBA_PaintBoxを作成したら、OnPaintの実装に戻り、DrawBoxのBrush引数にSBA_PaintBoxを設定します。設定したらUI_PaintTestは完成です。
レベルブループリントを図6のように実装し、ゲーム中にウィジェットが表示されるようにします。
図6 レベルブループリントの実装

ここまで出来たら、ゲームを開始してみます。図7のようになりました。
図7 ゲーム画面

DrawBoxノードによって、S_Actorが描画されました。DrawBoxノードは
は、指定された矩形内にSlateBrushAssetの内容を描画するノードで、Imageウィジェットが内部で行っている描画処理とほぼ同じことができるノードです。
Imageウィジェットとほぼ同じことをしているのなら、Imageウィジェット使えばいいと思われるかもしれません(実際、DrawBoxノードに関してはImageウィジェットで代替するのがベストだと思います)。ただ、実装によっては、ウィジェットでは実現が難しいことを簡単に実現できるものもあります。
プリミティブ描画ノードはDrawBoxノードを除いて3種類あるので、簡単に紹介します。

DrawLineノード

PositionAからPositionBまで、指定した色で線を引くノードです。AntiAliasにチェックを入れると、線にアンチエイリアスがかかるようになります。このノードは、ウィジェットによる実現が困難な、線を引くという処理を簡単に実現することができます。
図8 DrawLineノード(右下:OnPaint関数の実装)

DrawLinesノード

DrawLineノードの複数点に対応したノードです。
図9 DrawLinesノード(右下:OnPaint関数の実装)

DrawTextノード

テキストを指定したフォントによって描画するノードです。これに関しては、TextBlockウィジェットを使ったほうが良さそうです。
図10 DrawTextノード(右下:OnPaint関数の実装)

プリミティブ描画関数を使って描画する

Blueprintで扱えるプリミティブ描画ノードは4つしかありません。しかもその内の2つは、普通にウィジェットを使った方が良く、実質的には線を描画する用途でしか使えません。
しかし実は、FSlateDrawElementクラスにあるMake~静的関数を使うことで、より多くのプリミティブ描画を扱えるようになります。まずは、FSlateDrawElement::MakeCustomVerts関数を例に、任意の頂点による図形を描画してみます。
まず、.Build.csにSlateモジュールを追加します。次のようにしました。
using UnrealBuildTool;
public class AC17 : ModuleRules
{
public AC17(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "Slate", "SlateCore", "UMG" });
PrivateDependencyModuleNames.AddRange(new string[] { });
}
}
view raw AC17.Build.cs hosted with ❤ by GitHub
次に図11のように、コンテンツブラウザ上で右クリックしてBlueprintFunctionLibraryのC++クラスを追加します。ここでは名前を「BPFL_OnPaintUtil」としました。
図11 BPFL_OnPaintUtilクラスの作成

作成したBPFL_OnPaintUtil.hとBPFL_OnPaintUtil.cppを次のように実装します。
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "UserWidget.h"
#include "Rendering/RenderingCommon.h"
#include "BPFL_OnPaintUtil.generated.h"
USTRUCT(BlueprintType)
struct AC17_API FSlateVertexBP
{
GENERATED_BODY()
UPROPERTY(BlueprintReadWrite)
FVector4 TexCoords;
UPROPERTY(BlueprintReadWrite)
FVector2D MaterialTexCoords;
UPROPERTY(BlueprintReadWrite)
FVector2D Position;
UPROPERTY(BlueprintReadWrite)
FColor Color;
UPROPERTY(BlueprintReadWrite)
int32 PixelWidth;
UPROPERTY(BlueprintReadWrite)
int32 PixelHeight;
FSlateVertex Make() const
{
FSlateVertex result;
result.TexCoords[0] = TexCoords.X;
result.TexCoords[1] = TexCoords.Y;
result.TexCoords[2] = TexCoords.Z;
result.TexCoords[3] = TexCoords.W;
result.MaterialTexCoords = MaterialTexCoords;
result.Position = Position;
result.Color = Color;
result.PixelSize[0] = PixelWidth;
result.PixelSize[1] = PixelHeight;
return result;
}
};
UCLASS()
class AC17_API UBPFL_OnPaintUtil : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "Painting")
static void DrawCustomVerts(UPARAM(ref) FPaintContext& Context, class USlateBrushAsset* Brush, const TArray<FSlateVertexBP>& InVerts, const TArray<int32>& InIndexes);
};
#include "BPFL_OnPaintUtil.h"
#include "Rendering/DrawElements.h"
#include "Framework/Application/SlateApplication.h"
#include "Slate/SlateBrushAsset.h"
void UBPFL_OnPaintUtil::DrawCustomVerts(FPaintContext& Context, USlateBrushAsset* Brush, const TArray<FSlateVertexBP>& InVerts, const TArray<int32>& InIndexes)
{
Context.MaxLayer++;
if (Brush != nullptr)
{
TArray<FSlateVertex> inVertsNative;
inVertsNative.Reserve(InVerts.Num());
for (const auto& v : InVerts)
{
inVertsNative.Add(v.Make());
}
TArray<SlateIndex> inIndexesNative;
inIndexesNative.Reserve(InIndexes.Num());
for (const auto& i : InIndexes)
{
inIndexesNative.Add((SlateIndex)i);
}
FSlateDrawElement::MakeCustomVerts(
Context.OutDrawElements,
Context.MaxLayer,
FSlateApplication::Get().GetRenderer()->GetResourceHandle(Brush->Brush),
inVertsNative,
inIndexesNative,
nullptr,
0,
0
);
}
}
実装を順に説明します。

BPFL_OnPaintUtil.hの9~41行目

ここでは頂点情報をBlueprint上で扱うための構造体FSlateVertexBPを定義しています。FSlateDrawElement::MakeCustomVerts関数は、FSlateVertexという構造体で描画に必要な頂点情報をやり取りします。しかし、この型はBlueprintに公開されていません。Blueprintで扱うためには、FSlateVertexに変換可能な構造体を用意する必要があので、ここでFSlateVertexに変換可能なFSlateVertexBP構造体を定義しています。27~40行目のMake関数は、FSlateVertexBPをFSlateVertexに変換するための関数です。

BPFL_OnPaintUtil.hの43~52行目

ここではFSlateDrawElement::MakeCustomVerts関数を、BlueprintFunctionLibraryとしてBlueprintで扱えるようにするための関数を定義しています。引数はそれぞれ、次のようになります。
FPaintContext& Context:OnPaint関数で受け取れる、描画情報を含む構造体です。この構造体にはカリング矩形やレイヤー番号といったものの他に、座標系の変換行列なども含みます。
USlateBrushAsset* Brush:DrawBoxノードで指定できたBrush引数と同様のものです。
TArray<FSlateVertexBP>& InVerts:頂点情報です。
 TArray<int32>& InIndexes:頂点インデックスです。頂点インデックスの詳細な説明は省きますが、簡単にいうと頂点同士をどのような順で繋げるか、というデータです。

BPFL_OnPaintUtil.cppの11~16行目

ここから、DrawCustomVerts関数の実装に入ります。基本な実装は、DrawBoxノードの実装を参考にしています。11~16行目では、FSlateVertextBPをFSlateVertextに変換しています。余談ですが、DrawCustomVerts関数は毎フレーム呼び出されるOnPaint関数上で使用されるため、頂点情報に変更がなくとも毎フレーム頂点情報の変換を行ってしまいます。もしパフォーマンスを考慮する場合は、キャッシュデータを持つ別クラスを用意するなどの工夫が必要となります。

BPFL_OnPaintUtil.cppの18~23行目

頂点インデックスとして与えたint32型のリストを、SlateIndex(uint32)型のリストに変換しています。

BPFL_OnPaintUtil.cppの25~34行目

FSlateDrawElement::MakeCustomVertsに必要な引数を与えて、描画を行っています。

Blueprintで扱うために必要な関数は定義できました。次はBlueprintで実際に関数を使用します。OnPaint関数を、図12のように実装しました。
図12 DrawCustomVertsを用いたOnPaint関数の実装


単純な三角形を描画するような頂点情報を指定しました。実際に実行してみると、図13のようになります。
図13 DrawCustomVertsによる描画結果

今回はFSlateDrawElement::MakeCustomVertsを例に実装してみましたが、Make~静的関数は他にもあります。ここでは、簡単に扱えそうなMake~静的関数をいくつか紹介します。

MakeDrawSpaceGradientSpline 関数

Draw-Space座標系におけるグラデーションつきスプラインを描画する関数です。似たような関数に、MakeDrawSpaceSpline(グラデーション機能なし)、MakeSpline(Local-Space座標系、単色)、MakeLines(直線)、MakeGradient(縦方向か横方向のみのグラデーション)などがあります。
図14 MakeDrawSpaceGradientSpline(右下:OnPaint関数の実装)

図15 MakeGradient(右下:OnPaint関数の実装)

MakeDebugQuad関数

デバック用途のワイヤーフレームQuadを描画します。
図16 MakeDebugQuad(右下:OnPaint関数の実装)

プリミティブ描画ウィジェットを扱いやすくする

OnPaint関数をオーバーライドすれば、様々なプリミティブを描画できるところまでわかりました。しかし、現在の実装はスクリーンに対してプリミティブを描画させる命令を「ウィジェットを介して」行っているだけで、「ビジュアルとしてのプリミティブを持っているウィジェット」とは言い難い状態です。これでは、ウィジェット本来の再利用性(カスタムユーザーウィジェット内に別のカスタムユーザーウィジェットをネストするなど)を活かせていません。
そこで、ここではプリミティブ描画を行うウィジェットを、より扱いやすくするため実装の改良を行います。ただし、Make~関数によっては、問題が起きないものもあります(MakeDebugQuad関数など)。ここでは、DrawCustomVerts関数の場合について説明するものとします。

デザイナー上に表示する

OnPaint関数に実装されたプリミティブ描画の処理が実行されるのは、OnPaint関数が呼び出された時で、OnPaint関数が呼び出されないデザイナー上では描画結果を表示することはできません。しかし、実はPreConstructを使うことで、OnPaint関数を無理やり呼び出すことができます。
まず、デザイナー画面を開き、デフォルトでCanvasPanelとなっているところをOverlayに変更します。
図17 CanvasPanelをOverlayに変更する

次に、PreConstructを図18のように実装します。実装内容は、自分自身であるウィジェットを作成し、Ovelayの子ウィジェットに追加しているだけです。これにより、子として追加した自分自身のウィジェットが、デザイナー上に表示する時にOnPaint関数が呼ばれることでプリミティブが描画されるようになります。なお、CreateWidgetした時点でPreConstructが再帰的に呼び出されてスタックオーバーフローを起こすような実装に見えるかもしれませんが、これはIsDesignTimeで分岐させることで回避できます。また、ランタイムではIsDesignTimeがFalseになるため、Branch以降がランタイムに実行されることもありません。
図18 PreConstructの実装

これをデザイナー上で表示すると、図19のようになります。三角形が表示されていることが確認できます。しかし、ウィジェットの位置からずれるという問題が発生しています。
図19 デザイナー上での表示例

ローカル座標系に描画する

プリミティブの描画位置がずれてしまうのは、座標系が考慮されていないために起こっています。これを解決するためには、頂点情報をローカル座標系における頂点情報に変換する必要があります。BPFL_OnPaintUtil.h, BPFL_OnPaintUtil.cppに次の関数を追加しました。
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Painting")
static FVector2D TransformPointFromPaintContext(UPARAM(ref) FPaintContext& Context, const FVector2D& Point);
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Painting")
static FVector2D GetLocalSizeFromPaintContext(UPARAM(ref) FPaintContext& Context);
FVector2D UBPFL_OnPaintUtil::TransformPointFromPaintContext(FPaintContext& Context, const FVector2D& Point)
{
return Context.AllottedGeometry.ToPaintGeometry().GetAccumulatedRenderTransform().TransformPoint(Point);
}
FVector2D UBPFL_OnPaintUtil::GetLocalSizeFromPaintContext(FPaintContext& Context)
{
return Context.AllottedGeometry.GetLocalSize();
}
TransformPointFromPaintContext関数は、FPaintContextを用いて座標をローカル座標系に変換します。GetLocalSizeFromPaintContext関数は、FPaintContextを用いてウィジェットのローカルサイズを取得します。
この関数を用いて行う操作のイメージを図20に示します。TransformPointFromPaintContext関数が行うのは、斜めに伸びている黄色い矢印の部分です(オレンジとわかりにくくてすみません)。また、話の単純化のために、図20で示されているx, yはそれぞれGetLocalSizeFromPaintContext関数によって得られるGetLocalSize.xとGetLocalSize.yとします。これにより、三角形はウィジェットのローカルサイズと一致するように表示されます。
図20 変換のイメージ

図20に示されている、黄色い矢印の先の座標は、図21のように変換します。
図21 座標のローカル座標系への変換

図21のように変換された座標を用いて、OnPaint関数を図22のように実装します。
図22 OnPaint関数の実装

このようにした後、デザイナーで確認してみると、図23のようになり、ウィジェットサイズと三角形の位置が一致し、ビューの移動や拡大縮小を行っても、正しく描画できていることが確認できます。
図23 デザイナー上での表示

また、図24のように、別のカスタムユーザーウィジェット上に、作成したウィジェットを配置することもできます。これなら、プリミティブ描画を行うウィジェットを汎用的に扱うこともできそうです。
図24 別のカスタムユーザーウィジェットに配置する

おわりに

長々と紹介してきましたが、いかがでしたでしょうか。プリミティブ描画が必要になるシチュエーションはそれほど多くは無さそうですが、この方法を使えば、かなり複雑なUIの制作に活用できるかもしれません。

最終日である明日のUE4 Advent Calenderの担当は、とげとげPさんです! コミケのUE4本も楽しみにしています!


この記事は次のバージョンで作成されました。
Unreal Editor(4.18.2-3794801+++UE4+Release-4.18)
Microsoft Visual Studio Community 2015 Version 14.0.25425.01 Update3

コメント

コメントを投稿