UE BlendSpace处理SyncMarker相关代码研究
参考:https://arrowinmyknee.com/2020/10/13/deep-dive-into-blendspace-in-ue4/
主要是对UE的Sync相关的代码不太理解,然后BlendSpace在SyncMarker作用时出的Bug不太好查,所以写下了这篇文章
列一下学习之前的几个疑问,比较简单的我就直接写内容上去了:
- BlendSpace如何处理SyncMarker的
- BlendSpace什么时候会启用SyncMarker,是不是只要有一个Sample对应的Sequence使用了SyncMarker,就可以启用SyncMarker了
- SyncMarker与SyncGroup的区别:貌似SyncMarker在没有SyncGroup的时候也能起作用,比如BlendSpace里应该只需要SyncMarker即可
- UE5 Editor与Runtime下Tick的区别
- FAnimNode_SingleNode的用法
- FMarkerTickRecord类的用法:TODO
- FAnimSyncGroupScope的作用: 应该只是个Wrapper类,方便调用FAnimSync.AddRecord而已
- FAnimSync的作用:负责tick那些带SyncMarker和SyncGroup的动画
背景
先捋一下主要的动画逻辑
动画的执行逻辑
UE正常的动画节点都是在两个重要函数阶段执行以下操作的:
- 在Update_AnyThread里执行权重的计算、以及计算Tick后的播放时间
- 在Evaluate_AnyThread里基于前面计算的权重和时间,计算出实际的Pose
这两个阶段都发生在SkeletalMeshComponent的Tick过程中,Update阶段发生于SkeletalMeshComponent.TickPose函数里,Evaluate阶段发生于SkeletalMeshComponent.RefreshBoneTransforms函数里,执行顺序很合理,是先Update,再Evaluate。
涉及到具体代码调用时,动画部分是从USkeletalMeshComponent.TickPose开始,逐一里面调用AnimInstance的UpdateAnimation函数,这里的SkeletalMeshComponent里会依次顺序调用以下三种AnimInstance:
- LinkedInstances数组里的AnimInstance
- AnimScriptInstance(类型为AnimInstance)->AnimInstance: 比如Editor下预览BlendSpace资产,就是调用的此函数
- PostProcessAnimInstance->UpdateAnimation
再在UAnimInstance的UpdateAnimation函数里,它会分为以下的主要步骤:
- Tick Montage动画
- PreUpdateAnimation阶段
- 再次Tick Montage动画
- NativeUpdateAnimation
- BlueprintUpdateAnimation
- ParallelUpdateAnimation:UE内部支持的动画节点一般在这个函数里执行,比如执行UBlendSpace::TickAssetPlayer
- PostUpdateAnimation
所以Tick动画的核心部分就在AnimInstance.ParallelUpdateAnimation里
关于AnimInstance.ParallelUpdateAnimation
它其实只是个空壳子,调用的是FAnimInstanceProxy::UpdateAnimation,里面做的事情还是挺清楚的:
- 调用UpdateAnimation_WithRoot函数:也就是遍历每个动画节点的Update_AnyThread函数,从而调用各资产的TickAssetPlayer函数
- 调用Sync.TickAssetPlayerInstances,这里就是SyncMarker起效的部分了,在动画资产都提交了TickRecord指令后, 在这里统一Tick它们的时间
关于TickAssetPlayer函数
Unreal里其实有两种TickAssetPlayer函数,一种定义在UAnimationAsset里,另一种定义在FAnimNode_AssetPlayerBase里。这是由于对于有着动画播放的AnimNode,它们有一些通用的东西,比如权重这些数据,因此UE把它自行占用了,至于资产各自对应的动画节点需要Update的内容则挪到了TickAssetPlayer函数里,代码如下:
void FAnimNode_AssetPlayerBase::Update_AnyThread(const FAnimationUpdateContext& Context)
{
// Cache the current weight and update the node
BlendWeight = Context.GetFinalBlendWeight();
bHasBeenFullWeight = bHasBeenFullWeight || (BlendWeight >= (1.0f - ZERO_ANIMWEIGHT_THRESH));
// 此类的UpdateAssetPlayer只是空的虚函数
UpdateAssetPlayer(Context);
}
而各自Asset对应的AnimNode会调用自己的AnimationAsset.TickAssetPlayer函数,总的来说,其实UpdateAssetPlayer就等同于Update_AnyThread函数
BlendSpace如何处理SyncMarker的
这里分为Editor下的Tick和Runtime下的Tick,UE的Runtime是使用AnimInstance来执行AnimGraph的逻辑的,而UE的编辑器下使用的是AnimPreviewInstanceProxy,继承关系为:
struct ANIMGRAPH_API FAnimPreviewInstanceProxy : public FAnimSingleNodeInstanceProxy
struct ENGINE_API FAnimSingleNodeInstanceProxy : public FAnimInstanceProxy
但无论是哪种形式的Tick,BlendSpace的Tick逻辑都是类似的,它们都是在UpdateAnimation_WithRoot阶段创建AnimTickRecord对象,加入到Proxy里,再调用Sync.TickAssetPlayerInstances统一Tick即可,这里的Sync对应的AnimSync对象存在于Proxy里。
Editor Preview的Tick过程
拿预览BlendSpace资产为例,此时的AnimInstance类型为UAnimPreviewInstance
,AnimInstanceProxy类型也为AnimPreviewInstanceProxy
。
正常Runtime下的Tick逻辑是在FAnimInstanceProxy::UpdateAnimation函数里的UpdateAnimation_WithRoot函数,从RootNode开始逐一调用UpdateAnimationNode_WithRoot
函数,实现整个AnimGraph里动画节点的Update过程。
这里的PreviewInstance则是直接Override了此函数,代码如下:
void FAnimPreviewInstanceProxy::UpdateAnimationNode(const FAnimationUpdateContext& InContext)
{
...
else
{
FAnimSingleNodeInstanceProxy::UpdateAnimationNode(InContext);
}
}
进入之后,发现会调用FAnimNode_SingleNode::Update_AnyThread
函数
void FAnimSingleNodeInstanceProxy::UpdateAnimationNode(const FAnimationUpdateContext& InContext)
{
UpdateCounter.Increment();
SingleNode.Update_AnyThread(InContext);
}
这里的FAnimNode_SingleNode
继承于FAnimNode_Base
,是定义在FAnimSingleNodeInstanceProxy.h
里的特殊节点,可以理解为,原本要对AnimInstance里的每个AnimNode执行Update和Evaluate操作,而这里的AnimPreviewInstance只需要对这单独的一个Node执行上述操作。
看了下,这个SingleNode,它支持播放的动画格式有:
- UBlendSpace
- UAnimSequence
- UAnimStreamable
- UAnimComposite
- UAnimMontage
- PoseAsset
类声明如下:
/**
* Local anim node for extensible processing.
* Cant be used outside of this context as it has no graph node counterpart
*/
USTRUCT(BlueprintInternalUseOnly)
struct ENGINE_API FAnimNode_SingleNode : public FAnimNode_Base
{
friend struct FAnimSingleNodeInstanceProxy;
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Links)
FPoseLink SourcePose;
// Slot to use if we are evaluating a montage
FName ActiveMontageSlot;
// FAnimNode_Base interface
virtual void Evaluate_AnyThread(FPoseContext& Output) override;
// 里面会根据Asset类型, 基于proxy的数据, 创建各自的TickRecord, 然后加到全局的FAnimSyncGroupScope里
virtual void Update_AnyThread(const FAnimationUpdateContext& Context) override;
// End of FAnimNode_Base interface
private:
/** Parent proxy */
FAnimSingleNodeInstanceProxy* Proxy;// 可以通过Proxy获取要Update和Evaluate的动画资源
};
关于BlendSpace的Tick代码如下:
void FAnimNode_SingleNode::Update_AnyThread(const FAnimationUpdateContext& Context)
{
float NewPlayRate = Proxy->PlayRate;
UAnimSequence* PreviewBasePose = NULL;
if (Proxy->bPlaying == false)
{
// we still have to tick animation when bPlaying is false because
NewPlayRate = 0.f;
}
if(Proxy->CurrentAsset != NULL)
{
UE::Anim::FAnimSyncGroupScope& SyncScope = Context.GetMessageChecked<UE::Anim::FAnimSyncGroupScope>();
if (UBlendSpace* BlendSpace = Cast<UBlendSpace>(Proxy->CurrentAsset))
{
FAnimTickRecord TickRecord(
BlendSpace, Proxy->BlendSpacePosition, Proxy->BlendSampleData, Proxy->BlendFilter, Proxy->bLooping,
NewPlayRate, false, false, 1.f, /*inout*/ Proxy->CurrentTime, Proxy->MarkerTickRecord);
TickRecord.DeltaTimeRecord = &(Proxy->DeltaTimeRecord);
// 内部其实是通过Proxy.AnimSync添加TickRecord到Proxy的相应的数组里
SyncScope.AddTickRecord(TickRecord);
TRACE_ANIM_TICK_RECORD(Context, TickRecord);
#if WITH_EDITORONLY_DATA
PreviewBasePose = BlendSpace->PreviewBasePose;
#endif
}
...
}
接下来的核心问题就是Proxy里的MarkerTickRecord是如何计算的了,看了下,前面创建的TickRecord对象记录了Proxy里的MarkerTickRecord的指针,它最终其实是添加到了Proxy的相应的数组里:
// 由多个AnimNode派生类在其InternalUpdate函数里调用, 交给AnimSync来Tick动画资产播放的时间
// 外部的FAnimSyncGroupScope的AddTickRecord函数最终会转到这里
void FAnimSync::AddTickRecord(const FAnimTickRecord& InTickRecord, const FAnimSyncParams& InSyncParams)
{
// 如果有Group, 那么添加到SyncGroupMaps里
if (InSyncParams.GroupName != NAME_None)
{
FSyncGroupMap& SyncGroupMap = SyncGroupMaps[GetSyncGroupWriteIndex()];
FAnimGroupInstance& SyncGroupInstance = SyncGroupMap.FindOrAdd(InSyncParams.GroupName);
SyncGroupInstance.ActivePlayers.Add(InTickRecord);
SyncGroupInstance.ActivePlayers.Top().MirrorDataTable = MirrorDataTable;
SyncGroupInstance.TestTickRecordForLeadership(InSyncParams.Role);
}
// 否则加入非Group数组
else
{
UngroupedActivePlayerArrays[GetSyncGroupWriteIndex()].Add(InTickRecord);
UngroupedActivePlayerArrays[GetSyncGroupWriteIndex()].Top().MirrorDataTable = MirrorDataTable;
}
}
由于我这里BlendSpace里没有设置SyncGroup,所以添加的TickRecord应该存在UngroupedActivePlayerArrays数组里,那么只需要看UngroupedActivePlayerArrays里元素的改变即可
UE5 Editor与Runtime下Tick的区别
Runtime下的Tick,也跟上述流程差不多,无非Editor下通过SingleNode机制只Update了一个特殊动画节点,而Runtime下要Update整个AnimGraph的节点而已,具体Tick阶段都是由AnimSync完成的,Tick阶段应该没啥区别。
所以Editor下是不会使用到FAnimNode_BlendSpacePlayer
节点和里面的任何API的,因为它自己调用的SingleNode实现了相关功能,这也是为什么我Editor下Debug不到相关信息的原因。
BlendSpace什么时候会启用SyncMarker
UE在BlendSpace的Update阶段,也就是TickAssetPlayer
写了这么段注释:
// @note for sync group vs non sync group
// in blendspace, it will still sync even if only one node in sync group
// so you’re never non-sync group unless you have situation where some markers are relevant to one sync group but not all the time
翻译过来就是:
- 在BlendSpace里, 就算我有多个不受Sync Group控制的Sample节点, 只要有一个节点处于Sync Group(即有Sync Maker)控制的状态, 那么整个BlendSpace也会启用Sync Group效果
但其实研究到这里还不够,还有几个问题,以后研究吧:
- BlendSpace里部分Sample有Marker,部分没有,那么到底是怎么同步时间的?
- Does blendspaces support sync markers?,这人说要保证Sample里都有SyncMarker,那如果要保证这个,前面的BlendSpace为啥要支持里面的Sample可以没有SyncMarker,这个机制有什么意义