1. 模块概述

  • IndicatorSystem 是 3D-to-2D 屏幕指示器系统,解决”把 3D 世界中的目标投影到 2D HUD 上”的核心 UX 需求:敌人头顶血条、锁敌标记、屏幕外敌人箭头指示。
  • 核心价值拆成三点:
    1. 性能:多名敌人同屏战斗场景下,3D 投影 + 布局 + Widget 更新在一次 OnArrangeChildren 内完成;Widget 实例走 FUserWidgetPool 复用避免 GC 抖动;FAsyncMixin 异步加载 TSoftClassPtr 不阻塞主线程;FActiveTimer 在没有指示器时 Stop 让面板脱离帧循环。
    2. 解耦
      1. Framework 层(IndicatorManagerComponent / IndicatorDescriptor / SActorCanvas / IIndicatorWidgetInterface)无业务感知;
      2. 业务层翻译战斗事件为 Descriptor 配置;
      3. Widget 层通过接口实现自主订阅数据源。
      4. 四层独立,新增一类指示器(友军血条、地图传送门、任务目标)零框架改动。
    3. 表现:5 种投影模式(ComponentPoint / ComponentBoundingBox / ComponentScreenBoundingBox / ActorBoundingBox / ActorScreenBoundingBox)覆盖大多数场景需求;屏幕外目标用池化箭头 Widget 沿 4 边指引;Priority + Depth 双键稳定排序让近处指示器盖在远处之上。
  • 整个模块不是简单的 WorldSpaceWidgetComponent 包装,而是原生 Slate SPanel 的完整 3D 投影引擎

2. 设计思路与架构决策

2.1 自绘 Slate SPanel 而不是 UMG Canvas

  • 问题:3D 屏幕指示器每帧都要把世界坐标投影到屏幕坐标。如果用 UMG Canvas + N 个独立 UserWidget 各自 Tick,规模一上来(30+ 敌人同屏,算上锁敌标记、远程敌人指示等可能 50+ 指示器)性能直接爆炸。每个 Widget 独占一份 SlateRT + PrePass + Layout。
  • 方案:自绘 SActorCanvas : SPanel + FAsyncMixin + FGCObject
    • 所有指示器作为 Canvas 的 FSlot,共用一次 OnArrangeChildren 完成所有布局。
    • Widget 实例通过 FUserWidgetPool 池化复用。
    • FAsyncMixin::AsyncLoad 异步加载 TSoftClassPtr<UUserWidget>
    • FActiveTimerHandle 驱动投影计算,没有指示器时自动 Stop。
    • 屏幕边缘箭头预创建 10 个池化 Widget,在 OnArrangeChildren 中根据钳制状态分配。
  • 理由
    • 自绘 Slate 是性能上唯一合理的选择——多人游戏目标 30+ 敌人同屏,UMG 方案不可接受。
    • Pool 复用避免敌人频繁出生 / 死亡导致的 GC 抖动。
    • 备选方案是给每个敌人 Mesh 挂一个 WidgetComponent(World Space Widget),但完全不能做”屏幕外箭头””屏幕边缘钳制”这些 2D 表现,且性能更差。

2.2 Manager / Descriptor / Canvas / Widget 四层解耦

  • 问题:3D-to-2D 指示器涉及”业务事件 → 数据配置 → 屏幕投影 → Widget 数据绑定”4 个阶段。如果糅合在一起,新增一种指示器就得改引擎代码。
  • 方案:严格四层分工:
    1. IndicatorManagerComponent:指示器注册表(Controller 上挂件),只负责 Add/Remove Descriptor 和广播事件,完全无业务感知。
    2. IndicatorDescriptor:配置体(UObject),携带”投影目标 + Widget 类 + 投影模式 + 布局参数”,可以被蓝图/C++ 动态填写。
    3. SActorCanvas:渲染引擎(Slate SPanel),只看 Descriptor 做投影 + 排序 + Clamp + Arrow 分配。
    4. IIndicatorWidgetInterface:Widget 数据绑定接口,Widget 实现 BindIndicator 自主从 Descriptor->GetDataObject() 拿数据源。
  • 理由
    • 四层完全独立。新增一类指示器(友军血条、地图传送门、任务目标、屏幕边缘掉落提示)零框架改动。
    • 业务方是”翻译官”——把相关事件翻译成 Descriptor 配置,框架不知道业务方的存在。

2.3 目标销毁自动清理

  • 问题:游戏里敌人可能通过”死亡 → 尸体消失 → Actor 被 Destroy”的路径结束生命,桥接组件要监听所有这些路径才能保证 Descriptor 被清理。容易漏监听导致孤儿指示器。
  • 方案
    • UIndicatorDescriptor 增加 bAutoRemoveWhenIndicatorComponentIsNull 字段。
    • SActorCanvas::UpdateCanvas 每帧检查 Descriptor->CanAutomaticallyRemove()——如果开启自动移除且 SceneComponent.IsValid() == false(说明目标已 GC),框架自动清理。
  • 理由
    • 业务方不需要监听”敌人死亡 / 退出房间 / 读档”等各种生命周期,配置一个字段就交给框架处理。
    • TWeakObjectPtr 的失效检测是 O(1) 操作,每帧遍历的额外开销可忽略。
    • 业务方如果希望保持主动控制(比如延迟消失播动画),关掉这个字段即可,完全向后兼容。

2.4 5 种投影模式覆盖大多数场景需求

  • 问题:不同指示器需要”吸附”到不同的锚点:
    • 敌人头顶血条 → 吸附到 Head Socket(ComponentPoint)
    • 屏幕边缘大 Boss 箭头 → 吸附到 Actor 中心(ActorBoundingBox)
    • 锁敌标记 → 吸附到 Mesh 包围盒顶部(ComponentScreenBoundingBox + Anchor(0.5, 0))
    • 巨型敌人血条 → 吸附到屏幕投影包围盒中心(ActorScreenBoundingBox + Anchor(0.5, 0.5))
  • 方案:定义枚举 EIndicatorProjectionMode { ComponentPoint, ComponentBoundingBox, ComponentScreenBoundingBox, ActorBoundingBox, ActorScreenBoundingBox }IndicatorDescriptor::Project 中 switch 执行对应投影逻辑:
    • ComponentPointComponent->GetSocketLocation()ProjectWorldToScreen
    • ComponentBoundingBox:3D BBox 8 顶点 → 取 BoundingBoxAnchor 插值点 → 投影
    • ComponentScreenBoundingBox:8 顶点各自投影 → 2D BBox → 取 Anchor 插值。针对 Mesh 顶点不规则时,屏幕投影 BBox 比 3D BBox 更准确。
    • ActorBoundingBox / ActorScreenBoundingBox:同理但在 Actor 级
  • 理由
    • 覆盖了大多数场景常见吸附需求,策划/美术不需要理解数学原理就能选对模式。

2.5 Widget 自主订阅数据源

  • 问题:框架需要通知 Widget”你的数据源是 Actor X”,但框架不能知道 Widget 具体需要订阅什么事件(血条订 OnLifeChanged、Reticle 订 OnLockStateChanged,差异巨大)。
  • 方案IIndicatorWidgetInterface::BindIndicator(Descriptor)
    • Widget 在 BindIndicator 中主动调 Descriptor->GetDataObject() 拿到 Actor
    • FindComponentByClass 拿到业务组件(AttributeComponent / HitReactComponent),订阅对应委托
    • UnbindIndicator 中清理订阅
  • 理由
    • 框架不需要知道任何业务,Widget 自己决定订阅什么。
    • Widget 扩展完全无侵入——新增一种指示器只需实现接口并在蓝图里配置 Descriptor 字段。

3 Q&A

3.1 为什么选择自绘 Slate Panel 而不是 UMG WorldSpace WidgetComponent 做 3D 指示器?性能差异有多大?

参考回答

  • 简单来说:
    • 首先目标是支持多名敌人同屏,每个敌人有头顶血条 + 眩晕条 + 可能的锁敌标记。如果用 UMG WorldSpace WidgetComponent,每个 Widget 会获得自己的 SlateRT,每帧都要 PrePass + Layout + Paint,规模一上来就掉帧。
    • 更关键的是,World Space Widget 不能做”屏幕外目标用箭头沿屏幕边缘指示”这件事——它跟着世界坐标走,目标移出视野它就消失了。
  • 详细来说:
    1. 每帧重投影 N 个屏幕坐标,UMG 的 UPanelSlot 表达不了
      • 自绘 Slate Panel 绕过了任何 UPanelSlot 的”声明式布局”,直接构造 ArrangedWidget.
      • 而 UMG 的 UCanvasPanel 实际上只是 SConstraintCanvas 的包装。SConstraintCanvas 的布局规则是”锚点 + 偏移 + Anchor Box“,参数是预设的、声明式的、为静态布局设计的。如果想每帧改一个子项的位置,你要:
        1. 拿到 UCanvasPanelSlot
        2. 调 SetPosition() / SetAnchors() / SetSize()
        3. 触发 SlateAttribute 的 Invalidate
        4. 等下一帧 layout 重新跑一次
      • 对 1 个 widget 没问题,对屏幕上几十个怪物头顶 HUD + 锁定标记每帧都这么干,性能会被 SObjectWidget 包装层、UPanelSlot 反射调用、Invalidate 链路全部吃掉
      • 自绘 Slate Panel (SActorCanvas) 直接重写 OnArrangeChildren , 这是最低成本的几何下发 —— 一次乘加,没有反射、没有 BP VM、没有属性失效广播.
    2. 自定义的 FSlot 携带”业务态”字段,UPanelSlot 表达不了
      • SActorCanvas 专门塞了 7 个比特位 + Depth/Priority/ScreenPosition 等”上一帧投影状态”。这些状态用于脏标记驱动的局部失效——bDirty 决定是否调 Invalidate(EInvalidateWidget::Paint),避免每帧无脑刷新整张 HUD
      • UMG 的 UPanelSlot 是个 UObject,内存开销/序列化/反射成本都比 Slate 的 POD 风格 TSlotBase 高几个数量级,更不可能让你在槽位里塞 bWasIndicatorClampedStatusChanged 这种纯运行时位字段——那是设计期资产里完全无意义的东西
    3. 箭头需要 OnPaint 直接画,UMG 没法画一下就完事
      • SActorCanvas 里的 SActorCanvasArrowWidget 继承自 SLeafWidget , 它直接给 Slate 的渲染列表 FSlateWindowElementList 喂一个旋转矩形元素,零中间层。
      • 如果走 UMG,要做这件事得:建一个 UImage → 包一层 SObjectWidget(GC root) → 走 UMG 反射设置 RenderTransformAngle → 内部转 FSlateRenderTransform → 再发同样一条 DrawElement。多了 3~4 层无谓抽象,而且每个箭头都要一个 UObject 实例,GC 压力变大。
    4. 所有权与生命周期更干净,避开 SObjectWidget 这个 GC 桥
      • UWidget::TakeWidget 内部会把 SWidget 包进 SObjectWidget 这个特殊的 GC 桥
      • SObjectWidget 的作用是让 Slate 引用的 UObject 在 GC 视角下是可达的。这对于”用户可编辑的蓝图 widget”是必要的,但对于一个”系统级、永远不会被蓝图引用的根容器”(指示器层本身)就是浪费。
      • SSamplerActorCanvas 干脆继承 FGCObject,自己实现 AddReferencedObjects , 就引用 AllIndicators 这一个数组,干净精确,不需要 SObjectWidget。
    5. HitTest、可见性、批渲染(Batching)方面 Slate 更可控
      • SSamplerActorCanvas 通过 RegisterActiveTimer “按需 tick”
      • 当 AllIndicators.Num() == 0 时直接 TickHandle.Reset() 停 tick——彻底零开销。UWidget 在 UMG 里没有这种细粒度的 Active Timer 控制(你只能用 Tick,或者用更重的 FTSTicker)。
      • Invalidate(EInvalidateWidget::Paint) 也是 Slate 直接的失效控制 API,UMG 上层只暴露了 InvalidateLayoutAndVolatility() 这种粗粒度版本。
  • 总的来说:
    • Slate 是 UE 的 UI 渲染原语;UMG 是 Slate 的 UObject 包装。
    • 指示器系统的瓶颈在于每帧对 N 个 widget 做世界→屏幕投影、自定义排序、屏幕边缘裁剪与箭头绘制——这些都需要直接控制 OnArrangeChildren / OnPaint / 自定义 FSlot / FGCObject / 主动 Timer 等 Slate 底层接口。
    • UMG 的 UCanvasPanel 走声明式锚点 + UPanelSlot UObject 槽位的路线,每加一层都要付出 GC、反射、SObjectWidget 包装的成本,做不到高帧率要求的 HUD 投影系统。
    • 所以容器层用 Slate (SPanel 自定义子类),单个 Marker 内容仍用 UMG,二者通过 UWidget::TakeWidget() + FUserWidgetPool 桥接

3.2 为什么 Slot 要做双键(Priority + Depth)稳定排序?

参考回答

  • 场景是:玩家视野里有 10 个敌人,1 个 Boss(Priority 800)、5 个精英(Priority 500)、4 个普通怪(Priority 300)。
  • Priority 排序:保证 Boss 指示器在最顶层——不管它在空间上远近,都会画在其他敌人的上面。
  • Depth 排序:处理同 Priority 内的情况——5 个精英同屏时,相机距离近地应该盖在远的上面。Depth 用投影时算出的相机空间 Z 值,值越小越近。A.GetDepth() > B.GetDepth() 意思是深度大(远)的先画,深度小(近)的后画,后画的在上层——符合直觉。
  • 为什么用 StableSort(类似归并排序) 而不是 Sort(类似快排)
    1. 当 Priority 相同且 Depth 相同(多个指示器锚定同一个 Actor、或 Depth 在浮点精度上恰好相等),稳定排序保证它们的相对顺序与 CanvasChildren 中的添加顺序一致。
    2. 这避免了帧与帧之间因为快排/堆排的不稳定性产生抖动(z-fighting / 闪烁):上一帧 A 在 B 上面,下一帧反过来,UI 看起来就在”打架”。
    3. 也保证了”先注册的指示器在视觉上更稳定”这种可预期的行为。
  • 这套排序规则在 OnArrangeChildren 里一次性完成所有指示器的层级分配,比用 SetZOrder 一个一个设简单且性能更好。