MotionWarping深度研究

前言

UE5新加了实验性的插件MotionWarping, 先前已经有很多大佬写过类似的文章, 本文主要针对部分源码进行其工作原理的深入理解和相关RootMotion的思考

image-20211027191359070

image-20211027192501459

录制_2021_10_27_19_12_34_828

从哪里开始?

我们先从这里开始看, 我们需要找到这个模块是在哪里修改了RootMotion信息, 先看调用栈

image-20211026171303258

1
2
3
4
5
6
FTransform UCharacterMovementComponent::ConvertLocalRootMotionToWorld(const FTransform& LocalRootMotionTransform, float DeltaSeconds)
{
const FTransform PreProcessedRootMotion = ProcessRootMotionPreConvertToWorld.IsBound() ? ProcessRootMotionPreConvertToWorld.Execute(LocalRootMotionTransform, this, DeltaSeconds) : LocalRootMotionTransform;
const FTransform WorldSpaceRootMotion = CharacterOwner->GetMesh()->ConvertLocalRootMotionToWorld(PreProcessedRootMotion);
return ProcessRootMotionPostConvertToWorld.IsBound() ? ProcessRootMotionPostConvertToWorld.Execute(WorldSpaceRootMotion, this, DeltaSeconds) : WorldSpaceRootMotion;
}

角色移动组件里有这个把RootMotion数据从本地转换成世界的方法

这里有两个带返回值的代理, 分别针对预处理Pre和后处理Post

1
2
3
4
5
6
7
8
9
void UMotionWarpingComponent::InitializeComponent()
{
//..............
if (CharacterMovementComp)
{
CharacterMovementComp->ProcessRootMotionPreConvertToWorld.BindUObject(this, &UMotionWarpingComponent::ProcessRootMotionPreConvertToWorld);
CharacterMovementComp->ProcessRootMotionPostConvertToWorld.BindUObject(this, &UMotionWarpingComponent::ProcessRootMotionPostConvertToWorld);
}
}

MotionWarping组件中绑定了移动组件的2个代理, 在Pre函数中调用了Update()函数(后面讲)来刷新修改类器URootMotionModifier信息

然后统一都执行修改器类的虚函数ProcessRootMotion()修改变换信息

小结: MotionWarping的思路就是对移动组件需要用到RootMotion数据的前后(Pre/Post)进行数据额外处理, 最终反馈给移动组件进行更进步的RootMotion的处理

动画通知

image-20211026172347474

image-20211026172613061

MotionWarping使用第一步就是往动画里添加动画通知

看了源码发现这个通知不按常理出牌, Begin/Tick/End虚函数都没重写, 所以, 这个通知就真的只是一个配置作用

如果动画通知是放在AnimSequence上的, 那么需要勾选MotionWarping组件的bSearchForWindowsInAnimsWithinMontage选项, 因为默认是只在蒙太奇内查找, 勾选以后会额外从蒙太奇的每个片段动画序列中去查找, 同时会增加开销

image-20211026180158230

UMotionWarpingComponent

Update()

看名字以为是Tick()函数, 实则是上面从移动组件监听的代理的回调事件, 在Pre过程中先调用Update()函数, 换句话说就是RootMotion有更新的时候会先调用到这里

用伪代码来描述这个过程

1
2
3
4
5
6
7
8
9
10
11
12
13
if(当前有播放蒙太奇)
{
遍历蒙太奇所有通知
{
if(当前播放位置在通知返回内)
{
if(通知里的修改器对象骄傲没有添加到数组中)
{
通过通知类激活修改器对象并添加到数组中
}
}
}
}

这里要稍微看一下通知里的一个方法 UAnimNotifyState_MotionWarping::OnBecomeRelevant()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void UAnimNotifyState_MotionWarping::OnBecomeRelevant(UMotionWarpingComponent* MotionWarpingComp, const UAnimSequenceBase* Animation, float StartTime, float EndTime) const
{
URootMotionModifier* RootMotionModifierNew = AddRootMotionModifier(MotionWarpingComp, Animation, StartTime, EndTime);

if (RootMotionModifierNew)
{
if (!RootMotionModifierNew->OnActivateDelegate.IsBound())
{
RootMotionModifierNew->OnActivateDelegate.BindDynamic(this, &UAnimNotifyState_MotionWarping::OnRootMotionModifierActivate);
}

if (!RootMotionModifierNew->OnUpdateDelegate.IsBound())
{
RootMotionModifierNew->OnUpdateDelegate.BindDynamic(this, &UAnimNotifyState_MotionWarping::OnRootMotionModifierUpdate);
}

if (!RootMotionModifierNew->OnDeactivateDelegate.IsBound())
{
RootMotionModifierNew->OnDeactivateDelegate.BindDynamic(this, &UAnimNotifyState_MotionWarping::OnRootMotionModifierDeactivate);
}
}
}

之前说的动画通知啥也没干也不完全正确, 这里动画通知创建了修改器(Modifier)对象, 把动画数据塞给它, 同时监听修改器类的回调事件, 然后调用到是蓝图事件

image-20211026173754546

理论上可以进行蓝图扩展, 不过怎么感觉也不是很方便呢? 你给我一个const函数图我修改啥?


接下来就是遍历所有修改器对象刷新其状态, 如有必要广播修改器对象的内部代理事件, 然后移除标记为MarkedForRemoval的修改器对象

WarpTargetMap

1
2
UPROPERTY(Transient)
TMap<FName, FMotionWarpingTarget> WarpTargetMap;

只用通知不能运行MotionWarping, 需要通过代码或者蓝图Api往组件内添加WarpTargetMap成员

image-20211026175825781

每个动画通知都配置有一个FName用来查找WarpTarget, 在完成以后会标记为移除

Modifier类

对RootMotion数据处理的核心就是这个类, 在插件内派生了下面几个类

URootMotionModifier

  • URootMotionModifier_Warp
    • URootMotionModifier_SkewWarp
    • URootMotionModifier_AdjustmentBlendWarp
    • UDEPRECATED_RootMotionModifier_SimpleWarp(这个在5.1中被作废了, 可以用SkewWarp代替)
  • URootMotionModifier_Scale

接下来逐一细看

下面这部分主要是数据和计算, 可以略过

URootMotionModifier

基类, 声明了与动画相关的基础数据, 如开始/结束时间, 上一帧/当前帧的时间等等, 这些数据都不需要在动画通知中配置, 另外也定义了通知的状态枚举和相应的代理, 一部分声明属性如下

1
2
3
4
5
6
7
TWeakObjectPtr<const UAnimSequenceBase> Animation = nullptr;
float StartTime = 0.f;
float EndTime = 0.f;
float PreviousPosition = 0.f;
float CurrentPosition = 0.f;
float Weight = 0.f;
FOnRootMotionModifierDelegate OnActivateDelegate;

FMotionWarpingTarget

作为适配目标信息的一个数据结构, 在MotionWarpingComponent中最为TMap存储起来, 主要属性如下

1
2
3
4
FTransform Transform;
TWeakObjectPtr<const USceneComponent> Component;
FName BoneName;
bool bFollowComponent;

URootMotionModifier_Warp

我们在蓝图中可以配置的属性都在这个类中申明, 在UE5.1中额外增加了WarpPointAnimProvider以及于此相关属性

重写了update()函数, 主要增加了EWarpPointAnimProvider相关的逻辑

EWarpPointAnimProvider

主要用来定义目标Transform的空间, 默认为None, 即设定的目标Transform就是最终的目标, 可以选择静态或者骨骼两个类型来进行转换

  • static
1
2
3
4
5
6
7
if (WarpPointAnimProvider == EWarpPointAnimProvider::Static)
{
RootTransform = MeshCompRelativeRotInverse * UMotionWarpingUtilities::ExtractRootTransformFromAnimation(GetAnimation(), EndTime);
WarpPointTransform = MeshCompRelativeRotInverse * WarpPointAnimTransform;
}
CachedOffsetFromWarpPoint = RootTransform.GetRelativeTransform(WarpPointTransform);
TargetTransform = CachedOffsetFromWarpPoint.GetValue() * WarpPointTransformGame;

所以就是静态的目标TargetTransform 进行了一个偏移运算

  • Bone
1
2
RootTransform = MeshCompRelativeRotInverse * Pose.GetComponentSpaceTransform(FCompactPoseBoneIndex(0));
WarpPointTransform = MeshCompRelativeRotInverse * Pose.GetComponentSpaceTransform(FCompactPoseBoneIndex(1));

类似的省略, 主要就是上述两行代码, 选择设定的BoneName的骨骼以及下一级骨骼的空间作为参考进行Transform变换, 暂时没想到可以用于哪种场景

EMotionWarpRotationType

旋转类型, 分DefaultFacing两种

Default即保持原来的旋转

Facing即一直保持LookAt目标Location

URootMotionModifier_SkewWarp

计算都放在函数ProcessRootMotion()中, 包括其他几个修改器类

用伪代码看看这个类怎么处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
从动画数据中获取最终的变换信息(Transform);
从动画数据中获取delta变换信息;
if(开启位置适配 && delta信息不为0)
{
最终变换信息转换到世界空间;
delta变换信息转换到世界空间;
计算原始的偏移和期望的偏移, 这里包括最终的和delta的;
计算出最适配的轴向并构造出最佳空间变换信息;
把前面的变换信息转换到最佳空间中;
计算yaw和pitch的偏移角度和缩放值;
把偏移角度和缩放更新到新的变换信息FinalRootMotion的Translation中;
}
if(开启旋转适配)
{
计算期望和当前旋转的偏移;
把偏移应用到FinalRootMotion的Rotation中;
}

}

简单概括就是通过改变RootMotion的delta位置和旋转信息来达到期望目标

URootMotionModifier_AdjustmentBlendWarp

计算比较复杂, 具体不展开, 参考知乎大佬的结论

Adjustment Blend Warp缩放算法采用与Simple Warp完全不同的缩放思路。该算法是在第一次缩放时,就计算好窗口期间,root骨骼在组件空间下的缩放差值,也就是root骨骼的Mesh空间缩放叠加动画,每帧对RootMotion源动画并应用缩放叠加动画,从而实现缩放。采用这种算法,不仅可以针对root骨骼叠加动画数据进行缩放,理论还能针对骨架上的任意骨骼进行同样的缩放,因此Adjustment Blend Warp缩放支持配置,针对ik骨骼进行缩放。比如对带脚步移动的RootMotion动画应用Adjustment Blend Warp缩放,不仅能够灵活缩放位移,配合TwoBone IK,还能规避缩放位移导致的腿部滑步表现。

URootMotionModifier_Scale

简单粗暴的在窗口期缩放RootMotion的数据, 适用场景有限

关于RootMotion

可以从上面得知, MotionWarping从动画资源中获取RootMotion数据, 那么有必要看一下这个数据怎么获取

还是从移动组件的PerformMovement()开始

RootMotion数据是从下面代码中的TickCharacterPose()获取的

1
2
3
4
if( CharacterOwner->IsPlayingRootMotion() && CharacterOwner->GetMesh() )
{
TickCharacterPose(DeltaSeconds);
}

首先需要判断是否处于RootMotion中, 然而看代码到深处, 看到如果RootMotion模式使用的默认RootMotionFromMontagesOnly的情况下, 那就取决于当前的MontageInstance对象是否开启了RootMotion, 测试发现这个MontageInstance在蒙太奇BlendOut的时候已经被清理了,

这个要从Montage的刷新开始看

image-20211027155327565

上图看调用栈, 重点是要看一下什么时候清理, 看下面代码, 位于FAnimMontageInstance::Advance()

1
2
3
4
5
if (PlayTimeToEnd <= FMath::Max<float>(BlendOutTriggerTime, KINDA_SMALL_NUMBER))
{
const float BlendOutTime = bCustomBlendOutTriggerTime ? DefaultBlendOutTime : PlayTimeToEnd;
Stop(FAlphaBlend(Montage->BlendOut, BlendOutTime), false);
}

所以就解释清楚了

那么如果改成RootMotionForEveryThing呢?

测试发现可以正常的进行计算, 但是RootMotion数据会因为混合权重值而进行缩放, 大致可以看下面位于AnimInstance中的代码

1
2
3
4
5
6
7
// blend in any montage-blended root motion that we now have correct weights for
for(const FQueuedRootMotionBlend& RootMotionBlend : RootMotionBlendQueue)
{
const float RootMotionSlotWeight = GetSlotNodeGlobalWeight(RootMotionBlend.SlotName);
const float RootMotionInstanceWeight = RootMotionBlend.Weight * RootMotionSlotWeight;
ExtractedRootMotion.AccumulateWithBlend(RootMotionBlend.Transform, RootMotionInstanceWeight);
}

这个ExtractedRootMotion是被提取的RootMotion数据, 那么即使Modifier对象能拿到数据, 这个数据也是被缩放过的

那么理论上来说, 如果能拿到数据, 即使缩放过, 再反向补偿回去是否可行?

然而并不能

1
2
3
4
void UMotionWarpingComponent::Update()
{
const FAnimMontageInstance* RootMotionMontageInstance = GetCharacterOwner()->GetRootMotionAnimMontageInstance();
}

上面这一行代码已经把MotionWarping的处理可以终止了, 因为已经拿不到MotionMontageInstance对象了

总结

  1. MotionWarping改的是CharacterMovementComponent对RootMotion处理阶段的中间数据
  2. MotionWarping通过动态创建的Modifier修改器对象来修改
  3. 动画通知用来配置时间窗口和修改器类型
  4. 需要配合动画通知窗口在通知运行之前设定目标位移/旋转
  5. MotionWarping依赖RootMotionInstance对象, 此对象在蒙太奇BlendOut的时候会被清理, 所以BlendOut过程中没有MotionWarping效果

参考文献

  1. 知乎大佬
  2. 油管InsideUnreal
  3. Epic官网