《Exploring in UE4》RootMotion详解【原理分析】

本篇文章适合有一定经验的虚幻/游戏开发者,不过为了照顾部分刚接触虚幻引擎的萌新(大佬),也会对一些引擎中的基本概念做出讲解。


RootMotion概述

RootMotion,根骨格位移,属于移动组与动画系统相结合的一个部分,表示角色的整体运动(包括物理)是由动画来驱动的。

一般来说,在大部分游戏的应用里面,玩家的移动与动画是分开的。移动系统只负责处理玩家的位置与旋转,动画系统只做对应的动画表现,只要移动的速度合适就可以与动画做到完美的匹配,也就是说,动画播放的位置(即Mesh的位置)是由角色移动来驱动的(UE4里面,动画是由胶囊体的位置数据来驱动)。这样的好处之一就是解耦,移动与动画之间不需要紧密的联合,只关注自己的内容即可。

没有移动数据配合的动画

但是,有一些复杂的移动是很难模拟的,比如UE4官方给出的例子:一个举着锤子的人向前挥舞,一开始速度比较慢,中间挥舞时由于角色控制不住速度会很快,到最后锤子落地时,速度又变的很慢,角色会踉跄的走两步。

实际上,无论使用哪种方法,我们都很难找到一种可以处理所有类似表现的通用模拟方案。那么我们不模拟可以么?就让玩家动画播放到很远的位置再移动胶囊体呢?也不行,因为这样的话,如果中间有墙面,角色的动画就会因为没有碰撞而穿模过去。最理想的方法就是就是交给美术去做一个带有位移的动画,玩家的位置完全交给动画去处理,不同的动画可以有不同的移动表现。(还有一种通过曲线去处理的方法,不过两者其实是类似的)

因此,我们需要有RootMotion来应对部分复杂的动画,可以让角色的移动位置与动画完美匹配。

3A游戏里面几乎都会用到Rootmotion

RootMotion操作与测试

概念介绍完后,不妨动手测试一下。我们可以直接下载官方的ContentExample并打开Animation Level的案例1.9进行测试。可以看到绿色Character开启了Rootmotion,胶囊体会一直跟随动画移动,而红色Character没有开启Rootmotion所以胶囊体没有发生位移,动画由于没有任何碰撞穿了过去。

打开其角色蓝图与动画蓝图,简单看一下蓝图上的处理逻辑。

1.通过事件先设置其MovementMode为Walk,然后找到其AnimInstance转换为动画蓝图并执行事先定义好的事件——PlayRootMotionExample

2.PlayRootMotionExample位于动画蓝图,只是执行了一个Montage的播放。该Montage是由一个带位移的动画合成的,动画由动画设计师在Maya里面制作并将导出的.fbx文件导入到UE4里面。同时,需要让Montage的slot插槽设置为FullBody的并在动画图表里面播放该slot

3.只导入动画资源是不够的,需要做一些配置。首先,角色的根骨格应该在原点且无旋转的。其次,可以看到在动画序列(资源)蓝图的左边有一个RootMotion的选项栏,要勾选上EnableRootmotion。最后,在动画蓝图的右边AnimPreviewEditor(动画预览编辑器)的下面有一个RootMotionMode,选择RootMotionFromMontagesOnly。

RootMotionMode有四个选项,含义如下:

  • NoRootMotionExtraction 表示不会处理其动画内含的Transform数据
  • IgnoreRootMotion 表示会解压出来Transform数据但是不应用
  • RootMotionFromEverything 表示解析所有含有Rootmotion动画资源都可以去解析其Transform数据并参与到整个动画系统的权重计算
  • RootMotionFromMontagesOnly 表示只取蒙太奇的Transform数据作为Rootmotion的计算

另外,值得注意的是,该系统是支持网络同步的。当我们开启多人模式的时候,表现依旧流畅,这也是这篇文章的分析重点之一。

理论上AnimSequence, Blendspace, AnimMontage都可以支持网络同步,但是这样在网络环境下会有几个问题,比如提高动画同步的复杂度、影响游戏性能、提高预测难度、容易破坏动画表现等,所以一般我们要关闭Montage以外的同步,将Root Motion Mode设置为Root Motion From Montages Only。如果开启了动画资源的Rootmotion但是不设置RootMotionMode会有卡顿的表现。

实际上,虽然Rootmotion支持Montages的网络同步,但是由于其预测难度远大于普通移动,在网络环境不稳定的情况下,表现是相当糟糕的。因此,在多人游戏的时候,要适度的使用Rootmotion。

至于同步的具体细节以及为什么会这样,后面会有详细的分析。

(Youtube上有很多关于Rootmotion的教学视频,这里给出一个从零开始搭建并根据玩家输入控制的案例


UE4中RootMotion相关概念理解

前面从概念和使用上比较详细的做了讲解,下面我们开始深入源码。首先,不妨先思考一下Rootmotion的实现,其基本思路还是挺简单的,就是移动组件每帧不断的去读取动画数据里面的移动Transform,然后应用到实际的胶囊体上面。当然,具体实现起来可能与我们所想的有所差异,这里并不是直接获取坐标并设置。而且,涉及到的数据与类非常多,逻辑也比较复杂,我们需要同时理解动画系统以及移动组件的实现原理。

这里先给出涉及到的相关概念

【动画系统相关】

  • USkeletalMeshComponent:对应一个包含骨骼动画的Mesh模型,可以以组件的形式Attach到一个玩家身上
  • UAnimInstance:动画实例,其实就是C++版本的动画蓝图,每个SkeletalMeshComponent组件都会有一个UAnimInstance Class类型的配置项以及一个UAnimInstance类型的指针,我们动画的大部分逻辑都是在这里处理的。
  • FAnimInstanceProxy:动画实例代理,属于多线程优化动画系统的核心对象。他可以分担动画蓝图的更新工作,将部分动画蓝图的任务分配到其他线程去做。
  • FAnimMontageInstance:Montage动画实例,播放时会在动画实例UAnimInstance里面存储一个FAnimMontageInstance数组用于记录该实例的所有Montage。

另存一个Map记录当前激活的Montage

另外,还有一个特殊的FAnimMontageInstance来记录当前正常播放含有RootMotion的那个动画

  • FRootMotionMovementParams:用于累积计算Rootmotion的自定义结构体,可以临时存储当前帧的RootmotionTransform数据。FAnimMontageInstance保存一个用于临时缓存Rootmotion数据的成员变量
  • FRootMotionExtractionStep:由于一个蒙太奇动画里面可能有多个动画序列组成,所以在提取RootMotion数据的时候需要记录当前所在的片段以及具体位置,FRootMotionExtractionStep就是将这些数据封装并整合成一个结构体
  • FSlotAnimationTrack:组成当前Montage插槽片段的集合,下图里面有两个FSlotAnimationTrack。
  • FAnimTrack:一个FAnimTrack表示一个动画轨道,每个轨道可以由一个或多个Animsequence片段构成,下图的动画轨道就由三个动画序列构成
  • FRepRootMotionMontage: Character身上用于同步RootMotion相关信息的属性,包括UAnimMontage、执行位置、旋转、速度等
  • FSimulatedRootMotionReplicatedMove:在Character上以数组的形式存储,记录最近一段时间每帧的Rootmotion信息,用于服务器到Simulated客户端的数据同步

以下几个属于非常规性的Rootmotion,常规的Rootmotion不会使用到:

  • FRootMotionSource广义上的Rootmotion,本质上没有具体的动画数据,通过模拟力产生每帧的Transform信息。比如说玩家受击产生位移,如果是使用普通的受力属于物理影响,同步效果比较差。在移动组件里面集成FRootMotionSource,就可以使用类似Rootmotion的方式非常流畅处理角色的移动,同时还可以兼顾网络同步。
  • FRootMotionSourceGroup: containing active root motion sources being applied to movement ,包含了一组RootmotionSource的结构体。同一时刻可能有多个不同的力(或者说RootmotionSource)作用于玩家,移动组件可以根据权重优先级等混合出一个合理的移动位置。
  • FRootMotionServerToLocalIDMapping:用于同步匹配客户端和服务器上面FRootMotionSourceGroup里面不同的RootmotionSource。


【移动组件相关】

UE4里面移动的能力是被封装到一个组件里面的(组件式设计,大部分功能都被封装到不同的组件里),与真正的玩家角色分离,不同的角色可以配置不同的组件并设置各自的参数从而实现不同的移动效果

  • ACharacter:玩家角色,可以以一个人物角色的表现存在于游戏世界中,默认包含了一个骨骼Mesh组件、一个角色移动组件、一个胶囊体组件、一个箭头指示组件,有基本的移动、物理碰撞、模型显示的功能。只有使用ACharacter,我们才能完整的使用Rootmotion所有的相关功能
  • UMovementComponent:移动组件的基类,只包含基本的移动位置处理
  • UCharacterMovementComponent:提供一套完整的角色移动的解决方案,包括行走、游泳、飞行等状态,网络同步,插值优化,Rootmotion

关于移动组件,可以到我的另一篇文章里《Exploring in UE4》移动组件详解[原理分析]去查看,里面详细的描述了各种移动细节、同步流程、插值优化等


Rootmotion单机执行流程与原理

动画数据初始化:

【对于动画蓝图里面的动画数据】

1.绑定动画蓝图的Character进入场景时就已经开始了各种动画数据相关的初始化(UAnimInstance::InitializeAnimation),随后通过UpdateAnimation不断的更新动画蓝图里面的逻辑,同时把一部分逻辑交给了FAnimInstanceProxy处理。

【对于非动画蓝图里面的Montage数据】

2.一般是玩家手动触发Montage的播放,通过USkeletalMeshComponent找到对应的AnimInstance并执行UAnimInstance::Montage_Play

3.创建一个FAnimMontageInstance并进行相关的初始化,开始真正的播放蒙太奇

4.判断蒙太奇是否带有Rootmontion,是的话将赋值给RootMotionMontageInstance,用于后续的ACharacter::IsPlayingNetworkedRootMotionMontage判断

5.Montage初始化之后就会在后续每帧执行的AnimInstance::UpdateAnimation里面参与计算了。在通常情况下,只要我们的动画的更新方式不选择EVisibilityBasedAnimTickOption::OnlyTickPoseWhenRendered(有四种),Montage都是会参与更新的,更新逻辑在UAnimInstance::UpdateMontage里面


移动组件PerformMovement :

Prepare RootMotion阶段

1.移动组件在TickComponent的PerformMovement里,先判断其是否处于RootMotion的状态。满足下面两个条件之一即可先做一些Rootmotion相关数据的处理和清理:

  • a.CurrentRootMotion里面是否有ActiveRootMotionSources(即前面提到的非常规性的Rootmotion
  • b.通过当前角色身上的Mesh组件函数USkeletalMeshComponent::IsPlayingRootMotion判断其是否处于Rootmotion状态

2.再次进行判断来确保是否要更新动画系统。如果上面的b条件再次成立,就开始做准备工作了,即更新动画执行TickCharacterPose(TickPose默认在Mesh组件里面会每帧都去调用的,这里由于其Tick时机与Mesh组件不同所以需要强制执行一次),这里面会调用UAnimInstance::UpdateAnimation更新动画,并在MontageInstance->Advance(DeltaSeconds, RootMotionParams, bUsingBlendedRootMotion)将当前Montage位移信息更新到AnimInstance的成员变量ExtractedRootmotion里面。

那么这里可能会有一个疑问,Montage动画里面的Transform数据具体是怎么解析出来的呢?答案都在UAnimMontage::ExtractRootMotionFromTrackRange这个函数里面。我们需要将这一帧动画播放前的播放位置以及当前Montage执行到的位置作为参数传递进去,Montage就会从当前的轨道SlotTrack里面获取到所有AnimSequence片段(也就是下面堆栈图中的FAnimSegment),判断当前Montage处于哪一个AnimSequence片段并解析出对应的FRootMotionExtractionStep(该对象其实只是封装了对应的AnimSequence以及这一帧播放的起始位置)。
最后,根据对应的AnimSequence以及当前播放的起始位置计算出RootMotion的DeltaTransform,需要注意的是这里面得到的是一个位移而不是一个位置。(通过UAnimSequence::GetBoneTransform获取)

3.假如我们现在已经将Montage的信息提取到ExtractedRootmotion里面,但是动画蓝图里面的AnimSequence、BlendSpace等动画数据怎么一起参与计算呢?在概念理解里面我们提到了多线程优化的核心类FAnimInstanceProxy,默认情况下,动画蓝图里面的大部分更新逻辑都被放到了FAnimInstanceProxy里面,无论是否是多线程,我们都需要通过FAnimInstanceProxy::TickAssetPlayerInstances去处理相关逻辑,并在这里根据权重去将所有的动画资源的Rootmotion数据提取到ExtractedRootMotion里面。

如果RootMotionMode为RootMotionFromEverything,那么我们在主线程Tick的时候就会立刻去更新TickAssetPlayerInstances,这样是为了能及时获取到每一帧的Rootmotion信息。

主线程更新

如果RootMotionMode是其他模式,那么TickAssetPlayerInstances就会被放到其他线程里面取执行。

其他线程更新

同理,如果RootMotionMode为RootMotionFromEverything,在Proxy更新完毕后,我们需要及时地根据权重将所有参与计算的资源数据提取到ExtractedRootMotion里面。

如果RootmotionMode不为RootMotionFromEverything,那么我们可以在FParallelAnimationCompletionTask里面去更新Rootmotion相关数据(不过下面的堆栈仍然是在主线程执行的)

应用阶段:

1.TickPose之后,如果角色处于IsPlayingRootMotion状态就会去执行ConsumeRootMotion消耗掉Animinstance在前面阶段产生的ExtractedRootmotion,其实也就是将前面得到的ExtractedRootmotion数据复制到新的变量RootMotion里面并清空ExtractedRootmotion

2.得到的新的Rootmotion数据会先根据ACharacter的AnimRootMotionTranslationScale进行缩放调整,同时把其相关数据拷贝到移动组件的成员变量 RootMotionParams 里面

3.对RootMotionParams 进行局部到世界的坐标转换

4.执行移动模拟,也就是将前面得到的Transform应用到移动组件里面。这里会先根据当前的Rootmotion的DeltaTransform以及deltaTime算出一个速度AnimRootMotionVelocity进行模拟,具体逻辑在StartNewPhysics里(包括PhysWalking、Flying等),这里面不同的移动状态都会判断Rootmotion进而处理速度。注意:这一步并不会更新其Rotation

5.模拟结束,读取RootMotionParams的Transform来更新Rotation

6.清除移动组件的成员RootMotionParams里面的数据


有一点需要强调一下,Rootmotion是逐渐积累的,也就是说每次我们得到的Transform都是当前Tick时间内其移动的位移,而不是指一个特定的坐标。Accumulate函数就是表示将当前传入的DeltaTransform赋值到我们当前记录的数据上。
另外,上面还提到了一个Character身上的配置AnimRootMotionTranslationScale。由于动画师制作的动画长度是固定的,而游戏里面的需求是变化的,我们能保证每次角色移动的距离就是动画师设计的那样么?不能,比如玩家翻越一个障碍,障碍的大小不可能是严格一致的,所以我们需要一个参数去做适配调整,这个参数就AnimRootMotionTranslationScale,可以通过函数SetAnimRootMotionTranslationScale设置。

RootMotion的同步


目前的引擎中,Rootmotion只支持Montage的同步,这里分析的也只是基于Montage的同步流程。同步分为Simulated客户端以及Autonomous客户端两种情况,也就是说你显示屏上其他的玩家与你本地控制的玩家执行Rootmotion的同步流程是有差异的,这与移动组件的实现密切相关。如果对Simulated或者Autonomous不懂,可以参考《Exploring in UE4》关于网络同步的理解与思考[概念理解]


Simulated客户端同步

(没有收到服务器同步数据时与服务器的执行逻辑大致相同,只不过在SimulateTick里触发)

动画 Montage初始化:(同步只支持Montage)

1.服务器本地先触发执行MontagePlay并赋值给RootMotionMontageInstance

2.Simulated客户端在服务器触发MontagePlay后,通过属性回调随后触发MontagePlay

注意:这里第二步是需要开发者自己处理的,一般来说应该是服务器执行后修改某个属性,然后这个属性的回调函数触发客户端去执行MontagePlay,二者执行有一个短暂的网络延迟。

移动组件 SimulatedTick:

1.移动组件执行Tick ,UCharacterMovementComponent::SimulatedTick

2.如果当前玩家的Mesh对应的AnimScriptInstance→RootMotionMode为RootMotionFromMontagesOnly(也就是说其他三种ERootMotionMode不支持网络同步),触发Rootmotion在Simulated客户端的同步操作

3.TickCharacterPose,从动画当前位置里面解析出DeltaTransform,用AnimInstance上的ExtractedRootMotion提取出来。具体细节可以参考上面的单机流程

4.根据CharacterOwner->GetAnimRootMotionTranslationScale()设置RootMotion的Scale并提取到移动组件的RootMotionParams里面

5.调用SimulateRootMotion转换到世界坐标,计算Rootmotion的速度并开始调用StartNewPhysics进行模拟,与单机版本不同,其模拟流程都在函数SimulateRootMotion里面处理。这里有一点需要特别注意,由于Simulated客户端通过网络同步,可能因为网络波动而卡顿,所以这该函数只会先更新胶囊体的位置(bEnableScopedMovementUpdates为true即可),而不更新Mesh的位置,Mesh需要通过本地的Tick去平滑,其逻辑在下面会进一步描述。

6.模拟完毕后,如果Rotation不为默认值FQuat::Identity就会通过MoveUpdatedComponent修改,清除临时提取的RootMotionParams的相关数据

上面提到的是客户端模拟的流程,不过既然是同步,当然需要客户端与服务器交互了

注意:玩家身上有一个FRepRootMotionMontage RepRootMotion记录了每帧服务器的Rootmotion的运行信息,包括当前帧的坐标、旋转、Montage、速度、执行的位置等。这个属性是同步的(而且只在播放Rootmotion的时候同步),在服务器播放Rootmotion的时候每帧都会通过ACharacter::PreReplication处理这些数据并发送给Simulated客户端
数组TArray<FSimulatedRootMotionReplicatedMove> RootMotionRepMoves;存储了服务器发来中的所有Rootmotion的数据,前面提到的RepRootMotion在客户端的回调函数里会被提取数据并添加到RootMotionRepMoves数组里面。

7.客户端模拟移动后会开始根据服务器的Rootmotion信息开始校验,执行函数ACharacter::SimulatedRootMotionPositionFixup。这里会先判断客户端是否要用这个RootMotionRepMoves数组里面的数据(条件:小于0.5秒,在同一个Section片段,非循环Montage,服务器落后于客户端的位置最接近客户端的那个)

8.如果寻找到满足条件的数据RepRootMotion,就会先通过ACharacter::RestoreReplicatedMove按照服务器传递的数据修改本地角色的坐标与旋转。随后,按照当前服务器的位置以及客户端的位置执行ExtractRootMotionFromTrackRange实现Rootmotion的回滚,这里只是为了得到一个回滚的结果。

9.最后,根据deltaposition、playrate算出一个deltatime,再根据回滚结果LocalRootMotionTransform进行一次本地模拟SimulateRootMotion。

10.模拟之后调用SmoothCorrection进行平滑处理。前面提到SimulateRootMotion只会更新胶囊体位置而不更新Mesh的位置,就是为了在这里进行平滑。平滑的逻辑大概就是客户端记录一个ClientData数据去记录当前的Mesh偏移以及服务器的时间戳,在随后的每帧的Tick里面,不断的更新Offset偏移,让其逐渐为0,当偏移为0的时候Mesh就和胶囊体完全重合完成了平滑。具体的逻辑可以参考我的另一篇文章《Exploring in UE4》移动组件详解[原理分析]

Autonomous客户端的同步流程

Montage初始化:

1.客户端本地先执行Montage播放,通过RPC通知服务器播放

2.服务器收到RPC触发MontagePlay并赋值给RootMotionMontageInstance

(当然,这里也可以采用类似simulated客户端的方式,先在服务器播放,通过属性回调触发Autonomous客户端进行播放)


移动组件 ReplicateMoveToServer(由于逻辑重复,这里会对一些步骤做简化):

1.类似上面的simulated客户端执行TickComponent,不过这里每帧触发的函数不是SimulatedTick而是ReplicateMoveToServer。随后执行PerformMovement里面提取Rootmotion的信息(执行TickCharacterPose)

2.根据deltatime与DeltaTransform计算速度,调用StartNewPhysics进行Rootmotion移动模拟

3.更新Rotation,清除临时提取的RootMotionParams的相关数据。将本次移动的相关数据存放到FSavedMove_Character里面,记录这次移动并存储到SavedMoves数组里面(用于回滚等)

4.执行CallServerMove,将本地计算的数据发送到服务器,调用RPC函数ServerMove

(这里如果遇到了PendingMove执行优化移动的情况,则需要执行特殊的RPC ServerMoveDualHybridRootMotion)

服务器处理:

5.服务器根据客户端信息执行MoveAutonomous重新模拟计算,并根据结果判断客户端移动是否合法。

6.随后,在服务器执行UNetDriver::ServerReplicateActors的同步时,发送ACK(ClientAckGoodMove)或者Adjust(SendClientAdjustment)。如果这时候服务器正在播放Montage且发现客户端数据有问题,就会执行ClientAdjustRootMotionSourcePosition进行纠正。否则,就会执行正常的纠正逻辑。

7.客户端如果收到ClientAdjustRootMotionSourcePosition信息,首先会根据服务器传递来坐标等信息先更新移动组件的数据(执行ClientAdjustPosition),随后根据服务器传递的Montage Position来直接设置本地Montage动画的Position。

Autonomous客户端的同步流程其实也很复杂,建议先看一下官方文档或者我前面提到的文章来做一个大体的理解。



梳理上面的同步逻辑,其实我们发现RootMotion本质上走的还是移动组件的处理流程,只不过其移动数据是从动画里面提取的。而且很明显的可以看出,Rootmotion只支持Montage的同步,其他的模式根本走不进这套流程(IsPlayingNetworkedRootMotionMontage),其他模式也不会从动画蓝图Proxy里面提取相关的数据(FAnimInstanceProxy::TickAssetPlayerInstances)。

前面我们提到,之所以这么做根本原因是动画系统(或者说动画状态机)的同步是复杂而困难的,目前虚幻引擎通用的动画同步方法是客户端与服务器各自维护一个状态机以及几个同步的属性值,然后通过这些属性的判断来同步动画,这里的动画状态机并没有同步。一旦动画系统复杂起来,各个状态的间的切换与转变都会变得极为复杂,由于网络环境的不稳定,状态机的同步需要非常严密的设计与处理(当然,这也并不是说做不了),最好把每一帧的状态与触发事件都同步过去,再进一步做更多额外的校验工作。所以,我们看到很多游戏的动画除了基本的状态外,其他很多都是通过Montage来同步的,Montage之间可以打断而且独立,所以同步起来相对容易。

另外,因为Rootmotion本质上就是为了提高表现效果才使用的方案,单机模拟都比较困难,对于还需要预测的网络同步就更困难了,或者说除非是常规的线性运动的Rootmotion,其他的不规则的运动几乎无法预测。而如果不预测,我们就很难应对网络抖动,一旦网络一卡整个表现就不再流畅。所以,网络环境不好的情况下,Rootmotion的表现是相当差的。

编辑于 2019-07-22

文章被以下专栏收录