动画基础概念
3D模型动画的基本原理是让模型中各顶点的位置随时间变化。主要种类有Morph(变形)动画,关节动画和骨骼蒙皮动画(SkinnedMesh)。
(自己搭的blog被黑了,只有本地备份,太伤了,所以文章会有一些格式问题)
从动画数据的角度来说,三者一般都采用关键帧技术,即只给出关键帧的数据,其他帧的数据使用插值得到。但由于这三种技术的不同,关键帧的数据是不一样的。
- Morph(渐变,变形)动画是直接指定动画每一帧的顶点位置,其动画关键中存储的是Mesh所有顶点在关键帧对应时刻的位置。
- 关节动画的模型不是一个整体的Mesh,而是分成很多部分(Mesh),通过一个父子层次结构将这些分散的Mesh组织在一起,父Mesh带动其下子Mesh的运动,各Mesh中的顶点坐标定义在自己的坐标系中,这样各个Mesh是作为一个整体参与运动的。动画帧中设置各子Mesh相对于其父Mesh的变换(主要是旋转,当然也可包括移动和缩放),通过子到父,一级级的变换累加(当然从技术上,如果是矩阵操作是累乘)得到该Mesh在整个动画模型所在的坐标空间中的变换(从本文的视角来说就是世界坐标系了,下同),从而确定每个Mesh在世界坐标系中的位置和方向,然后以Mesh为单位渲染即可。关节动画的问题是,各部分Mesh中的顶点是固定在其Mesh坐标系中的,这样在两个Mesh结合处就可能产生裂缝。
- 第三类就是骨骼蒙皮动画即SkinnedMesh了,骨骼蒙皮动画的出现解决了关节动画的裂缝问题,而且效果非常酷,发明这个算法的人一定是个天才,因为SkinnedMesh的原理简单的难以置信,而效果却那么好。骨骼动画的基本原理可概括为:在骨骼控制下,通过顶点混合动态计算蒙皮网格的顶点,而骨骼的运动相对于其父骨骼,并由动画关键帧数据驱动。一个骨骼动画通常包括骨骼层次结构数据,网格(Mesh)数据,网格蒙皮数据(skin info)和骨骼的动画(关键帧)数据。下面将具体分析。
SkinnedMesh中文一般称作骨骼蒙皮动画,正如其名,这种动画中包含骨骼(Bone)和蒙皮(Skinned Mesh)两个部分,Bone的层次结构和关节动画类似,Mesh则和关节动画不同:关节动画中是使用多个分散的Mesh,而Skinned Mesh中Mesh是一个整体,也就是说只有一个Mesh,实际上如果没有骨骼让Mesh运动变形,Mesh就和静态模型一样了。
Skinned Mesh技术的精华在于蒙皮,所谓的皮并不是模型的贴图(也许会有人这么想过吧),而是Mesh本身,蒙皮是指将Mesh中的顶点附着(绑定)在骨骼之上,而且每个顶点可以被多个骨骼所控制,这样在关节处的顶点由于同时受到父子骨骼的拉扯而改变位置就消除了裂缝。
Skinned Mesh这个词从字面上理解似乎是有皮的模型,哦,如果贴图是皮,那么普通静态模型不也都有吗?所以我觉得应该理解为具有蒙皮信息的Mesh或可当做皮肤用的Mesh,这个皮肤就是Mesh。
而为了有皮肤功能,Mesh还需要蒙皮信息,即Skin数据,没有Skin数据就是一个普通的静态Mesh了。
Skin数据决定顶点如何绑定到骨骼上。顶点的Skin数据包括顶点受哪些骨骼影响以及这些骨骼影响该顶点时的权重(weight),另外对于每块骨骼还需要骨骼偏移矩阵(BoneOffsetMatrix)用来将顶点从Mesh空间变换到骨骼空间。在本文中,提到骨骼动画中的Mesh特指这个皮肤Mesh,提到模型是指骨骼动画模型整体。骨骼控制蒙皮运动,而骨骼本身的运动呢?当然是动画数据了。
每个关键帧中包含时间和骨骼运动信息,运动信息可以用一个矩阵直接表示骨骼新的变换,也可用四元数表示骨骼的旋转,也可以随便自己定义什么只要能让骨骼动就行。除了使用编辑设定好的动画帧数据,也可以使用物理计算对骨骼进行实时控制。
-
线性混合蒙皮算法
在骨骼动画的蒙皮算法中,出现最早、最经典,也是应用最为广泛的算法是线性混合蒙皮算法。
根据骨骼动画的基本原理,动画模型之所以能够运动,是由于其骨骼带动了蒙在骨骼之上的皮肤一同动作,实现了动画效果。因此,因首先设置好模型骨架以及各骨骼之间的关联性,当运动数据到来时,计算皮肤顶点的新位置,就可以完成模型的运动。
黑色与白色的皮肤顶点分别与其相同颜色的骨骼相绑定。
方框里的皮肤顶点离两个骨骼关节最近,它们同时受到两个骨骼关节的影响。当骨架运动的时候,对于这些受多个骨骼共同影响的皮肤顶点,我们要计算它们变换后的位置信息,即找到皮肤网格自动变形后的方法,传统一般采用线性混合蒙皮算法。线性混合蒙皮算法是由 Lander 最早提出并实现的一种柔性绑定算法。Lander 利用线性混合蒙皮算法实现了人体上臂的动画,解决了之前的刚性绑定算法在关节处的失真问题。该算法的基本原理可以用下列公式表示
V表示顶点变换前的世界坐标系中的位置,V'表示顶点变换后的位置,i 表示同时影响该顶点的骨骼数量,一般取 2-4 之间的值。W_i表示第 i 个骨骼对该顶点的施加的影响权重,取 0-1 之间的值,M_i表示在模型初始参考姿势下,与顶点相关的第 i 个骨骼由本地坐标转换为世界坐标的转换矩阵(即骨骼变换的绝对矩阵),通过矩阵M_i能将骨骼 i 从初始位置转换到动画数据来到时的新位置上.
综上所述,线性混合蒙皮算法即是求得一个顶点在每个骨骼影响下的一系列新的位置,然后对这些位置数据进行加权平均计算得到最后的结果。
在线性混合蒙皮算法中,顶点的新位置 V′是通过其初始位置V 乘以一个矩阵 C 得到,这个矩阵被称为变换矩阵。
我们可以使用 OFFSET(偏移)的 3 个量来表示子关节相对父关节的偏移量;用 CHANNELS 来表示关节旋转通道数量和旋转顺序,其中根关节有6个通道,其他关节有3个通道,与根关节相比少了XYZ的位置(position)信息,这是因为其他关节都可以根据相对其于父关节的偏移量计算坐标位置。
运动数据对应的是骨架信息中各关节点的层次数据,即CHANNELS 中 Zrotation Xrotation Yrotation 顺序的数据。对于子关节来说,平移信息存储在骨架信息的 OFFSET 中,旋转信息则来自于运动数据部分;对于根关节来说,平移量是 OFFSET 和运动数据部分中定义的平移量之和。要得到蒙皮所需的绝对变换矩阵,首先需要根据 BVH 文件中的旋转数据分别创建三个方向轴(Y 轴,X 轴,Z 轴)对应的旋转矩阵,然后将它们按顺序相乘得到矩阵R (也称相对矩阵):
绝对变换矩阵是由关节的相对矩阵乘上它的父关节的绝对矩阵得到的,其中,根关节的绝对变换矩阵就是它的相对矩阵。因此,根据骨架各关节之间的关系,可以计算出每一个关节的绝对变换矩阵,用来将关节的本地坐标变换为世界坐标。
在骨骼动画中,一般使用正向运动学和逆向运动学将运动数据作用到动画模型上。正向运动学是从模型的根节点开始(对人体模型来说,髋关节就是根节点),根据骨骼的拓展顺序,逐个计算各关节在动画数据下的偏移和旋转量,直至到达末端节点为止。
蒙皮算法中变换矩阵的计算实际上就是插值的计算。对于动画中发生动作的骨骼,应根据该骨骼的数据找出其前后两个关键帧,根据时间差进行插值计算。对于使用四元数表示旋转的情况,可以使用四元数线性插值或四元数球面插值。将插值得到的四元数转换成变换矩阵(旋转矩阵部分),最后更新骨骼之间的层次关系,计算出各个骨骼的绝对变换矩阵,完成顶点的新位置计算。
2.线性混合蒙皮算法的缺陷
线性混合蒙皮算法需要手工设置骨骼对皮肤顶点影响的权重值,这项工作繁琐耗时,并且要求设计者对模型的构成要比较熟悉。不过随着建模软件的日趋完善,现在已经有很多建模软件简化了权重设置这项工作,比如常用的 3DMax、Maya 等大型 3D 建模软件,为骨骼与皮肤的绑定提供了很多便捷的操作功能,能大大节省该工作的时间,提高工作效率。
另外,线性混合蒙皮算法因其原理为线性计算,有一个无法克服的缺陷:对于比较灵活的关节(如肩膀),当关节处旋转角度很大时,会产生皮肤失真的结果,比如皮肤的塌陷、扭曲打结(裹糖纸)等现象。
v1和v2是皮肤顶点v分别受两端骨骼单独作用时变换的位置,v点变换后的坐标是v1和v2的线性加权平均。因为v1和v2都是在世界坐标系下变换得到的顶点坐标,直接的线性加权平均导致混合后的新顶点损失了v在关节局部坐标系下的向量长度信息,所以导致了皮肤塌陷的现象。除了产生皮肤塌陷的失真问题,发生更大角度的旋转的关节区域的皮肤还会出现扭曲现象(即“裹糖纸”现象)。我们假设人体模型的肩部关节绕 x 轴旋转180 度,那么在上述公式中骨骼变换的绝对矩阵可以写为:
也就是说,我们可以将骨骼变换的绝对矩阵进行混合操作,再与顶点位置V相乘得到新顶点的位置。可以看到,即使所有的变换矩阵Mi是刚性的,括号内也是一个线性的变换过程,得到的结果不一定是刚性的转换矩阵(比如几个正交矩阵的线性组合不一定还是正交矩阵),这便可以解释线性混合蒙皮在关节旋转角度过大时出现皮肤塌陷或扭曲的现象了:在骨骼的旋转变换过程中出现了我们不需要的缩放和平移信息,而骨架只提供运动信息,没有对皮肤的体积进行很好的控制和支撑,因此皮肤可以任意内陷。在关节旋转超过 60 度时,这种内陷尤其明显,这就是线性混合蒙皮中皮肤失真的主要原因。
- World Space:世界坐标系
- local-space :局部空间假定 骨骼变换相对于其父骨骼
- Component Space:组件空间假定骨骼变换相对于 SkeletalMeshComponent
- Bone Space:以EffectorSpaceBoneName配置的骨骼为原点的坐标系。
Poses 的变换可以在localSpace和Component Space都可以,一般来讲,在动画蓝图中使用姿势时,它们都位于局部空间中。但是,特定混合 节点和所有SkeletalControl都在组件空间中运算。这意味着,在将输入姿势传入这些类型的节点前, 需要变换这些姿势。如果输入姿势来自输出局部空间姿势的某个节点, 必须先将该姿势转换到正确的空间,然后SkeletalControl才能对它执行运算。 在执行运算后,必须将转换后的姿势重新转换回 局部空间,以便为其他混合或“结果(Result)”引脚提供输入。
一.UE4动画基本数据结构解析
骨架网格体由两部分构成:构成骨架网格体表面的一组多边形,用于是使多边形顶点产生动画的一组层次化的关联骨骼。USkeletalMeshComponent继承自USkinnedMeshComponent,也支持骨骼蒙皮的基本组件。
1.1USkeleton
对骨骼的理解
我们想要理解骨骼,首先先看看静态模型吧,静态模型没有骨骼,我们在世界坐标系中放置静态模型时,只要指定模型自身坐标系在世界坐标系中的位置和朝向。
在骨骼动画中,不是把Mesh直接放到世界坐标系中,Mesh只是作为Skin使用的,是依附于骨骼的,真正决定模型在世界坐标系中的位置和朝向的是骨骼。
在渲染静态模型时,由于模型的顶点都是定义在模型坐标系中的,所以各顶点只要经过模型坐标系到世界坐标系的变换后就可进行渲染。
而对于骨骼动画,我们设置模型的位置和朝向,实际是在设置根骨骼的位置和朝向,然后根据骨骼层次结构中父子骨骼之间的变换关系计算出各个骨骼的位置和朝向,然后根据骨骼对Mesh中顶点的绑定计算出顶点在世界坐标系中的坐标,从而对顶点进行渲染。
要记住,在骨骼动画中,骨骼才是模型主体,Mesh不过是一层皮,一件衣服。
如何理解骨骼?请看第二个观念:骨骼可理解为一个坐标空间。
在一些文章中往往会提到关节和骨骼,那么关节是什么?骨骼又是什么?下图是一个手臂的骨骼层次的示例。
骨骼只是一个形象的说法,实际上骨骼可理解为一个坐标空间,关节可理解为骨骼坐标空间的原点。关节的位置由它在父骨骼坐标空间中的位置描述。上图中有三块骨骼,分别是上臂,前臂和两个手指。Clavicle(锁骨)是一个关节,它是上臂的原点,同样肘关节(elbow joint)是前臂的原点,腕关节(wrist)是手指骨骼的原点。关节既决定了骨骼空间的位置,又是骨骼空间的旋转和缩放中心。为什么用一个4X4矩阵就可以表达一个骨骼,因为4X4矩阵中含有的平移分量决定了关节的位置,旋转和缩放分量决定了骨骼空间的旋转和缩放。我们来看前臂这个骨骼,其原点位置是位于上臂上某处的,对于上臂来说,它知道自己的坐标空间某处(即肘关节所在的位置)有一个子空间,那就是前臂,至于前臂里面是啥就不考虑了。当前臂绕肘关节旋转时,实际是前臂坐标空间在旋转,从而其中包含的子空间也在绕肘关节旋转,在这个例子中是finger骨骼。和实际生物骨骼不同的是,我们这里的骨骼并没有实质的骨头,所以前臂旋转时,他自己没啥可转的,改变的只是坐标空间的朝向。你可以说上图的蓝线在转,但实际蓝线并不存在,蓝线只是画上去表示骨骼之间关系的,真正转的是骨骼空间,我们能看到在转的是wrist joint,也就是两个finger骨骼的坐标空间,因为他们是子空间,会跟随父空间运动,就好比人跟着地球转一样。
骨骼就是坐标空间,骨骼层次就是嵌套的坐标空间。关节只是描述骨骼的位置即骨骼自己的坐标空间原点在其父空间中的位置,绕关节旋转是指骨骼坐标空间(包括所有子空间)自身的旋转,如此理解足矣。但还有两个可能的疑问,一是骨骼的长度问题,由于骨骼是坐标空间,没有所谓的长度和宽度的限制,我们看到的长度一方面是蒙皮后的结果,另一方面子骨骼的原点(也就是关节)的位置往往决定了视觉上父骨骼的长度,比如这里upper arm线段的长度实际是由elbow joint的位置决定的。第二个问题,手指的那个端点是啥啊?实际上在我们的例子中手指没有子骨骼,所以那个端点并不存在:)那是为了方便演示画上去的。实际问题中总有最下层的骨骼,他们不能决定其他骨骼了,他们的作用只剩下控制Mesh顶点。对了,那么手指的长度如何确定?我们看到的长度应该是由手指部分的顶点和蒙皮决定的,也就是由Mesh中属于手指的那些点离腕关节的距离决定。
经过一段长篇大论,我们终于清楚骨骼和骨骼层次是啥了,但是为什么要将骨骼组织成层次结构呢?答案是为了做动画方便,设想如果只有一块骨骼,那么让他动起来就太简单了,动画每一帧直接指定他的位置即可。如果是n块呢?通过组成一个层次结构,就可以通过父骨骼控制子骨骼的运动,牵一发而动全身,改变某骨骼时并不需要设置其下子骨骼的位置,子骨骼的位置会通过计算自动得到。上文已经说过,父子骨骼之间的关系可以理解为,子骨骼位于父骨骼的坐标系中。
我们知道物体在坐标系中可以做平移变换,以及自身的旋转和缩放变换。子骨骼在父骨骼的坐标系中也可以做这些变换来改变自己在其父骨骼坐标系中的位置和朝向等。那么如何表示呢?
由于4X4矩阵可以同时表示上述三种变换,所以一般描述骨骼在其父骨骼坐标系中的变换时使用一个矩阵,也就是DirectX SkinnedMesh中的FrameTransformMatrix。实际上这不是唯一的方法,但应该是公认的方法,因为矩阵不光可以同时表示多种变换还可以方便的通过连乘进行变换的组合,这在层次结构中非常方便。
UE4中的定义
/** New Reference skeleton type **/
FReferenceSkeleton RefSkeleton;
在UE4中,USkeleton并不会直接使用自身的数据而是会生成一个FReferenceSkeleton来提供给mesh来使用。
其将所有的原始Bone数据分成两份,一份存储Bone的名字和父节点名称,一份是之前我们说的Transform来表示一个bone。当然还会有其他的数据信息,例如Name和Index的关系,等等,
在SkinnedMeshComponent中,将会把数据放在
1.2USkeletalMesh
作为链接Mesh和动画的桥梁,其实其中所拥有的数据几乎没有什么东西。
- BlendProfiles:混合描述文件
- AnimRetargetSources:对于这个骨骼来说可以序列化的重定向资源
- Sockets:每一根骨骼的点
- VirtualBones:在引擎中不增加蒙皮的虚拟骨骼,可以在动画蓝图中使用,
- RefLocalPoses_DEPRECATED:和在local space 下的skeleton poses对应
- BoneTree:由boneNode组成,这里记录着的是所有的骨架结构
- SmartNames
渲染数据SkeletalMeshRenderData
对于mesh来说,不用于渲染的数据,其实并没有什么用处。
//标记是scetion的阶段
TArray<FSkelMeshRenderSection> RenderSections;
// Index Buffer (MultiSize: 16bit or 32bit)
FMultiSizeIndexContainer MultiSizeIndexContainer;
FMultiSizeIndexContainer AdjacencyMultiSizeIndexContainer;
//static vertices from chunks for skinning on GPU
FStaticMeshVertexBuffers StaticVertexBuffers;
//Skin weights for skinning
FSkinWeightVertexBuffer SkinWeightVertexBuffer;
// cloth mesh-mesh mapping
FSkeletalMeshVertexClothBuffer ClothVertexBuffer;
//MorphTargets buffer
FMorphTargetVertexInfoBuffers MorphTargetVertexInfoBuffers;
//权重数据,可以存储不同的权重
FSkinWeightProfilesData SkinWeightProfilesData;
//激活的骨骼
TArray<FBoneIndexType> ActiveBoneIndices;
//需要的骨骼
TArray<FBoneIndexType> RequiredBones;
- ImportedModel:原始的mesh
- SkeletalMeshRenderData:用于渲染的资源,存储lod与每个lod中section的信息
- Materials:重新定义的FSkeletalMaterial
1.3.UAnimInstance
这里是我们最需要关心的地方之一,这是所有逻辑的控制中心。但是一般而言对他的更新我们为了高效率的进行,通常会在他的代理中进行,参考第二章的动画更新。
1.3.1节点
所有节点的父类是FAnimNode_Base。FAnimNode_Base是Anim graph中实时动画节点的父类,由于动画节点实在是太多了,我们将管中窥豹。
- Initialize_AnyThread:当节点第一次运行时,会运行Initialize_AnyThread。
- CacheBones_AnyThread:CacheBones用于刷新该节点所引用的骨骼索引。
- Update_AnyThread:更新
- Update_AnyThread:调用以根据Update()中设置的权重计算局部空间骨骼变换。
- GatherDebugData:GatherDebugData用于使用"ShowDebug Animation"数据进行调试。为了保持到子项的连接,使用这些是很重要的。
- FPoseLink SourcePose;这是在节点上的pose连结点,会串联出来基本的运行逻辑。基本上每个animNode都会。
1.3.1.1AssetPlayerBase节点
所有播放动画的节点父类是FAnimNode_AssetPlayerBase。
- FAnimNode_BlendSpacePlayer
- FAnimNode_AimOffsetLookAt
- FAnimNode_BlendSpaceEvaluator
- FAnimNode_RotationOffsetBlendSpace
- FAnimNode_PoseHandler
- FAnimNode_PoseBlendNode
- FAnimNode_PoseByName
- FAnimNode_PoseDriver
- Sqeuence.FAnimNode_SeequencePlayer
其大部分工作都是对于UAnimSequence 资源类型的更新。在执行时,执行:
Sequence->GetAnimationPose(Output.Pose, Output.Curve, FAnimExtractContext(InternalTimeAccumulator, Output.AnimInstanceProxy->ShouldExtractRootMotion()));
1.3.1.1FAnimNode_Base节点
1.3.1.1StateMachine节点
FAnimNode_StateMachine
GetRelevantAssetPlayerFromState
1.3.1.1LiveLinkPose节点
这是进行LiveLink链接的重要节点,其中最重要的就是ILiveLinkClient,其通过流式传输从额外的数据源传递数据给Unreal。其更新并不需要我们来管理,我们只需要取其数据就可以了。
Evaluate_AnyThread
1.3.2连接点
1.3.2.1FPoseLinkBase ,FPoseLink
其表示一个 local空间的pose链接 用于pose 的传递 。
int32 LinkID;//所链接的ID
int32 SourceLinkID;
struct FAnimNode_Base* LinkedNode;//
从初始化的函数可以看出,其初始化流程是通过LinkedNode去驱动的,也就是说,在PoseLink初始化时会将链接的FAnimNode_Base初始化。
FPoseLink调用CacheBones_AnyThread时会驱动链接的CacheBones调用CacheBones_AnyThread
1.3.2.2链接点的数据传输
1.4 UAnimationAsset
从这里我们逐步将进入动画阶段,以为之气的资源类型要不是使用于控制,要不就是使用于存储数据。UAnimationAsset是所有动画资源的父类类型。
几乎跟所有的骨骼相关内容一样,所有的动画类型都必须进行骨骼的绑定,也就是说所有的动画都不是无根之木,都必须依赖于骨骼。当然除了标记动画继承关系外,这里并没有什么东西。所有的动画资源都是要在动画蓝图中使用。
其子类主要有三中,UAnimSequenceBase是所有动画的子类
1.4.1UAnimSequenceBase动画序列基类
UAnimSequenceBase是帧动画序列的类型。其主要是根据时间来进行的变化。其主要功能是动画通知和曲线控制。其子类为UAnimCompositeBase,UAnimSequence,UAnimStreamable
1.4.1.1UAnimCompositeBase动画合成基类
这里依然是一个虚的资源类型,这里是动画合成的基类。没什么东西
1.4.1.1.1UAnimComposite动画合成
- 在某些情况下,您可能会遇到这样的情况:您需要将多个动画序列拼接在一起,这样就可以将它们当作一个序列而不是多个序列来使用。这正是 动画合成 的目的。动画合成是一种动画资源,专门设计用于允许您将多个动画组合在一起以作为单个单元进行处理。但是,请注意合成只是追加动画;它不提供任何混合能力。
1.4.1.1.2UAnimMontage动画蒙太奇
动画蒙太奇(Animation Montage)(简称 蒙太奇)提供了一种直接通过蓝图或C++代码控制动画资源的途径。 你可以使用动画蒙太奇将多个不同动画序列 组合成一个资源。你可以将该资源分成若干 片段(Sections),选择播放其中的个别片段,或者选择播放所有片段。 你可以触发蒙太奇中的 事件(Events) 以执行各种本地或复制任务,例如播放Sound Cue或粒子效果,更改玩家数值(如弹药数量)等,甚至在动画启用“根运动”时复制联网游戏中的根运动 。
- FAnimMontageInstance
1.4.1.2UAnimSequence
动画序列 是可在骨架网格体上播放的单个动画资源。这些序列包含各个关键帧,而关键帧又规定了骨骼在特定时间点的位置、旋转和比例。依次回放这些关键帧(相互合成)可以顺利实现骨架网格体中的骨骼动画。
- AnimSequence几乎是所有动画的主要的组成部分,其主要的运行机理我们将在后面讨论。
1.4.2UBlendSpaceBase混合空间基类
其子类分别是UBlendSpace,UBlendSpace1D。混合空间(Blend Space) 允许根据两个输入的值混合动画。要根据一个输入在两个动画之间实现简单混合, 可以使用动画蓝图 中提供的一个标准 混合节点 。混合空间提供的方法是根据多个值(目前仅限于两个) 在多个动画之间进行更复杂的混合。这里没什么好说的。
1.4.3UPoseAsset
传统的动画都是关键帧,在时间轴上进行混合,形程所需要的姿势,但是对于面部表情来所,这种方式并不适用,而是使用曲线进行驱动,使用诸多加权值驱动动画表情。但是这里并不是只用于blendshape,其他的骨骼曲线也是可以使用的。
在资源里面存储着每一个pose 的基础数据。
由于如何使用AnimationAsset的数据是在动画蓝图中使用,所以在动画蓝图中将介绍其主要的功能。
1.5UPhysicsAsset
二.动画逻辑
init - 初始化
Update – 动画蓝图从游戏逻辑中收集状态变量并更新骨骼位置
Evaluate – 根据骨骼位置对动画进行解压和混合
Complete – 将运算后的顶点数据推送到渲染现场,更新物体位置和动画通知
2.1初始化
- USkeletalMeshComponent::InitAnim()
- ClearAnimScriptInstance();将animInstance和SubInstances赋空
- RecalcRequiredBones():计算特定LodIndex的需要的骨骼,来自于USkeletalMesh的Renerdata。会将虚拟骨骼加入其中。当然如果资源拥有PhysicsAsset,会将每个骨骼的phyBone一一对应的找到,并在之后会进行更新。找到Mesh 并对应所有的骨骼,mesh和骨骼的点不一定是一一对应的。然后把依赖所有的socket找到。
- InitializeAnimScriptInstance():这里主要是创建需要的动画蓝图,当然这里是有不同分支,一种是如果你指定了动画蓝图,并且其链接的骨骼是一致的,那么会进行创建和放置。还会判断是否会创建PostPhysicsInstance。并且会创建MorphTargetCurves这里由于非常的重要,将在下个一节详细介绍
- TickAnimation(0.f, false); 这里主要做的就是更新curve
- RefreshBoneTransforms();主要是更新bone
创建MorphTargets并更新
在初始化动画蓝图时,我们会进行MorphTargets的创建。这里需要注意的是,这里的curve并不是动画的curve,而是mesh 的。
首先,所有的MorphTargets是在Mesh中,所以我们会从我们现有的mesh中找到所有激活的MorphTarget分配空间。这里的存储结构会有一个浮点数序列存储其权重值。
之后,遍历所有的AnimScriptInstance和SubInstances,看是否这些动画蓝图有更改的的曲线,然后去更新MorphTargets。
2.2动画更新
执行更新的地方是在组件的TickComponent中,每帧进行调用。
:USkinnedMeshComponent:TickComponent
USkeletalMeshComponent::TickComponent
在USkinnedMeshComponent中主要是更新pose,更新Bone的操作,然后是TickAnimation,调用动画蓝图的UpdateAnimation。也就是说,在更新中是先更新动画蓝图,动画,然后根据曲线数据去更新骨骼和mesh。
TickPose(DeltaTime, false);
RefreshMorphTargets();
RefreshBoneTransforms(ThisTickFunction);
UpdateSlaveComponent();
其中,更新的内容主要是两部分,第一部分是animInstance的更新(TickPose),第二是自身数据的更新。
2.2.1更新动画实例
https://mp.weixin.qq.com/s/GSe_NIJZ-dGNjhfXAFKhqQ
在更新事件中,更新动画是我们数据驱动的主要方式。
bool UAnimInstance::UpdateAnimation();
{
UpdateMontage(DeltaSeconds);
PreUpdateAnimation(DeltaSeconds);
UpdateMontageSyncGroup();
BlueprintUpdateAnimation(DeltaSeconds);
ParallelUpdateAnimation();
PostUpdateAnimation();
}
这里是更新动画的主层逻辑,在这里之前,我们需要另一个重要的概念FAnimInstanceProxy。什么是AnimInstanceProxy?
2.2.1.1多线程动画更新
该选项控制默认情况下,是否允许在非游戏线程上执行动画蓝图图形更新。 还允许在动画蓝图编译器中进行一些额外检查,并在尝试执行不安全的操作时发出警告。 在 动画蓝图(Animation Blueprints) 中,也需要确保设置为 使用多线程动画更新(Use Multi Threaded Animation Update)。
在动画蓝图(Animation Blueprints)中的 类设置(Class Settings) 下面,确保启用 使用多线程动画更新(Use Multi Threaded Animation Update)。
其主要原因是为了更严密地控制各个线程中的数据访问。为此,大部分动画图形访问的数据已经从UAnimInstance 移至一个新的结构,名为FAnimInstanceProxy 。 该代理结构存放有关`UAnimInstance`的大量数据。
2.2.1.2动画实例代理AnimInstanceProxy
AnimInstanceProxy是,动画实例代理,属于多线程优化动画系统的核心对象。他可以分担动画蓝图的更新工作,将部分动画蓝图的任务分配到其他线程去做。
一般而言,不能从动画图形节点(Update/Evaluate calls)访问或修改`UAnimInstance,因为它们可以在其他线程上运行。 有一些锁定封装器(GetProxyOnAnyThread 和GetProxyOnGameThread )可以在任务运行期间阻止访问`FAnimInstanceProxy。
主要想法是在最差的情况下,任务等待完成,然后才允许从代理读取或写入数据。
从动画图形的角度而言,从动画节点只能访问`FAnimInstanceProxy,而不能访问`UAnimInstance。 对于FAnimInstanceProxy::PreUpdate 或FAnimInstaceProxy::PreEvaluateAnimation 中的每次更新,必须与代理交换数据(通过缓冲、复制或其他策略)。 接下来需要被外部对象访问的任何数据应该从FAnimInstanceProxy::PostUpdate 中的代理进行交换/复制。
这与`UAnimInstance`的一般用法冲突,在一般用法中,可以在任务运行期间从其他类访问成员变量。 建议最好不要从其他类直接访问动画实例。动画实例应从其他位置拉取数据。
总之,将游戏逻辑得更新从UAnimInstance转移到AnimInstanceProxy,并且动画图表中只能访问AnimInstanceProxy中得数据,从而做并行优化。
2.2.1.3具体细节
当我们知道动画的更新策略后,我们再仔细看一下其中的内容,
1. 更新蒙太奇UpdateMontage
更新蒙太奇,蒙太奇的数据主要是存在于动画实例中MontageInstances。首先更新他的权重,并且计算其对应的骨骼和curve的比重。
2. 准备工作
这里主要做的是在更新前的准备工作,诸如时间的计算,lod的切换,整体的Transform 的移动,通知事件的重置,所有的权重重置等等
在AnimInstanceProxy中,存储了这个AnimInstance所有的必要信息。所以对于其中的所有动画节点,也会在这个阶段进行准备工作
3. 组同步更新MontageSyncGroup
同步组 使相关的动画相互保持同步,即使它们长度不一也不例外。
4. BlueprintUpdateAnimation
更新动画蓝图,实现全在蓝图当中。
5. ParallelUpdateAnimation
这里是更新中最重要的函数,在这里会调用FAnimInstanceProxy的更新函数,使用GetProxyOnAnyThread<FAnimInstanceProxy>()调用以下两个函数
FAnimInstanceProxy::UpdateAnimation
FAnimInstanceProxy::TickAssetPlayerInstances
6. FAnimInstanceProxy ::UpdateAnimation
在AnimInstanceProxy,会从他的RootNode 开始。RootNode并不是最开始的点, 而是终点,其跟新是递归的找到最开始的点。
- 对于root点,在最开始CacheBones_AnyThread,这个点会根据他的sourcePose一直向前找,直至最前面没有deflaut的。
- 然后调用Root节点默认的Update。在root节点的update中调用Update_AnyThread。同样的我们的Root依旧会一次向前找,最终把所有的点进行更新
- UpdateAnimationNode()之后直接将该动画实例中存储的所有FAnimNode_SaveCachedPose进行更新。FAnimNode_SaveCachedPose是啥
7. FAnimInstanceProxy :: TickAssetPlayerInstances
我看这里全是同步组的东西,没兴趣
8. PostUpdateAnimation
2.2.2更新数据
之前所有的数据都是在animation中的数据,而我们的组件并没有获得。
2.2.2.1更新MorphTarget;
我们之前已经进行了整个组件中所有的动画实例的更新,我们把动画中所有的使用Curves进行更新。着里是非常简单的,毕竟MorphTarget只是曲线而已。
之后我们把使用到的MorphTarget添加。等待使用
2.2.2.2更新bonesRefreshBoneTransforms(ThisTickFunction);
计算所有需要的bones,之后我们将填充AnimEvaluationContext,之后我们会大概到了Evaluation。这里也会有多线程的优化,不过我们为了简单起见,看在GameThread的支线。
SwapEvaluationContextBuffers();
ParallelAnimationEvaluation();
SwapEvaluationContextBuffers();
首先是交换EvaluationContext,在组件上是有非常多的缓存,存储着主要的数据。我们把AnimEvaluationContext上的缓存进行交换。注意这里是进行了两次交换的。
在ParallelAnimationEvaluation,就复杂的多。
这里主要是执行AnimInstance的EvaluateAnimation,依旧是转为FAnimInstanceProxy。对于FAnimInstanceProxy来说,执行后返回的数据是FPoseContext,这里就是通过变换后的结果。
注意,直到这里,我们才执行了动画蓝图,而之前的更新proxy,我们只是更新他的状态,而没有执行他们的数据并且获得结果。
这里当然还是老套路,从root开始一直执行Evaluate_AnyThread,最后传出来。最后在固化就可以了
2.2.2.3UpdateSlaveComponent();
???
三.骨骼的渲染
3.1数据和流程
创建并生成渲染段的接口是USkinnedMeshComponent::CreateRenderState_Concurrent。基本的数据传输基本是Unreal Mesh Drawing 中的顺序和数据。所以我们最关系的就是FPrimitiveSceneProxy里面的数据和staticMesh的不同。
MeshRenderData 的数据存储在USkeletalMesh中,并且是其唯一占有。在创建渲染数据时,将把它拿出来用于创建MeshObject,其类型是FSkeletalMeshObject,创建会根据选项创建其子类(参见3.1.1)。当建立之后MeshObject将常驻内存,轻易不会销毁。
当我们创建出MeshObject后,就会根据它创建渲染代理。由于不是静态状态,所以每帧会根据initview里面的动态物体可见性检测来判断是否需要从proxy中创建 MeshBatch。可参见解析initview 。
至此会渲染数据进行提交和渲染。
3.1.1MeshObject分类
FSkeletalMeshObject是SkeletalMesh 蒙皮的父类。子类为:FSkeletalMeshObjectCPUSkin;FSkeletalMeshObjectGPUSkin;FSkeletalMeshObjectStatic。
在创建时,会根据是否静态渲染,是否支持GPU蒙皮和是否需要CPU蒙皮来创建。
对于FSkeletalMeshObjectStatic上的配置是GPU skin vertex buffer + LocalVertexFactory
- todo:三种的区分蒙皮研究
3.2渲染数据的更新
之前由于一直在渲染线程,并且大多都是对staticMesh进行相对应的研究和操作,由于本文是对skeletalMesh的研究,就避不开对动态数据的研究。MeshObject是我们需要重点关心的数据存储的地点,由于是指针传递,所以其实虽然是使用的指针,但是我们还是为了线程安全的问题,必须把需要更新的阶段交给渲染线程。
SendRenderDynamicData_Concurrent();
之后就会调用MeshObject->Update,并且调用UpdateMorphMaterialUsageOnProxy来更新MorphMaterial。当然这这些函数下由于还是在主线程,所以都会推到渲染线程去更新数据例如:
在父类中所做的更新有:
3.2.1更新的是什么数据
针对不同的MeshObject信息,我们的更新数据是不同的,对于CPUSkin我们更新FDynamicSkelMeshObjectDataCPUSkin;GPUSkin我们更新FDynamicSkelMeshObjectDataGPUSkin等等。
核心数据是在FSkeletalMeshObjectLOD中的
FDynamicSkelMeshObjectDataGPUSkin* DynamicData;
这个数据是我们更新和使用他渲染的数据结构。
InitMorphResources:对于MorphTarget非常的简单。更新激活的MorphTarget和对应的权重,并会筛选出影响mesh 的曲线,剔除不需要的,数据来自自己的SkeletalMeshCompent中的MorphTarget。
NewDynamicData->InitDynamicSkelMeshObjectDataGPUSkin:初始化GPU蒙皮Data
- UpdateRefToLocalMatrices
- UpdateRefToLocalMatricesInner
- UpdatePreviousRefToLocalMatrices
- UpdateRefToLocalMatricesInner
- UpdateClothSimulationData(InMeshComponent);
- MeshObject->UpdateDynamicData_RenderThread
- WaitForRHIThreadFenceForDynamicData、
- FreeDynamicSkelMeshObjectDataGPUSkin(DynamicData);
- ProcessUpdatedDynamicData
- UpdateMorphVertexBufferGPU
- ShaderData.UpdateBoneData
- ClothShaderData.UpdateClothSimulData
对于骨骼数据,我们
- UpdateRefToLocalMatrices
- ComponentSpaceTransformsArray
- UpdateClothSimulationData
- 更新矩阵,例如pose对应的local space transforms,遍历需要的骨骼,更新每一个骨骼的矩阵。
- 更新ClothSimulationData
当然更新的时候会设置fence。最终会在渲染线程去更新buffer例如UpdateMorphVertexBufferGPU
UpdateMorphVertexBufferCPU。关于其buffer中的东西我们将在shader和buffer中进行观察。
3.2.2数据的使用
3.3渲染shader和buffer
MorphVertexBuffer和MorphVertexBuffer都存在UAV当中。
UpdateMorphVertexBufferGPU
MorphVertexFactories
3.4渲染分析
3.4.1顶点工厂
我们几乎在之前是UE4渲染的主要结构框架都已经分析,完全,但是并没有进行经验性的总结和归纳,不过这并不影响我们来理解所有的Mesh和他的factory在其中的作用。其负责将顶点数据从C++端带到Shader端,继承自FRenderResource,是渲染资源的一种。
之前我们总是看的是staticMesh,对应的Factory也是FLocalVertexFactory。而在SkeletalMesh中,我们需要知道的是,这GPU和CPU skin的factory是不同的。
对于CPUSkin使用的依旧是FLocalVertexFactory,而对于GPUSkin,我们使用更多是其他的Factory。
- 如果GPU蒙皮Cache开启(啥时候开启,Cache了啥),我们提供FGPUSkinPassthroughVertexFactory,FGPUSkinPassthroughVertexFactory继承自FLocalVertexFactory。
- 如果布料模拟更新,提供FGPUBaseSkinAPEXClothVertexFactory
- 如果有任何的Morph Target被启动,我们使用FGPUBaseSkinVertexFactory
FGPUSkinPassthroughVertexFactory开启了GPUSKIN_PASS_THROUGH,在LocalVertexFactory中可以看到其应用。但是应该是不会用到的把,好像rayTrace要用到,那我就懂了。
所以其中最重要的就应该是FGPUBaseSkinVertexFactory
FGPUBaseSkinVertexFactory//(纯虚)
TGPUSkinVertexFactory
TGPUSkinAPEXClothVertexFactory
TGPUSkinMorphVertexFactory
关于顶点工厂的内容,参考
顶点工厂FVertexFactory
这里已经讲的很详细了,所以我们直接进shader里看他到底传入什么东西。其会绑定一个shader文件,我们能找到。
struct FVertexFactoryInput
{
float4 Position : ATTRIBUTE0;
//切线
half3 TangentX : ATTRIBUTE1;
//副法线
half4 TangentZ : ATTRIBUTE2;
#if FEATURE_LEVEL >= FEATURE_LEVEL_ES3_1 || COMPILER_METAL || COMPILER_VULKAN
uint4 BlendIndices : ATTRIBUTE3;
#if GPUSKIN_USE_EXTRA_INFLUENCES
uint4 BlendIndicesExtra : ATTRIBUTE14;
#endif
#else
// Continue using int for SM3, compatibility of uint is unknown across SM3 platforms
int4 BlendIndices : ATTRIBUTE3;
#if GPUSKIN_USE_EXTRA_INFLUENCES
int4 BlendIndicesExtra : ATTRIBUTE14;
#endif
#endif
float4 BlendWeights : ATTRIBUTE4;
#if GPUSKIN_USE_EXTRA_INFLUENCES
float4 BlendWeightsExtra : ATTRIBUTE15;
#endif
#if NUM_MATERIAL_TEXCOORDS_VERTEX
// If this changes make sure to update LocalVertexFactory.usf
float2 TexCoords[NUM_MATERIAL_TEXCOORDS_VERTEX] : ATTRIBUTE5;
#if NUM_MATERIAL_TEXCOORDS_VERTEX > 4
#error Too many texture coordinate sets defined on GPUSkin vertex input. Max: 4.
#endif
#endif
#if GPUSKIN_MORPH_BLEND
// NOTE: TEXCOORD6,TEXCOORD7 used instead of POSITION1,NORMAL1 since those semantics are not supported by Cg
/** added to the Position */
float3 DeltaPosition : ATTRIBUTE9; //POSITION1;
/** added to the TangentZ and then used to derive new TangentX,TangentY, .w contains the weight of the tangent blend */
float3 DeltaTangentZ : ATTRIBUTE10; //NORMAL1;
#endif
//顶点ClothID
#if GPUSKIN_APEX_CLOTH
uint ClothVertexID : SV_VertexID;
#endif
//顶点色
float4 Color : ATTRIBUTE13;
};
3.4.2蒙皮位置
接下来我们来看一下在GPU中是如何改变位置的。不管使用的是什么Factory,我们的shader是不变了的。所以我们看一下BasePassVertexShdader中的位置计算。
当然在此之前我们要看一下SkinVertexFactory使用到的uniform。
这里在FSkeletalMeshObjectGPUSkin::Update中进行填充和更新
在FGPUBaseSkinVertexFactory::FShaderDataType::UpdateBoneData进行更新数据
VertexFactoryIntermediates VFIntermediates = GetVertexFactoryIntermediates(Input);
float4 WorldPositionExcludingWPO = VertexFactoryGetWorldPosition(Input, VFIntermediates);
float4 WorldPosition = WorldPositionExcludingWPO;
float4 ClipSpacePosition;
float3x3 TangentToLocal = VertexFactoryGetTangentToLocal(Input, VFIntermediates);
FMaterialVertexParameters VertexParameters = GetMaterialVertexParameters(Input, VFIntermediates, WorldPosition.xyz, TangentToLocal);
ClipSpacePosition = mul(float4(RasterizedWorldPosition.xyz + ODS, 1.0), ResolvedView.TranslatedWorldToClip);
Output.Position = INVARIANT(ClipSpacePosition);
首先看GetVertexFactoryIntermediates得到的坐标。我们忽略GPUSKIN_APEX_CLOTH
- position就是本身传入的position。
- BlendMatrix,通过CalcBoneMatrix。BoneMatrices是每个骨骼的
- TangentToLocal
- Color
- VertexFactoryGetTangentToLocal直接返回