はじめに
ここに UStruct のプロパティがあるじゃろ。
これをこうして…
( ^ω^)
≡⊃⊂≡
こうじゃ。
UStruct のプロパティのカスタマイズをするエディタ拡張やってみた。IPropertyTypeCustomization 継承。 #UE4 #UE4Study pic.twitter.com/PIYRKe1cbE
— ねぎもち (@negimochi) 2017年10月7日
ということで、今回は、UE4 のエディタ拡張に関して書いてみる。
UE4 のエディタ拡張は、かなり独特な構文で、特定の箇所に機能を盛り込むためには、そのルールに即して実装する必要がある。
正直、Unity と比べるとかなり複雑。
そもそも UE4 のエディタ拡張そのものに関して全く知らない、という場合には、
まずは、以下の資料を参考にしてみると良い。
猫でも分かるUMG
https://www.slideshare.net/EpicGamesJapan/umg-80334310
(特に、Slate に関する解説を参照)
0. 前提知識
詳細パネル(Details Panel)
詳細パネル とは、ブループリント等をダブルクリックで開いたときや、ワールドでアクターを選択したときなど、
各プロパティが一覧で表示され編集できるパネルのこと。
この詳細パネルは、クラスごとにエディタ UI をカスタマイズすることができる。
これに関しては以下に詳しく解説されている。
ボタンを激しく連打するとD言語くんが大暴れする UE4 エディタ拡張
http://rarihoma.xvs.jp/2015/12/03/1/
そちらの記事に書かれているように、基本的には
-
IDetailCustomization
を継承して、表示したい内容を記述 - どのクラスに対して適用するのか
PropertyEditorModule
に登録
という手順で、クラス単位で詳細パネルのカスタマイズ が可能となる。
構造体単位のカスタマイズ
上記の方法は、クラス単位で詳細パネルをカスタマイズする方法であるが、
実は、構造体単位でもカスタマイズが可能である。
構造体単位でカスタマイズした場合、以下のような利点が考えられる。
- ブループリントによらず、同じ構造体を利用していれば同じカスタマイズが適用される
- 通常、構造体のプロパティが下段に隠れてしまう(特に構造体の中身が複雑なほど一覧性が悪くなする)のが、カスタマイズによって解消できる
- 構造体であるという内部構造は隠して、プロパティのみの公開ができる
実際のところ、標準の UE4 エディタでは、すでに様々なところでこの機能が利用されている。
例えば、FVector
, FRotator
, FLinearColor
などのブループリント上でもよく使われる構造体。
これらには各構造体ごとに専用のカスタマイズがされている。
また、 UStaticMeshComponent
, USkeletalMeshComponent
などのプロパティとして存在する Collision Presets
も、
元々は FBodyInstance
のプロパティだったものを隠蔽してカスタマイズされたものである。
これらの仕組みを詳しく見たい場合は、以下のフォルダにあるソースコードが手がかりになる。
Engine/Source/Editor/DetailCustomizations/Private
1. エディタモジュールを用意する
エディタ拡張を行うには、まず、エディタ用のモジュールを用意する。
(なお、ここから先の話は、UE4.17.2 での実装例)
UE4 のプログラムはモジュールという単位でソースコードが管理されている。
モジュールは ランタイム / エディタで切り分けができるようになっているため、
予めエディタモジュールを用意しておくことで、Shipping 時にはエディタ拡張したコードをビルド対象から外すことができる。
モジュールに関しては、詳しくは以下の記事が詳しい。
[UE4] モジュールについて
http://historia.co.jp/archives/3097
基本的に上記の記事の通りの手順
- uproject ファイルを編集してエディタモジュールを定義
- C++ プロジェクト名のついた Build.cs/cpp/h をコピーして、同じフォルダ構成の場所に置く
- フォルダ名、ファイル名とその中身のクラス名などをエディタモジュールの名前に置換する
- uproject ファイルを右クリックして Generate Visual Studio project files
となる。
ただし、Target.cs の記述に関しては、UE4.16 以降から書式が変更になった。
[UBT]Target.csとBuild.csをUE4.16へ移行させる
http://unwitherer.blogspot.jp/2017/06/ubttargetcsbuildcsue416.html
仮にプロジェクト名を「SlateTest」、追加したいエディタモジュール名を「SlateTestEditor」としたとき、
フォルダ構成と SlateTestEditor.Target.cs は以下のようになる。
SlateTest/
- SlateTest.uproject
- Source/
- SlateTest.Target.cs
- SlateTestEditor.Target.cs
- SlateTest/ (C++プロジェクト作成時に自動で生成されるランタイムモジュール)
- SlateTest.Build.cs
- SlateTest.cpp
- SlateTest.h
- SlateTestEditor/ (エディタ拡張するために手動で追加するエディタモジュール)
- SlateTestEditor.Build.cs
- SlateTestEditor.cpp
- SlateTestEditor.h
using UnrealBuildTool;
using System.Collections.Generic;
public class SlateTestEditorTarget : TargetRules
{
public SlateTestEditorTarget(TargetInfo Target) : base(Target)
{
Type = TargetType.Editor; // Game -> Editor
ExtraModuleNames.AddRange( new string[] { "SlateTest", "SlateTestEditor" } ); // エディタモジュール名を追加
}
}
上記で一度ビルド、実行してみて、SlateTestEditor がモジュールとして認識されていることを確認しておく。
(Window -> DeveloperTools -> Modules でモジュールを一覧できる)
2. エディタモジュールの Build.cs にモジュール依存関係を追記
カスタマイズ定義を書く前準備として SlateTestEditor.Build.cs
に使用するモジュールを設定しておく。
今回の場合は、Slate
, SlateCore
, PropertyEditor
を追記しておく。
using UnrealBuildTool;
public class SlateTestEditor : ModuleRules
{
public SlateTestEditor(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "SlateTest" });
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore", "PropertyEditor" });
}
}
3. IPropertyTypeCustomization 継承クラスを作成
下準備ができたところで、IPropertyTypeCustomization
の継承クラスを定義する。
その中で、具体的にどのような見た目にするのか、カスタマイズしたい内容を記述することになる。
ここでは例として、以下のような構造体がランタイム側で定義されていたとする。
#pragma once
#include "CoreMinimal.h"
#include "TestStruct.generated.h"
UENUM(BlueprintType)
enum class ETestSelectType : uint8
{
None,
Cube,
Cone,
Sphere,
Cylinder,
Custom
};
USTRUCT(BlueprintType)
struct FTestStruct
{
GENERATED_USTRUCT_BODY()
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
ETestSelectType Type = ETestSelectType::None;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
UStaticMesh* SelectMesh = nullptr;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
FVector DefaultScale = FVector::OneVector;
public:
// Enum とMeshアセットパスの紐づけ
static FString GetMeshPath(ETestSelectType Type)
{
FString Path;
switch (Type) {
case ETestSelectType::Cube:
Path = TEXT("/Engine/BasicShapes/Cube.Cube");
break;
case ETestSelectType::Cylinder:
Path = TEXT("/Engine/BasicShapes/Cylinder.Cylinder");
break;
case ETestSelectType::Sphere:
Path = TEXT("/Engine/BasicShapes/Sphere.Sphere");
break;
case ETestSelectType::Cone:
Path = TEXT("/Engine/BasicShapes/Cone.Cone");
break;
default:
;
}
return Path;
}
};
この構造体のプロパティカスタマイズをし、セットされた Enum に応じて Mesh が自動で選択されるようにしたい。
IPropertyTypeCustomization
の継承クラス FTestStructCustomization
を作成する。
IDetailCustomization
を継承する場合とほぼ似たようなイメージの実装になる。
#pragma once
#include "CoreMinimal.h"
#include "IPropertyTypeCustomization.h"
#include "PropertyHandle.h"
#include "Widgets/Input/SComboBox.h"
#include "TestStruct.h"
class FTestStructCustomization : public IPropertyTypeCustomization
{
public:
FTestStructCustomization();
static TSharedRef<IPropertyTypeCustomization> MakeInstance()
{
return MakeShareable(new FTestStructCustomization);
}
/** IPropertyTypeCustomization interface */
virtual void CustomizeHeader(TSharedRef<IPropertyHandle> StructPropertyHandle, class FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& StructCustomizationUtils) override;
virtual void CustomizeChildren(TSharedRef<class IPropertyHandle> StructPropertyHandle, class IDetailChildrenBuilder& StructBuilder, IPropertyTypeCustomizationUtils& StructCustomizationUtils) override;
private:
using TestSelectTypePtr = TSharedPtr<ETestSelectType>;
void OnChangedMesh();
void OnSelectionChanged(TestSelectTypePtr Type, ESelectInfo::Type SelectionType);
TSharedRef<SWidget> OnGenerateWidget(TestSelectTypePtr Type);
FText GetSelectedTypeText() const;
FText GetTypeText(TestSelectTypePtr Type) const;
uint8 GetTypeIndex() const;
bool IsValidMesh() const;
TSharedPtr<IPropertyHandle> TypeHandle;
TSharedPtr<IPropertyHandle> SelectMeshHandle;
TSharedPtr<IPropertyHandle> DefaultScaleHandle;
TArray< TestSelectTypePtr > OptionTypes;
TSharedPtr< SComboBox< TestSelectTypePtr > > TypeComboBox;
};
#include "TestStructCustomization.h"
#include "SlateTestEditor.h"
#include "PropertyEditing.h"
#define LOCTEXT_NAMESPACE "TestStruct"
FTestStructCustomization::FTestStructCustomization()
{
// Enum の文字列リストアップ
auto* EnumPtr = FindObject<UEnum>(ANY_PACKAGE, TEXT("ETestSelectType"));
FString MaxEnumName = EnumPtr->GenerateEnumPrefix() + TEXT("_MAX");
for (int32 i = 0; i < EnumPtr->NumEnums(); i++) {
auto target = EnumPtr->GetNameStringByIndex(i);
if (target == MaxEnumName) continue;
OptionTypes.Add(MakeShareable(new ETestSelectType(static_cast<ETestSelectType>(i))));
}
}
void FTestStructCustomization::CustomizeHeader(TSharedRef<IPropertyHandle> StructPropertyHandle, class FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& StructCustomizationUtils)
{
// プロパティハンドル取得
TypeHandle = StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FTestStruct, Type));
SelectMeshHandle = StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FTestStruct, SelectMesh));
DefaultScaleHandle = StructPropertyHandle->GetChildHandle(GET_MEMBER_NAME_CHECKED(FTestStruct, DefaultScale));
// ハンドルに直接コールバックを指定する場合
if (SelectMeshHandle.IsValid()) {
SelectMeshHandle->SetOnPropertyValueChanged(
FSimpleDelegate::CreateSP(this, &FTestStructCustomization::OnChangedMesh)
);
}
}
void FTestStructCustomization::CustomizeChildren(TSharedRef<class IPropertyHandle> StructPropertyHandle, class IDetailChildrenBuilder& StructBuilder, IPropertyTypeCustomizationUtils& StructCustomizationUtils)
{
// Slate の ComboBox を使用して、Enum の名前でコンボボックスをつくる
StructBuilder.AddCustomRow(LOCTEXT("TypeRow","TypeRow"))
.NameContent()
[
SNew(STextBlock)
.Text(LOCTEXT("TypeTitle", "Select Type"))
]
.ValueContent()
[
SAssignNew(TypeComboBox, SComboBox<TestSelectTypePtr>)
.OptionsSource(&OptionTypes)
.InitiallySelectedItem(OptionTypes[GetTypeIndex()])
.OnSelectionChanged(this, &FTestStructCustomization::OnSelectionChanged)
.OnGenerateWidget(this, &FTestStructCustomization::OnGenerateWidget)
[
SNew(STextBlock)
.Text(this, &FTestStructCustomization::GetSelectedTypeText)
]
];
// プロパティを表示するだけの場合
// UE4.17 からは AddChildProperty は非推奨らしい
StructBuilder.AddProperty(SelectMeshHandle.ToSharedRef());
// DefaultScale を表示する
// EditCondition も設定する場合はこんな感じ
StructBuilder.AddProperty(DefaultScaleHandle.ToSharedRef()).EditCondition(
TAttribute<bool>(this, &FTestStructCustomization::IsValidMesh), nullptr);
}
void FTestStructCustomization::OnChangedMesh()
{
// 現在の設定と比較して違ってたら、 Custom に変更
FString Path = FTestStruct::GetMeshPath(static_cast<ETestSelectType>(GetTypeIndex()));
UObject* Mesh = nullptr;
if (SelectMeshHandle.IsValid()) {
SelectMeshHandle->GetValue(Mesh);
}
if (Mesh && Mesh->GetPathName().Compare(Path) != 0) {
if (TypeHandle.IsValid()) {
TypeHandle->SetValue(static_cast<uint8>(ETestSelectType::Custom));
}
if (TypeComboBox.IsValid()) {
TypeComboBox->SetSelectedItem(OptionTypes[GetTypeIndex()]);
// TypeComboBox->RefreshOptions();
}
}
}
void FTestStructCustomization::OnSelectionChanged(TestSelectTypePtr Type, ESelectInfo::Type SelectionType)
{
// 設定に合わせて Mesh の参照をセット
if (TypeHandle.IsValid()) {
TypeHandle->SetValue(static_cast<uint8>(*Type));
}
if (SelectMeshHandle.IsValid()) {
if(*Type == ETestSelectType::None)
{
UStaticMesh* Mesh = nullptr;
SelectMeshHandle->SetValue(Mesh);
return;
}
FString Path = FTestStruct::GetMeshPath(*Type);
if (!Path.IsEmpty()) {
auto* Mesh = FindObject<UStaticMesh>(ANY_PACKAGE, *Path);
SelectMeshHandle->SetValue(Mesh);
}
}
}
TSharedRef<SWidget> FTestStructCustomization::OnGenerateWidget(TestSelectTypePtr Type)
{
return SNew(STextBlock).Text(GetTypeText(Type));
}
FText FTestStructCustomization::GetSelectedTypeText() const
{
TestSelectTypePtr SelectedType = TypeComboBox->GetSelectedItem();
return (SelectedType.IsValid()) ? GetTypeText(SelectedType) : FText::GetEmpty();
}
FText FTestStructCustomization::GetTypeText(TestSelectTypePtr Type) const
{
// Enum から FText に変換
auto* EnumPtr = FindObject<UEnum>(ANY_PACKAGE, TEXT("ETestSelectType"));
return (EnumPtr != nullptr)
? FText::FromString(EnumPtr->GetNameStringByIndex(static_cast<int32>(*Type))) : FText::GetEmpty();
}
uint8 FTestStructCustomization::GetTypeIndex() const
{
uint8 Index = 0;
if (TypeHandle.IsValid()) {
TypeHandle->GetValue(Index);
}
return Index;
}
bool FTestStructCustomization::IsValidMesh() const
{
// SelectMesh にメッシュがセットされているかチェック
UObject* Mesh = nullptr;
if (SelectMeshHandle.IsValid()) {
SelectMeshHandle->GetValue(Mesh);
}
return (Cast<UStaticMesh>(Mesh) != nullptr);
}
#undef LOCTEXT_NAMESPACE
4. PropertyEditorModule に登録
IDetailCustomization
の場合と同様に、IPropertyTypeCustomization
の継承クラスは、
事前に FPropertyEditorModule
に登録する必要がある。
#pragma once
#include "CoreMinimal.h"
#include "UnrealEd.h"
class FSlateTestEditor : public IModuleInterface
{
public:
void StartupModule() override;
void ShutdownModule() override;
};
#include "SlateTestEditor.h"
#include "Modules/ModuleManager.h"
#include "Editor/PropertyEditor/Public/PropertyEditorModule.h"
#include "TestStruct.h"
#include "TestStructCustomization.h"
void FSlateTestEditor::StartupModule()
{
// PropertyEditorModule 取得
FPropertyEditorModule& PropertyEditorModule = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");
// FTestStruct に FTestStructCustomization を登録する
PropertyEditorModule.RegisterCustomPropertyTypeLayout(
("TestStruct"),
FOnGetPropertyTypeCustomizationInstance::CreateStatic(&FTestStructCustomization::MakeInstance)
);
// 変更の通知
PropertyEditorModule.NotifyCustomizationModuleChanged();
}
void FSlateTestEditor::ShutdownModule()
{
// PropertyEditorModule 取得
FPropertyEditorModule& PropertyEditorModule = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");
// FTestStruct の登録を解除
PropertyEditorModule.UnregisterCustomPropertyTypeLayout(("TestStruct"));
}
IMPLEMENT_MODULE(FSlateTestEditor, SlateTestEditor);
以上!
ビルドできたら、試しに、適当なブループリントや DataAsset 等を新規作成し、FTestStruct
をプロパティに追加すると確認できる。