ぼっちプログラマのメモ

Unreal Engineについて書いたりしてます

【UE5】StaticMeshアセットをD&Dした際、プロジェクト固有のStaticMeshActor・Componentが使われるようにする方法

はじめに

プロジェクトが進んでいくと、UE標準のComponentを拡張したくなることがしばしばあります。しかし、エンジン改造による拡張は色々面倒・大変なので、標準Componentの派生クラスをプロジェクト側で用意して拡張することが比較的多いと思います。

ここで問題になるのが、「UE標準ではなくプロジェクト側のComponentを使用することをどう徹底するか?」です。ここが曖昧になって、UE標準版と拡張版が混在していると様々なトラブルの元になります。そして、ヒューマンはミスをする生き物なので「UE標準版を使わせない」ための仕組みが重要になってきます。

この仕組みを実現する際、以下のような選択肢がまず挙がってきますが、

  1. EditorUtility機能で、監視 & 拡張版への置き換え処理を用意する
  2. DataValidation機能で、UE標準版を使用した際にエラー・警告を出す
    1. Unreal Engine でのデータ検証 | Unreal Engine 5.6 ドキュメンテーション | Epic Developer Community
    2. UE4.23から入った「Editor Validator Subsystem」を使って、アセット保存時などで走るチェック処理(Validate Assets)を拡張しよう! - ぼっちプログラマのメモ

今回はもう少し踏み込んで、「対象Assetをレベル上にD&Dした際に、自動的に拡張版ComponentActorが使われる」という対応をエンジン改造なしで実現してみました!

具体的には、StaticMeshアセットをD&Dすると、拡張版StaticMeshComponentを持つStaticMeshActorがレベルに配置されるようにしています。こうすることで、例えば背景担当のアーティストさんは特殊な操作をしなくても拡張版StaticMeshComponentに追加された便利機能を活用できます!(↑の例では、ライトマップ解像度の調整用パラメータを拡張版に追加してます。便利)

と実現内容はシンプルですが、この辺りの情報があまりなかったり、UEのアップデートで一部プロパティが機能してなかったりで地味に対応が大変だったので…今回記事にしました…(´;ω;`)

なお、検証環境はUE5.6.0 です

やったこと

まずはざっくり全体像をということで、やったことをリストにします

  1. StaticMeshComponentの派生クラスを作成し、拡張処理・プロパティを追加
  2. StaticMeshActorの派生クラスを作成し、内部で持つStaticMeshComponentを①と置き換え
  3. StaticMeshアセットがD&Dされた際の挙動を変更するため、ActorFactoryStaticMeshの派生クラスを作成
  4. ③が使われるようにするため、UE標準のStaticMesh用ActorFactoryの登録を解除

ここの④が一番たいへん…。エンジン改造ありならサクッと対応可能ですが、改造なしだと少し回りくどいことをする必要があります…

①②StaticMeshComponent, StaticMeshActor の派生クラスを作成

ここは特に変わったことはしてないので、コードだけ…

MyStaticMeshComponent.cpp/h

UCLASS(Blueprintable, ClassGroup=(Rendering, MyProject), meta = (BlueprintSpawnableComponent))
class XXX_API UMyStaticMeshComponent : public UStaticMeshComponent
{
    GENERATED_BODY()

public:
    virtual bool GetLightMapResolution(int32& Width, int32& Height) const override;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "LightMap")
    float LightMapResMultiplier = 1.0f;

#if WITH_EDITOR
    virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
#endif
    
};
#include "MyStaticMeshComponent.h"

bool UMyStaticMeshComponent::GetLightMapResolution(int32& Width, int32& Height) const
{
    const bool bPadded = Super::GetLightMapResolution(Width, Height);

    Width *= LightMapResMultiplier;
    Height *= LightMapResMultiplier;

    return bPadded;
}

#if WITH_EDITOR
void UMyStaticMeshComponent::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
    if (const FProperty* PropertyThatChanged = PropertyChangedEvent.Property)
    {
        if (PropertyThatChanged->GetFName() == GET_MEMBER_NAME_CHECKED(UMyStaticMeshComponent, LightMapResMultiplier))
        {
            InvalidateLightingCache();
        }
    }
    
    Super::PostEditChangeProperty(PropertyChangedEvent);
}
#endif

MyStaticMeshActor.cpp/h

UCLASS()
class XXX_API AMyStaticMeshActor : public AStaticMeshActor
{
    GENERATED_BODY()

public:
    AMyStaticMeshActor(const FObjectInitializer& ObjectInitializer);
};
AMyStaticMeshActor::AMyStaticMeshActor(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer.SetDefaultSubobjectClass<UMyStaticMeshComponent>(TEXT("StaticMeshComponent0")))
{
    PrimaryActorTick.bCanEverTick = true;
}

ぷちTips:GetLightMapResolution関数について

ライトマップ解像度はStaticMeshアセットのLightMapResolutionStaticMeshComponentOverriddenLightMapResで調整できます。そして、最終的にライトビルド時に使われる解像度はUStaticMeshComponent::GetLightMapResolution関数が返します。

そのため、派生クラスにてこの関数をoverrideすることで、「ライトマップ解像度の一括調整」などの便利機能を用意できたりします。気になる方は上記関数の中身や使われてる箇所を覗いてみてください

ActorFactoryStaticMeshの派生クラスを作成

アセットをD&Dした際の挙動はActorFactoryXXXクラスで実装されています。そして、ActorFactoryは各アセットクラス毎に用意されており、Engine/Source/Editor/UnrealEd/Classes/ActorFactories/ 以下に沢山あります。

今回はStaticMeshアセットが対象なので、ActorFactoryStaticMeshの派生クラスを作成します。中身はほぼActorFactoryStaticMeshの丸コピで、コンストラクタにおけるDisplayNameNewActorClassの部分だけ拡張版のものに変更します。

UCLASS()
class XXX_API UMyActorFactoryStaticMesh : public UActorFactoryStaticMesh
{
    GENERATED_BODY()

public:
    UMyActorFactoryStaticMesh(const FObjectInitializer& ObjectInitializer);

    //~ Begin UActorFactory Interface
    virtual bool CanCreateActorFrom( const FAssetData& AssetData, FText& OutErrorMsg ) override;
    virtual void PostSpawnActor( UObject* Asset, AActor* NewActor) override;
    virtual UObject* GetAssetFromActorInstance(AActor* ActorInstance) override;
    virtual FQuat AlignObjectToSurfaceNormal(const FVector& InSurfaceNormal, const FQuat& ActorRotation) const override;
    //~ End UActorFactory Interface
};
#define LOCTEXT_NAMESPACE "MyActorFactory"

UMyActorFactoryStaticMesh::UMyActorFactoryStaticMesh(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
    DisplayName = LOCTEXT("MyStaticMeshDisplayName", "My Static Mesh");
    NewActorClass = AMyStaticMeshActor::StaticClass();
    bUseSurfaceOrientation = true;
}

(今回の検証範囲ではこれで十分ですが、もしかすると UEditorStaticMeshFactoryからもコードをコピペしたほうがいいかも…という懸念は少しあります)

④ UE標準のActorFactoryStaticMeshの登録を解除

最後に、StaicMeshアセットをD&Dした際に、標準のStaticMeshActorFactoryではなく、③で作ったUMyActorFactoryStaticMeshが使われるようにします!

ここが今回苦労した所でした(´;ω;`)

ハマりポイント①

UE5.6時点では、各ActorFactoryは UEditorEngine(GEditor)UPlacementSubsystemにて別々に管理されています。履歴などを見る限りだとUPlacementSubsystemに移行途中のように見えますが、現状は二重管理に近いという少しカオスな状況です。

そして、アセットからのD&Dに関しては UPlacementSubsystem側の処理が使われるため、検索でヒットするGEditor->ActorFactoriesに対して行う方法は現状はあまり意味がありません()

ハマりポイント②

UPlacementSubsystemActorFactoryクラスを自動収集するため、作成したActorFactoryを明示的に追加する実装は不要です。ただし、指定アセットに対して複数のActorFactoryが候補になる場合、採用されるのは配列に先に追加されたActorFactory が採用されます。

そして、エンジン標準のActorFactoryが先に登録されるため…プロジェクト側のActorFactoryは採用されません。

TScriptInterface<IAssetFactoryInterface> UPlacementSubsystem::FindAssetFactoryFromAssetData(const FAssetData& InAssetData)
{
    for (const TScriptInterface<IAssetFactoryInterface>& AssetFactory : AssetFactories)
    {
        if (AssetFactory && AssetFactory->CanPlaceElementsFromAssetData(InAssetData))
        {
            return AssetFactory;
        }
    }

    return nullptr;
}

ちなみに、ActorFactoryにはMenuPriorityという優先度設定があり、XXXEditor.ini で設定することが可能です。UEditorEngine(GEditor)ではこの設定を元にソート処理(FCompareUActorFactoryByMenuPriority)が行われるのですが…UPlacementSubsystemでは一切使われません!ナンデェ!?

[/Script/UnrealEd.ActorFactory]
MenuPriority=10

[/Script/UnrealEd.ActorFactoryStaticMesh]
MenuPriority=30

ハマりポイント③

UPlacementSubsystemが持つActorFactoryのリスト(AssetFactories) 、残念ながら外部からアクセスできません。なので、自前でソートしたりすることはできません。つらい

今回とった解決策

「標準のStaticMesh用ActorFactoryをリストから削除すれば、拡張版のUMyActorFactoryStaticMeshが採用される(はず)」という強引方針です!

というわけで、対応コードです。OnPostEngineInit関数の中身が今回のメイン所です。

void FXXXModule::StartupModule()
{

#if WITH_EDITOR

    if (GEditor)
    {
        OnPostEngineInit();
    }
    else
    {
        FCoreDelegates::OnPostEngineInit.AddRaw(this, &FXXXModule::OnPostEngineInit);
    }
    
#endif
}

void FXXXModule::OnPostEngineInit()
{
    if (UPlacementSubsystem* PlacementSubsystem = GEditor->GetEditorSubsystem<UPlacementSubsystem>())
    {
        
        FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
        FAssetData StaticMeshAssetData = AssetRegistryModule.Get().GetAssetByObjectPath(FSoftObjectPath(TEXT("/Engine/EditorMeshes/EditorCube.EditorCube")));        
        if (StaticMeshAssetData.IsValid())
        {
            if (const TScriptInterface<IAssetFactoryInterface> AssetFactory = PlacementSubsystem->FindAssetFactoryFromAssetData(StaticMeshAssetData))
            {
                PlacementSubsystem->UnregisterAssetFactory(AssetFactory);
            }
        }
    }
}

UPlacementSubsystemUnregisterAssetFactory関数により指定のAssetFactoryをリストから削除することができます。今回はStaticMesh用のAssetFactoryを指定すればOKなのですが…それ自体を取得するのに一手間かかります…

UPlacementSubsystemの実装を見ていると、GetAssetFactoryFromFactoryClass関数というまさに「これ!」という関数があるのですが… IsAで判定しているのでうまくいきません…(UActorFactoryStaticMeshを継承してるクラスはエンジン側にも複数あるためです)

TScriptInterface<IAssetFactoryInterface> UPlacementSubsystem::GetAssetFactoryFromFactoryClass(UClass* InFactoryInterfaceClass) const
{
    if (InFactoryInterfaceClass)
    {
        for (const TScriptInterface<IAssetFactoryInterface>& AssetFactory : AssetFactories)
        {
            if (AssetFactory.GetObject()->IsA(InFactoryInterfaceClass))
            {
                return AssetFactory;
            }
        }
    }

    return nullptr;
}

そこで、アセット情報からActorFactoryを取得できる FindAssetFactoryFromAssetData関数を使います。これにStaticMeshアセットを渡せば、エディタ上でStaticMeshアセットをD&Dした際に使われるActorFactoryを取得できるというわけです。そのため、エンジン内部にあるStaticMeshアセットをロードしていますが、このパスは他のものでも大丈夫です。

なお、UPlacementSubsystemはActorFactoryの動的追加をサポートしているので、恐らくStartupModuleよりも遅いタイミングでこの処理を実行しても多分動くと思いますが、念の為です

さいごに

長くなりましたが、これでStaticMeshアセットをD&Dした際に、プロジェクト固有のStaticMeshActor・Componentが使われる」ようになります!

ただし、既に配置されているStaticMeshActor・Componentはそのままですし、BPにてUE標準のStaticMeshComponentが使われることは防げていません。そのため、前半で挙げたEditorUtilityによる差し替え機能やDataValidationによるチェック処理は用意しておいた方が安全です。

…既にエンジン改造をしている環境なら 直接StaticMeshComponentを弄ったほうが安全 & 楽な気もしますが、ActorFactory自体は知っておくと色々便利だと思うので、今回の記事が少しでも参考になれば幸いです(PostSpawnActor関数にて一定の条件を満たしてなかったら DestoryActorを呼ぶことで、アセットの配置操作を制限したりとか…)。

おしまい!