Post

我对5.6中 Unreal Animation Framework 的理解

我对5.6中 Unreal Animation Framework 的理解

约两个月前,Unreal Fest中的巫师4技术演示为我们展示了虚幻引擎下一代动画系统(Unreal Animation Framwork,下文简称UAF。同时把原来的动画蓝图系统简称为ABP),也引起了我强烈的好奇心,感觉是时候好好了解下这个系统了

本文会从程序框架的角度分析此系统 主要介绍系统的构成,各个类型的含义,它们之间的逻辑关系,希望能帮助大家理解和入手这个新动画系统 但不会涉及如动画混合的计算,或是动画重定向等细节

简单运行演示

先看一个简单的运行示例:

UAF vs ABP

视频中是一个简单的分层混合效果,上半身来自拉弓动画的静帧,下半身使用循环的冲刺动画

BlendMask使用了一个HierarchyTable:

HierarchyTable

它是一个通用的层级数据容器,这里用作BlendProfile

从左视图可以看到,两个模型完全重合,动画效果完全一致:

左视图

因为它们两个运行了相同的UAF的动画图表

UAF Graph LayerBlend

左上是下半身动画,左下是上半身动画,右边是分层混合

不同的是,左边使用了UAF框架更新上述图表:

UAF Module PrePhysics

这里的节点调用可能不是最优的实现,但用于简单的演示足够了

右边使用了ABP更新上述图表:

ABP Graph

UAF的动画图表目前可通过这个特殊动画节点集成到动画蓝图

统一的工作区界面 Workspace

UAF接入了工作区编辑器,提供了多个资产的集成视图, 工作区自身也有一个对应资产,类型为UAF Workspace应该是用于存放工作区相关的元数据

工作区编辑器模块来自新的实验性插件Workspace,它允许多种资产在一个统一的界面中被编辑

UAF接入了这个功能,指定了UAF相关的资产类型可以在同一个工作区下编辑

详见:UAnimNextWorkspaceSchema, IWorkspaceEditorModule::RegisterObjectDocumentType

左上角的workspace选项卡,列出了在当前工作区打开的资产:

UAF Module PrePhysics

AG_SequencePlayer, UAFM_Module,和工作区自身的资产:UAFW_Workspace

Workspace

系统组成

以下裸cpp类型基本都位于UE::AnimNext名字空间下

UAF的逻辑载体目前由两大块组成:ModuleAnimationGraph,都运行于RigVM之中,支持多线程执行

AnimNextDataInterface

AnimNextRigVMAsset

其中线程间的数据交互,通过UAnimNextComponent::PublicVariablesProxy完成

FAnimNextPublicVariablesProxy注释中有写到,目前是每帧拷贝脏标记过的数据,将来计划改成双缓冲数组(参考USkinnedMeshComponent::ComponentSpaceTransformsArray)

详见:

FAnimNextModuleInstance::CopyProxyVariables

IAnimNextVariableProxyHost::FlipPublicVariablesProxy

UAnimNextComponent::SetVariable

UAnimNextComponentWorldSubsystem::Register

Module

模块在这里是使用各个函数编写逻辑业务的地方,类似ABP里的蓝图部分/UAnimInstance::NativeUpdateAnimation,UAnimInstance::NativeThreadSafeUpdateAnimation,但更加强大,灵活

FRigUnit_AnimNextModuleEventBase

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组成的结构,这个栈包含1base trait和若干个additive trait

而与它对应的节点,只是一个正常的RigUnit节点(结构体):

Trait Stack Node

一个TraitStack节点可以包含一个或多个TraitStack

在编辑器中,就是上文分层混合图表中的样子

节点形式只是为了方便编辑器下的可视化编译后会把对应的TraitStack序列化到动画图表中,这个RigUnit节点并不会被执行

Trait

直译为特性,理解为动画逻辑中可复用的功能,类似ABP中的动画节点,但同样的,更为强大,灵活

FTrait

FTrait是所有Trait的基类,定义了所需的基础接口,比如获取其唯一ID

FTrait

子Trait由FBaseTraitFAdditiveTrait加上ITraitInterface子接口类组合而来,

Base and Additive Trait

TraitHierarchy

ITraitInterface是所有trait interface的基类,

ITraitInterface

它里面只有一个获取UID的方法,即每个trait interface唯一ID

目前这两个唯一ID都是对类名应用FNV1a哈希算法得来, 这个算法的特点是,对于同样的字符组合,无论字符是普通字符或是宽字符,不会影响哈希结果,产生的哈希值相同,并且简单高效

详见:

FTraitUID::MakeUID

FTraitInterfaceUID::MakeUID

Trait对象本身不能有内部状态,即是无状态的,因为它们的逻辑会跑在工作线程中(比如同一帧,多个复用同一动画图表的对象,在不同的线程执行)

它的状态数据应该通过FSharedDataFInstanceData这两个类型别名来声明,UAF系统会在Trait对象外部分配好

SharedData and InstanceData

FSharedData是同一动画图表的多个实例可以共享的只读数据,是USTRUCT,会序列化保存到文件,通常是一些硬编码的配置

FInstanceData是每个动画图表实例中的节点所需的动态数据,是裸CPP结构体

FSharedData类似于ABP中的FoldProperty

而InstanceData的机制与StateTree中的FInstanceDataType/UInstanceDataType几乎一致

代码生成

UAF使用了一些宏,来简单快速的生成框架所需的代码,减少重复劳动

Trait Interface的宏

trait interface这边,比较简单,只有两个宏

DECLARE_ANIM_TRAIT_INTERFACE 声明并实现GetInterfaceUID,返回编译期常量

DECLARE_ANIM_TRAIT_INTERFACE

Trait Interface: IEvaluate

AUTO_REGISTER_ANIM_TRAIT_INTERFACE 静态注册trait interface类的共享指针全局trait interface注册表

AUTO_REGISTER_ANIM_TRAIT_INTERFACE

Trait的宏

Trait这边相对就复杂很多:

首先需要在trait类中使用DECLARE_ANIM_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宏,定义上述接口,

GENERATE_ANIM_TRAIT_IMPLEMENTATION

比较特别的是InterfaceEnumeratorMacro, RequiredInterfaceEnumeratorMacro, EventEnumeratorMacro这三个参数, 从名字可以看出它们是EnumeratorMacro,是用于枚举的宏,枚举的东西是它们的前缀:trait interface必须的trait interfacetrait事件

枚举宏有一个参数,也是宏,这个宏接收枚举的东西作为参数,执行相应的操作

FBlendTwoWayTrait为例:

FBlendTwoWayTrait

Macro definition for BlendTwoWayTrait

局部定义的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的属性:

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事件

FAnimNextTraitEventtrait事件的基类

FAnimNextTraitEvent

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 节点

粗略的内存布局视图,用drawio绘制

这节介绍一些跟节点相关的关键类型

FNodeTemplate

一个FNodeTemplate就是一组trait的排列组合,可以有多组base + additivetrait,所有动画图表共享此模板对象

FNodeTemplate

组成节点模板的trait们,以FTraitTemplate的对象形式存放在FNodeTemplate对象末尾的连续内存

FNodeTemplate::GetTraits

FNodeTemplateBuilder::BuildNodeTemplate 在一个TArray<uint8>的连续缓冲区中构造FNodeTemplate对象及其中包含的FTraitTemplate

从它可以获取UIDtrait数组首地址,trait数量等信息

  • FNodeTemplate::NodeSharedDataSize 所有trait的共享数据(加上其中base trait的FLatentPropertiesHeader),对齐之后的大小
  • FNodeTemplate::NodeInstanceDataSize 所有trait的实例数据,对齐之后的大小

rough memory layout of FNodeTemplate

FTraitTemplate

FTraitTemplate就是节点模板里的trait,用它除了可以获取到关于trait的UID,trait类型,共享/实例数据,子trait数量,Latent Property数量等基本信息外,还可以获取到共享数据的偏移量共享的latent property的数组指针偏移量以及实例数据的偏移量

FTraitTemplate

详见:FNodeTemplate::Finalize

我觉得FTraitTemplate::GetTraitDescription这两个成员函数命名有点容易混淆,更好理解的命名应该是GetTraitSharedData,用于获取trait的共享数据指针,可能是笔误或者是改名了

FNodeDescription

在一个动画图表内唯一的只读数据,对象本身虽然是8字节大小,但分配内存时,加上节点上trait们的共享数据的大小,是一个用法上大小变化的对象

rough memory layout of FNodeDescription

详见:

FTraitReader::ReadGraphSharedData

和一些其它细节: FNodeDescription::Serialize FTrait::SerializeTraitSharedData FTraitWriter::WriteNode FTrait::SaveTraitSharedData

读取之后存放在UAnimNextAnimationGraph::SharedDataBuffer,使用上参考FExecutionContext::GetNodeDescription

  • FNodeDescription::TemplateHandle 用于从FNodeTemplateRegistry获取FNodeTemplate对象实例
  • FNodeDescription::NodeInstanceDataSize 包含实例化数据的大小,加上所有Latent Property总大小

注意FNodeDescription::NodeInstanceDataSizeFNodeTemplate::NodeInstanceDataSize的区别

另外,FNodeDescriptionFNodeTemplate多对一的,从它们的含义上可以理解

FNodeInstance

节点的实例化数据,运行时动态创建,自身16字节大小,分配内存时,加上了trait们的实例化数据大小以及它们的Latent Property大小,也是一个用法上大小变化的对象;内置引用计数

rough memory layout of FNodeInstance

用法上可以参考:FAnimationAnimNextRuntimeTest_TraitSerialization::RunTestFExecutionContext::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查询接口

图表的UpdateEvaluate流程封装在一个RigVM节点的执行函数中:FRigUnit_AnimNextRunAnimationGraph_v2_Execute()

FUpdateTraversalContext

更新图表时,用到的上下文子类对象

内部使用了在MemStack上分配的栈(LIFO)实现trait树深度优先遍历,不再是ABP中的递归方式

详见:UE::AnimNext::UpdateGraph,每个trait在while循环中会被执行两次,分别对应IUpdate::PreUpdateIUpdate::PostUpdate

IUpdate::OnBecomeRelevant也是在这里调用

FEvaluateTraversalContext

评估图表时,用到的上下文子类对象

内部的FEvaluationProgram用于存放遍历图表时,各个节点需要执行的FAnimNextEvaluationTask 随后调用FEvaluationProgram::ExecuteFEvaluationVMStack上执行各评估任务

详见:UE::AnimNext::EvaluateGraph,执行流程与UpdateGraph类似

FAnimNextEvaluationTask

FAnimNextEvaluationTask是可在trait之间复用的逻辑对象,代表运行于评估虚拟机上的微指令,通过虚拟机的内部状态(也是)可以同时处理输入和输出

系统模块

UAF由多个模块组成:

UAF On 5.6

ue5-main分支上,系列模块的名字前缀从AnimNext改成了UAF

UAF On ue5-main

其中 UAF/AnimNext :提供核心的动画工具函数,定义基类接口等。如:UE::AnimNext::FDecompressionTools::GetAnimationPose

UAFAnimGraph/AnimNextAnimGraph:实现基于RigVM的动画图表相关功能

这两个模块也是本文主要讨论的范围

其他模块则是为UAF引入其它模块/系统的功能,比如引入StateTreePoseSearch

SoA (struct of array)

TransformArrayOperations.h中的每个工具函数都有AoSSoA两个版本,并且代码中优先使用了AoS,由此可见在数据结构设计上,UAF会更面向数据一些

结论

UAF是一个重新设计的,面向数据的,倾向于组合范式的,高性能,灵活,简洁,易拓展的动画框架

完全抛弃了ABP的框架,拥抱RigVM

因其特性集尚未成熟,而处于实验性阶段

杂项

代码里写动画蓝图

参考UAFTestSuite/AnimNextTestSuite以及UAFAnimGraphTestSuite/AnimNextAnimGraphTestSuite模块中的测试用例代码

在UAF中播放“蒙太奇”

由于本文篇幅已经较长,我觉得还是不要再详细阐述了,

相关节点是UInjectionCallbackProxyUPlayAnimCallbackProxy

相关代码在UE::AnimNext::FInjectionUtils::Inject

官方FAQ链接

Unreal Animation Framework (UAF,AnimNext) FAQ

This post is licensed under CC BY 4.0 by the author.