UE5 八向移动 简介(参考 Lyra)
1. 模块概述
- 本模块是一套基于 UE5 CMC 的,覆盖了从输入采集 → 角色 Yaw 跟随策略切换 → 动画状态机 4 主方向 + 8 方向贴合 → 起步/停步/急转/撞墙感知 → Distance Matching / Stride Warping → 多端动画一致性同步的完整链路。
- 模块在功能上回答了三类问题:
- 怎么把”玩家面向(相机)”和”实际移动方向(输入)”解耦 —— 为索敌/瞄准等场景提供 360° 横向跨步(Strafe)能力,同时保留非索敌时”跑哪儿朝哪儿”的轻盈感
- 怎么把连续的运动数据(速度向量、加速度向量、空中状态)转换成动画图能用的离散信号 —— 4 主方向枚举 + 不对称迟滞 + 双门闩 Pivot + 撞墙判定
- 怎么让动画切换在三端(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(空间维度)—— 保证角色真的转到了新方向
- A:
- 关键支撑:进入 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:
- 三者缺一不可: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 是一次性广播,不会回放历史,正好匹配瞬时事件的语义。
- 完整流程:
- 客户端
TryDash:本地冷却预判 → 立即PerformDashLocal(预测)→ServerDashRPC - 服务器
ServerDash_Implementation:再做一次权威冷却校验 →PerformDashLocal(服务端权威)→MulticastDash 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 网络的教科书范式。
评论




