UE5源码分析——ActorPickerMode模块全面分析

ActorPickerMode 模块全面系统分析


目录

  1. 模块概述
  2. 模块整体架构解析
  3. 类级代码注释详解
  4. 功能使用示例编写
  5. 总结与最佳实践

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
  • 所有核心逻辑都委托给 FEdModeActorPickerFEditorModeTools
  • 通过 FLevelEditorModule 获取编辑器模式管理器,不依赖全局单例假设

2. FEdModeActorPicker ------ 编辑器模式层

职责:

  • 接管视口鼠标交互(移动、点击、进入、离开)
  • 实时进行 Hit Proxy 检测,判断鼠标下方是否有 Actor
  • 综合 OnGetAllowedClassesOnShouldFilterActor 两个委托判断 Actor 是否可选
  • 管理光标装饰窗口(ToolTip),实时向用户反馈当前选取状态
  • 处理视口失焦、ESC 取消等边界情况

特点:

  • 通过 EPickState 枚举区分四种交互状态:NotOverViewportOverViewportOverIncompatibleActorOverActor
  • 对 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 架构设计优势

  1. 职责分离清晰:模块层管生命周期和注册,模式层管交互逻辑,调用方只需提供三个委托即可
  2. 委托驱动,高度解耦:调用方不需要知道 FEdMode 的任何内部机制,只通过三个委托表达意图
  3. 状态机设计EPickState 四态枚举精确描述交互阶段,每种状态对应不同的光标和提示文本
  4. 边界情况覆盖全面:失焦退出、应用切换退出、ESC 取消、ChildActor 追溯、Widget 显示恢复------这些容易遗漏的细节一个不落
  5. 光标的即时反馈 :通过 SWindow::MakeCursorDecorator() 实现的跟随鼠标 ToolTip,比在视口里绘制信息更轻量且不干扰场景渲染
  6. 代码体积极小:总共约 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 辅助方法

EndActorPickingModeIsInActorPickingMode 都是对 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;
}

这段代码是整套交互的"引擎"。它做了这么几件事:

  1. 视口校验 :只有当前活跃的关卡编辑视口(GCurrentLevelEditingViewportClient)才响应。如果编辑器开了多个视口,只在活跃的那个里做选取。
  2. Hit Proxy 检测Viewport->GetHitProxy(HitX, HitY) 返回鼠标正下方最顶层的 Hit Proxy。HActor 类型的 Proxy 意味着下面有一个 Actor。
  3. ChildActor 追溯while (Actor->IsChildActor()) Actor = Actor->GetParentActor() ------这是一个容易被忽略但非常重要的细节。如果没有这段逻辑,当用户点击一个 ChildActorComponent 生成的子 Actor 时,选中的是这个"代理Actor"而非用户真正关心的父 Actor。
  4. 有效性判定 :根据 OnGetAllowedClassesOnShouldFilterActor 的综合结果,将状态设为 OverActorOverIncompatibleActor

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 缓存。这个设计是防御性的------MouseMoveInputKey 之间可能隔了几帧,而游戏线程上 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,而是:

  1. 读取当前标志:bPreviousModeWidgets = InViewportClient->EngineShowFlags.ModeWidgets
  2. 创建一个 Lambda,捕获了视口客户端指针和原始标志值
  3. 将 Lambda 保存为 WidgetVisibilityFunction
  4. 关闭 ModeWidgets 标志
  5. 将来恢复时,调用这个 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 核心要点

  1. ActorPickerMode 本质上是一个 FEdMode 的封装。它把编辑器模式的注册管理、委托注入、交互控制的复杂细节都收拢在模块内部,对外只暴露三个方法 + 三个委托。

  2. 委托驱动的"好莱坞原则"------"不要调用我们,我们会调用你"。调用方不需要轮询状态,不需要管理 Tick,只需要提供规则(哪些能选)和结果处理(选中后做什么),模块会在合适的时机回调。

  3. EPickState 的四态设计是关键NotOverViewportOverViewportOverIncompatibleActor / OverActor,每一种状态都精确对应了一套光标反馈和提示文字。这是编辑器交互设计中的"可发现性"(discoverability)------用户无需阅读文档就能理解当前能做什么、不能做什么。

  4. 边界情况处理体现工程素养。ChildActor 追溯、LostFocus 退出、应用失活退出、Widget 显示状态的暂存-恢复------这些细节单独看都不起眼,但少了一个就会在某些场景下出问题。

5.2 最佳实践

  1. 尽量使用委托而非继承来使用此模块FEdModeActorPicker 的设计意图就是通过委托配置行为,没有必要为了自定义选取逻辑而去继承它。

  2. OnGetAllowedClasses + OnShouldFilterActor 的职责要分清。前者管"类型对不对",后者管"这个特定实例行不行"。类型检查放前者(利用缓存和接口匹配),实例级别的逻辑放后者(如排除特定 Actor、检查运行时状态)。

  3. 过滤委托中可以考虑使用接口而非具体类IsChildOf(UInterface::StaticClass()) 的分支就是为了支持这种用法------AllowedClasses.Add(UMyInterface::StaticClass())AllowedClasses.Add(AMyBaseClass::StaticClass()) 更灵活,不限制 Actor 的继承层级。

  4. 避免在 OnActorSelected 中执行耗时操作 。这个回调在 FEdMode::Exit() 之前执行,如果阻塞太久会延迟模式的退出流程。需要做重操作的话,可以在回调里抛一个异步任务。

  5. 如果需要格式化 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 实现
相关推荐
mengzhi啊1 天前
UE5 把剑绑定到骨骼-给要播放的动画及动画蒙太奇添加武器
ue5
directx3d_beginner1 天前
32,每帧更新怪物朝向改为c++
ue5
吴梓穆2 天前
UE5 播放透明视频
ue5
日月云棠2 天前
UE5源码分析之Editor——AnimationBlueprintEditor模块全面分析
ue5
远离UE42 天前
UE5 不同贴图采样类型的区别
ue5
weixin_404679314 天前
虚幻5 如何打开关卡蓝图
ue5
dong1326976 天前
UE5FPS游戏开发教程(一)
ue5
朗迹 - 张伟8 天前
UE5.8 用Trae的AI开发功能
ue5
日月云棠9 天前
UE5 Lyra Teams模块深度分析:从队伍创建到伤害判定的完整链路
ue5