ActorPickerMode 模块全面系统分析
目录
1 模块概述
1.1 写在前面
在 UE5 编辑器开发中,有一个场景大家一定不陌生------属性面板里有一个 Actor 引用字段,旁边带着一个"吸管"小按钮。点击它之后,鼠标会变成吸管形状,然后就可以在视口里点选一个 Actor,选中的 Actor 自动填进那个字段。这个看似简单的交互,背后正是 ActorPickerMode 模块在支撑。

如果让每位开发者都在自己的插件里重写一套"点击视口→过滤Actor→填回引用"的逻辑,那不仅是重复劳动,更容易踩到各种边界情况的坑:鼠标离开视口怎么办?编辑器失去焦点怎么处理?怎么让光标提示文字实时反映当前悬停的 Actor?ActorPickerMode 把这一整套交互流程抽象成了统一的编辑器模式,只通过三个委托就把控制权交给调用方。
整个模块的代码体量极小------四个文件加一个 Build.cs,核心逻辑集中在 FEdModeActorPicker 这一个编辑器模式类里。虽然代码不多,但它对 FEdMode 生命周期的理解和 Slate UI 的运用方式,非常值得扩展编辑器功能时参考。
1.2 基本信息
| 属性 | 值 |
|---|---|
| 模块名称 | ActorPickerMode |
| 类型 | Editor |
| 位置 | Engine/Source/Editor/ActorPickerMode |
| 描述 | 提供在关卡编辑器视口中交互式选取 Actor 的编辑器模式 |
整个模块由四个源文件组成:
ActorPickerMode/
├── ActorPickerMode.Build.cs
├── Public/
│ └── ActorPickerMode.h ------ 模块接口与委托声明
└── Private/
├── ActorPickerMode.cpp ------ 模块实现
├── EditorModeActorPicker.h ------ 编辑器模式类声明
└── EditorModeActorPicker.cpp ------ 编辑器模式类实现
在这个模块中,主要依赖了引擎的 Core、CoreUObject、Engine、InputCore、Slate、SlateCore、EditorFramework、UnrealEd 共 8 个模块。使用到的核心依赖是:FEdMode(编辑器模式基类)、FEditorModeRegistry(模式注册表)、FLevelEditorModule(关卡编辑器模块)、FSlateApplication(Slate 应用层)、HHitProxy(视口 Hit 代理)。
2 模块整体架构解析
2.1 架构图
┌─────────────────────────────────────────────────────────────────────┐
│ 调用方(任意编辑器插件 / 细节面板) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 1. 准备三个委托: │ │
│ │ - FOnGetAllowedClasses → 限定可选的 Actor 类型 │ │
│ │ - FOnShouldFilterActor → 自定义过滤逻辑 │ │
│ │ - FOnActorSelected → 选中后的回调 │ │
│ │ 2. 调用 BeginActorPickingMode(...) 进入选取模式 │ │
│ │ 3. 用户在视口中点选 Actor → 回调触发 → 模式自动退出 │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ FActorPickerModeModule (模块接口层) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 公开 API: │ │
│ │ - BeginActorPickingMode(...) 进入 Actor 选取模式 │ │
│ │ - EndActorPickingMode() 退出 Actor 选取模式 │ │
│ │ - IsInActorPickingMode() 查询是否处于选取模式 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 内部职责: │ │
│ │ - 在 StartupModule 中注册 FEdModeActorPicker 到全局注册表 │ │
│ │ - 监听应用失活事件(Alt+Tab 切换窗口)自动退出选取模式 │ │
│ │ - 通过 FLevelEditorModule 获取当前关卡编辑器的 ModeManager │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ FEdModeActorPicker (编辑器模式层) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 核心属性: │ │
│ │ - HoveredActor: TWeakObjectPtr<AActor> 当前悬停的 Actor │ │
│ │ - PickState: EPickState::Type 当前交互状态 │ │
│ │ - CursorDecoratorWindow: TSharedPtr<SWindow> 光标提示浮窗 │ │
│ │ - OnActorSelected / OnGetAllowedClasses / OnShouldFilterActor │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 核心交互(重写 FEdMode 接口): │ │
│ │ - Enter() 启动模式,创建光标装饰窗口 │ │
│ │ - Exit() 退出模式,清理委托和窗口 │ │
│ │ - MouseEnter/Leave 跟踪鼠标是否在视口内 │ │
│ │ - MouseMove 实时 Hit 检测,更新悬停 Actor │ │
│ │ - InputKey 处理左键选中 / ESC 取消 │ │
│ │ - GetCursor 根据 PickState 切换吸管/禁止光标 │ │
│ │ - IsActorValid() 综合委托判定 Actor 是否可选 │ │
│ │ - LostFocus 视口失焦时自动退出模式 │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
2.2 模块划分
1. FActorPickerModeModule ------ 模块接口层
职责:
- 管理
FEdModeActorPicker编辑模式的注册与反注册 - 提供简洁的公开 API,让调用方无需接触 FEdMode 内部细节
- 监听
FSlateApplication的应用激活状态变化,避免 Alt+Tab 后模式悬空
特点:
- 模块本身几乎不持有状态,仅保存一个
FDelegateHandle - 所有核心逻辑都委托给
FEdModeActorPicker和FEditorModeTools - 通过
FLevelEditorModule获取编辑器模式管理器,不依赖全局单例假设
2. FEdModeActorPicker ------ 编辑器模式层
职责:
- 接管视口鼠标交互(移动、点击、进入、离开)
- 实时进行 Hit Proxy 检测,判断鼠标下方是否有 Actor
- 综合
OnGetAllowedClasses和OnShouldFilterActor两个委托判断 Actor 是否可选 - 管理光标装饰窗口(ToolTip),实时向用户反馈当前选取状态
- 处理视口失焦、ESC 取消等边界情况
特点:
- 通过
EPickState枚举区分四种交互状态:NotOverViewport、OverViewport、OverIncompatibleActor、OverActor - 对 ChildActor 做了特殊处理------自动追溯父 Actor
- 光标提示文本随状态动态变化,提供即时视觉反馈
- 用 Lambda 闭包实现视口 Widget 显示标志的"暂存-恢复"模式
2.3 模块间依赖关系
FActorPickerModeModule
│
├── 依赖 FLevelEditorModule → 获取 ILevelEditor → FEditorModeTools
│
├── 依赖 FEditorModeRegistry → 注册/反注册 FEdModeActorPicker
│
├── 依赖 FSlateApplication → 监听应用激活/失活
│
└── 激活/停用
↓
FEdModeActorPicker (继承 FEdMode)
│
├── 依赖 FEditorViewportClient → 获取视口交互事件
├── 依赖 FViewport / HHitProxy → 视口拾取检测
└── 依赖 SWindow / SToolTip → 光标装饰窗口
这个依赖结构很清晰------模块接口层只依赖引擎框架层(LevelEditor、ModeRegistry、SlateApplication),实际的交互逻辑全部封装在 FEdModeActorPicker 中。两层之间通过 FEditorModeTools::ActivateMode / DeactivateMode 解耦,模块层只负责"何时进入/退出",模式层负责"进入后怎么做"。
2.4 数据流走向
正常选取流程:
1. 调用方调用 BeginActorPickingMode(OnGetAllowedClasses, OnShouldFilterActor, OnActorSelected)
↓
2. FActorPickerModeModule 通过 LevelEditorModule 获取 FEditorModeTools
↓
3. 调用 FEditorModeTools::ActivateMode(EM_ActorPicker),激活编辑器模式
↓
4. FEdModeActorPicker::Enter() 被触发
- 创建 CursorDecoratorWindow(跟随鼠标的 ToolTip)
- PickState = NotOverViewport
↓
5. 用户移动鼠标到视口 → MouseEnter → PickState = OverViewport
↓
6. 用户移动鼠标 → MouseMove 每帧执行 Hit 检测
- 如果命中 HActor:追溯 ChildActor 父级 → 调用 IsActorValid()
- 如果有效 → PickState = OverActor,光标变成吸管
- 如果无效 → PickState = OverIncompatibleActor,光标变成禁止符号
- 如果未命中 Actor → PickState = OverViewport
- 同步更新 CursorDecoratorWindow 位置和文本
↓
7. 用户左键点击
- 再次 Hit 检测 + IsActorValid 校验
- 调用 OnActorSelected.ExecuteIfBound(Actor)
- UpdateWidgetVisibility(Restore) 恢复视口 Widget 显示
- RequestDeletion() 退出模式
↓
8. FEdModeActorPicker::Exit() 被触发
- 清理三个委托
- 销毁 CursorDecoratorWindow
- 恢复 Widget 显示状态
取消 / 异常退出流程:
触发条件:
- 用户按 ESC 键
- 视口失去焦点(LostFocus)
- 编辑器应用失活(OnApplicationDeactivated)
- 调用方主动调用 EndActorPickingMode()
↓
↓
FEdModeActorPicker::RequestDeletion() 或 FEditorModeTools::DeactivateMode()
↓
FEdModeActorPicker::Exit()
- 清空所有委托(防止悬空引用)
- 销毁 CursorDecoratorWindow
- 恢复视口 Widget 显示
2.5 核心技术栈
| 技术/类 | 用途 |
|---|---|
FEdMode |
编辑器模式基类,提供 Enter/Exit/Tick/InputKey 等生命周期 |
FEditorModeRegistry |
全局编辑器模式注册表,管理模式的注册与实例化 |
FEditorModeTools |
编辑器模式管理器,负责模式的激活/停用/查询 |
FLevelEditorModule |
关卡编辑器模块接口,提供对 ILevelEditor 的访问 |
HHitProxy / HActor |
视口 Hit 代理,用于检测鼠标下方的 Actor |
FEditorViewportClient |
视口客户端,提供鼠标事件和 EngineShowFlags 控制 |
SWindow / SToolTip |
Slate UI 控件,实现跟随鼠标的提示浮窗 |
FSlateApplication |
Slate 应用层,提供光标位置、应用激活状态等全局信息 |
TWeakObjectPtr |
弱指针,安全引用 HoveredActor 而不阻止 GC |
2.6 架构设计优势
- 职责分离清晰:模块层管生命周期和注册,模式层管交互逻辑,调用方只需提供三个委托即可
- 委托驱动,高度解耦:调用方不需要知道 FEdMode 的任何内部机制,只通过三个委托表达意图
- 状态机设计 :
EPickState四态枚举精确描述交互阶段,每种状态对应不同的光标和提示文本 - 边界情况覆盖全面:失焦退出、应用切换退出、ESC 取消、ChildActor 追溯、Widget 显示恢复------这些容易遗漏的细节一个不落
- 光标的即时反馈 :通过
SWindow::MakeCursorDecorator()实现的跟随鼠标 ToolTip,比在视口里绘制信息更轻量且不干扰场景渲染 - 代码体积极小:总共约 300 行实现代码,学习成本低,非常适合作为自定义 FEdMode 的参考范本
3 类级代码注释详解
3.1 FActorPickerModeModule 类
3.1.1 概述
FActorPickerModeModule 是 ActorPickerMode 模块的唯一对外接口。它实现了 IModuleInterface,在模块启动时完成编辑器模式的注册和应用事件监听,在模块关闭时完成反注册。同时提供三个简洁的公开方法供调用方使用。
核心设计理念:
- 薄封装:本身不做交互逻辑,只做"激活模式 + 传入委托"这一件事
- 自动清理:监听应用失活事件,确保用户 Alt+Tab 切换后不会卡在选取模式
- 健壮的 ModeManager 获取 :通过
FLevelEditorModule而非全局假设来获取FEditorModeTools
3.1.2 StartupModule 与 ShutdownModule
cpp
void FActorPickerModeModule::StartupModule()
{
// 确保 LevelEditor 模块已加载,后面需要通过它获取 ModeManager
FModuleManager::Get().LoadModuleChecked("LevelEditor");
// 向全局注册表注册 FEdModeActorPicker
FEditorModeRegistry::Get().RegisterMode<FEdModeActorPicker>(FBuiltinEditorModes::EM_ActorPicker);
// 监听应用激活状态变化(处理 Alt+Tab 场景)
if (FSlateApplication::IsInitialized())
{
OnApplicationDeactivatedHandle = FSlateApplication::Get()
.OnApplicationActivationStateChanged()
.Add(TDelegate<void(const bool)>::CreateRaw(this, &FActorPickerModeModule::OnApplicationDeactivated));
}
}
几个关键细节:
LoadModuleChecked("LevelEditor")放在StartupModule里而非BeginActorPickingMode中,是因为模块加载属于一次性开销,不需要每次选取都重复加载FSlateApplication::IsInitialized()的判断是必要的------编辑器启动顺序可能导致模块加载时 Slate 尚未就绪CreateRaw绑定原始指针而非共享引用,因为模块的生命周期由引擎保证,不存在悬空风险
反注册同样干净利落:
cpp
void FActorPickerModeModule::ShutdownModule()
{
// 先移除应用事件监听
if (FSlateApplication::IsInitialized())
{
FSlateApplication::Get().OnApplicationActivationStateChanged().Remove(OnApplicationDeactivatedHandle);
OnApplicationDeactivatedHandle.Reset();
}
// 再反注册编辑器模式
FEditorModeRegistry::Get().UnregisterMode(FBuiltinEditorModes::EM_ActorPicker);
}
3.1.3 BeginActorPickingMode ------ 进入选取模式
cpp
void FActorPickerModeModule::BeginActorPickingMode(
FOnGetAllowedClasses InOnGetAllowedClasses,
FOnShouldFilterActor InOnShouldFilterActor,
FOnActorSelected InOnActorSelected) const
{
if (FEditorModeTools* ModeTools = GetLevelEditorModeManager())
{
// 1. 激活编辑器模式(这会触发 FEdModeActorPicker::Enter())
ModeTools->ActivateMode(FBuiltinEditorModes::EM_ActorPicker);
// 2. 获取激活后的模式实例,注入三个委托
FEdModeActorPicker* Mode = ModeTools->GetActiveModeTyped<FEdModeActorPicker>(FBuiltinEditorModes::EM_ActorPicker);
if (ensure(Mode))
{
Mode->OnActorSelected = InOnActorSelected;
Mode->OnGetAllowedClasses = InOnGetAllowedClasses;
Mode->OnShouldFilterActor = InOnShouldFilterActor;
}
}
}
这里有一个容易被忽略的设计要点:委托是在 ActivateMode 之后 设置的,而不是通过构造参数传进去。这是因为 FEditorModeRegistry 负责创建模式实例,创建时机不由调用方控制。这种"先激活、再配置"的模式在 UE 编辑器模式中很常见。
ensure(Mode) 的使用也值得注意------如果获取不到模式实例,说明注册或激活过程出了问题,ensure 会在开发期触发断言,但不会在生产环境崩溃。
3.1.4 辅助方法
EndActorPickingMode 和 IsInActorPickingMode 都是对 FEditorModeTools 的薄封装:
cpp
void FActorPickerModeModule::EndActorPickingMode() const
{
if (FEditorModeTools* ModeTools = GetLevelEditorModeManager())
{
ModeTools->DeactivateMode(FBuiltinEditorModes::EM_ActorPicker);
}
}
bool FActorPickerModeModule::IsInActorPickingMode() const
{
if (FEditorModeTools* ModeTools = GetLevelEditorModeManager())
{
return ModeTools->IsModeActive(FBuiltinEditorModes::EM_ActorPicker);
}
return false;
}
OnApplicationDeactivated 是应用失活时的自动保护:
cpp
void FActorPickerModeModule::OnApplicationDeactivated(const bool IsActive) const
{
if (!IsActive)
{
EndActorPickingMode();
}
}
场景很直观------正在选取 Actor 时用户按了 Alt+Tab,编辑器窗口失去焦点。如果不主动退出选取模式,用户切回来后会发现自己仍然处于奇怪的"吸管状态",而且视口 Widget 还被隐藏着。
GetLevelEditorModeManager 是获取 ModeManager 的唯一入口:
cpp
FEditorModeTools* FActorPickerModeModule::GetLevelEditorModeManager()
{
FLevelEditorModule& LevelEditorModule = FModuleManager::GetModuleChecked<FLevelEditorModule>("LevelEditor");
TSharedPtr<ILevelEditor> FirstLevelEditor = LevelEditorModule.GetFirstLevelEditor();
if (FirstLevelEditor.IsValid())
{
return &FirstLevelEditor->GetEditorModeManager();
}
return nullptr;
}
这里使用 GetFirstLevelEditor() 而非假设某个全局变量,是因为 UE 编辑器支持多窗口,而当前活跃的关卡编辑器只有一个。这比直接硬编码引用更加稳健。
3.2 FEdModeActorPicker 类
3.2.1 概述
FEdModeActorPicker 是整个模块的核心,继承自 FEdMode。它通过重写 FEdMode 的一系列虚函数,接管了视口内的鼠标交互,实现了一套完整的"悬停检测 → 有效性判定 → 视觉反馈 → 点击确认"交互流程。
关键设计点:
- 用
EPickState枚举驱动状态机,每种状态对应不同的光标和提示文字 - 通过
HHitProxy做视口拾取,这是 UE 编辑器中最标准的"鼠标下面是什么"查询方式 - 对 ChildActor 做了向上追溯,避免选中 Actor 的某个子组件而忽略其逻辑父级
3.2.2 EPickState 状态枚举
cpp
namespace EPickState
{
enum Type
{
NotOverViewport, // 鼠标不在任何视口内
OverViewport, // 鼠标在视口内,但不在任何 Actor 上
OverIncompatibleActor, // 鼠标悬停在 Actor 上,但该 Actor 不符合过滤条件
OverActor, // 鼠标悬停在符合条件的 Actor 上
};
}
这个四级状态机的设计很精巧。它不只是简单的"可选/不可选"二分,而是把"不可选"进一步拆分为"因为没对准 Actor"和"对准了但不满足条件"两种情况------后者可以向用户显示具体是哪个 Actor 以及为什么不符合条件("XXX is incompatible")。
3.2.3 Enter ------ 启动选取模式
cpp
void FEdModeActorPicker::Enter()
{
FEdMode::Enter();
PickState = EPickState::NotOverViewport;
HoveredActor.Reset();
// 创建跟随光标的小浮窗,显示提示文字
CursorDecoratorWindow = SWindow::MakeCursorDecorator();
FSlateApplication::Get().AddWindow(CursorDecoratorWindow.ToSharedRef(), true);
CursorDecoratorWindow->SetContent(
SNew(SToolTip)
.Text(this, &FEdModeActorPicker::GetCursorDecoratorText)
);
}
SWindow::MakeCursorDecorator() 创建的是一个特殊的无边框窗口,它会自动跟随鼠标位置(在 Tick 中通过 MoveWindowTo 更新)。内容是一个动态文本的 SToolTip,文本内容通过绑定 GetCursorDecoratorText 方法实时更新。
这里不用直接在视口里 DrawHUD 的原因很简单------那个区域是场景渲染的空间,塞一段提示文字进去不仅难看,还可能被后处理效果扭曲。而独立的 Slate 窗口浮在一切之上,干净且不干扰场景。
3.2.4 Tick ------ 每帧更新光标装饰窗口位置
cpp
void FEdModeActorPicker::Tick(FEditorViewportClient* ViewportClient, float DeltaTime)
{
if (CursorDecoratorWindow.IsValid())
{
CursorDecoratorWindow->MoveWindowTo(
FSlateApplication::Get().GetCursorPos() + FSlateApplication::Get().GetCursorSize()
);
}
FEdMode::Tick(ViewportClient, DeltaTime);
}
GetCursorPos() + GetCursorSize() 让浮窗显示在光标右下方,这是一个常见的 UI 惯例------就像标准 ToolTip 一样,不会遮挡光标指向的内容。
3.2.5 MouseMove ------ 核心 Hit 检测逻辑
cpp
bool FEdModeActorPicker::MouseMove(FEditorViewportClient* ViewportClient, FViewport* Viewport, int32 x, int32 y)
{
if (ViewportClient == GCurrentLevelEditingViewportClient)
{
PickState = EPickState::OverViewport;
HoveredActor.Reset();
int32 HitX = Viewport->GetMouseX();
int32 HitY = Viewport->GetMouseY();
HHitProxy* HitProxy = Viewport->GetHitProxy(HitX, HitY);
if (HitProxy != NULL && HitProxy->IsA(HActor::StaticGetType()))
{
HActor* ActorHit = static_cast<HActor*>(HitProxy);
if (ActorHit->Actor != NULL)
{
AActor* Actor = ActorHit->Actor;
// 如果是 ChildActor,追溯到最顶层的父 Actor
while (Actor->IsChildActor())
{
Actor = Actor->GetParentActor();
}
HoveredActor = Actor;
PickState = IsActorValid(Actor) ? EPickState::OverActor : EPickState::OverIncompatibleActor;
}
}
}
else
{
PickState = EPickState::NotOverViewport;
HoveredActor.Reset();
}
return true;
}
这段代码是整套交互的"引擎"。它做了这么几件事:
- 视口校验 :只有当前活跃的关卡编辑视口(
GCurrentLevelEditingViewportClient)才响应。如果编辑器开了多个视口,只在活跃的那个里做选取。 - Hit Proxy 检测 :
Viewport->GetHitProxy(HitX, HitY)返回鼠标正下方最顶层的 Hit Proxy。HActor类型的 Proxy 意味着下面有一个 Actor。 - ChildActor 追溯 :
while (Actor->IsChildActor()) Actor = Actor->GetParentActor()------这是一个容易被忽略但非常重要的细节。如果没有这段逻辑,当用户点击一个 ChildActorComponent 生成的子 Actor 时,选中的是这个"代理Actor"而非用户真正关心的父 Actor。 - 有效性判定 :根据
OnGetAllowedClasses和OnShouldFilterActor的综合结果,将状态设为OverActor或OverIncompatibleActor。
3.2.6 InputKey ------ 左键选中与 ESC 取消
cpp
bool FEdModeActorPicker::InputKey(FEditorViewportClient* ViewportClient, FViewport* Viewport, FKey Key, EInputEvent Event)
{
if (ViewportClient == GCurrentLevelEditingViewportClient)
{
if (Key == EKeys::LeftMouseButton && Event == IE_Pressed)
{
// 重复 MouseMove 中的 Hit 检测 + ChildActor 追溯逻辑
int32 HitX = Viewport->GetMouseX();
int32 HitY = Viewport->GetMouseY();
HHitProxy* HitProxy = Viewport->GetHitProxy(HitX, HitY);
if (HitProxy != NULL && HitProxy->IsA(HActor::StaticGetType()))
{
HActor* ActorHit = static_cast<HActor*>(HitProxy);
AActor* Actor = ActorHit->Actor;
if (Actor->IsChildActor())
{
Actor = Actor->GetParentActor();
}
if (IsActorValid(Actor))
{
OnActorSelected.ExecuteIfBound(Actor); // 触发回调
UpdateWidgetVisibility(WidgetVisibilityState::Restore);
RequestDeletion(); // 退出模式
}
}
return true;
}
else if (Key == EKeys::Escape && Event == IE_Pressed)
{
UpdateWidgetVisibility(WidgetVisibilityState::Restore);
RequestDeletion(); // ESC 直接退出,不触发选中回调
return true;
}
}
else
{
// 非活跃视口→直接退出
UpdateWidgetVisibility(WidgetVisibilityState::Restore);
RequestDeletion();
}
return false;
}
选中时又做了一次 Hit 检测 ,而不是直接使用 HoveredActor 缓存。这个设计是防御性的------MouseMove 和 InputKey 之间可能隔了几帧,而游戏线程上 Actor 可能已经被销毁或移动了。重新检测虽然多一次 Hit 查询,但换来的是数据的绝对准确。
ExecuteIfBound 的使用也值得注意------调用方可能传入了空的委托。如果直接 Execute 会导致断言,而 ExecuteIfBound 安全地跳过未绑定的委托。
3.2.7 GetCursor ------ 光标反馈
cpp
bool FEdModeActorPicker::GetCursor(EMouseCursor::Type& OutCursor) const
{
if (HoveredActor.IsValid() && PickState == EPickState::OverActor)
{
OutCursor = EMouseCursor::EyeDropper; // 吸管------表示可以选取
}
else
{
OutCursor = EMouseCursor::SlashedCircle; // 禁止符号------当前不能选取
}
return true;
}
EyeDropper(吸管)是 UE 编辑器里经典的"选取"光标,和材质编辑器里的取色器、属性面板里的 Actor 选取共用同一个视觉语言。SlashedCircle(圆圈斜杠)告诉用户"这里不能点"。这种即时视觉反馈让用户几乎不需要看提示文字就能知道当前状态。
3.2.8 IsActorValid ------ 双重过滤判定
cpp
bool FEdModeActorPicker::IsActorValid(const AActor *const Actor) const
{
bool bIsValid = false;
if (Actor)
{
// 第一层过滤:类型检查
bool bHasValidClass = true;
if (OnGetAllowedClasses.IsBound())
{
bHasValidClass = false;
TArray<const UClass*> AllowedClasses;
OnGetAllowedClasses.Execute(AllowedClasses);
for (const UClass* AllowedClass : AllowedClasses)
{
// 支持接口匹配:如果 AllowedClass 是接口类型,检查 Actor 是否实现了该接口
if ((AllowedClass->IsChildOf(UInterface::StaticClass()) && Actor->GetClass()->ImplementsInterface(AllowedClass)) ||
Actor->IsA(AllowedClass))
{
bHasValidClass = true;
break;
}
}
}
// 第二层过滤:自定义逻辑
bool bHasValidActor = true;
if (OnShouldFilterActor.IsBound())
{
bHasValidActor = OnShouldFilterActor.Execute(Actor);
}
bIsValid = bHasValidClass && bHasValidActor;
}
return bIsValid;
}
这个方法有两个值得细品的设计选择:
为什么"接口匹配"要特殊处理? 默认的 Actor->IsA(UInterface派生类) 总是返回 false,因为 Actor 不可能"是"一个接口。正确的判断方式是 Actor->GetClass()->ImplementsInterface(AllowedClass)。这行逻辑让调用方可以在 OnGetAllowedClasses 中传入接口类型(比如 UInterface 的子类),然后只选取实现了特定接口的 Actor。这在大型项目中非常实用------比如只需要"实现了可交互接口的 Actor",而不限定它们具体是哪个蓝图类。
两层过滤的配合关系 :OnGetAllowedClasses 是"白名单"(只有这些类型或其子类型可以通过),OnShouldFilterActor 是"自定义条件"(可以是任意逻辑)。两者取 AND------必须同时满足才能被选中。如果调用方不绑定某个委托,对应的过滤层就当"全部放行"处理。
3.2.9 GetCursorDecoratorText ------ 动态提示文字
cpp
FText FEdModeActorPicker::GetCursorDecoratorText() const
{
switch (PickState)
{
case EPickState::NotOverViewport:
return LOCTEXT("...", "Pick an actor by clicking on it in the active level viewport");
case EPickState::OverViewport:
return LOCTEXT("...", "Pick an actor by clicking on it");
case EPickState::OverIncompatibleActor:
if (HoveredActor.IsValid())
return FText::Format(LOCTEXT("...", "{Actor} is incompatible"), ...);
else
return LOCTEXT("...", "Pick an actor by clicking on it");
case EPickState::OverActor:
if (HoveredActor.IsValid())
return FText::Format(LOCTEXT("...", "Pick {Actor}"), ...);
else
return LOCTEXT("...", "Pick an actor by clicking on it");
}
}
完全由 PickState 驱动,四种状态、五种不同的文本(其中两种有子分支)。当鼠标悬停在不兼容的 Actor 上时,会显示该 Actor 的名字------这就把"为什么不能选"的原因直接告诉了用户,而不是让人摸不着头脑地看到禁止光标却不知道为什么。
3.2.10 MouseEnter / MouseLeave / LostFocus ------ 边界保护
cpp
// MouseEnter: 记录鼠标进入视口,隐藏视口自身的 Mode Widget(避免干扰选取)
bool FEdModeActorPicker::MouseEnter(...)
{
PickState = EPickState::OverViewport;
HoveredActor.Reset();
UpdateWidgetVisibility(WidgetVisibilityState::StoreAndHide, ViewportClient);
return FEdMode::MouseEnter(ViewportClient, Viewport, x, y);
}
// MouseLeave: 记录鼠标离开视口,恢复 Mode Widget 显示
bool FEdModeActorPicker::MouseLeave(...)
{
PickState = EPickState::NotOverViewport;
HoveredActor.Reset();
UpdateWidgetVisibility(WidgetVisibilityState::Restore);
return FEdMode::MouseLeave(ViewportClient, Viewport);
}
// LostFocus: 视口失焦自动退出
bool FEdModeActorPicker::LostFocus(...)
{
if (ViewportClient == GCurrentLevelEditingViewportClient)
{
UpdateWidgetVisibility(WidgetVisibilityState::Restore);
RequestDeletion();
return true;
}
return false;
}
UpdateWidgetVisibility 在这里首次出现------进入选取模式后,视口里可能有一些编辑模式的小控件(Mode Widgets,比如旋转缩放箭头),它们会干扰选取操作。所以进入视口时隐藏它们,离开或退出时恢复。
而 LostFocus 的处理则更"激进"------直接退出整个选取模式。这是因为 UE 编辑器切换视口焦点是非常频繁的操作,继续挂着选取模式只会让用户困惑。
3.2.11 UpdateWidgetVisibility ------ 暂存-恢复模式
cpp
void FEdModeActorPicker::UpdateWidgetVisibility(const WidgetVisibilityState InState, FEditorViewportClient* InViewportClient)
{
// 如果有之前暂存的恢复函数,先执行恢复
if (WidgetVisibilityFunction)
{
WidgetVisibilityFunction();
WidgetVisibilityFunction.Reset();
}
if (InState == WidgetVisibilityState::StoreAndHide && InViewportClient)
{
// 暂存当前 ModeWidgets 标志,并关闭它
const bool bPreviousModeWidgets = InViewportClient->EngineShowFlags.ModeWidgets;
WidgetVisibilityFunction = [InViewportClient, bPreviousModeWidgets]()
{
if (InViewportClient)
{
InViewportClient->EngineShowFlags.SetModeWidgets(bPreviousModeWidgets);
InViewportClient->Invalidate(false, false);
}
};
InViewportClient->EngineShowFlags.SetModeWidgets(false);
InViewportClient->Invalidate(false, false);
}
}
这个方法用了一个很巧妙的技术------Lambda 捕获做状态暂存 。它不是简单地把 ModeWidgets 设为 false 再设为 true,而是:
- 读取当前标志:
bPreviousModeWidgets = InViewportClient->EngineShowFlags.ModeWidgets - 创建一个 Lambda,捕获了视口客户端指针和原始标志值
- 将 Lambda 保存为
WidgetVisibilityFunction - 关闭
ModeWidgets标志 - 将来恢复时,调用这个 Lambda,它会把标志设回原始值
这样做的好处是:无论调用方在选取期间做了什么操作,恢复时一定能回到最原始的状态 。如果只是简单地 SetModeWidgets(true),就可能错误的打开一个本来就没开的标志。
3.2.12 Exit ------ 清理
cpp
void FEdModeActorPicker::Exit()
{
// 清空外部委托,防止悬空引用
OnActorSelected = FOnActorSelected();
OnGetAllowedClasses = FOnGetAllowedClasses();
OnShouldFilterActor = FOnShouldFilterActor();
// 销毁光标装饰窗口
if (CursorDecoratorWindow.IsValid())
{
CursorDecoratorWindow->RequestDestroyWindow();
CursorDecoratorWindow.Reset();
}
HoveredActor.Reset();
PickState = EPickState::NotOverViewport;
UpdateWidgetVisibility(WidgetVisibilityState::Restore);
FEdMode::Exit();
}
Exit 做了彻底的清理,值得注意的一个细节是:先重置第一组委托,再清空第二个,再清空第三个。它们都是独立操作,顺序上无关紧要,但这种"逐个清零"的写法保证了每行代码的意图明确,不会出现"某些字段忘记清理"的遗漏。
4 功能使用示例编写
示例1:细节面板中的 Actor 属性选取
这是 ActorPickerMode 最常见的应用场景------自定义细节面板中的一个 AActor* 属性,旁边放一个"选取"按钮。
cpp
// MyDetailCustomization.h
#pragma once
#include "IDetailCustomization.h"
#include "ActorPickerMode.h"
class FMyComponentDetailCustomization : public IDetailCustomization
{
public:
static TSharedRef<IDetailCustomization> MakeInstance();
virtual void CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) override;
private:
// "选取 Actor" 按钮的回调
FReply OnPickActorClicked();
// Actor 选取完成后的回调
void OnActorPicked(AActor* SelectedActor);
// 限定可选的 Actor 类型
void OnGetAllowedClasses(TArray<const UClass*>& AllowedClasses) const;
// 自定义过滤
bool OnShouldFilterActor(const AActor* Actor) const;
// 缓存的引用
TWeakObjectPtr<AActor> TargetActorRef;
TSharedPtr<IPropertyHandle> TargetActorPropertyHandle;
};
cpp
// MyDetailCustomization.cpp
#include "MyDetailCustomization.h"
#include "PropertyCustomizationHelpers.h"
#include "EngineUtils.h"
TSharedRef<IDetailCustomization> FMyComponentDetailCustomization::MakeInstance()
{
return MakeShareable(new FMyComponentDetailCustomization);
}
void FMyComponentDetailCustomization::CustomizeDetails(IDetailLayoutBuilder& DetailBuilder)
{
// 获取要自定义的属性
TargetActorPropertyHandle = DetailBuilder.GetProperty(GET_MEMBER_NAME_CHECKED(UMyComponent, TargetActor));
IDetailCategoryBuilder& Category = DetailBuilder.EditCategory("Targeting");
Category.AddCustomRow(LOCTEXT("TargetActor", "Target Actor"))
.NameContent()
[
TargetActorPropertyHandle->CreatePropertyNameWidget()
]
.ValueContent()
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
[
TargetActorPropertyHandle->CreatePropertyValueWidget()
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(2.0f, 0.0f)
[
PropertyCustomizationHelpers::MakeActorPickerAnchorButton(
FOnGetActorFilters::CreateLambda([this]() -> FOnShouldFilterActor
{
return FOnShouldFilterActor::CreateSP(this, &FMyComponentDetailCustomization::OnShouldFilterActor);
}),
FOnActorSelected::CreateSP(this, &FMyComponentDetailCustomization::OnActorPicked)
)
]
];
}
void FMyComponentDetailCustomization::OnActorPicked(AActor* SelectedActor)
{
if (TargetActorPropertyHandle.IsValid() && SelectedActor)
{
TargetActorPropertyHandle->SetValue(SelectedActor);
}
}
void FMyComponentDetailCustomization::OnGetAllowedClasses(TArray<const UClass*>& AllowedClasses) const
{
// 只允许选取实现了 UMyTargetableInterface 接口的 Actor
AllowedClasses.Add(UMyTargetableInterface::StaticClass());
}
bool FMyComponentDetailCustomization::OnShouldFilterActor(const AActor* Actor) const
{
// 不能选取自己
if (Actor == TargetActorRef.Get())
{
return false;
}
// 只允许在同一个关卡中的 Actor
if (TargetActorRef.IsValid() && Actor->GetLevel() != TargetActorRef->GetLevel())
{
return false;
}
return true;
}
示例2:自定义编辑器工具中直接调用选取模式
有时需要在某个编辑器工具的按钮响应中直接进入 Actor 选取模式,比如一个"选择传送目标"的功能:
cpp
// MyEditorTool.cpp
#include "ActorPickerMode.h"
#include "LevelEditor.h"
void UMyEditorTool::StartPickTeleportTarget()
{
FActorPickerModeModule& ActorPicker = FModuleManager::GetModuleChecked<FActorPickerModeModule>("ActorPickerMode");
// 定义允许的类型:只要实现了 ITeleportTarget 接口的 Actor
auto GetAllowedClasses = [](TArray<const UClass*>& OutClasses)
{
OutClasses.Add(UTeleportTargetInterface::StaticClass());
};
// 自定义过滤:排除正在播放动画的 Actor
auto ShouldFilter = [](const AActor* Actor) -> bool
{
// 排除自己
if (Actor == GetOwner())
{
return false;
}
// 排除不可见的 Actor
if (Actor->IsHidden())
{
return false;
}
return true;
};
// 选中后的处理
auto OnSelected = [](AActor* Actor)
{
if (Actor)
{
UE_LOG(LogTemp, Log, TEXT("传送目标已选择: %s"), *Actor->GetActorNameOrLabel());
// 执行传送逻辑...
}
};
ActorPicker.BeginActorPickingMode(
FOnGetAllowedClasses::CreateLambda(GetAllowedClasses),
FOnShouldFilterActor::CreateLambda(ShouldFilter),
FOnActorSelected::CreateLambda(OnSelected)
);
}
示例3:蓝图友好的包装器
如果想在蓝图中也能使用 Actor 选取功能,需要写一个简单的包装 UObject:
cpp
// ActorPickerBlueprintLibrary.h
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "ActorPickerBlueprintLibrary.generated.h"
DECLARE_DYNAMIC_DELEGATE_OneParam(FOnActorPickedDyn, AActor*, SelectedActor);
DECLARE_DYNAMIC_DELEGATE_RetVal_OneParam(bool, FOnShouldFilterActorDyn, const AActor*, Actor);
UCLASS()
class UMyEditorBlueprintLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/** 进入 Actor 选取模式,选取完成后触发 OnPicked 回调 */
UFUNCTION(BlueprintCallable, Category = "Editor Tools")
static void BeginPickActor(
UPARAM(meta = (AllowAbstract = "false", AllowedClasses = "/Script/Engine.Actor")) TSubclassOf<AActor> AllowedClass,
FOnActorPickedDyn OnPicked,
FOnShouldFilterActorDyn OnShouldFilter);
/** 退出 Actor 选取模式 */
UFUNCTION(BlueprintCallable, Category = "Editor Tools")
static void EndPickActor();
};
cpp
// ActorPickerBlueprintLibrary.cpp
#include "ActorPickerBlueprintLibrary.h"
#include "ActorPickerMode.h"
void UMyEditorBlueprintLibrary::BeginPickActor(
TSubclassOf<AActor> AllowedClass,
FOnActorPickedDyn OnPicked,
FOnShouldFilterActorDyn OnShouldFilter)
{
FActorPickerModeModule& ActorPicker = FModuleManager::GetModuleChecked<FActorPickerModeModule>("ActorPickerMode");
FOnGetAllowedClasses GetAllowedClasses;
if (AllowedClass)
{
GetAllowedClasses = FOnGetAllowedClasses::CreateLambda([AllowedClass](TArray<const UClass*>& OutClasses)
{
OutClasses.Add(AllowedClass);
});
}
FOnShouldFilterActor ShouldFilterActor;
if (OnShouldFilter.IsBound())
{
ShouldFilterActor = FOnShouldFilterActor::CreateLambda([OnShouldFilter](const AActor* Actor) -> bool
{
return OnShouldFilter.Execute(Actor);
});
}
FOnActorSelected OnSelected;
if (OnPicked.IsBound())
{
OnSelected = FOnActorSelected::CreateLambda([OnPicked](AActor* Actor)
{
OnPicked.ExecuteIfBound(Actor);
});
}
ActorPicker.BeginActorPickingMode(GetAllowedClasses, ShouldFilterActor, OnSelected);
}
void UMyEditorBlueprintLibrary::EndPickActor()
{
FActorPickerModeModule& ActorPicker = FModuleManager::GetModuleChecked<FActorPickerModeModule>("ActorPickerMode");
ActorPicker.EndActorPickingMode();
}
5 总结与最佳实践
5.1 核心要点
-
ActorPickerMode 本质上是一个 FEdMode 的封装。它把编辑器模式的注册管理、委托注入、交互控制的复杂细节都收拢在模块内部,对外只暴露三个方法 + 三个委托。
-
委托驱动的"好莱坞原则"------"不要调用我们,我们会调用你"。调用方不需要轮询状态,不需要管理 Tick,只需要提供规则(哪些能选)和结果处理(选中后做什么),模块会在合适的时机回调。
-
EPickState 的四态设计是关键 。
NotOverViewport→OverViewport→OverIncompatibleActor/OverActor,每一种状态都精确对应了一套光标反馈和提示文字。这是编辑器交互设计中的"可发现性"(discoverability)------用户无需阅读文档就能理解当前能做什么、不能做什么。 -
边界情况处理体现工程素养。ChildActor 追溯、LostFocus 退出、应用失活退出、Widget 显示状态的暂存-恢复------这些细节单独看都不起眼,但少了一个就会在某些场景下出问题。
5.2 最佳实践
-
尽量使用委托而非继承来使用此模块 。
FEdModeActorPicker的设计意图就是通过委托配置行为,没有必要为了自定义选取逻辑而去继承它。 -
OnGetAllowedClasses+OnShouldFilterActor的职责要分清。前者管"类型对不对",后者管"这个特定实例行不行"。类型检查放前者(利用缓存和接口匹配),实例级别的逻辑放后者(如排除特定 Actor、检查运行时状态)。 -
过滤委托中可以考虑使用接口而非具体类 。
IsChildOf(UInterface::StaticClass())的分支就是为了支持这种用法------AllowedClasses.Add(UMyInterface::StaticClass())比AllowedClasses.Add(AMyBaseClass::StaticClass())更灵活,不限制 Actor 的继承层级。 -
避免在
OnActorSelected中执行耗时操作 。这个回调在FEdMode::Exit()之前执行,如果阻塞太久会延迟模式的退出流程。需要做重操作的话,可以在回调里抛一个异步任务。 -
如果需要格式化 Actor 名称显示在 UI 上,可以参考
GetCursorDecoratorText的做法 ------它使用GetActorNameOrLabel()而非GetName(),前者返回的是编辑器中显示的名称(可以带空格和中文),后者是内部对象名。
5.3 常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 进入选取模式后光标没变化 | 鼠标不在活跃的关卡视口内 | 确保鼠标移入了视口区域(MouseEnter 被触发) |
| ChildActor 选中后回调收到的是子 Actor | 调用方自己做了 Hit 检测而没有使用 ActorPickerMode | 参考 FEdModeActorPicker::MouseMove 中的 ChildActor 追溯逻辑 |
| Alt+Tab 后仍卡在选取模式 | 应用失活事件监听未正常工作 | 检查 FSlateApplication::IsInitialized() 是否在模块启动时返回 true |
| 选取模式下视口控件消失了(旋转/缩放箭头等) | 这是设计行为------UpdateWidgetVisibility 隐藏了 ModeWidgets |
正常现象,退出选取模式后会自动恢复 |
只绑定了 OnGetAllowedClasses 但某个 Actor 仍然不能选 |
OnShouldFilterActor 虽然未绑定,但 OnGetAllowedClasses 的匹配可能失败了 |
检查 IsActorValid 中的接口匹配逻辑------确保 AllowedClass 和 Actor 的类型关系符合预期 |
附录:文件结构
ActorPickerMode/
├── ActorPickerMode.Build.cs ------ 模块构建配置
├── Public/
│ └── ActorPickerMode.h ------ FActorPickerModeModule 类 + 委托声明
└── Private/
├── ActorPickerMode.cpp ------ FActorPickerModeModule 实现
├── EditorModeActorPicker.h ------ FEdModeActorPicker 类声明
└── EditorModeActorPicker.cpp ------ FEdModeActorPicker 实现