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。
  • 方案:自绘 SSamplerActorCanvas : 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 被清理。容易漏监听导致孤儿指示器。
  • 方案
    • USamplerIndicatorDescriptor 增加 bAutoRemoveWhenIndicatorComponentIsNull 字段。
    • SSamplerActorCanvas::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))
  • 方案:定义枚举 ESamplerIndicatorProjectionMode { 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,差异巨大)。
  • 方案ISamplerIndicatorWidgetInterface::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 不能做”屏幕外目标用箭头沿屏幕边缘指示”这件事——它跟着世界坐标走,目标移出视野它就消失了。

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 一个一个设简单且性能更好。