언리얼 커스텀 에디터 모드 툴 제작에 대한 조사

김종민·2025년 5월 20일

목차

  1. 서론
  2. 커스텀 에디터 모드
  3. ITF (Interactive Tool Framework)

서론

📖 우선 커스텀 에디터 제작기가 아닌 조사인 이유는 원래 만드려던 툴이 이미 엔진에 있다는 걸 뒤늦게 알아버렸기 때문...

📖 코드를 뒤져본 시간이 아까워서 나중에 진짜 필요한 툴을 만들 때 어떻게든 쓰이지 않을까 + 누군가 궁금해할까 싶어 글로 남기는 것


커스텀 에디터 모드

뷰포트의 상단에 보면 확인할 수 있듯이 기본 상태는 Selection Mode로 에디터상의 액터들을 선택하고 배치할 수 있는 상태이다.

이미 제작된 여러 모드가 있는데 그 외에 나만의 에디터 모드를 만드는 방법을 소개하고자한다.


에디터 모드 추가하는 법

플러그인 추가

Edit 탭 -> Plugins 창 -> Add -> Editor Mode 플러그인 제작

플러그인을 사용하지 않고 직접 추가하는 방식도 가능은 하지만 권장되지 않는다고 함 (GPT 의견)

📖 Slate Widget이나 언리얼 엔진 자체에 대한 이해도가 높으면 상관없겠지만 모듈을 등록하고 해제하는 것도 그렇고 ITF에 대한 예제를 확인할 수 없다는 점에서도 프리셋이 되어있는 Editor Mode 기반의 플러그인을 사용해서 작업하는 것이 수월할 것 같다.


플러그인에 원하는 기능 추가하기

처음 플러그인을 추가하면 플러그인 이름 기반으로 만들어진 소스 파일들과 기본 예제 툴 2개가 생성된다.
[플러그인 이름]EditorMode, [플러그인 이름]EditorModeCommands, [플러그인 이름]EditorModeToolkit, [플러그인 이름]Module, Tools

뭐 어쩌라는 건가 싶다..


ITF (Interactive Tool Framework)

📖 여기서부터는 여기저기 헤매면서 코드를 뒤져본 내용 위주기때문에 부정확한 정보가 섞여있을 수 있으니 알고있는 정보와 다르다면 그게 맞을 확률이 높습니다..😢

UE 4버전을 기준으로 작성된 문서이긴하지만 프레임워크에 대한 상세한 설명이 되어있는 링크를 남깁니다. 필요하신 분은 읽어보시면 좋을듯(저는 이해 못했지만...)
https://www.gradientspace.com/tutorials/2021/01/19/the-interactive-tools-framework-in-ue426


Tool 추가하기

모드에서 필요한 기능을 실질적으로 수행해줄 클래스.
UInteractiveTool의 자식 클래스.

Tool의 등록 과정

엔진을 실행시켜보면 Tool 등록을 위해 크게 2가지의 과정이 필요하고 이를 위한 최소한의 흐름을 설명하고자 한다.

  1. RegisterCommands()

[플러그인 이름]EditorModeCommands : 모드에서 사용될 모든 Command를 등록한다.

	TSharedPtr<FUICommandInfo> SelectTool;
	TSharedPtr<FUICommandInfo> AddInstanceTool;

이런식으로 사용할 Tool의 Command를 모두 멤버로 선언해둔다.

[플러그인 이름]EditorModeCommands.cpp

void FISMAddInstanceEditorModeCommands::RegisterCommands()
{
	TArray <TSharedPtr<FUICommandInfo>>& ToolCommands = Commands.FindOrAdd(NAME_Default);

	UI_COMMAND(SelectTool, "Select Actor", "Select Actor To Add Instance, Actor should have Instanced Static Mesh Component", EUserInterfaceActionType::Button, FInputChord());
	ToolCommands.Add(SelectTool);

	UI_COMMAND(AddInstanceTool, "Add Instance", "Add Instances matches to Selected Actors Transform, Check 'Replace' to Delete Selected Actor After Add Instances", EUserInterfaceActionType::Button, FInputChord());
	ToolCommands.Add(AddInstanceTool);
}

UI_COMMAND 매크로를 사용해 버튼과 텍스트 등을 바인딩해주고 ToolCommands 배열에도 추가한다.

  1. Enter()

[플러그인 이름]EditorMode : UEdMode를 상속 받은 클래스

  • Tool 식별자로 쓸 문자열을 멤버로 선언해둔다.
	static FString SelectToolName;
	static FString AddInstanceToolName;

[플러그인 이름]EditorMode.cpp

// ID 지정해두고
FString UISMAddInstanceEditorMode::SelectToolName = TEXT("ISMAddInstance_SelectTool");
FString UISMAddInstanceEditorMode::AddInstanceToolName = TEXT("ISMAddInstance_AddInstanceTool");

// Enter()에서 Tool 등록
void UISMAddInstanceEditorMode::Enter()
{
	UEdMode::Enter();

	const FISMAddInstanceEditorModeCommands& ToolCommands = FISMAddInstanceEditorModeCommands::Get();

	RegisterTool(ToolCommands.SelectTool, SelectToolName, NewObject<UISMAddInstanceSimpleToolBuilder>(this));
	RegisterTool(ToolCommands.AddInstanceTool, AddInstanceToolName, NewObject<UISMAddInstanceInteractiveToolBuilder>(this));

	// active tool type is not relevant here, we just set to default
	GetToolManager()->SelectActiveToolType(EToolSide::Left, SelectToolName);
}

이 때 RegisterTool()에서 Builder를 넘겨주는 부분에 유의!
Tool 자체를 넘기는 것이 아니고 우리가 필요한 ToolBuilder 클래스를 만들어서 넘긴다.

📖 여기까지가 기본 셋업 과정으로 이렇게하면 원하는 툴만 추가해서 기본 형태의 모드 위젯을 구성할 수 있다.
(툴은 그냥 클래스만 추가해서는 안 되고 빌더가 같이 있어야한다, 엔진에서도 툴은 직접 인스턴싱하지말고 항상 빌더를 통하라고 설명되어있다.)


Interactive Tool Class

예제로 주어지는 Tool 클래스를 보면 이미 3가지의 클래스가 선언되어있다.

  1. UInteractiveToolBuilder 자식 클래스

RegisterTool()에 넘겨줄 빌더.


  1. UInteractiveToolPropertySet 자식 클래스
    UPROPERTY(EditAnywhere, Category = Options)
	FVector SomeProperty;

Tool에서 표현할 속성값들을 넣고
Tool 클래스의 Setup()에서 등록한다.

	// Create the property set and register it with the Tool
	Properties = NewObject<UISMAddInstanceInteractiveToolProperties>(this, "Measurement");
	AddToolPropertySource(Properties);

  1. UInteractiveTool 자식 클래스

Properties를 멤버로 들고있다.
생성 및 할당은 Setup()에서.

	UPROPERTY()
	TObjectPtr<UISMAddInstanceInteractiveToolProperties> Properties;

다양한 모디파이어 인터페이스 구현을 통해서 클릭이나 드래그 등의 행동에 바인딩이 가능하다.

ex) USingleClickTool.h

UCLASS(MinimalAPI)
class USingleClickTool : public UInteractiveTool, public IClickBehaviorTarget
{
	GENERATED_BODY()

public:

	/**
	 * Register default primary-button-click InputBehaviors
	 */
	INTERACTIVETOOLSFRAMEWORK_API virtual void Setup() override;

	/**
	 * Test if the Target is hit at this 2D position / 3D ray
	 */
	INTERACTIVETOOLSFRAMEWORK_API virtual FInputRayHit IsHitByClick(const FInputDeviceRay& ClickPos);

	/**
	 * Click the Target at this 2D position / 3D ray. Default behavior is to print debug string.
	 */
	INTERACTIVETOOLSFRAMEWORK_API virtual void OnClicked(const FInputDeviceRay& ClickPos);

};

FModeToolkit

InteractiveTool이 툴로써 기능에대한 클래스라면 Toolkit 클래스는 UI 부분을 정의하고 있다.

GetToolkitFName() , GetToolPaletteNames() 등등 에디터에서 보여지는 UI 버튼 등의 이름에 대해 지정되어있고 오버라이드를 통해 커스터마이징이 가능하다.

크게 팔레트와 툴 버튼이 들어갈 TSharedPtr<SBorder> ModeToolHeader
툴 속성을 포함한 Details View가 들어갈 TSharedPtr<SBorder> InlineContentHolder로 나누어져 있다.


CreatePrimaryModePanel() 뼈대가 되는 SlateWidget 구성

SharedRef<SDockTab> FModeToolkit::CreatePrimaryModePanel(const FSpawnTabArgs& Args)
{
	TSharedPtr<SWidget> TabContent;
	if (!HasToolkitBuilder())
	{
		TabContent = SNew(SBorder)
		.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
		.Padding(0.0f)
		[
			SNew(SVerticalBox)
			+ SVerticalBox::Slot()
			.AutoHeight()
			.HAlign(HAlign_Left)
			[
				SAssignNew(ModeToolBarContainer, SBorder)
				.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
				.Padding(FMargin(4, 0, 0, 0))
			]

			+ SVerticalBox::Slot()
			.FillHeight(1.0f)
			[
				SNew(SVerticalBox)

				+ SVerticalBox::Slot()
				.Padding(0.0, 8.0, 0.0, 0.0)
				.AutoHeight()
				[
					SAssignNew(ModeToolHeader, SBorder)
					.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
				]

				+ SVerticalBox::Slot()
				.FillHeight(1)
				[
					SAssignNew(InlineContentHolder, SBorder)
					.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
					.Visibility(this, &FModeToolkit::GetInlineContentHolderVisibility)
				]
		]
		];
	}
	// else if ToolkitBuilder is defined, make the Toolkit
	else
	{
		TabContent = SAssignNew(InlineContentHolder, SBorder)
		.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
			.Padding(0.0f)
		[
			SNew(SVerticalBox)
			+ SVerticalBox::Slot()
			.AutoHeight()
			.HAlign(HAlign_Left)
			[
				SAssignNew(ModeToolBarContainer, SBorder)
				.Padding(FMargin(4, 0, 0, 0))
				.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
			]
		];
	}

	const TSharedPtr<SDockTab> CreatedTab = SNew(SDockTab);
	if (TabContent)
	{
		CreatedTab->SetContent(TabContent.ToSharedRef());
	}
	PrimaryTab = CreatedTab;	
	UpdatePrimaryModePanel();
	return CreatedTab.ToSharedRef();

}

UpdatePrimaryModePanel() : UI 패널 업데이트

void FModeToolkit::UpdatePrimaryModePanel()
{
	if (GetEditorMode() || GetScriptableEditorMode().IsValid())
	{
		const FText TabName = GetEditorModeDisplayName();
		const FSlateBrush* TabIcon = GetEditorModeIcon().GetSmallIcon();
		if (PrimaryTab.IsValid())
		{
			TSharedPtr<SDockTab> TabPtr  = PrimaryTab.Pin(); 
			TabPtr->SetTabIcon(TabIcon);
			TabPtr->SetLabel(TabName);

			if (HasToolkitBuilder())
			{
				TabPtr->SetParentDockTabStackTabWellHidden(HasToolkitBuilder());
				const TSharedPtr<SWidget> Content = GetInlineContent() ;
				
				if ( Content && InlineContentHolder.IsValid() )
				{
					InlineContentHolder->SetContent( Content.ToSharedRef() );
				}
				return;
			}
		}

		if (HasIntegratedToolPalettes())
		{
			TSharedRef<SSegmentedControl<FName>> PaletteTabBox = SNew(SSegmentedControl<FName>)
				.UniformPadding(FMargin(8.f, 3.f))
				.Value_Lambda([this]() { return GetCurrentPalette(); } ) 
				.OnValueChanged_Lambda([this](const FName& Palette) { SetCurrentPalette(Palette); } );

			// Only show if there is more than one child in the switcher
			PaletteTabBox->SetVisibility(TAttribute<EVisibility>::Create(TAttribute<EVisibility>::FGetter::CreateLambda([PaletteTabBox]() -> EVisibility
				{
					return PaletteTabBox->NumSlots() > 1 ? EVisibility::Visible : EVisibility::Collapsed;
				})));

			// Also build the toolkit here 
			TArray<FName> PaletteNames;
			GetToolPaletteNames(PaletteNames);

			TSharedPtr<FUICommandList> CommandList;
			CommandList = GetToolkitCommands();

			TSharedRef< SWidgetSwitcher > PaletteSwitcher = SNew(SWidgetSwitcher)
				.WidgetIndex_Lambda(
				[this, PaletteNames]() -> int32
				{
					int32 FoundIndex;
					if (PaletteNames.Find(GetCurrentPalette(), FoundIndex))
					{
						return FoundIndex;
					}
					return 0;
				});

			
			for (auto Palette : PaletteNames)
			{
				FName ToolbarCustomizationName = GetEditorMode() ?  GetEditorMode()->GetModeInfo().ToolbarCustomizationName : GetScriptableEditorMode()->GetModeInfo().ToolbarCustomizationName;
				TSharedRef<SWidget> PaletteWidget = CreatePaletteWidget(CommandList, ToolbarCustomizationName, Palette);

				const bool bRebuildChildren = false;
				PaletteTabBox->AddSlot(Palette, false)
				.Text(GetToolPaletteDisplayName(Palette));

				PaletteSwitcher->AddSlot()
				[
					PaletteWidget
				];
			}

			PaletteTabBox->RebuildChildren();

			if (ModeToolHeader)
			{
				ModeToolHeader->SetContent(
					SNew(SVerticalBox)
					+ SVerticalBox::Slot()
					.Padding(0.f, 0.f, 0.f, 8.f)
					.HAlign(HAlign_Center)
					.AutoHeight()
					[
						PaletteTabBox
					]

				+ SVerticalBox::Slot()
					.AutoHeight()
					[
						PaletteSwitcher
					]
				);
			}
		}
		else
		{
			if (ModeToolHeader)
			{
				ModeToolHeader->SetContent(SNullWidget::NullWidget);
			}
			if (ModeToolBarContainer)
			{
				ModeToolBarContainer->SetContent(SNullWidget::NullWidget);
			}
		}

		if (InlineContentHolder.IsValid())
		{
			if (TSharedPtr<SWidget> InlineContent = GetInlineContent())
			{
				InlineContentHolder->SetContent(
                ///////////////스크롤박스로 만드는 것에 주의!!!//////////////////
					SNew(SScrollBox)
					+ SScrollBox::Slot()
					[
						InlineContent.ToSharedRef()
					]);
			}
		}

	}
	
}

GetInlineContent() : 여기서 넘겨주는 위젯으로 InlineContent 패널을 업데이트

오버라이딩을 통해서 자유롭게 구성이 가능하다.


기타 Tip(?)

Palette : 위젯 스위칭 개념


하단 고정 박스 만들기

Inline Content Holder 는 업데이트 될 때마다 스크롤 박스로 만들어지기때문에 그 안에 하단 고정 위젯을 만들어봤자 위로 딸려올라가버린다.

이를 해결하기위해 ModeToolKit에서 InvokeUI()를 오버라이드 후 다시 그리는 방법이 있다.
(언리얼 엔진의 모델링 툴 에디터에서 사용 중인 방법)

  • InvokeUI()에서 UpdatePrimaryModePanel()를 호출하고
  • UpdatePrimaryModePanel()에서 Inline Content Holder를 그리고 있기 때문.
void FISMAddInstanceEditorModeToolkit::InvokeUI()
{
	FModeToolkit::InvokeUI();

	InlineContentHolder->SetContent(GetInlineContent().ToSharedRef());
}


스크롤박스를 쓰지 않으면 이런식으로 하단 정렬된 박스를 사용할 수 있다.


FModelingToolsEditorModeToolkit과 WidgetReflector 활용하기

엔진의 FModelingToolsEditorModeToolkit 소스코드와
WidgetReflector를 통해 디버깅을 하면 필요한 기능이나 SlateWidget 구성 정보를 확인할 수 있다.
(확인은 되는데 구현이 내 마음같이 안 되서 문제긴 했지만...)

Tools -> Debug -> Widget Reflector로 창을 열고
마우스 모양으로 위젯을 피킹해서 선택된 위젯의 정보를 확인할 수 있다.

어떤 위젯이 선택된 상태인지, 상하위 계층구조, 어디서 생성됐는지 등 다양한 정보를 확인할 수 있다.


ITF와 무관한 방법으로 툴 만드는 방법

https://lxjk.github.io/2019/10/01/How-to-Make-Tools-in-U-E.html
자세하게 설명이 되어있기는한데 샘플 프로젝트가 내 엔진(5.5)에서는 제대로 동작을 안 해서 큰 도움은 받지 못했다.

에디터 모드에 한정되지 않고 탭에 추가하는 것 등등 더 포괄적인 설명이 되어있다.

profile
정리와 기록

0개의 댓글