本文为 虚幻4(UE4) 动画技术 深入浅出 高级运动系统 的笔记

项目设置

这个教程基于Advanced Locomotion System的动画资源(在Advanced Locomotion System插件的CharacterAssets/MannequinSkeleton/AnimationExamples路径中) 上展开的。因此,需要订阅该插件后从中提取资源。

由于订阅插件到提取资源过程教程中介绍的不详细,这里给出详细步骤。

在Epic Games Launcher的商城中订阅Advanced Locomotion System(下简称ALS)插件。

由于ALS插件无法直接加入到现有项目中因此,需要创建一个临时项目。

在临时项目中,找到CharacterAssets/MannequinSkeleton/AnimationExample文件夹,右键文件夹选择Migrate。将文件夹迁移到目标项目的Content文件夹中。

骨骼-姿势-动画

在UE中带有骨骼动画的资源都由一个Skeleton Mesh资源表示。其中一个Skeleton Mesh与一个Skeleton一一对应,且在其上可以绑定各种Mesh,动画以及物理效果。这种结构使得Skeleton和mesh以及动画解耦,多种动画资源可以应用在同一个骨骼上而无需关心骨骼对应的Mesh是什么,而mesh也无需关心会有什么动画与Skeleton绑定(利好mod作者)。

在与Skeleton绑定的资源中,相较于其它游戏引擎,动画是一个相对特殊的存在。UE在动画和骨骼之间引入了一个中间概念:姿态。一个姿态可以理解为动画的一个关键帧,而一系列姿态组合在一起构成了一个动画序列。

在Skeleton Mesh面板下,摆出一个姿势后,便可以从Create Asset/Create Pose Asset/Create Pose 创建一个姿态。

对于一个Pose可以在面板中通过调整weight让其与标准姿态插值:

TODO 从姿态创建动画

叠加动画

在ue中动画可以被设置为叠加动画。叠加动画存储了动画中的各个姿态在基础姿态上的差值,在计算时通过计算差值并将其加到基础姿态上来实现动画效果。

在动画面板的additive settings中,可以通过设置additive anim type为Local Space/Mesh Space来将动画转为叠加动画。设置为叠加动画后,需要设置一个作为参考的Base Pose。

叠加动画最大的好处是可以通过多个叠加动画的组合实现多种多样的动画,例如:前倾+前进=身体前倾中前进,左倾+前进=身体左倾中前进。

局部空间/网格空间

叠加动画分为两类,定义在局部空间(Local Space)以及定义在网格空间(Mesh Space)。一般动画中各个骨骼节点的旋转都是定义在其父节点的局部空间下的,而网格空间则为模型本身的物体空间。假设有个骨骼,其到根节点经过了 这些节点,则这个骨骼在局部空间下的位置为 在网格空间的位置为 。下图展示了定义在局部空间以及定义在网格体空间的叠加动画的区别。

可以看到,局部空间下的动画会把直接把叠加的增量加到对应的骨骼上。这样做会让骨骼的父节点对动画混合的结果产生影响(手臂的动画随着倾斜姿势的腰部)。而网格体空间下,动画的叠加不会受到动画骨骼的父子关系的影响。

从源代码可以更好理解这两种变化的区别。负责动画混合的函数AccumulateAdditivePose 的源代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 在 Animation/AnimTypes.h 的第557行。
UENUM()
enum EAdditiveAnimationType
{
/** No additive. */
AAT_None UMETA(DisplayName="No additive"),
/* Create Additive from LocalSpace Base. */
AAT_LocalSpaceBase UMETA(DisplayName="Local Space"),
/* Create Additive from MeshSpace Rotation Only, Translation still will be LocalSpace. */
AAT_RotationOffsetMeshSpace UMETA(DisplayName="Mesh Space"),
AAT_MAX,
};


//在 Animation/AnimationRuntime.cpp 1044行

void FAnimationRuntime::AccumulateAdditivePose(FAnimationPoseData& BaseAnimationPoseData, const FAnimationPoseData& AdditiveAnimationPoseData, float Weight, enum EAdditiveAnimationType AdditiveType)
{
if (AdditiveType == AAT_RotationOffsetMeshSpace)
{
AccumulateMeshSpaceRotationAdditiveToLocalPoseInternal(BaseAnimationPoseData.GetPose(), AdditiveAnimationPoseData.GetPose(), Weight);
}
else
{
AccumulateLocalSpaceAdditivePoseInternal(BaseAnimationPoseData.GetPose(), AdditiveAnimationPoseData.GetPose(), Weight);
}
...
}

其输入为参考姿态BaseAnimationPoseData ,叠加动画数据AdditiveAnimationPoseData ,权重Weight以及叠加动画类型AdditiveType。可以看到,UE会首先判断目前叠加的动画类型是否为Mesh Space。如果不是则调用AccumulateLocalSpaceAdditivePoseInternal 遍历每个骨骼,与叠加动画直接混合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//在 Animation/AnimationRuntime.cpp中

void FAnimationRuntime::AccumulateLocalSpaceAdditivePoseInternal(FCompactPose& BasePose, const FCompactPose& AdditivePose, float Weight)
{
// 一些优化步骤
...
// Slower path w/ weighting
for (FCompactPoseBoneIndex BoneIndex : BasePose.ForEachBoneIndex())
{
// copy additive, because BlendFromIdentityAndAccumulate modifies it.
FTransform Additive = AdditivePose[BoneIndex];
// 混合的数学运算
// 定义在Math/TransformVectorized.h中
FTransform::BlendFromIdentityAndAccumulate(BasePose[BoneIndex], Additive, VBlendWeight);
}
}


如果是Mesh Space则调用AccumulateMeshSpaceRotationAdditiveToLocalPoseInternal 混合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//在 Animation/AnimationRuntime.cpp中

void FAnimationRuntime::AccumulateMeshSpaceRotationAdditiveToLocalPoseInternal(FCompactPose& BasePose, const FCompactPose& MeshSpaceRotationAdditive, float Weight)
{
...
// Convert base pose from local space to mesh space rotation.
FAnimationRuntime::ConvertPoseToMeshRotation(BasePose);

// Add MeshSpaceRotAdditive to it
FAnimationRuntime::AccumulateLocalSpaceAdditivePoseInternal(BasePose, MeshSpaceRotationAdditive, Weight);

// Convert back to local space
FAnimationRuntime::ConvertMeshRotationPoseToLocalSpace(BasePose);
}

可以看到,这个过程分为三步,将姿态转到Mesh Space,对叠加动画插值,将姿态转回局部空间。其中ConvertPoseToMeshRotation 的实现如下:

1
2
3
4
5
6
7
8
9
10
11
void FAnimationRuntime::ConvertPoseToMeshRotation(FCompactPose& LocalPose)
{
...
for (FCompactPoseBoneIndex BoneIndex(1); BoneIndex < LocalPose.GetNumBones(); ++BoneIndex)
{
const FCompactPoseBoneIndex ParentIndex = LocalPose.GetParentBoneIndex(BoneIndex);

const FQuat MeshSpaceRotation = LocalPose[ParentIndex].GetRotation() * LocalPose[BoneIndex].GetRotation();
LocalPose[BoneIndex].SetRotation(MeshSpaceRotation);
}
}

可以看到这个转换其实就是给每个骨骼乘上其所有父节点的变换,将其转到模型的物体空间。

ConvertMeshRotationPoseToLocalSpace 就是ConvertPoseToMeshRotation 的逆变换,其给每个骨骼乘上其父骨骼的空间变换的逆,将其变回局部空间。

1
2
3
4
5
6
7
8
9
10
11
void FAnimationRuntime::ConvertMeshRotationPoseToLocalSpace(FCompactPose& Pose)
{
...
for (FCompactPoseBoneIndex BoneIndex(Pose.GetNumBones() - 1); BoneIndex > 0; --BoneIndex)
{
const FCompactPoseBoneIndex ParentIndex = Pose.GetParentBoneIndex(BoneIndex);

FQuat LocalSpaceRotation = Pose[ParentIndex].GetRotation().Inverse() * Pose[BoneIndex] .GetRotation();
Pose[BoneIndex].SetRotation(LocalSpaceRotation);
}
}

混合空间/瞄准偏移

混合空间可以根据输入的值的大小混合多种动画。

在UE中通过右键Animation/(Blend Space 1D/ Blend Space)创建,在创建时需要选择Blend Space对应的Skeleton Mesh。

下面以Blend Space 1D为例。Blend Space 1D的编辑界面如图所示。

其中,黄色区域为当前Skeleton Mesh对应的动画资源,蓝色区域为混合空间的编辑器。

对于Blend Space 1D混合空间为一个一维的坐标轴,这个坐标轴的取值范围可由Minimun Axis和Maximun Axis参数设置。同时这个坐标轴被划分成了若干个均匀网格(蓝色区域中灰色竖线),每条网格线上都可以放置一个动画(从黄色区域中拖拽到网格上)。

混合空间可以接受一个输入的坐标值,根据坐标值,混合空间找到坐标对应的网格以及器两端的动画并对动画线性插值。这可以实现游戏中某一类动作的平滑过度。例如,对于跑步/走路的动画,可以通过创建一个Blend Space 1D。在Blend Space 1D中沿着数轴放置 向后跑-向后走-静止-向前走-向前跑的动画,并将输入设置为当前角色的速度。

在编辑器模式下,可以拖拽绿色点来预览不同输入值下混合空间输出的动画是什么。

在UE中还有一类特殊的混合空间为瞄准偏移。在射击游戏中,经常出现角色需要随着瞄准的方向做出相应动作的需求。这可以通过瞄准偏移实现。此时瞄准偏移可以定义为玩家可能朝向的所有方向空间,其输入为玩家的当前朝向,空间网格上放置了玩家朝向不同方向时的动画。

在UE的源码中,可以看到瞄准偏移就是混合空间

1
2
3
4
5
6
7
8
9
10
11
//在 Animation/AimOffsetBlendSpace.h中

UCLASS(config=Engine, hidecategories=Object, MinimalAPI, BlueprintType)
class UAimOffsetBlendSpace : public UBlendSpace
{
GENERATED_UCLASS_BODY()

virtual bool IsValidAdditiveType(EAdditiveAnimationType AdditiveType) const override;
virtual bool IsValidAdditive() const override;
};

其中,瞄准偏移还限制了其使用的动画必须定义在网格体空间中的叠加动画。

1
2
3
4
5
6
7
//在 Animation/AimOffsetBlendSpace.cpp中

bool UAimOffsetBlendSpace::IsValidAdditiveType(EAdditiveAnimationType AdditiveType) const
{
return (AdditiveType == AAT_RotationOffsetMeshSpace);
}

动画蓝图

之前讨论的姿态/动画/混合空间,都是可以组成角色动画的资源。而动画蓝图则是把这些资源组合起来的”逻辑”。

和混合空间类似,动画蓝图可以右键Content Browser选择Skeleton创建。在Skeleton Mesh的Animation部分修改模型的动画蓝图。

其编辑器面板可以分为两个部分,事件图以及动画图:

事件图和其它蓝图类似,可以创建变量,编写对象处理各种事件的逻辑。而动画图负责动画混合的逻辑,得到混合后的姿态。目前我们讨论过的动画资源最终输出的结果都是姿态:

  • 姿态本身可以通过调整权重值获取插值后的姿态
  • 动画会根据输入的时间以及参考姿态(叠加动画)对姿态混合。
  • 混合空间根据输入的时间,变量以及参考姿态(叠加动画)混合姿态。

而动画蓝图实现了这些混合过程资源之间互动的逻辑部分,这大大延展了游戏引擎动画系统的功能。

变换骨骼/双骨骼IK

在动画蓝图中可以通过节点调整输出的姿态。其中变换骨骼(Transform Bone)可以选取一个骨骼并改变其Transform。而Two Bone IK可以选取一个骨骼,对其父节点以及父父节点做IK解算。

在动画蓝图中,两个节点如下图所示。Transform Bone比较简单直观这里不再详细讨论,下面主要讨论Two Bone IK节点。

在Two Bone IK中被选取的节点称为Target, 其父节点被称为Lower Limb而父父节点被称为Upper Limb。引擎中,查找各个节点的代码如下, IKBoneCompactPoseIndex,CachedLowerLimbIndexCachedUpperLimbIndex 分别对应Target节点,Lower Limb以及Upper Limb。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在Animation/AnimNode_TwoBoneIK.cpp第235行

FCompactPoseBoneIndex IKBoneCompactPoseIndex = IKBone.GetCompactPoseIndex(RequiredBones);
CachedLowerLimbIndex = FCompactPoseBoneIndex(INDEX_NONE);
CachedUpperLimbIndex = FCompactPoseBoneIndex(INDEX_NONE);
if (IKBoneCompactPoseIndex != INDEX_NONE)
{
CachedLowerLimbIndex = RequiredBones.GetParentBoneIndex(IKBoneCompactPoseIndex);
if (CachedLowerLimbIndex != INDEX_NONE)
{
CachedUpperLimbIndex = RequiredBones.GetParentBoneIndex(CachedLowerLimbIndex);
}
}

Two Bone IK的参数Effector Location 对影响Target节点的位置,当Effector Location在臂展范围内时,Target会移动到Effector Location位置,否则,Target会移动到距离Effector Location 最近的位置。 Joint Target Location 则决定了TargetLower Limb 以及Upper Limb 所处平面,由于Two Bone IK问题是一个欠约束问题,因此Joint Target Location 给系统添加了一个额外约束以保证有唯一解。

在UE的代码中,双骨骼IK可以分为3个步骤:

  1. 计算结算结果所在的空间坐标系,通过joint position计算该坐标系三个轴的方向。
  2. 在平面上解IK点形成的三角形。
  3. 将平面结果转到joint position形成的坐标系下。

整个求解过程的示意图如下。其中 分别代表求解前Upper 骨骼 Lower骨骼以及Target骨骼的位置。红色点 为IK求解的目标位置,以及求解后Target骨骼的位置(在目标位置点没超过两个骨骼距离的情况下), 为IK求解后Lower骨骼的位置,绿色点 为Joint Position。

下面将结合示意图以及UE源代码讲解IK实现过程。

解双骨骼IK的函数声明如下:

1
2
3
4
5
// 在 Source/Runtime/Private/TwoBoneIK.cpp第70行
void SolveTwoBoneIK(const FVector& RootPos, const FVector& JointPos, const FVector& EndPos, const FVector& JointTarget, const FVector& Effector, FVector& OutJointPos, FVector& OutEndPos, float UpperLimbLength, float LowerLimbLength, bool bAllowStretching, float StartStretchRatio, float MaxStretchScale)
{
...
}

其中,RootPos 对应 ,JointPos 对应 ,EndPos 对应, JointTarget 对应,Effector 对应, OutJointPos 对应 , OutEndPos 对应,UpperLimbLength 对应 的长度,LowerLimbLength 对应 的长度。

在第一步中,我们需要求解的第一个轴为 方向,计算该方向的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// This is our reach goal.
FVector DesiredPos = Effector;
FVector DesiredDelta = DesiredPos - RootPos;
float DesiredLength = DesiredDelta.Size();

// Find lengths of upper and lower limb in the ref skeleton.
// Use actual sizes instead of ref skeleton, so we take into account translation and scaling from other bone controllers.
float MaxLimbLength = LowerLimbLength + UpperLimbLength;

// Check to handle case where DesiredPos is the same as RootPos.
FVector DesiredDir;
if (DesiredLength < (float)KINDA_SMALL_NUMBER)
{
DesiredLength = (float)KINDA_SMALL_NUMBER;
DesiredDir = FVector(1, 0, 0);
}
else
{
DesiredDir = DesiredDelta.GetSafeNormal();
}

其中,为了防止出现 重合的退化情况,需要在 长度小于阈值时,设定一个默认方向。令这个方向为 ,对应代码里的DesiredDir

接着,UE会计算平面的法方向 以及垂直于 以及 的方向

平面法方向可以通过 以及 的叉乘计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Get joint target (used for defining plane that joint should be in).
FVector JointTargetDelta = JointTarget - RootPos; // UT
const float JointTargetLengthSqr = JointTargetDelta.SizeSquared();

// Same check as above, to cover case when JointTarget position is the same as RootPos.
FVector JointPlaneNormal, JointBendDir;
if (JointTargetLengthSqr < FMath::Square((float)KINDA_SMALL_NUMBER))
{
...//处理退化情况
}
else
{
JointPlaneNormal = DesiredDir ^ JointTargetDelta;// UT' X UJ
...
}

方向可以通过 减去其在 方向上投影的分量计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// we have to just pick 2 random vector perp to DesiredDir and each other.
if (JointPlaneNormal.SizeSquared() < FMath::Square((float)KINDA_SMALL_NUMBER))
{
...//处理退化情况
}
else
{
JointPlaneNormal.Normalize();

// Find the final member of the reference frame by removing any component of JointTargetDelta along DesiredDir.
// This should never leave a zero vector, because we've checked DesiredDir and JointTargetDelta are not parallel.
JointBendDir = JointTargetDelta - ((JointTargetDelta | DesiredDir) * DesiredDir);
JointBendDir.Normalize();
}

在第二步解三角形过程中分为两种情况:1. 超出的范围 2. 的范围内。

对于第一种情况,UE将骨骼沿着 方向按比例排布,这部分代码不在关键路径上,故按下不表。

第二中情况的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

if (bAllowStretching)
{
...// 处理第一种情况
}

OutEndPos = DesiredPos;
OutJointPos = JointPos;

// If we are trying to reach a goal beyond the length of the limb, clamp it to something solvable and extend limb fully.
if (DesiredLength >= MaxLimbLength)
{
...//处理第一种情况
}
else
{
// So we have a triangle we know the side lengths of. We can work out the angle between DesiredDir and the direction of the upper limb
// using the sin rule:
const float TwoAB = 2.f * UpperLimbLength * DesiredLength;

const float CosAngle = (TwoAB != 0.f) ? ((UpperLimbLength*UpperLimbLength) + (DesiredLength*DesiredLength) - (LowerLimbLength*LowerLimbLength)) / TwoAB : 0.f;

// If CosAngle is less than 0, the upper arm actually points the opposite way to DesiredDir, so we handle that.
const bool bReverseUpperBone = (CosAngle < 0.f);

// Angle between upper limb and DesiredDir
// ACos clamps internally so we dont need to worry about out-of-range values here.
const float Angle = FMath::Acos(CosAngle);

// Now we calculate the distance of the joint from the root -> effector line.
// This forms a right-angle triangle, with the upper limb as the hypotenuse.
const float JointLineDist = UpperLimbLength * FMath::Sin(Angle);

// And the final side of that triangle - distance along DesiredDir of perpendicular.
// ProjJointDistSqr can't be neg, because JointLineDist must be <= UpperLimbLength because appSin(Angle) is <= 1.
const float ProjJointDistSqr = (UpperLimbLength*UpperLimbLength) - (JointLineDist*JointLineDist);
// although this shouldn't be ever negative, sometimes Xbox release produces -0.f, causing ProjJointDist to be NaN
// so now I branch it.
float ProjJointDist = (ProjJointDistSqr > 0.f) ? FMath::Sqrt(ProjJointDistSqr) : 0.f;
if (bReverseUpperBone)
{
ProjJointDist *= -1.f;
}
// So now we can work out where to put the joint!

...//第三步
}
}

第二步的主要思路为通过解三角形,求解 平面上的坐标。其关键为通过余弦公式计算 的角度:

1
2
3
4
5
6
7
8
9
10
const float TwoAB = 2.f * UpperLimbLength * DesiredLength;

const float CosAngle = (TwoAB != 0.f) ? ((UpperLimbLength*UpperLimbLength) + (DesiredLength*DesiredLength) - (LowerLimbLength*LowerLimbLength)) / TwoAB : 0.f;

// If CosAngle is less than 0, the upper arm actually points the opposite way to DesiredDir, so we handle that.
const bool bReverseUpperBone = (CosAngle < 0.f);

// Angle between upper limb and DesiredDir
// ACos clamps internally so we dont need to worry about out-of-range values here.
const float Angle = FMath::Acos(CosAngle);

计算该 的投影ProjJointDist 以及在 上的投影JointLineDist

1
2
3
4
5
6
7
8
9
10
11
12
const float JointLineDist = UpperLimbLength * FMath::Sin(Angle);

// And the final side of that triangle - distance along DesiredDir of perpendicular.
// ProjJointDistSqr can't be neg, because JointLineDist must be <= UpperLimbLength because appSin(Angle) is <= 1.
const float ProjJointDistSqr = (UpperLimbLength*UpperLimbLength) - (JointLineDist*JointLineDist);
// although this shouldn't be ever negative, sometimes Xbox release produces -0.f, causing ProjJointDist to be NaN
// so now I branch it.
float ProjJointDist = (ProjJointDistSqr > 0.f) ? FMath::Sqrt(ProjJointDistSqr) : 0.f;
if (bReverseUpperBone)
{
ProjJointDist *= -1.f;
}

最后,在第三步,得到平面上解三角形的结果后,将结果从平面空间转到3D空间。

1
OutJointPos = RootPos + (ProjJointDist * DesiredDir) + (JointLineDist * JointBendDir);

结语

看到这里感觉对UE动画系统的基本工具有个大概的了解(自我感觉良好)。至于后面对ALS系统的解刨部分等我有时间在跟进吧。