我对5.6中 Unreal Animation Framework 的理解
约两个月前,Unreal Fest中的巫师4技术演示为我们展示了虚幻引擎下一代动画系统(Unreal Animation Framwork,下文简称UAF。同时把原来的动画蓝图系统简称为ABP),也引起了我强烈的好奇心,感觉是时候好好了解下这个系统了
本文会从程序框架的角度分析此系统 主要介绍系统的构成,各个类型的含义,它们之间的逻辑关系,希望能帮助大家理解和入手这个新动画系统 但不会涉及如动画混合的计算,或是动画重定向等细节
简单运行演示
先看一个简单的运行示例:
视频中是一个简单的分层混合效果,上半身来自拉弓动画的静帧
,下半身使用循环的冲刺动画
,
BlendMask
使用了一个HierarchyTable
:
它是一个通用的层级数据容器,这里用作
BlendProfile
从左视图可以看到,两个模型完全重合,动画效果完全一致:
因为它们两个运行了相同的UAF的动画图表
:
左上是
下半身
动画,左下是上半身
动画,右边是分层混合
不同的是,左边使用了UAF框架更新上述图表:
这里的节点调用可能不是最优的实现,但用于简单的演示足够了
右边使用了ABP更新上述图表:
UAF的动画图表目前可通过这个特殊动画节点集成到动画蓝图
统一的工作区界面 Workspace
UAF接入了工作区编辑器
,提供了多个资产的集成视图,
工作区自身也有一个对应资产,类型为UAF Workspace
,应该
是用于存放工作区相关的元数据
工作区编辑器
模块来自新的实验性插件Workspace
,它允许多种资产在一个统一的界面中被编辑UAF接入了这个功能,指定了UAF相关的资产类型可以在同一个工作区下编辑
详见:
UAnimNextWorkspaceSchema
,IWorkspaceEditorModule::RegisterObjectDocumentType
左上角的workspace
选项卡,列出了在当前工作区打开的资产:
即AG_SequencePlayer
, UAFM_Module
,和工作区自身的资产:UAFW_Workspace
:
系统组成
以下裸cpp类型基本都位于
UE::AnimNext
名字空间下
UAF的逻辑载体目前由两大块组成:Module
和AnimationGraph
,都运行于RigVM
之中,支持多线程
执行
其中线程间的数据交互,通过UAnimNextComponent::PublicVariablesProxy
完成
FAnimNextPublicVariablesProxy
注释中有写到,目前是每帧拷贝脏标记过的数据,将来计划改成双缓冲数组
(参考USkinnedMeshComponent::ComponentSpaceTransformsArray
)
详见:
FAnimNextModuleInstance::CopyProxyVariables
IAnimNextVariableProxyHost::FlipPublicVariablesProxy
UAnimNextComponent::SetVariable
UAnimNextComponentWorldSubsystem::Register
Module
模块
在这里是使用各个函数
编写逻辑业务
的地方,类似ABP里的蓝图部分
/UAnimInstance::NativeUpdateAnimation
,UAnimInstance::NativeThreadSafeUpdateAnimation
,但更加强大,灵活
FRigUnit_AnimNextModuleEventBase
:
通过基类提供的接口,每个模块
可以选择是否需要独立的TickFunction
,要运行在哪个Tick Group
,是否运行在游戏线程
等功能
UAF的编译器也会自动生成部分模块
,比如变量绑定相关的FRigUnit_AnimNextExecuteBindings_GT
FRigUnit_AnimNextExecuteBindings_WT
AnimationGraph
动画图表
是动画逻辑
及其数据
的集合,类似ABP里的动画树
不一样的是,UAF里不再有各式各样的动画节点
,取而代之的是一个TraitStack
节点加上各种各样的Trait
的组合
动画图表自身作为UObject
,也承担着持有图表中共享数据
的UObject
对象引用,不让它们被GC的功能
详见:
UAnimNextAnimationGraph::GraphReferencedObjects
UAnimNextAnimationGraph::GraphReferencedSoftObjects
另外动画图表可以有多入口
,而不只是Root
TraitStack和TraitStack节点
TraitStack
:顾名思义,这是一个由Trait
组成的栈
结构,这个栈包含1
个base trait
和若干个additive trait
而与它对应的节点
,只是一个正常的RigUnit
节点(结构体):
一个TraitStack节点可以包含一个或多个
TraitStack
在编辑器中,就是上文分层混合图表中的样子
节点形式只是为了方便编辑器下的
可视化
,编译
后会把对应的TraitStack
序列化到动画图表中,这个RigUnit
节点并不会被执行
Trait
直译为特性,理解为动画逻辑中可复用的功能,类似ABP中的动画节点
,但同样的,更为强大,灵活
FTrait
FTrait
是所有Trait
的基类,定义了所需的基础接口,比如获取其唯一ID
子Trait由FBaseTrait
或FAdditiveTrait
加上ITraitInterface
的子接口类
组合
而来,
ITraitInterface是所有trait interface
的基类,
它里面只有一个获取UID
的方法,即每个trait interface也
有唯一ID
目前这两个唯一ID都是对类名
应用FNV1a
哈希算法得来,
这个算法的特点是,对于同样的字符组合,无论字符是普通字符
或是宽字符
,不会影响哈希结果,产生的哈希值相同,并且简单高效
详见:
FTraitUID::MakeUID
FTraitInterfaceUID::MakeUID
Trait对象本身不能有内部状态,即是无状态的
,因为它们的逻辑会跑在工作线程中(比如同一帧,多个复用同一动画图表的对象,在不同的线程执行)
它的状态数据应该通过FSharedData
和FInstanceData
这两个类型别名来声明,UAF系统会在Trait对象外部
分配好
FSharedData是同一动画图表的多个实例可以共享的只读数据,是USTRUCT
,会序列化保存
到文件,通常是一些硬编码的配置
FInstanceData是每个动画图表实例中的节点所需的动态数据,是裸CPP结构体
FSharedData类似于ABP中的
FoldProperty
,而InstanceData的机制与
StateTree
中的FInstanceDataType
/UInstanceDataType
几乎一致
代码生成
UAF使用了一些宏,来简单快速的生成框架所需的代码,减少重复劳动
Trait Interface的宏
trait interface这边,比较简单,只有两个宏
DECLARE_ANIM_TRAIT_INTERFACE
声明并实现GetInterfaceUID
,返回编译期常量
:
AUTO_REGISTER_ANIM_TRAIT_INTERFACE
静态注册
trait interface类的共享指针
到全局trait interface注册表
:
Trait的宏
Trait这边相对就复杂很多:
首先需要在trait类中使用DECLARE_ANIM_TRAIT
宏,声明
一些接口的覆写:
其包含的几个嵌套宏:
ANIM_NEXT_IMPL_DECLARE_ANIM_TRAIT_BASIC
声明并实现GetTraitUID
,返回编译期常量
;GetTraitName
返回trait名字;声明TraitSuper
别名
ANIM_NEXT_IMPL_DECLARE_ANIM_TRAIT_INSTANCING_SUPPORT
trait数据相关的声明
ANIM_NEXT_IMPL_DECLARE_ANIM_TRAIT_INTERFACE_SUPPORT
trait interface获取相关的声明
ANIM_NEXT_IMPL_DECLARE_ANIM_TRAIT_EVENT_SUPPORT
trait事件相关的声明
ANIM_NEXT_IMPL_DECLARE_ANIM_TRAIT_LATENT_PROPERTY_SUPPORT
Latent Property相关的声明(含义见下文Latent Property
)
然后使用GENERATE_ANIM_TRAIT_IMPLEMENTATION
宏,定义上述接口,
比较特别的是InterfaceEnumeratorMacro, RequiredInterfaceEnumeratorMacro, EventEnumeratorMacro
这三个参数,
从名字可以看出它们是EnumeratorMacro
,是用于枚举的宏,枚举的东西是它们的前缀:trait interface
,必须的trait interface
,trait事件
枚举宏
有一个参数,也是宏,这个宏接收枚举的东西
作为参数,执行相应的操作
以FBlendTwoWayTrait
为例:
局部定义的TRAIT_INTERFACE_ENUMERATOR
宏,枚举了FBlendTwoWayTrait
实现的所有的trait interface,
并把这些接口传给了GeneratorMacro
这个参数
结合GENERATE_ANIM_TRAIT_IMPLEMENTATION
中嵌套的宏:
ANIM_NEXT_IMPL_DEFINE_ANIM_TRAIT
定义共享数据和实例数据的内存大小和对其,构造和析构函数
ANIM_NEXT_IMPL_DEFINE_ANIM_TRAIT_GET_LATENT_PROPERTY_MEMORY_LAYOUT
定义获取Latent Property的内存布局信息的函数
ANIM_NEXT_IMPL_DEFINE_ANIM_TRAIT_IS_PROPERTY_LATENT
定义判定对应名字的属性是否为Latent Property的函数
ANIM_NEXT_IMPL_DEFINE_ANIM_TRAIT_GET_INTERFACE
定义获取指定trait interface指针的函数
ANIM_NEXT_IMPL_DEFINE_ANIM_TRAIT_GET_INTERFACES
定义获取所有实现的trait interface的ID的函数
ANIM_NEXT_IMPL_DEFINE_ANIM_TRAIT_GET_REQUIRED_INTERFACES
定义获取所有必须的trait interface的ID的函数
ANIM_NEXT_IMPL_DEFINE_ANIM_TRAIT_ON_TRAIT_EVENT
定义响应所需trait事件回调的函数
ANIM_NEXT_IMPL_DEFINE_ANIM_TRAIT_GET_TRAIT_EVENTS
定义获取所有响应的trait事件的ID的函数
至此,便可自动生成框架所需的trait定义
AUTO_REGISTER_ANIM_TRAIT
,与AUTO_REGISTER_ANIM_TRAIT_INTERFACE
类似,只不过这次注册的是trait
trait注册没有使用共享指针,而是在
UE::AnimNext::TraitConstructorFunc
回调传入的DestPtr
地址构造
Latent Property
Latent Property
是共享数据中需要实例化的部分,放在实例化数据
区的后面
对于继承自FAnimNextTraitSharedData
的共享数据,需要用GENERATE_TRAIT_LATENT_PROPERTIES
宏,手动
选择性
注册需要标记为Latent Property
的属性:
这个宏同样使用了枚举宏作为参数,其中它嵌套的宏:
ANIM_NEXT_IMPL_DEFINE_LATENT_CONSTRUCTOR
使用placement new
,逐个
构造Latent Property
(内存地址的见后文FNodeInstance
这节)
详见:
FExecutionContext::AllocateNodeInstance
分配内存,构造实例化数据
,以及Latent Property
GENERATE_ANIM_TRAIT_IMPLEMENTATION
-ANIM_NEXT_IMPL_DEFINE_ANIM_TRAIT
-ConstructTraitInstance
GENERATE_TRAIT_LATENT_PROPERTIES
-ANIM_NEXT_IMPL_DEFINE_LATENT_CONSTRUCTOR
-ConstructLatentProperties
ANIM_NEXT_IMPL_DEFINE_LATENT_DESTRUCTOR
逐个
析构Latent Property
ANIM_NEXT_IMPL_DEFINE_GET_LATENT_PROPERTY_INDEX
查询对应偏移量的Latent Property
下标
FAnimNextTraitSharedData::GetLatentPropertyIndex
注释中提到:如果对应偏移量的Latent Property
能找到,则返回从1开始数的下标;找不到则返回Latent Property
的数量,数值小于等于0(需要取负号)
ANIM_NEXT_IMPL_DEFINE_LATENT_GETTER
为每个Latent Property
生成从FTraitBinding
中获取属性值的getter
函数
宏魔法!使用了
constexpr
函数GetLatentPropertyIndex
获取到LatentPropertyIndex
,然后从Binding
中获得Latent Property
引用
TraitEvent trait事件
FAnimNextTraitEvent
是trait事件
的基类
DECLARE_ANIM_TRAIT_EVENT
与trait和trait interface类似,声明和定义了EventUID
,并且还额外
支持IsA
的功能,是一个简单的RTTI的替代机制
由于FAnimNextTraitEvent是
USTRUCT
,自定义的IsA
应该不是必要的,这里可能是出于性能或其它考虑
trait事件类似UI的点击事件,可以被标记为Handled
,并且可以设定有效时间或者无限时间等
全局注册表
FTraitRegistry
trait
对象的全局注册表
使用宏注册trait时,FTraitRegistry
优先使用默认分配的8KB
大小的StaticTraitBuffer
来存放trait
,超出后使用DynamicTraits
存放,是一个内存局部性
的优化
这里有个有趣的小细节,DynamicTraits数组存放的是
uintptr_t
而不是void*
或者FTrait*
,即选择了用整数存放指针
因为用整数,就可以同时存放
数组下标
,以实现后续的FreeList
机制:
DynamicTraitFreeIndexHead
有效时,DynamicTraits[DynamicTraitFreeIndexHead]
存放的是下一个可复用的数组元素
另外还存了几个Map
用于加速查询
FTraitRegistry::Register
用于注册trait到DynamicTraits
FTraitInterfaceRegistry
trait interface
对象的全局注册表
对比下来,FTraitInterfaceRegistry
就显得朴实无华,只是一个interface ID到智能指针的map
Node 节点
这节介绍一些跟节点相关的关键类型
FNodeTemplate
一个FNodeTemplate
就是一组trait
的排列组合,可以有多组base + additive
trait,所有动画图表共享此模板对象
组成节点模板的trait
们,以FTraitTemplate
的对象形式存放在FNodeTemplate
对象末尾的连续内存
处
FNodeTemplateBuilder::BuildNodeTemplate
在一个TArray<uint8>
的连续缓冲区中构造FNodeTemplate对象及其中包含的FTraitTemplate
从它可以获取UID
,trait数组首地址
,trait数量等信息
FNodeTemplate::NodeSharedDataSize
所有trait的共享数据
(加上其中base trait的FLatentPropertiesHeader
),对齐之后的大小FNodeTemplate::NodeInstanceDataSize
所有trait的实例数据
,对齐之后的大小
FTraitTemplate
FTraitTemplate
就是节点模板里的trait
,用它除了可以获取到关于trait的UID
,trait类型,共享/实例数据,子trait数量,Latent Property
数量等基本信息外,还可以获取到共享数据的偏移量
,共享的latent property的数组指针偏移量
以及实例数据的偏移量
详见:
FNodeTemplate::Finalize
我觉得
FTraitTemplate::GetTraitDescription
这两个成员函数命名有点容易混淆,更好理解的命名应该是GetTraitSharedData
,用于获取trait的共享数据指针,可能是笔误或者是改名了
FNodeDescription
在一个动画图表
内唯一的只读数据,对象本身虽然是8字节大小,但分配内存时,加上节点上trait们的共享数据的大小,是一个用法上大小变化的对象
详见:
FTraitReader::ReadGraphSharedData
和一些其它细节:
FNodeDescription::Serialize
FTrait::SerializeTraitSharedData
FTraitWriter::WriteNode
FTrait::SaveTraitSharedData
读取之后存放在
UAnimNextAnimationGraph::SharedDataBuffer
,使用上参考FExecutionContext::GetNodeDescription
FNodeDescription::TemplateHandle
用于从FNodeTemplateRegistry
获取FNodeTemplate
对象实例FNodeDescription::NodeInstanceDataSize
包含实例化数据
的大小,加上所有Latent Property
的总大小
注意
FNodeDescription::NodeInstanceDataSize
与FNodeTemplate::NodeInstanceDataSize
的区别另外,
FNodeDescription
跟FNodeTemplate
是多对一
的,从它们的含义上可以理解
FNodeInstance
节点的实例化数据,运行时动态创建,自身16字节大小,分配内存时,加上了trait们的实例化数据大小以及它们的Latent Property
大小,也是一个用法上大小变化的对象
;内置引用计数
用法上可以参考:
FAnimationAnimNextRuntimeTest_TraitSerialization::RunTest
,FExecutionContext::AllocateNodeInstance
FNodeTemplateRegistry
FNodeTemplate
对象的全局注册表,确保了所有FNodeTemplate内存连续
FTraitStackBinding
描述一组trait
(1个base trait及其children/additive trait们)所需要的数据,用于查询trait或者trait interface
详见:
FTraitStackBinding::FTraitStackBinding
,特别是最后几行
例如,FTraitStackBinding::GetInterfaceImpl
:从trait栈
上尝试找到实现了指定InterfaceUID
的trait,并返回该trait的binding
FTraitBinding
描述一组trait中的特定trait
的数据,可以查询当前trait是否实现了指定trait interface
TTraitBinding
强类型/类型安全的FTraitBinding
FExecutionContext
由于trait是无状态的,执行时的动态数据
/实例数据
需要一个对象来承载,它就是FExecutionContext
,执行上下文对象
它用于绑定
到一个图表实例,并为节点提供统一的trait查询
接口
图表的Update
和Evaluate
流程封装在一个RigVM节点的执行函数中:FRigUnit_AnimNextRunAnimationGraph_v2_Execute()
FUpdateTraversalContext
更新
图表时,用到的上下文子类对象
内部使用了在MemStack
上分配的栈(LIFO
)实现trait树
的深度优先遍历
,不再是ABP中的递归
方式
详见:
UE::AnimNext::UpdateGraph
,每个trait在while循环中会被执行两次,分别对应IUpdate::PreUpdate
和IUpdate::PostUpdate
IUpdate::OnBecomeRelevant
也是在这里调用
FEvaluateTraversalContext
评估
图表时,用到的上下文子类对象
内部的FEvaluationProgram
用于存放遍历图表时,各个节点需要执行的FAnimNextEvaluationTask
随后调用FEvaluationProgram::Execute
在FEvaluationVMStack
上执行各评估任务
详见:
UE::AnimNext::EvaluateGraph
,执行流程与UpdateGraph
类似
FAnimNextEvaluationTask
FAnimNextEvaluationTask
是可在trait之间复用的逻辑对象,代表运行于评估虚拟机
上的微指令,通过虚拟机的内部状态(也是栈
)可以同时处理输入和输出
系统模块
UAF由多个模块组成:
在ue5-main
分支上,系列模块的名字前缀从AnimNext
改成了UAF
其中
UAF
/AnimNext
:提供核心的动画工具函数,定义基类接口等。如:UE::AnimNext::FDecompressionTools::GetAnimationPose
UAFAnimGraph
/AnimNextAnimGraph
:实现基于RigVM的动画图表相关功能
这两个模块也是本文主要讨论的范围
其他模块则是为UAF
引入其它模块/系统
的功能,比如引入StateTree
,PoseSearch
等
SoA (struct of array)
在TransformArrayOperations.h
中的每个工具函数都有AoS
和SoA
两个版本,并且代码中优先使用了AoS
,由此可见在数据结构设计上,UAF会更面向数据
一些
结论
UAF是一个重新设计的,面向数据的,倾向于组合范式的,高性能,灵活,简洁,易拓展的动画框架
完全抛弃了ABP的框架,拥抱RigVM
因其特性集尚未成熟,而处于实验性阶段
杂项
代码里写动画蓝图
参考UAFTestSuite
/AnimNextTestSuite
以及UAFAnimGraphTestSuite
/AnimNextAnimGraphTestSuite
模块中的测试用例
代码
在UAF中播放“蒙太奇”
由于本文篇幅已经较长,我觉得还是不要再详细阐述了,
相关节点是UInjectionCallbackProxy
,UPlayAnimCallbackProxy
相关代码在UE::AnimNext::FInjectionUtils::Inject