본문 바로가기

에디터 확장과 커스텀 툴

메뉴·툴바·Slate 패널·에셋 에디터·디테일 패널을 확장해 반복 작업용 편집 도구를 만듭니다.

레벨 디자이너가 맵마다 수백 개의 조명 액터를 열어 같은 값을 하나씩 고치고 있다면, 그 시간은 게임을 더 좋게 만드는 데 쓰이지 못합니다. 언리얼 에디터는 기본으로 많은 기능을 제공하지만, 프로젝트마다 다른 반복 작업까지 모두 덜어 주지는 못합니다. 그래서 큰 프로젝트일수록 팀의 작업 방식에 맞춘 전용 도구를 에디터 안에 직접 만들어 씁니다.

플러그인 개발에서 에디터 모듈을 나누는 법을 보았으니, 이 글에서는 그 에디터 모듈로 언리얼 에디터를 넓히는 방법을 살펴봅니다. 확장할 수 있는 영역과 핵심 모듈을 정리하고, ToolMenus로 메뉴를 더해 Slate 탭을 여는 C++ 예제를 따라간 뒤, 에디터 유틸리티 위젯과 Python 스크립팅도 함께 다룹니다.


에디터를 넓히면 좋아지는 점

에디터를 확장하면 프로젝트 특유의 반복 작업을 자동화해 시간을 아끼고 실수를 줄일 수 있습니다. 개발자가 더 효율적으로 일할 수 있는 맞춤 환경을 만들고, 게임의 특정 데이터나 에셋을 만들고 고치고 관리하는 전용 도구를 둘 수도 있습니다. 팀 안에서 같은 도구를 함께 쓰면 작업 방식이 표준화되어 협업도 쉬워지고, 에디터 수준에서 새 기능을 더하거나 기존 기능을 바꿀 수도 있습니다.

에디터 확장은 주로 C++ 플러그인으로 만들지만, 간단한 도구는 블루프린트나 Python 스크립트로도 만들 수 있습니다.


확장할 수 있는 영역

에디터를 넓히는 방법은 여러 가지입니다. 먼저 메뉴와 툴바가 있습니다. FExtensibilityManager, FMenuBuilder, FToolBarBuilder로 에디터의 메인 메뉴, 툴바, 마우스 오른쪽 버튼 메뉴에 새 항목이나 버튼을 더할 수 있고, 언리얼 엔진 5에서는 뒤에서 볼 ToolMenus 시스템을 주로 씁니다. 명령(Commands)을 정의하면 키보드 단축키와도 연결할 수 있습니다.

커스텀 에셋 에디터도 만들 수 있습니다. IAssetTypeActions로 에셋 클래스의 동작과 편집 진입점을 등록해 직접 만든 에셋 에디터에 연결하면, 그 에셋을 편집하는 방식을 완전히 정할 수 있습니다. 최신 버전에서는 UAssetDefinition을 쓰는 방식도 있으므로 대상 엔진 버전의 문서를 확인합니다. 새 에셋을 만드는 팩토리는 UFactory로 정의합니다.

에디터 안에 독립된 창이나 패널을 만들 때는 언리얼의 UI 프레임워크인 슬레이트(Slate)를 씁니다. SDockTab과 SCompoundWidget으로 복잡한 UI를 만들고, FGlobalTabmanager로 그 탭을 에디터에 등록합니다.

디테일 패널도 바꿀 수 있습니다. IDetailCustomization은 액터나 컴포넌트의 디테일 패널에 보이는 속성을 숨기거나, 순서를 바꾸거나, 커스텀 위젯을 더해 편집을 편하게 만들고, IPropertyTypeCustomization은 주로 구조체 같은 특정 속성 타입이 디테일 패널에 보이는 방식을 전체적으로 바꿉니다.

뷰포트에서 상호작용하는 에디터 모드도 만들 수 있습니다. 랜드스케이프 모드처럼 특정 종류의 대상을 선택하고 편집하는 데 특화된 모드로, 예전에는 FEdMode로 만들었고 언리얼 엔진 5에서는 UEdMode와 인터랙티브 툴 프레임워크를 쓰는 방식이 있습니다. FViewportClient로 뷰포트에 커스텀 시각화나 기즈모를 그려 디버깅 정보를 보여 주거나 편집 도구를 제공할 수도 있습니다.

데이터 관리와 자동화도 중요한 영역입니다. UnrealEd 모듈에는 에디터 전용 함수가 들어 있어 에셋 로딩과 저장, 액터 생성과 삭제, 레벨 변경 같은 에디터 작업을 코드에서 할 수 있습니다. Python Editor Script 플러그인으로 복잡한 작업 자동화, 데이터 가져오기와 내보내기, 일괄 처리를 할 수 있고, 에디터 유틸리티 위젯으로는 데이터 검사나 에셋 일괄 처리 같은 간단한 도구를 빠르게 만들 수 있습니다.


에디터 확장의 핵심 모듈

에디터 확장은 기능에 따라 여러 에디터 모듈에 의존합니다. 여기서 모듈의 이름과, .uplugin에 정하는 Editor라는 모듈 유형은 서로 다른 개념이라는 점을 구분합니다.

모듈쓰임
UnrealEd에셋 로딩과 저장, 트랜잭션, 월드 관리 같은 에디터 핵심 기능 대부분
Slate, SlateCore언리얼 에디터의 UI를 이루는 UI 프레임워크로, 커스텀 패널과 위젯에 필요
PropertyEditorIDetailCustomization, IPropertyTypeCustomization 같은 디테일 패널 커스터마이징
ToolMenusFToolMenuOwner, FToolMenuSection 등으로 메뉴와 툴바를 유연하게 확장

에디터의 현재 스타일에 접근할 때는 FAppStyle을 씁니다. Styling/AppStyle.h와 필요한 모듈을 확인하고, 예전 예제에 나오는 FEditorStyle과 섞어 쓰지 않습니다.


C++로 메뉴와 탭 만들기

먼저 앞 글 플러그인 개발에서 본 새 플러그인 마법사에서 Editor Standalone Window 템플릿을 고르거나, 기존 플러그인에 에디터 모듈을 더합니다. .uplugin에는 Type: "Editor"인 모듈을 추가하고, LoadingPhase를 Default나 PostEngineInit으로 정합니다.

그다음 에디터 모듈의 Build.cs에서 공개 헤더에 필요한 의존성과 내부 구현의 의존성을 나눕니다. 아래 예제는 PrivateDependencyModuleNames에 Core, CoreUObject, Engine, Slate, SlateCore, ToolMenus를 쓰며, 에셋 편집이나 디테일 패널 커스터마이징을 더할 때 UnrealEd, PropertyEditor 같은 모듈을 추가합니다.

모듈의 StartupModule()에서는 커스텀 UI 만들기, 메뉴와 툴바 등록, 디테일 패널 커스터마이징 등록 같은 초기화를 합니다. 다음은 언리얼 엔진 5의 ToolMenus로 메뉴를 더하고 탭을 등록하는 예입니다. 세 코드 블록은 같은 cpp 파일에 차례로 둡니다.

Private/MyEditorPluginModule.cpp (이어지는 코드)
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
#include "ToolMenus.h"
#include "Framework/Application/SlateApplication.h"
#include "Framework/Docking/TabManager.h"
#include "Widgets/Docking/SDockTab.h"
#include "Widgets/Text/STextBlock.h"

static const FName MyPluginTabName(TEXT("MyPluginTools"));

class FMyEditorPluginModule final : public IModuleInterface
{
public:
    virtual void StartupModule() override;
    virtual void ShutdownModule() override;
private:
    void RegisterMenus();
    void OpenMyPluginTab();
    TSharedRef<SDockTab> SpawnMyPluginTab(const FSpawnTabArgs& Args);
};

void FMyEditorPluginModule::StartupModule()
{
    // GUI 없는 commandlet 등에서는 Slate 탭을 등록하지 않습니다.
    if (!FSlateApplication::IsInitialized()) return;
    FGlobalTabmanager::Get()->RegisterNomadTabSpawner(
        MyPluginTabName,
        FOnSpawnTab::CreateRaw(this, &FMyEditorPluginModule::SpawnMyPluginTab))
        .SetDisplayName(NSLOCTEXT("MyEditorPlugin", "TabTitle", "My Plugin Tools"));
    UToolMenus::RegisterStartupCallback(
        FSimpleMulticastDelegate::FDelegate::CreateRaw(
            this, &FMyEditorPluginModule::RegisterMenus));
}

StartupModule은 슬레이트가 초기화되지 않은 경우, 예를 들어 GUI 없이 실행되는 커맨드렛(commandlet)에서는 바로 돌아갑니다. 초기화되어 있으면 RegisterNomadTabSpawner로 MyPluginTabName이라는 이름의 탭을 만들 함수 SpawnMyPluginTab을 등록하고, UToolMenus::RegisterStartupCallback으로 메뉴를 등록할 수 있는 시점이 되면 RegisterMenus가 불리게 합니다. 탭을 만드는 함수를 등록하는 것이지, 이 순간 탭을 보여 주는 것은 아닙니다.

Private/MyEditorPluginModule.cpp (이어지는 코드)
void FMyEditorPluginModule::RegisterMenus()
{
    FToolMenuOwnerScoped Owner(this);
    UToolMenu* Menu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Window");
    FToolMenuSection& Section = Menu->FindOrAddSection("MyPluginSection");
    Section.AddMenuEntry(
        "OpenMyPluginTools",
        NSLOCTEXT("MyEditorPlugin", "OpenLabel", "My Plugin Tools"),
        NSLOCTEXT("MyEditorPlugin", "OpenTooltip", "Open the example tool tab"),
        FSlateIcon(),
        FUIAction(FExecuteAction::CreateRaw(
            this, &FMyEditorPluginModule::OpenMyPluginTab)));
}

void FMyEditorPluginModule::OpenMyPluginTab()
{
    FGlobalTabmanager::Get()->TryInvokeTab(MyPluginTabName);
}

void FMyEditorPluginModule::ShutdownModule()
{
    UToolMenus::UnRegisterStartupCallback(this);
    UToolMenus::UnregisterOwner(this);
    if (FSlateApplication::IsInitialized())
    {
        if (TSharedPtr<SDockTab> Tab =
                FGlobalTabmanager::Get()->FindExistingLiveTab(MyPluginTabName))
        {
            Tab->RequestCloseTab();
        }
        FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(MyPluginTabName);
    }
}

RegisterMenus는 FToolMenuOwnerScoped로 이 모듈을 소유자로 정한 뒤, 레벨 에디터의 Window 메뉴를 넓혀 MyPluginSection 섹션에 My Plugin Tools 항목을 더합니다. 이 항목을 누르면 OpenMyPluginTab이 TryInvokeTab으로 같은 MyPluginTabName에 등록된 생성 함수를 찾아 탭을 엽니다. 키보드 단축키가 필요하다면 TCommands와 FUICommandList를 따로 등록하고 해제합니다. 이 예제의 모듈 이름은 MyEditorPlugin이므로 .uplugin의 Modules 이름, Source 폴더, Build.cs의 클래스 이름도 같게 맞춥니다. 앞 글에서 나눈 MyPluginEditor 모듈에 이 코드를 넣는다면 마지막 줄을 IMPLEMENT_MODULE(FMyEditorPluginModule, MyPluginEditor)로 바꿔야 합니다.

ShutdownModule은 StartupModule에서 등록한 것을 모두 해제하고 정리합니다. 시작 콜백과 메뉴 소유자를 해제하고, 열린 탭이 있으면 닫은 뒤, 탭 생성 함수의 등록을 해제합니다.

Private/MyEditorPluginModule.cpp (이어지는 코드)
TSharedRef<SDockTab> FMyEditorPluginModule::SpawnMyPluginTab(
    const FSpawnTabArgs& Args)
{
    return SNew(SDockTab)
        .TabRole(ETabRole::NomadTab)
        [
            SNew(STextBlock)
            .Text(NSLOCTEXT("MyEditorPlugin", "Body", "Example editor tool"))
        ];
}

IMPLEMENT_MODULE(FMyEditorPluginModule, MyEditorPlugin)

SpawnMyPluginTab은 TSharedRef<SDockTab>을 돌려주며, 실제 도구 위젯은 탭의 자식으로 둡니다. 이 예제의 탭은 STextBlock으로 예제 문구를 보여 줄 뿐, 에셋을 일괄로 고치지는 않습니다. 코드를 다 썼으면 필요하면 프로젝트 파일을 다시 만들고 빌드한 뒤, 에디터를 다시 시작해 확인합니다.

메뉴를 눌렀을 때 탭이 생기기까지

세 코드 블록은 같은 MyEditorPluginModule.cpp의 선언과 정의입니다. 새 파일에 나눠 복사하기보다, 모듈 이름, 함수 선언, include, 그리고 앞 글의 에디터 모듈 설정을 맞춰 하나로 합칩니다.

흐름을 정리하면, StartupModule에서 탭 생성 함수를 등록하고, 메뉴를 등록할 수 있는 시점의 콜백에서 메뉴 항목을 더하며, 사용자가 그 항목을 누르면 TryInvokeTab이 같은 이름으로 등록한 생성 함수를 찾아 SDockTab과 그 안의 STextBlock을 만듭니다. UToolMenus API 문서는 시작 콜백을 메뉴를 등록할 수 있는 시점까지 미루며, GUI를 쓰지 않는 커맨드렛, 게임, 서버 실행에서는 그 콜백이 실행되지 않는다고 설명합니다. 이 코드도 슬레이트가 초기화되지 않으면 등록하기 전에 돌아갑니다. 그래서 메뉴가 보이지 않는다면 모듈 이름, 에디터 모듈의 로드 조건, GUI 여부, 콜백 등록부터 살펴봅니다.

등록한 것은 반드시 해제해야 합니다. 시작할 때 등록하는 것과 끝날 때 해제하는 것을 짝지어 보면 다음과 같습니다.

등록 대상시작할 때끝날 때
메뉴시작 콜백 등록, 소유자 범위 지정콜백 해제, UnregisterOwner
탭고유한 이름으로 생성 함수 등록열린 탭 닫기, 생성 함수 해제
디테일 패널클래스나 속성 타입별 등록같은 이름으로 등록 해제
객체 수정FScopedTransaction과 Modify실행 취소와 다시 실행, 저장 상태 확인

도구는 실행하기 전에 대상, 타입, 쓰기 가능 여부를 확인하고, 무엇을 바꿨는지와 실패한 이유를 남겨야 합니다. 객체를 고칠 때는 FScopedTransaction으로 작업을 묶고 고치기 전에 Modify()를 불러야 실행 취소가 됩니다. 에디터를 다시 시작하거나 도구를 여러 번 실행하거나 모듈을 해제할 수 있는 경로에서, 메뉴가 중복되거나 콜백이 남아 있지 않은지 확인합니다. 이 예제는 실제 엔진에서 실행해 본 결과가 아닙니다.


블루프린트 유틸리티 위젯

간단한 에디터 도구나 일괄 작업을 빨리 만들어야 할 때는 블루프린트로 만드는 에디터 유틸리티 위젯(Editor Utility Widget, 흔히 Blutility라고 부름)이 쓸모 있습니다. 콘텐츠 브라우저에서 마우스 오른쪽 버튼을 눌러 Editor Utilities 메뉴의 Editor Utility Widget이나 Editor Utility Blueprint를 만듭니다. 메뉴의 위치는 설치한 버전과 플러그인 활성화에 따라 확인합니다.

일반 UMG 위젯처럼 UI를 만들고, 이벤트 그래프에서 Editor Utilities 분류의 에디터 전용 노드로 에셋 접근, 액터 조작, 파일 입출력 같은 작업을 합니다. 만든 위젯은 콘텐츠 브라우저에서 마우스 오른쪽 버튼을 눌러 Run Editor Utility Widget으로 실행하며, 한 번 실행한 위젯은 레벨 에디터의 Tools 드롭다운에 있는 Editor Utility Widgets 분류에서 다시 열 수 있습니다. 코딩 없이 빠르게 도구를 만들 수 있어 프로토타이핑에 알맞지만, C++ 플러그인만큼 유연하지는 않아 복잡한 UI나 성능이 중요한 로직에는 한계가 있습니다.


Python 스크립팅

Python은 에디터의 작업을 자동화하는 데 아주 쓸모 있습니다. Edit > Plugins에서 Python Editor Script Plugin을 켜면, 에디터의 Output Log 창에서 Cmd 드롭다운을 Python으로 바꿔 바로 Python 코드를 입력할 수 있습니다. 스크립트 파일은 프로젝트의 Content/Python 같은 Python 검색 경로에 .py를 두거나, 에디터의 File 메뉴에 있는 Execute Python Script로 실행합니다. .py 파일이 일반 콘텐츠 에셋처럼 콘텐츠 브라우저에 보이는 것은 아닙니다.

Python으로는 에셋을 가져오고 내보내는 일괄 처리, 레벨 액터 속성의 일괄 변경, 장면 데이터를 분석해 보고서를 만드는 일, 그리고 PySide나 PyQt를 연동한 커스텀 UI 같은 것을 할 수 있습니다. 아티스트나 기획자도 비교적 쉽게 배울 수 있고 NumPy, SciPy 같은 외부 라이브러리와 연동할 수 있지만, 에디터 안에서만 동작하므로 런타임 게임 코드에는 쓸 수 없습니다.


정리

  • 에디터 확장은 반복 작업을 자동화하고 팀의 작업 방식을 표준화하며, 주로 C++ 에디터 플러그인으로 만들고 간단한 도구는 블루프린트나 Python으로도 만듭니다.
  • 메뉴와 툴바, 커스텀 에셋 에디터, 슬레이트 패널과 탭, 디테일 패널, 에디터 모드와 뷰포트, 데이터 자동화가 주요 확장 영역입니다.
  • UnrealEd, Slate, SlateCore, PropertyEditor, ToolMenus가 핵심 모듈이며, 스타일에는 FAppStyle을 씁니다.
  • StartupModule에서 탭 생성 함수를 등록하고 UToolMenus::RegisterStartupCallback으로 메뉴를 더하며, 메뉴를 누르면 TryInvokeTab이 같은 이름의 생성 함수로 탭을 엽니다.
  • GUI가 없는 실행에서는 시작 콜백이 실행되지 않으며, 등록한 콜백, 소유자, 탭, 생성 함수는 ShutdownModule에서 모두 해제합니다.
  • 객체를 고치는 도구는 FScopedTransaction과 Modify()로 실행 취소를 지원하고, 대상과 결과를 확인합니다.

연습 문제

1. 이름을 한 곳만 바꾸면

메뉴 항목은 보이는데, OpenMyPluginTab의 TryInvokeTab이 다른 FName을 찾도록 한 곳만 바꿨습니다. 메뉴를 눌러도 예제 탭이 열리지 않는다면, 연결이 어디서 끊긴 걸까요?

해설 보기

탭을 찾는 이름과 생성 함수를 등록한 이름이 달라져, TryInvokeTab이 등록된 생성 함수를 찾지 못한 것입니다. 조회하는 이름과 RegisterNomadTabSpawner로 등록한 이름이 같아야 그 함수를 찾을 수 있습니다. 새 이름을 쓰려면 등록, 조회, 해제의 세 곳을 함께 바꿔야 하며, 이 예제가 이름을 MyPluginTabName 상수 하나로 둔 이유도 이것입니다.

2. 메뉴만 지우면 충분할까

ShutdownModule에서 메뉴 항목만 제거하고, CreateRaw(this, ...)로 등록한 시작 콜백과 탭 생성 함수는 남겨 두었습니다. 이것으로 충분한 정리일까요?

해설 보기

충분하지 않습니다. 메뉴 소유자를 해제하는 것만으로 시작 콜백과 탭 생성 함수의 정리가 끝나지 않습니다. CreateRaw(this, ...)는 모듈 객체의 원시 포인터를 붙잡고 있으므로, 모듈 객체가 사라진 뒤에도 남아 있으면 엔진이 이미 사라진 객체를 부르는 위험이 생깁니다.

예제의 ShutdownModule처럼 시작 콜백, 메뉴 소유자, 열린 탭, 탭 생성 함수를 각각 정리해야 합니다. 이것은 실제로 메뉴를 누르거나 모듈을 다시 로드해 확인한 결과가 아니라, 등록과 해제의 짝을 따져 본 것입니다.

3. 실행 취소가 되지 않는 도구

선택한 액터들의 이름을 일괄로 바꾸는 C++ 도구를 만들었는데, 실행한 뒤 Ctrl+Z를 눌러도 이름이 되돌아가지 않습니다. 무엇을 빠뜨렸을까요?

해설 보기

작업을 트랜잭션으로 묶고 고치기 전에 객체를 기록하는 단계를 빠뜨렸을 가능성이 큽니다. 일괄 작업 전체를 FScopedTransaction으로 감싸고, 각 액터의 값을 바꾸기 전에 Modify()를 불러야 에디터가 바뀌기 전의 상태를 기록해 실행 취소를 할 수 있습니다. 그 뒤에 실행 취소와 다시 실행이 제대로 되는지, 저장이 필요한 상태로 표시되는지도 확인합니다.

에디터 유틸리티 위젯은 Epic의 Editor Utility Widgets 문서에서, Python 스크립팅은 Scripting the Unreal Editor Using Python 문서에서 확인할 수 있습니다.

다음 글 AI와 행동 트리에서는 AI 컨트롤러와 행동 트리, 블랙보드로 게임의 AI를 만드는 방법을 살펴봅니다.