1. 模块概述

  • 本模块是一套基于 UE5 CMC 的,覆盖了从输入采集 → 角色 Yaw 跟随策略切换 → 动画状态机 4 主方向 + 8 方向贴合 → 起步/停步/急转/撞墙感知 → Distance Matching / Stride Warping → 多端动画一致性同步的完整链路。
  • 模块在功能上回答了三类问题:
    1. 怎么把”玩家面向(相机)”和”实际移动方向(输入)”解耦 —— 为索敌/瞄准等场景提供 360° 横向跨步(Strafe)能力,同时保留非索敌时”跑哪儿朝哪儿”的轻盈感
    2. 怎么把连续的运动数据(速度向量、加速度向量、空中状态)转换成动画图能用的离散信号 —— 4 主方向枚举 + 不对称迟滞 + 双门闩 Pivot + 撞墙判定
    3. 怎么让动画切换在三端(Server / Autonomous / SimulatedProxy)一致 —— 关键洞察:LinkAnimClassLayers 是纯本地的 AnimInstance 节点树重建,不可跨网复制,必须把权威态挂在 Character 上 + 在每端 OnRep 本地重演

2. 技术架构

2.1 网络同步方案

状态 复制属性 RepCondition 本地端策略 远端策略
Strafe 锁定 bIsTargetLocked COND_SimulatedOnly SetTargetLocked → 本地 Apply + Server RPC OnRep_IsTargetLocked → 本地 Apply
武器切换 CurrentWeapon COND_SimulatedOnly SetWeapon → 本地 Apply + Server RPC OnRep_CurrentWeapon → 本地 Apply
Dash 冲刺 (无复制属性) TryDash → 本地 Perform + Server RPC MulticastDash → 跳过自身后 Perform

为什么 Strafe 和武器选 COND_SimulatedOnly 而 Dash 用 Multicast

  • 前两者是持久状态:远端玩家任何时刻新进入观察范围都需要看到正确的当前态 → Replicated 自带”OnRep 在 Actor 进入相关性时也会触发”的语义,是天然解
  • Dash 是瞬时事件:远端玩家”晚到了一秒”就不应该再看到这次 Dash → Multicast Reliable 是一次性广播,不会回放历史,正好匹配
  • 这是 UE 网络范式里 “persistent state vs transient event” 选型的教科书案例

2.2 动画分层

  • 这套机制本质是一套动画蓝图层面的接口多态,可以一一对应到 OOP 概念:
编程世界 UE 动画世界
Interface(接口) Anim Layer Interface(ALI_ItemAnimLayers
抽象类(持有接口字段、定义骨架算法) Base ABP(ABP_Mannequin_Base,内部调用 Layer 函数取姿态)
实现类(每个具体策略) Layer ABP(每把武器的 ABP_*AnimLayers
依赖注入 / 策略模式 LinkAnimClassLayers 在运行时替换实现
模板方法模式 Base ABP 的状态机 = 模板方法;Layer 函数 = 钩子

3. 设计思路与架构决策

3.1 为什么 LinkAnimClassLayers 不能跨网复制

  • 背景:UE 的 Linked Anim Layer 切换(LinkAnimClassLayers / UnlinkAnimClassLayers)是把 AnimInstance 内部的某个 LinkedInputPose 节点替换成新实现类的子图。这本质是 FAnimInstanceProxy 的内部节点树原地重建
  • 问题:节点指针、内部状态、Sync Group 索引这些数据只在本端进程内有意义,无法序列化跨网。
  • 解决方案:将”应该使用哪个 Layer”的意图复制(bIsTargetLocked × CurrentWeapon),让每端各自重建本地节点树。
  • 副产品:因为是本地操作,每端都能维护自己的 LOD、AnimBudget 节流等本地优化,互不干扰。

3.2 为什么 Pivot 退出要双门闩(时间 ∧ 空间)

  • 问题:Pivot 状态需要播一段过渡动画,何时允许它退出?
    • 仅按时间(”播够 0.2s 就走”)→ 玩家如果反应快,急转弯刚开始就放,动画会一闪而过看起来怪
    • 仅按空间(”转到与原方向垂直就走”)→ Pivot 第一帧 Velocity 还没变,判据立即满足,导致 Pivot 一帧都没播过就跳到 Cycle
  • 方案:双门闩 AND
    • A: LastPivotTime ≤ 0(时间维度)—— 保证 Pivot 动画至少播 PivotMinDuration(0.2s)
    • B: bIsMovingPerpendicularToInitialPivot(空间维度)—— 保证角色真的转到了新方向
  • 关键支撑进入 Pivot 瞬间锁存初始速度方向PivotInitialDirection)。锁存这一刻通过 AnimGraph 的 OnStateEntered 事件触发 → AnimInstance 暴露 NotifyPivotEntered() 蓝图函数(标 BlueprintThreadSafe,因为 OnStateEntered 与 ThreadSafeUpdate 同线程语境)。

3.3 怎么用两个相同状态模拟 self-transition 实现”二次反打 Pivot”

  • 问题:UE 状态机不支持 self-transition(一个状态指向自身)。但玩家的真实手感需求是:A→B 急转中又改成 A→C 的二次反打,应该重新播一次 Pivot 起手。
  • 方案(来自 Lyra 动画示例的范式):在 AnimGraph 里挂两个内容相同的 PivotA / PivotB 状态,互相用一条 bWantsToRePivot 转移条件连起来;进入任一个时都触发同样的 NotifyPivotEntered 重置追踪。
  • bWantsToRePivot 的三条件 AND
    • A: VelocityAccelerationDot < 0 —— 当前确实处于 Pivot 语义(速度/加速度反向)
    • B: !bIsMovingPerpendicularToInitialPivot —— 上一次还没转完(否则该走正常 Pivot→Cycle)
    • C: Dot(CurAccel2D, PivotStartingAcceleration) < 0 —— 玩家确实做了二次反打输入(防止 A、B 已满足但玩家没操作时反复触发)
  • 三者缺一不可:A 是入场资格,B 是”还没结束”,C 是”玩家确有意图”。

3.4 AnimInstance 的多线程更新切分

  • 问题:动画系统每帧都要做大量计算(方向、Pivot、撞墙、Stride、距离匹配、空中状态机……),放在 GameThread 会拖慢主线程;放在 WorkerThread 又不能直接访问 Actor/Component(线程不安全)。
  • 方案:明确切分两个生命周期函数的职责
    • NativeUpdateAnimation (GameThread):只采集。从 Actor / Component / Controller 拉取原始数据,缓存到 this 上的成员变量
    • NativeThreadSafeUpdateAnimation (WorkerThread):只计算。所有逻辑只读写 this 的成员变量,不碰外部对象
  • 拆分到独立 .cpp(参考 UE 官方惯例):
    • 主体 .cpp —— 生命周期 + 数据采集
    • Locomotion .cpp —— 地面运动计算(含 Pivot / Wall / Stride / DistanceMatch)
    • AirState .cpp —— 空中状态计算
  • 额外约束:从 AnimGraph 的 OnStateEntered 调到 AnimInstance 的函数(如 NotifyPivotEntered)也跑在 ThreadSafe 语境,所以这类函数必须只读写自身成员,且要标 meta=(BlueprintThreadSafe)

4. Q&A

4.1 Cardinal Direction 离散化为什么要用迟滞带?怎么实现的?

参考回答

  • 如果不加迟滞,玩家在扇区边界(比如 Forward 与 Right 之间的 65° 边界)轻微抖动方向时,CardinalDirection 会快速来回切换,导致动画图频繁切动画造成视觉抖动。
  • 我的实现用 Schmitt Trigger 思路:当前方向作为函数输入,在判定时给当前方向对应的扇区”向外扩展” DeadZone(10°),其它扇区用基础角度。也就是:
    • Forward 扇区基础 [0°, 65°),迟滞后变成 [0°, 75°)(仅当 CurrentDir == Forward 时使用扩展)
    • 离开 Forward 时按基础 65° 切到 Left/Right
    • 重新进入 Forward 时也按基础 65° 切回
  • 关键点:迟滞函数必须是有状态的——必须传入”当前方向”作为参数,纯无状态函数无法实现迟滞。
  • 这套思路可以推广到任何”连续值 → 离散状态 + 抖动抑制”的场景:潜行/正常切换、AI 距离行为分级、LOD 切换等等。

4.2 在 Strafe 模式下处理 OrientationWarping 时,为什么要扣除 Cardinal 基准角?

参考回答

  • Strafe 子图里,Select by CardinalDirection 选出的定向动画(Forward / Backward / Left / Right)本身已经携带基础朝向:F=0°、R=90°、B=180°、L=-90°。
  • 如果直接把 LocomotionAngle 送给 OrientationWarping 节点做扇区贴合,会与动画自带朝向叠加产生二次旋转,骨骼在非 Forward 扇区出现明显扭曲(特别是 Backward 区域,动画自带 180°,Warping 又转 180°,相当于转了 360°)。
  • 解决就一行:
    1
    OrientationWarpingAngle = NormalizeAxis(LocomotionAngle - CardinalBaseAngle)
  • NormalizeAxis 处理边界情况,比如 LocomotionAngle = -170°、CardinalBaseAngle = 180°,相减得 -350°,归一化后是 +10°——正确表达”角色朝后但稍微偏左”的意图。
  • 正常模式(非 Strafe)下 CardinalDirection 恒为 Forward、CardinalBaseAngle = 0,公式退化为 OrientationWarpingAngle = LocomotionAngle,对正常模式完全无副作用。

4.3 Dash 你是怎么做网络同步的?为什么用 Multicast 而不是 Replicated?

参考回答

  • Dash 是瞬时事件而不是持久状态——远端玩家”晚到了一秒”就不应该再看到这次 Dash。Replicated 自带”OnRep 在 Actor 进入相关性时也会触发”的语义,会导致后进入相关性的玩家看到一次过期的 Dash 重放,所以不合适。
  • Multicast Reliable 是一次性广播,不会回放历史,正好匹配瞬时事件的语义。
  • 完整流程:
    1. 客户端 TryDash:本地冷却预判 → 立即 PerformDashLocal(预测)→ ServerDash RPC
    2. 服务器 ServerDash_Implementation:再做一次权威冷却校验 → PerformDashLocal(服务端权威)→ MulticastDash
    3. MulticastDash_Implementation:跳过 Authority 和 AutonomousProxy(这两端已经执行过了),让 SimulatedProxy 重演 PerformDashLocal
  • PerformDashLocal 内部做三件事:
    • 位移:LaunchCharacter(LaunchVel, bXYOverride=true, bZOverride=false) —— 官方推荐的瞬时位移 API,bXYOverride 防止与残余速度叠加,bZOverride=false 保留重力
    • 动画:按 Strafe 状态 + CardinalDirection 选 Montage,记录 ActiveDashMontage 用于结束回调精确比对
    • 蓝图回调:K2_OnDashStarted
  • 对比下,Strafe / 武器切换是持久状态,用 COND_SimulatedOnly Replicated + OnRep 才对——这种”持久态用 Replicated,瞬时事件用 Multicast”的选型是 UE 网络的教科书范式。