LoginSignup
10
6

UnrealEngine UI 「ミニマップのアイコン」や「頭上のHPバー」など動くUIの作り方

Last updated at Posted at 2024-06-14
1 / 5

UE Tokyo .dev #3 (2024.06)
image.png
ほしいもん
合同会社パレットイブ 奥井 健
X、Qiita:@hoshiimonn


はじめに

今回お話するのは
アクションゲームによくある

  • 「ミニマップのアイコン」や
  • 「頭上のHPバー」

など常に動き続ける2Dをいかに効率的に描画するかというお話です。

エンジニア向け、C++が出てきます。


ミニマップのアイコンや記号
image.png
頭上のアレ
image.png
弾幕シューティングの弾
image.png
ヒットダメージ
image.png

こういうやつです。


結論

Widgetを個々に作らずSlateの関数を直接使おう!

どういうことかと言うと、専用のWidgetを1つだけ作ってそのOnPaint関数内で
Slateの関数を呼び出して矩形を描画するという方法です。

こんな感じで複数のアイコンを描画できる1つのWidgetを作成します
image.png

使う関数はこれです

static void MakeBox( 
		FSlateWindowElementList& ElementList,
		uint32 InLayer,
		const FPaintGeometry& PaintGeometry,
		const FSlateBrush* InBrush,
		ESlateDrawEffect InDrawEffects = ESlateDrawEffect::None,
		const FLinearColor& InTint = FLinearColor::White );

WidgetでおなじみのImageが内部で使用している関数です。
この関数を呼び出すだけで矩形が表示できるんです。簡単でしょう?

for文で100回呼べば100個表示できます。シンプルでいいですね。

メリット

  • 負荷が軽い
  • CreateWidgetで事前にインスタンスを作成しておく必要がない
  • Textを描画する関数やLineを描画する関数もある

デメリット

  • デザイナーが気軽に調整できない
  • ソースコードを読んでもどんな絵になるか想像しにくい

手順解説

UUserWidgetを継承したC++クラスを作成します。

cpp MiniMapIcon.h
#pragma once

#include "CoreMinimal.h"
#include <Blueprint/UserWidget.h>
#include "MiniMapIcon.generated.h"

UCLASS()
class UMiniMapIcon : public UUserWidget
{
	GENERATED_BODY()

	virtual int32 NativePaint(
	    const FPaintArgs& Args,
	    const FGeometry& AllottedGeometry,
	    const FSlateRect& MyCullingRect,
	    FSlateWindowElementList& OutDrawElements,
	    int32 LayerId,
	    const FWidgetStyle& InWidgetStyle,
	    bool bParentEnabled) const override;

private:
	UPROPERTY(EditAnywhere)
	FSlateBrush Brush;
};
cpp MiniMapIcon.cpp
#include "MiniMapIcon.h"

int32 UMiniMapIcon::NativePaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId,
                                const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const
{
	// 矩形描画
	LayerId++;
	FVector2D Position	= AllottedGeometry.GetLocalSize() * 0.5f;
	FVector2D Size		= Brush.GetImageSize();
	FSlateDrawElement::MakeBox(OutDrawElements, LayerId, AllottedGeometry.ToPaintGeometry(Position, Size), &Brush);

	return FMath::Max(LayerId, LayerId);
}

後はこれをWidgetに配置するだけです。
やったね!表示できました。
image.png

引数のBrushを設定すれば好きなテクスチャやマテリアルに変えられます。
image.png

もうちょっと使いやすくする

カスタム関数を作る

  • LayerIdのインクリメント忘れそう
  • スケールを指定したい
  • なんか右下にずれる、アライメント指定したい

こういう関数を作っておくと便利です

void DrawBox(FPaintContext& Context, const FSlateBrush& Brush, const FVector2D& Position, const FVector2D& Scale, const FVector2D& Alignment) const
{
	if (Brush.DrawAs == ESlateBrushDrawType::NoDrawType) { return; }

	const FVector2D& imageSize		= Brush.GetImageSize() * Scale;
	const FVector2D& imagePosition	= Position - imageSize * Alignment;

	// 描画エリア外だったら表示しない
	FSlateRect drawArea(-imageSize, Context.AllottedGeometry.GetLocalSize());
	if (drawArea.ContainsPoint(imagePosition) == false) { return; }

	Context.MaxLayer++;
	FSlateDrawElement::MakeBox(
	    Context.OutDrawElements,
	    Context.MaxLayer,
	    Context.AllottedGeometry.ToPaintGeometry(imageSize, FSlateLayoutTransform(imagePosition)),
	    &Brush,
	    GetIsEnabled() && Context.bParentEnabled ? ESlateDrawEffect::NoPixelSnapping : ESlateDrawEffect::DisabledEffect,
	    Context.WidgetStyle.GetColorAndOpacityTint() * Brush.GetTint(Context.WidgetStyle));
}

回転は?

MakeRotatedBox関数を使えば回転もできます。

マテリアルパラメータ変更したいんだけど

float1つだけでよければVertexColorのAlphaを使うテクニックが一番お手軽です。
historiaさんのブログ参考
[UE4] UMGで手軽にマテリアルのパラメーターをアニメーションさせる裏技

float1つじゃあ足りないよって場合はDynamicMaterialを作る
通常のWidgetと違ってインスタンスを持たないため毎回DynamicMaterialを作成しないといけませんが、
流石に無駄なのでキャッシュして次回以降はキャッシュから使われるようにしましょう

こういう関数を作っておくと便利です

UMaterialInstanceDynamic* GetDynamicMaterial(FSlateBrush& Brush, const UObject* Owner)
{
	// ActorなどユニークなObjectを渡してもらって判別するのに使う
	if (UMaterialInstanceDynamic** material = CachedMaterials.Find(Owner))
	{
		Brush.SetResourceObject(*material);
		return *material;
	}

	UMaterialInterface* material;
	if (UMaterialInstanceDynamic* dynamicMaterial = Cast<UMaterialInstanceDynamic>(Brush.GetResourceObject()))
	{
		material = dynamicMaterial->Parent;
	}
	else
	{
		material = Cast<UMaterialInterface>(Brush.GetResourceObject());
	}

	if (material == nullptr) { return nullptr; }

	UMaterialInstanceDynamic* dynamicMaterial = UMaterialInstanceDynamic::Create(material, this);
	Brush.SetResourceObject(dynamicMaterial);
	return CachedMaterials.Emplace(Owner, dynamicMaterial);
}
mutable TMap<const UObject*, UMaterialInstanceDynamic*>	CachedMaterials;

こうしておけば同じBrashを使いまわしても大丈夫です。

テキストは?

MakeTextでFontを指定して文字列の描画ができます。

テキストをアライメントして表示するためにサイズが知りたい

MakeText関数を使えばテキストも簡単に描画できます。
ただ、使うとなるとテキストのサイズが知りたくなります。

こういう関数を作っておくと便利です。

const FVector2D& GetTextSize(const FString& Text, const FSlateFontInfo& Font) const
{
	// 計算コスト高そうなのでキャッシュする
	if (const FVector2D* size = CachedTextSize.Find(Text)) { return *size; }

	float outlineSize			= Font.OutlineSettings.OutlineSize;
	const FVector2D& textSize	= FSlateApplication::Get().GetRenderer()->GetFontMeasureService()->Measure(Text, Font) + FVector2D(outlineSize * 2, outlineSize);
	return CachedTextSize.Emplace(Text, textSize);
}
mutable TMap<FString, FVector2D> CachedTextSize;

注意点

ピクセルスナップについて

UEはデフォルトでピクセルスナップが有効になっています。
通常のUIならば問題ないのですが常に動かすような2Dの場合ガクガクした動きになってしまい逆効果です。

MakeBoxの引数にNoPixelSnappingを指定して無効化しちゃいましょう。

ピクセルスナップ有
無題の動画.gif

ピクセルスナップ無
無題の動画-1.gif

フォントアトラス爆裂問題

冒頭で紹介したこういうダメージ表現、ヒット感を出すために色々工夫をするところだと思います。
image.png

ここでもMakeTextが活躍するのですが、フォントをいろんなサイズで描画するとちょっと困ったことがおきます。
フォントアトラスがいろんなサイズの文字であふれてある日突然強烈なヒッチがおきます。

floatでサイズを指定できてしまうため
・1.0のサイズのフォントで描いたAという文字
・1.00001のサイズのフォントで描いたAという文字
・1.00002のサイズのフォントで描いたAという文字
・・・

とちょっとした誤差レベルの違いでも新規に文字画像が作成されてしまいます。
こうならないようにサイズのパターンをあらかじめ決めておくといいですね。
image.png

さらなる最適化 MakeCustomVerts でインスタンシング描画

MakeCustomVertsを使えば1回のDrawCallで大量に描画できるらしい。
ということで上記のやり方とどちらが軽いか比較してみました。

以下のサンプルプロジェクトが参考になりました。
https://github.com/dantreble/MeshWidgetExample

検証用に適当に作ったコードを貼っておきます

static const FName SlateRHIModuleName("SlateRHIRenderer");

void UMiniMapIcon::SynchronizeProperties()
{
	Super::SynchronizeProperties();

	VertexData.Empty();
	if (MeshAsset)
	{
		for (const FSlateMeshVertex& data : MeshAsset->GetVertexData())
		{
			FSlateVertex& NewVert = VertexData[VertexData.AddUninitialized()];

			NewVert.Position[0] = data.Position.X;
			NewVert.Position[1] = data.Position.Y;
			NewVert.Color		= data.Color;
			NewVert.TexCoords[0] = data.UV0.X;
			NewVert.TexCoords[1] = data.UV0.Y;
			NewVert.TexCoords[2] = data.UV1.X;
			NewVert.TexCoords[3] = data.UV1.Y;
			NewVert.MaterialTexCoords[0] = data.UV2.X;
			NewVert.MaterialTexCoords[1] = data.UV2.Y;
		}

		ResourceHandle		= FSlateApplication::Get().GetRenderer()->GetResourceHandle(MeshBrush);
		PerInstanceBuffer	= FModuleManager::Get().GetModuleChecked<ISlateRHIRendererModule>(SlateRHIModuleName).CreateInstanceBuffer(1000 * sizeof(FVector4f));
	}
}

int32 UMiniMapIcon::NativePaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId,
                                const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const
{
	FVector2D Position	= AllottedGeometry.LocalToAbsolute(AllottedGeometry.GetLocalSize() * 0.5f);
	FVector2D Size		= Brush.GetImageSize();

	if (MeshAsset && PerInstanceBuffer)
	{
		FSlateInstanceBufferData PerInstaceUpdate;
		for (int32 i = 0; i < 1000; ++i)
		{
			FVector2D Pos = Position;
			Pos.X += FMath::RandRange(-300, 300);
			Pos.Y += FMath::RandRange(-300, 300);

			FSlateVectorArtInstanceData Data;
			Data.SetPosition(Pos);
			Data.SetScale(1.0f);
			PerInstaceUpdate.Add(TArray<UE::Math::TVector4<float>>::ElementType(Data.GetData()));
		}

		PerInstanceBuffer->Update(PerInstaceUpdate);

		LayerId++;
		FSlateDrawElement::MakeCustomVerts(
		    OutDrawElements,
		    LayerId,
		    ResourceHandle,
		    VertexData,
		    MeshAsset->GetIndexData(),
		    PerInstanceBuffer.Get(),
		    0,
		    1000);
	}

	return FMath::Max(LayerId, LayerId);
}
	UPROPERTY(EditAnywhere)
	FSlateBrush MeshBrush;

	UPROPERTY(EditAnywhere)
	USlateVectorArtData* MeshAsset = nullptr;

	TSharedPtr<ISlateUpdatableInstanceBuffer> PerInstanceBuffer;
	TArray<FSlateVertex> VertexData;
	FSlateResourceHandle ResourceHandle;

MakeBoxの場合

MakeBoxで1000個描画
image.png

image.png

image.png

MakeCustomVertsの場合

image.png

image.png

たしかにDrawCallが減ってRHIスレッドが軽くなってる気がする。

ん?ちょっとまってMakeBoxでも++LayerIdしなければDrawCallまとめられるんじゃね?

image.png

image.png

まとめられた!!

え、MakeBoxでええやん・・・。

まとめ

  • Widgetを個々に作らずSlateの関数を直接使おう!
  • LayerIdはなるべく変えない

特にUI関係は何も考えずに作るとすぐに処理負荷が数msecになって後でびっくり!となりがちです。今回の動くUIの作り方が参考になれば嬉しいです。

これ以外にもインゲームのUIでは気をつけてほしいことが色々あるのですが、
まずは

  • WBPのイベントノードは使用禁止
  • NativeTickも使用禁止
  • UCLASS(meta = (DisableNativeTick)) を必ずつける

これくらいやっておけば後はなんとかなる気がします。

以上。

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6