UE Tokyo .dev #3 (2024.06)
ほしいもん
合同会社パレットイブ 奥井 健
X、Qiita:@hoshiimonn
はじめに
今回お話するのは
アクションゲームによくある
- 「ミニマップのアイコン」や
- 「頭上のHPバー」
など常に動き続ける2Dをいかに効率的に描画するかというお話です。
エンジニア向け、C++が出てきます。
例
ミニマップのアイコンや記号
頭上のアレ
弾幕シューティングの弾
ヒットダメージ
こういうやつです。
結論
Widgetを個々に作らずSlateの関数を直接使おう!
どういうことかと言うと、専用のWidgetを1つだけ作ってそのOnPaint関数内で
Slateの関数を呼び出して矩形を描画するという方法です。
こんな感じで複数のアイコンを描画できる1つのWidgetを作成します
使う関数はこれです
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++クラスを作成します。
#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;
};
#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に配置するだけです。
やったね!表示できました。
引数のBrushを設定すれば好きなテクスチャやマテリアルに変えられます。
もうちょっと使いやすくする
カスタム関数を作る
- 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を指定して無効化しちゃいましょう。
フォントアトラス爆裂問題
冒頭で紹介したこういうダメージ表現、ヒット感を出すために色々工夫をするところだと思います。
ここでもMakeTextが活躍するのですが、フォントをいろんなサイズで描画するとちょっと困ったことがおきます。
フォントアトラスがいろんなサイズの文字であふれてある日突然強烈なヒッチがおきます。
floatでサイズを指定できてしまうため
・1.0のサイズのフォントで描いたAという文字
・1.00001のサイズのフォントで描いたAという文字
・1.00002のサイズのフォントで描いたAという文字
・・・
とちょっとした誤差レベルの違いでも新規に文字画像が作成されてしまいます。
こうならないようにサイズのパターンをあらかじめ決めておくといいですね。
さらなる最適化 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の場合
MakeCustomVertsの場合
たしかにDrawCallが減ってRHIスレッドが軽くなってる気がする。
ん?ちょっとまってMakeBoxでも++LayerIdしなければDrawCallまとめられるんじゃね?
まとめられた!!
え、MakeBoxでええやん・・・。
まとめ
- Widgetを個々に作らずSlateの関数を直接使おう!
- LayerIdはなるべく変えない
特にUI関係は何も考えずに作るとすぐに処理負荷が数msecになって後でびっくり!となりがちです。今回の動くUIの作り方が参考になれば嬉しいです。
これ以外にもインゲームのUIでは気をつけてほしいことが色々あるのですが、
まずは
- WBPのイベントノードは使用禁止
- NativeTickも使用禁止
- UCLASS(meta = (DisableNativeTick)) を必ずつける
これくらいやっておけば後はなんとかなる気がします。
以上。