C++
UE4

[UE4 エディタ拡張] 詳細パネルで UStruct のプロパティカスタマイズ

More than 1 year has passed since last update.

はじめに

ここに UStruct のプロパティがあるじゃろ。

BeforeStruct.png

これをこうして…
( ^ω^)
≡⊃⊂≡

こうじゃ。

ということで、今回は、UE4 のエディタ拡張に関して書いてみる。

UE4 のエディタ拡張は、かなり独特な構文で、特定の箇所に機能を盛り込むためには、そのルールに即して実装する必要がある。
正直、Unity と比べるとかなり複雑。

そもそも UE4 のエディタ拡張そのものに関して全く知らない、という場合には、
まずは、以下の資料を参考にしてみると良い。

猫でも分かるUMG
https://www.slideshare.net/EpicGamesJapan/umg-80334310
(特に、Slate に関する解説を参照)

0. 前提知識

詳細パネル(Details Panel)

詳細パネル とは、ブループリント等をダブルクリックで開いたときや、ワールドでアクターを選択したときなど、
各プロパティが一覧で表示され編集できるパネルのこと。

DetailsPanel.png

この詳細パネルは、クラスごとにエディタ UI をカスタマイズすることができる。
これに関しては以下に詳しく解説されている。

ボタンを激しく連打するとD言語くんが大暴れする UE4 エディタ拡張
http://rarihoma.xvs.jp/2015/12/03/1/

そちらの記事に書かれているように、基本的には

  1. IDetailCustomization を継承して、表示したい内容を記述
  2. どのクラスに対して適用するのか 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

基本的に上記の記事の通りの手順

  1. uproject ファイルを編集してエディタモジュールを定義
  2. C++ プロジェクト名のついた Build.cs/cpp/h をコピーして、同じフォルダ構成の場所に置く
  3. フォルダ名、ファイル名とその中身のクラス名などをエディタモジュールの名前に置換する
  4. 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
Source/SlateTestEditor.Target.cs
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 でモジュールを一覧できる)

image.png

2. エディタモジュールの Build.cs にモジュール依存関係を追記

カスタマイズ定義を書く前準備として SlateTestEditor.Build.cs に使用するモジュールを設定しておく。
今回の場合は、Slate, SlateCore, PropertyEditor を追記しておく。

Source/SlateTestEditor/SlateTestEditor.Build.cs
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 の継承クラスを定義する。
その中で、具体的にどのような見た目にするのか、カスタマイズしたい内容を記述することになる。

ここでは例として、以下のような構造体がランタイム側で定義されていたとする。

Source/SlateTest/TestStruct.h
#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 を継承する場合とほぼ似たようなイメージの実装になる。

Source/SlateTestEditor/TestStructCustomization.h
#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;
};
Source/SlateTestEditor/TestStructCustomization.cpp
#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 に登録する必要がある。

Source/SlateTestEditor/SlateTestEditor.h
#pragma once

#include "CoreMinimal.h"
#include "UnrealEd.h"


class FSlateTestEditor : public IModuleInterface
{
public:

    void StartupModule() override;
    void ShutdownModule() override;
};
Source/SlateTestEditor/SlateTestEditor.cpp
#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 をプロパティに追加すると確認できる。