RootMotionSource原理分析和插件封装

前言

说到Root Motion, 通常会想到的是Root Motion Animation, 将位移量嵌在Animation中, 角色Tick Pose时会计算frame之间的root bone的偏移量, 換算成velocity与acceleration, 然后PerformMovement, 如此可以表现到角色位移上, 但是在多人游戏中, Root Motion Animation是个不安定因素, 在网络延迟不稳定时, 玩家就会发生被Server拉回矫正的情况

Paragon中使用了RootMotionSource(下面简称RMS)这个技术, 策略是动画单独播放, 程序控制位移

RMS是一个非常规性的RootMotion技术, 本质上已经脱离了动画, 通过程序模拟来实现每一帧的根骨骼位移. 相比较手动SetActorLocation等等的暴力方式, 好处是这套流程是在角色移动组件里的(下面会细讲), 不仅使用方便也满足于角色移动同步流程.

录制_2022_05_07_17_43_17_140

如何使用

使用RMS最简单的方法是配合GAS使用, 见下图

image-20220120164653916

因为GAS本身可以满足同步, 所以我们可以简单的在GA里就直接调用这类API(K2Node), 但是如何在脱离GAS的环境下使用呢? 这个就需要模仿GAS的Task类封装一套函数库, 例如下面这样

image-20220120170355260

但是这样使用就需要自己手动同步两边的逻辑了

录制_2022_01_20_17_15_23_534~1

原理分析

RootMotion主要的代码都在角色的移动组件内, 即UCharacterMovementComponent中的PerformMovement()

我们按照阶段分类, 重点是看一些RMS相关的内容

准备阶段

准备阶段, 先会清理/重置一些过期数据

然后会判断是否需要执行RootMotion相关的逻辑, 这里的判断依据有几个, 看下面代码即可

image-20220120172642888

这个判定过了以后会先清理一波过期的RMS数据

然后再次进行一个一样的判定, 在两次之间其实就是做了一些数据的初始化, 然后这次判定过了以后就先判断是否是动画驱动的RootMotoin, 如果是那么就会执行TickCharacterPose(),这个逻辑会从动画中提取RootMotion数据设置到关键数据RootMotionParams上, 当中的过程本文省略

image-20220120193555533

然后如果是网络的情况, 那么处理本地和网络的RootMotionParams数据

image-20220120173424329

接下来就到了我们的RMS的处理, 这里会执行FRootMotionSourceGroup::PrepareRootMotion()的操作

这个过程以后我们的RootMotionParams已经是有数据了, 这个函数重点就是通过调用RMS具体对象的PrepareRootMotion()方法(后面我们通过具体案例讲解)来获取到这一帧应该得到的速度值

核心部分就是这里, 这里不通过动画, 而是通过一个RMS对象封装了一套位移的算法

这一步是最关键的, 我们RMS的数据就是这一步被提取出来的

FRootMotionSourceGroup::PrepareRootMotion

我们看重点, 首先根据优先级排序, 因为同一时间可能会有多个RootMotionSource, 在应用的时候会设置Prioty

然后根据不同的配置和服务端/客户端情况 计算得到当前期望的模拟时间, 作为参数传递给具体RMS对象的PrepareRootMotion()函数

我们这里使用的是RootMotionSource_JumpForce()那就会执行这个类的PrepareRootMotion()

实际上每个RMS类虽然数据有所差异, 但是大致逻辑没有差太多, 核心点就是计算得到RootMotionParams中所需要的偏移量

image-20220120174205955

这当中有几个讨巧的地方

默认位移就是按照线性的方式执行的, 但是RMS提供了几个曲线, 如

  • PathOffsetCurve: Vector曲线, 3个轴向随着时间做的偏移量
  • Time Mapping Curve: 时间的曲线, 因为动画中的位移肯定不是线性的, 所以就是把linear的time再做一层转换, mapping curve的結果值会直接当作lerp function的alpha, 也就是说0=起点, 1=终点

应用阶段

这里会区分动画驱动还是RMS的方式

如果是动画驱动, 那么首先就是通过ConvertLocalRootMotionToWorld()做root motion数据的空间转换, 这里就是MotionWarping的入口, 所以意味着我们用RMS的方式, 目前是无法实现MotionWarping的. 转换后就进行计算velocity的delta值

如果是RMS模式, 那么就从RMS对象中提取得到velocity(这里就是提取, 计算在Prepare阶段就完成了)

然后就把velocity反馈到移动计算当中了

当中有一部分代码是一些Debug和网络时间戳相关的代码, 这里先忽略了

多个RMS同时作用

在移动组件中的CurrentRootMotion成员一直是一个数组的存在, 也就是同时一直保存着所有的有效的RMS, 那么如何执行多个RMS呢?

首先需要RMS的覆盖类型, 即AccumulateMode是Override还是Additive, 如果是Additive,那么不论多少都是一起作用

如果是Override, 那么会根据优先级排序, 在Prepare阶段执行排序

1
2
3
4
5
6
7
8
9
10
11
12
if (RootMotionSources.Num() > 1)
{
RootMotionSources.StableSort([](const TSharedPtr<FRootMotionSource>& SourceL, const TSharedPtr<FRootMotionSource>& SourceR)
{
if (SourceL.IsValid() && SourceR.IsValid())
{
return SourceL->Priority > SourceR->Priority;
}
checkf(false, TEXT("RootMotionSources being sorted are invalid pointers"));
return true;
});
}

然后看到移动组件通过RMS设置速度的代码和RMS中提取速度的代码

1
2
3
4
5
6
7
FVector NewVelocity = Velocity;
CurrentRootMotion.AccumulateOverrideRootMotionVelocity(DeltaSeconds, *CharacterOwner, *this, NewVelocity);
if (IsFalling())
{
NewVelocity += CurrentRootMotion.HasOverrideVelocityWithIgnoreZAccumulate() ? FVector(DecayingFormerBaseVelocity.X, DecayingFormerBaseVelocity.Y, 0.f) : DecayingFormerBaseVelocity;
}
Velocity = NewVelocity;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void FRootMotionSourceGroup::AccumulateRootMotionVelocity
(
ERootMotionAccumulateMode RootMotionType,
float DeltaTime,
const ACharacter& Character,
const UCharacterMovementComponent& MoveComponent,
FVector& InOutVelocity
) const
{
check(RootMotionType == ERootMotionAccumulateMode::Additive || RootMotionType == ERootMotionAccumulateMode::Override);

// Go through all sources, accumulate their contribution to root motion
for (const auto& RootMotionSource : RootMotionSources)
{
if (RootMotionSource.IsValid() && RootMotionSource->AccumulateMode == RootMotionType)
{
AccumulateRootMotionVelocityFromSource(*RootMotionSource, DeltaTime, Character, MoveComponent, InOutVelocity);

// For Override root motion, we apply the highest priority override and ignore the rest
if (RootMotionSource->AccumulateMode == ERootMotionAccumulateMode::Override)
{
break;
}
}
}
}

所以找到最高优先级的Overide的RMS以后就break了;

同样的,如果应用了2个同名的RMS, 通过GetRootMotionSource获取的RMS对象, 只会获取当前最高优先级的同名RMS对象

RMS分析

RMS基类是FRootMotionSource, UE自己继承了好几个类, 如

  • FRootMotionSource_MoveToForce: 最基础的线性移动
  • FRootMotionSource_MoveToDynamicForce: 动态目标的移动, 举例如果上面的是MoveToLocation, 那么这个就是MoveToActor
  • FRootMotionSource_JumpForce: 其实就是抛物线运动

另外, RMS其实还有2个比较特殊的继承类, 因为RMS实际上是可以来用RootMotion来模拟类似物理中的AddImpulse/AddForce效果的, 相比于物理的力, RMS更可控, 下面就是2个已经封装好的力相关的RMS类

  • FRootMotionSource_RadialForce: 顾名思义, 范围力,类似AddRadialForce
  • FRootMotionSource_ConstantForce: 常量的力, 类似AddForce

先解释几个重要的基类变量的用途

  • Priority: 优先级, 结合AccumulateMode是覆盖还是叠加使用, 如果是覆盖那么同一时间只会使用优先级最高的RMS
  • AccumulateMode: 叠加/覆盖
  • InstanceName: 当前RMS对象的Key, 可以用来查找或者移除
  • Settings: 特殊标签, 一般使用默认即可, 如果要忽略Z方向可以用IgnoreZAccumulate
  • FinishVelocityParams: 完成 以后的速度模式, 默认就是维持RootMotion的速度, 也有设置速度和Clamp速度的选项

计算方式

Parepare时的计算的是前后两帧的delta速度值, 如果有相应的曲线, 就对时间或速度做一定偏移, 最后填充为为RootMotionParames的Velocity, 下图是Jump模式的主要计算代码

image-20220124191424403

扩展: 插件封装

虽然引擎已经自带了一些基础的API, 但是我们的目标是把他扩展成能适配多种场合的一项功能, 如果能类似MotionWarping的机制就更棒了

RootMotionSource开源插件GitHub地址

强调一点, UE自己的RMS实现没有旋转, 只有位移.

不过我们可以想办法加入旋转

基础API

ApplyRootMotionSource_MoveToForce

image-20220507172637196

点对点的移动, Start和Target都需要用角色中心作为参考点

拥有一个扩展Vector曲线做特定空间内的偏移(起点到终点为X轴, 此方向的右侧为Y轴, 下成RMS空间),

所有API都有一个ApplyMode, 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
UENUM(BlueprintType)
enum class ERMSApplyMode :uint8
{
//不做处理
None = 0,
//取代同名RMS, 会先把同名RMS移除掉
Replace,
//用同名的RMS优先级+1应用
ApplyHigherPriority,
//如果有同名的RMS,那就取消应用
Block,
//排队
Queue
};

因为有时候会有重复的同名RMS调用, 这个是为了解决冲突和优先级问题

另外所有不是必须的选项都统一放到一个结构体ExtraSetting

同时相比较GAS的用法多了StartTime, 即可以选择该路径中的某个时刻点开始位移

  • 2022.5.9更新: 添加了RotationSetting, Mode如果是Custom, 那么目标Rotator就是下面的TargetRotation, 如果是FaceToTarget就是Start指向Target的向量朝向

注意: 曲线偏移是局部空间的绝对值

ApplyRootMotionSource_JumpForce

image-20220507173040960

需要提供Height和Distance, 如下图, 角色跳跃的运行轨迹就是这样一个缺省的抛物线

image-20220124191018367

曲线的偏移比较遗憾, 并不是跟MoveToForce一样的绝对偏移值,而是接近于跳跃的抛物线, 就是你得自己算一个抛物线出来, 比较难以掌控, 测试发现使用缺省值还比较理想

image-20220124191658039

如果是按照下图的方式的曲线, 那么结果就是下面第二张图(左右反了)

image-20220124192511176

image-20220124192459992

ApplyRootMotionSource_DynamicMoveToForce

image-20220507173024559

动态目标的MoveTo, 需要UpdateDynamicMoveToTarget配合来修改目标点, 可以用来做类似AIMoveTo,目标点是Actor的操作, 但是如果目标变化过大, 会导致瞬切.另外由于设定了Duration, 所以到了时间就不会再继续跟着目标走了

录制_2022_04_13_20_50_15_326

ApplyRootMotionSource_ConstantForece/ApplyRootMotionSource_RadialForece

比较简单, 类似AddForce的效果, 暂略

移动扩展

ApplyRootMotionSource_MoveToForce_Parabola

image-20220413205509772

抛物线的移动, 提供了2个曲线, 一个定义抛物线形态, 一个定义时间缩放

image-20220413205544715

如果我们用上面的形态曲线,那么最终的结果如下

案例的分段数是8, 所以最终表现会有一些段落感, 分段越高越平滑

录制_2022_04_13_20_57_33_118

ApplyRootMotionSource_PathMoveToForce

image-20220507173228177

多路径点的MoveTo, 每个分段时间独立, 结果如下

录制_2022_05_07_17_33_47_406

适配动画的扩展

如果只是上面单纯的移动, 那么RMS没有办法代替稍微复杂一点的RootMotion, 比如攀爬/翻越这种, 那么这里我就思考了如何将RMS扩展成支持动画数据的移动

image-20220507173708734

基本上所有变量都有注释, 都不需要特殊解释, 不过有两种方式, 如果是bForwardCalculation的话原理是把动画数据提取出来生成动态的VectorCurve, 作为PathOffset传给MoveTo, 优点是大部分计算都在初始应用阶段, 之后移动组件每一帧提取数据时本质上跟MoveToForce没有任何区别. 反之那么所有数据都是通过Rumtime计算, 结果更为准确但是开销会大一点

按用途分大致分为了3类

  • ApplyRootMotionSource_SimpleAnimation

就是单纯的跟播放RootMotion动画是一样的, 使用原始的动画根骨位移信息

  • ApplyRootMotionSource_AnimationAdjustment

这个需要设定一个目标点,通过缩放从动画中提取的RootMotion数据来适配移动到目标点

image-20220124201957454

这里有几个参数需要注意

  1. AnimWarpingScale: 动画信息缩放的比例
  2. WarpingType: 因为写这个BM类型的时候没有参考MotionWarping的做法(实际上也不一定合适, 后续尝试修改)动画偏移的缩放是按照这个枚举的设置来决定缩放参考的
  3. WarpingAxis: 动画信息适配的是哪些轴, 比如攀爬很可能就是XZ平面就足够了, Y方向不需要有偏移, 因为动画本身实际上Y反向是有略微浮动的, 如果把这个信息加进去会稍微有点奇怪

4.19更新删除

  • ApplyRootMotionSource_AnimationWarping

这个就是多目标的前者, 这个目标通过动画通知来配置, 跟MotionWarping基本一致, 区别就是这种方式只需要在应用的第一时间传入对应的目标和目标的Key(FName)就行了

image-20220507174104994

这里的动画通知只需要配置一个FName, 是否使用旋转在API传参的时候确定

这个算法参考了MotionWarping, 实时计算每一帧对应的速度值和旋转

看最终表现

录制_2022_05_07_17_43_17_140

其他

同样的实现了几个预测位置的API, 分RMS正在Runtime运行的和离线的, 只看API应该能从字面上理解含义

image-20220414101634855

实验性功能

尝试写了常见的异步K2Node, 用于监听某个RMS结束以后的回调事件, 但是比较坑爹的是移动组件本身对RMS的移除没有进行广播, 所以这个就蛋疼了, 方法有2个, 要么改引擎, 要么就只有自己每一帧去查询RMS有没有存在

因为是插件, 所以只能是后者

用法

首先需要给角色加一个RMS组件, 确保组件的 ListenTaskEnd选项勾选

然后初始化

image-20220414101147636

接下里执行异步节点就可以收到回调事件了

image-20220414101224315

录制_2022_04_14_10_12_35_791

Update

  • 4.19: 优化SimpleAnimation和Adjustment的BM模式的计算, 删除了3个输入配置
  • 5.7:
    • 添加了RMS旋转
    • 整合了部分节点的2种算法, 用bForwardCalculation区别
    • 优化Dynamic算法, 目前修改位置和时间不会导致跳帧