Unreal 커스텀 에디터 개발

Unreal Engine Custom Asset & Editor System

Engine
Unreal Engine 5
Language
C++
Type
Editor Plugin

프로젝트 목표

  1. 커스텀 에셋을 제작합니다
  2. 커스텀 에셋을 더블 클릭하면 전용 에디터가 열립니다
  3. 커스텀 에디터에서 에셋의 StaticMesh와 Material을 편집할 수 있습니다

프로젝트 결과

커스텀 에셋과 에디터에 대한 기본적인 구현 방법을 학습했으며,
추 후 협업 환경에서 디자이너가 필요한 데이터만 모아 커스텀 에셋으로 관리하는 것도 가능해집니다.

주요 성과

  • 커스텀 에셋 타입을 정의하고 에디터에 통합하는 방법 학습
  • Slate UI 프레임워크를 활용한 에디터 인터페이스 구현
  • 프로퍼티 시스템과 델리게이트를 통한 실시간 프리뷰 동기화

프로젝트 구조

THMeshEditor 플러그인

  • CustomMesh 모듈 - 커스텀 에셋 정의
  • CustomMeshEditor 모듈 - 에디터 구현

개발 과정

1

커스텀 에셋 생성과 에디터 열기

UObject 기반 커스텀 에셋 정의 및 에셋 타입 액션 구현

2

에디터 구현하기

Slate 기반 에디터 레이아웃 설계 및 뷰포트/디테일 탭 구현

3

실시간 프리뷰 반영

디테일 탭의 값 수정 시 뷰포트에 즉시 반영되도록 구현

1. 커스텀 에셋 생성과 에디터 열기

1-1. 커스텀 에셋 정의

CustomMesh 모듈에서 UObject를 상속받는 커스텀 에셋 클래스를 정의합니다.

1-2. 에셋 타입 액션 구현

CustomMeshEditor 모듈에서 FCustomMeshAssetTypeActions 클래스를 구현합니다.

  • IAssetTypeActions를 상속받아 새로운 콘텐츠 유형을 정의합니다
  • 편집기에서의 표시 방식을 설정합니다
  • 에셋 더블 클릭 시 에디터가 열리도록 구현합니다
class  FCustomMeshAssetTypeActions : public FAssetTypeActions_Base 
{
    
public:
    // IAssetTypeActions interface
    virtual UClass* GetSupportedClass() const override;
    virtual FText GetName() const override;
    virtual FColor GetTypeColor() const override;
    virtual uint32 GetCategories() override;
    virtual void OpenAssetEditor(const TArray<UObject*>& InObjects, TSharedPtr<class IToolkitHost> EditWithinLevelEditor) override;
    // End of IAssetTypeActions interface
    
};

1-3. 타입 액션 모듈 등록

에셋 타입 액션은 UObject에서 파생되지 않으므로 수동으로 등록해야 합니다.

플러그인 활성화 시 AssetToolsModule에 등록하도록 StartupModule() 함수에 구현합니다.

void FCustomMeshEditorModule::StartupModule()
{
    CustomMeshAssetTypeActions = MakeShared<FCustomMeshAssetTypeActions>();
    FAssetToolsModule::GetModule().Get().RegisterAssetTypeActions(CustomMeshAssetTypeActions.ToSharedRef());

  ...
}

1-4. 콘텐츠 브라우저에서 생성 가능하도록 구현

UFactory를 상속받는 클래스를 구현하여 콘텐츠 브라우저에서 새 에셋을 생성할 수 있도록 합니다.

class UCustomMeshFactory : public UFactory
{
		...
    virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
};

UObject* UCustomMeshFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
{
    return NewObject<UCustomMesh>(InParent, Class,  Name, Flags, Context);
}
콘텐츠 브라우저

클래스 구현만으로 자동으로 브라우저에 표시됩니다

1-5. 커스텀 에셋 열기 시 에디터 표시

에셋을 더블 클릭하면 OpenAssetEditor가 호출되어 커스텀 에디터가 실행됩니다.

2. 커스텀 에디터 구현하기

에셋 실행 시 OpenAssetEditor에서 커스텀 에디터의 InitEditor를 호출합니다.

void FCustomMeshAssetTypeActions::OpenAssetEditor(const TArray<UObject*>& InObjects, TSharedPtr<class IToolkitHost> EditWithinLevelEditor)
{
    MakeShared<FCustomMeshEditor>()->InitEditor(EToolkitMode::Standalone,
     TSharedPtr<IToolkitHost>(), InObjects);
     
    UE_LOG(LogTemp, Error, TEXT("OpenEditor"));

    GEditor->RedrawAllViewports();
}

2-1. 커스텀 에디터 초기화

void FCustomMeshEditor::InitEditor(const EToolkitMode::Type Mode, const TSharedPtr< class IToolkitHost >& InitToolkitHost, const TArray<UObject*>& InCustomMesh)
{
    ...
    FAssetEditorToolkit::InitAssetEditor(Mode, InitToolkitHost, UCustomMeshAppIdentifier, 
       EditorDefaultLayout.ToSharedRef(), bCreateDefaultStandaloneMenu,
        bCreateDefaultToolbar, InCustomMesh);
}

FAssetEditorToolkit의 InitAssetEditor를 통해 에디터를 초기화하며, EditorDefaultLayout이 에디터의 레이아웃을 결정합니다.

2-2. 에디터 레이아웃 설정

Slate 문법을 사용하여 에디터 레이아웃을 구성합니다.

void FCustomMeshEditor::CreateLayouts()
{
    EditorDefaultLayout = FTabManager::NewLayout("CustomMeshEditor_Layout")
       ->AddArea
       (
          FTabManager::NewPrimaryArea()->SetOrientation(Orient_Horizontal)
          ->Split
          (
             FTabManager::NewStack()
             ->SetSizeCoefficient(0.4f)
             ->AddTab(FCustomMeshEditorTabs::ViewportTabId, ETabState::OpenedTab)
          )
          ->Split
          (
             FTabManager::NewSplitter()->SetOrientation(Orient_Vertical)
             ->SetSizeCoefficient(0.3f)
             ->Split
             (
                FTabManager::NewStack()
                ->AddTab(FCustomMeshEditorTabs::DetailTabId, ETabState::OpenedTab)
             )
          )
       );
}

첫 번째 탭은 ViewportTabId, 두 번째 탭은 DetailTabId로 설정했으며, 각 탭의 구체적인 형태는 RegisterTabSpawners 함수에서 구현합니다.

2-3. 에디터 탭 구현

void FCustomMeshEditor::RegisterTabSpawners(const TSharedRef<class FTabManager>& inTabManager)
{
    WorkspaceMenuCategory = TabManager->AddLocalWorkspaceMenuCategory(LOCTEXT("WorkspaceMenu_CustomMeshAssetEditor", "CustomMesh Asset Editor"));
    const auto WorkspaceMenuCategoryRef = WorkspaceMenuCategory.ToSharedRef();

    FAssetEditorToolkit::RegisterTabSpawners(inTabManager);

    TabManager->RegisterTabSpawner(FCustomMeshEditorTabs::ViewportTabId,
		    FOnSpawnTab::CreateSP(this, &FCustomMeshEditor::SpawnTab_Viewport))
       .SetGroup(WorkspaceMenuCategoryRef)
       .SetIcon(FSlateIcon(FExtensionStyle::GetStyleSetName(), "Extensions.Command1"));

    TabManager->RegisterTabSpawner(FCustomMeshEditorTabs::DetailTabId,
		    FOnSpawnTab::CreateSP(this, &FCustomMeshEditor::SpawnTab_Detail))
       .SetGroup(WorkspaceMenuCategoryRef)
       .SetIcon(FSlateIcon(FExtensionStyle::GetStyleSetName(), "Extensions.Command2"));
}

SpawnTab_Viewport와 SpawnTab_Detail 함수에서 Slate 문법으로 각 탭의 형태를 결정합니다.


TSharedRef<SDockTab> FCustomMeshEditor::SpawnTab_Viewport(const FSpawnTabArgs& Args) const
{
    check(Args.GetTabId() == FCustomMeshEditorTabs::ViewportTabId)
       return SNew(SDockTab)
       [
          Viewport.ToSharedRef()
       ];
}

TSharedRef<SDockTab> FCustomMeshEditor::SpawnTab_Detail(const FSpawnTabArgs& Args)
{
    check(Args.GetTabId() == FCustomMeshEditorTabs::DetailTabId)
       return SNew(SDockTab)
       [
          SNew(SVerticalBox)
          + SVerticalBox::Slot()
          .AutoHeight()
          .Padding(5)
          [
             DetailsView.ToSharedRef()
          ]
       ];
}

2-4. Details View 생성

프로퍼티 모듈을 사용하여 DetailsView를 설정합니다.

void FCustomMeshEditor::CreateDetailsView()
{
    //Create Detail View
    constexpr bool bIsUpdatable = false;
    constexpr bool bAllowFavorites = true;
    constexpr bool bIsLockable = false;
    
    //프로퍼티 모듈 = 오브젝트의 상세 값을 설정할 수 있게 해줌.
    FPropertyEditorModule& PropertyEditorModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");

    //디테일 뷰 생성
    const FDetailsViewArgs DetailsViewArgs(bIsUpdatable, bIsLockable, true, FDetailsViewArgs::ObjectsUseNameArea, false);
    DetailsView = PropertyEditorModule.CreateDetailView(DetailsViewArgs);

    //디테일 뷰에 CustomMesh 객체 설정
    if (DetailsView.IsValid() == true)
    {
       DetailsView->SetObject(CustomMeshBeingEdited);
    }
}

2-5. Viewport 구현

SEditorViewport를 상속받는 클래스를 구현하여 3D 프리뷰를 표시합니다.

class SCustomMeshViewport : public SEditorViewport, public FGCObject
{
		..
		// SEditorViewport interface
		virtual TSharedRef<FEditorViewportClient> MakeEditorViewportClient() override;
		virtual void BindCommands() override;
		// End of SEditorViewport interface
};

TSharedRef<FEditorViewportClient> SCustomMeshViewport::MakeEditorViewportClient()
{
    TypedViewportClient = MakeShareable(new FCustomMeshViewportClient(CustomMeshEditorPtr, SharedThis(this), CustomMeshBeingEdited));
    return TypedViewportClient.ToSharedRef();
}

2-6. ViewportClient 구현

ViewportClient에서 PreviewScene을 초기화하여 실제 프리뷰를 렌더링합니다.

FCustomMeshViewportClient::FCustomMeshViewportClient(const TWeakPtr<class FCustomMeshEditor>& ParentCustomMeshEditor,   const TSharedRef<class SCustomMeshViewport>& CustomMeshViewport, UCustomMesh* ObjectToEdit)

    : FEditorViewportClient(nullptr, nullptr, StaticCastSharedRef<SEditorViewport>(CustomMeshViewport))
    , CustomMeshEditorPtr(ParentCustomMeshEditor)
    , CustomMeshEditorViewportPtr(CustomMeshViewport)
    , CustomMeshBeingEdited(ObjectToEdit)
{
    const FPreviewScene::ConstructionValues ConstructionValue; 
    AdvancedPreviewScene = new FAdvancedPreviewScene(ConstructionValue, 0);
    PreviewScene = AdvancedPreviewScene;

    // Viewport Setting
    FEditorViewportClient::SetViewMode(VMI_Lit);

    // Preview Camera Setting
    SetViewLocation(FVector(0.0f, 0.0f, 3.0f));
    SetViewRotation(FRotator(-30.0f, -90.0f, 0.0f));
    SetViewLocationForOrbiting(FVector::ZeroVector, 1000.0f);

    // Preview Mesh Setting
    UStaticMesh* StaticMesh = CustomMeshBeingEdited->Mesh;
    UMaterialInterface* Material = CustomMeshBeingEdited->Material;
    const FTransform Transform = CustomMeshBeingEdited->Transform;
    
    PreviewMeshComponent = NewObject<UStaticMeshComponent>(GetTransientPackage(), NAME_None, RF_Transient);
    PreviewMeshComponent->SetStaticMesh(StaticMesh);
    PreviewMeshComponent->SetMaterial(0, Material);
    PreviewMeshComponent->SetSimulatePhysics(true);

    AdvancedPreviewScene->AddComponent(PreviewMeshComponent, Transform);
}

AdvancedPreviewScene 사용 이유

스카이박스와 바닥이 기본적으로 제공되어 더 나은 프리뷰 환경을 구성할 수 있습니다.

뷰포트 프리뷰

3. 디테일 탭 수정 시 뷰포트에 반영하기

디테일 패널

디테일 탭에 표시되는 값들은 CustomMesh 모듈의 UCustomMesh 프로퍼티들입니다.

class CUSTOMMESH_API UCustomMesh : public UObject
{
    GENERATED_BODY()

    UCustomMesh();
    virtual ~UCustomMesh() override;

public:
    UPROPERTY(EditAnywhere, Category = "Mesh")
       FTransform Transform;
    
    UPROPERTY(EditAnywhere, Category = "Mesh", meta = (DisplayName = "Static Mesh"))
       UStaticMesh* Mesh;

    UPROPERTY(EditAnywhere, Category = "Mesh", meta = (DisplayName = "Material"))
       UMaterialInterface* Material;
       
public:
    DECLARE_MULTICAST_DELEGATE_OneParam(FPropertyChanged, struct FPropertyChangedEvent&);
    FPropertyChanged OnPropertyChanged;

private:
    virtual void PostEditChangeProperty(struct FPropertyChangedEvent& P
    ropertyChangedEvent) override;

프로퍼티 값 변경 시 PostEditChangeProperty가 호출되며, OnPropertyChanged 이벤트를 에디터에서 처리합니다.

이벤트 바인딩

void FCustomMeshEditor::InitEditor(const EToolkitMode::Type Mode, 
		const TSharedPtr< class IToolkitHost >& InitToolkitHost,
		const TArray<UObject*>& InCustomMesh)
{
    //Set Being Edited Object
    CustomMeshBeingEdited = Cast<UCustomMesh>(InCustomMesh[0]); // 다중 선택한 것 중 첫번째
    CustomMeshBeingEdited->SetFlags(RF_Transactional); //Undo, Redo 지원
    CustomMeshBeingEdited->OnPropertyChanged.AddRaw(this,
     &FCustomMeshEditor::OnPropertyChanged); //프로퍼티 변경 감지

프로퍼티 변경 처리

OnPropertyChanged 함수에서 프리뷰 메시를 업데이트하여 변경사항을 즉시 반영합니다.

void FCustomMeshEditor::OnPropertyChanged(FPropertyChangedEvent& PropertyChangedEvent)
{
    if (PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(UCustomMesh, Mesh))
    {
       if (UStaticMesh* Mesh = CustomMeshBeingEdited->Mesh; Mesh != nullptr)
       {
          Viewport->GetPreviewMesh()->SetStaticMesh(Mesh);
       }
       else
       {
          Viewport->GetPreviewMesh()->SetStaticMesh(nullptr);
       }
    }

    ...
    }