概览 #
在 UE 中,骨骼动画相关类型关系如下:
其中,USkeletalMesh
是骨架网格体模型数据对象。USkinnedMeshComponent
支持了对骨架网格体的渲染,通过 FSkeletalMeshObject
将渲染所需数据发送到渲染线程,具体的渲染方式也由这个对象决定,例如使用 CPU 还是 GPU 进行渲染。 USkeletalMeshComponent
在此基础上支持了骨骼动画播放,具体动画播放逻辑由 UAnimInstance
实现。
在 USkinnedMeshComponent
的 TickComponent
中,会根据当前渲染状态和 tick 设置去决定是否要调用 TickPose
和 RefreshBoneTransforms
。例如我们可以配置 Only Tick Pose When Rendered 来避免一个对象在不被渲染的时候 tick 动画。另外,当一个对象被配置了 master pose component 的时候,RefreshBoneTransforms
这个函数就不会被回调,引擎会直接使用 master pose component 的 transform 数据。
这里的 RefreshBoneTransforms
需要每个继承了 USkinnedMeshComponent
的类型自行实现,用以更新骨骼的位置。除此之外,USkeletalMeshComponent
也重写了 TickPose
,在里面调用了 TickAnimation
函数更新动画。
动画更新 #
USkeletalMeshComponent
的 TickAnimation
调用了 TickAnimInstances
,这个是动画的主逻辑:
void USkeletalMeshComponent::TickAnimation(...) {
if (!AreRequiredCurvesUpToDate()) {
// 基于 RequiredBones 计算所需要更新的曲线
RecalcRequiredCurves();
}
TickAnimInstances(DeltaTime, bNeedsValidRootMotion);
// ...
}
TickAnimInstances
会触发 UAnimInstance
的 UpdateAnimation
以计算当前帧动画的变量、收集动画通知、更新动画曲线等,这里会分别调用几个 animation instance 的 UpdateAnimation
方法:
void USkeletalMeshComponent::TickAnimInstances(...) {
for (UAnimInstance* LinkedInstance : LinkedInstances) {
LinkedInstance->UpdateAnimation(...);
}
if (AnimScriptInstance != nullptr) {
AnimScriptInstance->UpdateAnimation(...);
}
if(ShouldUpdatePostProcessInstance()) {
PostProcessAnimInstance->UpdateAnimation(...);
}
}
UAnimInstance
就是动画蓝图对象的类型。上面的 LinkedInstances
用于将动画模块化,具体使用可以参考 Animation Blueprint Linking,PostProcessAnimInstance
主要用于进行 IK 计算、物理骨骼计算、表情动画叠加等。AnimScriptInstance
是主动画蓝图对象,主要的动画计算都在此完成。
UAnimInstance
的 UpdateAnimation
这个过程分为几个阶段:
- Pre Update
- Update
- Parallel Update
- Post Update
void UAnimInstance::UpdateAnimation(...) {
// 获取 proxy 对象
FAnimInstanceProxy& Proxy = GetProxyOnGameThread<FAnimInstanceProxy>();
// > 主流程 1 Pre Update
PreUpdateAnimation(DeltaSeconds);
// > 主流程 2 Update
NativeUpdateAnimation(DeltaSeconds);
BlueprintUpdateAnimation(DeltaSeconds);
// 根据配置选择是否并行执行,如果无法并行,就在这里使用主线程来完成计算,
// 一般来说都会使用并行计算,不会在这里直接执行
bool bShouldImmediateUpdate = /* ... */;
if(bShouldImmediateUpdate) {
// > 主流程 3 Parallel Update
ParallelUpdateAnimation();
// > 主流程 4 Post Update
PostUpdateAnimation();
}
}
FAnimInstanceProxy
是一个用于多线程优化动画系统的结构体,它存放了大量有关 UAnimInstance
的数据,可以被多线程访问,用于在工作线程上执行动画图形节点的更新和计算。
UAnimInstance::PreUpdateAnimation
对动画通知数据和 RootMotion 混合数据进行重置,然后调用 FAnimInstanceProxy::PreUpdate
进行代理更新,这个代理的更新其实就是进行常规赋值操作,比如给 RootMotionMode
、SkelMeshCompLocalToWorld
、UngroupedActivePlayers
、SyncGroups
、ComponentTransform
、ComponentRelativeTransform
、ActorTransform
等赋值。
UAnimInstance::NativeUpdateAnimation
用于给子类增加 C 层的计算逻辑,一般会在这里实现数据收集工作,然后在 UAnimInstance
的 NativeThreadSafeUpdateAnimation
函数中具体进行动画处理。这个 NativeThreadSafeUpdateAnimation
会在工作线程中被 proxy 对象在 UpdateAnimation_WithRoot
中调用。这两个函数实现在 C 层,其蓝图对应为 BlueprintUpdateAnimation
和 BlueprintThreadSafeUpdateAnimation
。这两个蓝图的对应函数往往为实际使用最多的部分,蓝图中的状态机、动画节点均在此进行控制。
接下来会根据是否使用并行动画计算来决定是否在此处调用 ParallelUpdateAnimation
和 PostUpdateAnimation
,一般来说,都不会在此进行。接下来应该就会进入前面提到的 RefreshBoneTransforms
这一步了。
更新骨骼 Transform #
USkeletalMeshComponent
的 RefreshBoneTransforms
中会确定当前帧是否需要更新骨骼 transform 数据,例如在执行 URO 的时候,可能这一帧会被跳过,在需要更新时,根据配置确定是并行更新还是串行更新(一般都是并行):
void USkeletalMeshComponent::RefreshBoneTransforms(...) {
// 更新 reuqired bones 和 required curves
if (!bRequiredBonesUpToDate) {
RecalcRequiredBones(GetPredictedLODLevel());
}
else if (!AreRequiredCurvesUpToDate()) {
RecalcRequiredCurves();
}
// 当没有执行 URO 的时候重置数据,后续进行填充
if (!bDoEvaluationRateOptimization) {
CachedBoneSpaceTransforms.Reset();
CachedComponentSpaceTransforms.Reset();
CachedCurve.Empty();
CachedAttributes.Empty();
}
if (bDoParallelEvaluation) {
// 派发并行计算任务
DispatchParallelEvaluationTasks(TickFunction);
} else {
// 同步计算动画的分支,一般不会执行 ...
}
if (TickFunction == nullptr && ShouldBlendPhysicsBones()) {
// 结束骨骼 transform 变换,在这个调用之后,获取到的骨骼信息应该都是最新的
FinalizeBoneTransform();
}
}
前面提到的并行计算任务会通过 DispatchParallelEvaluationTasks
间接调用,这里会新建类型为 FParallelAnimationEvaluationTask
的任务来实现并行计算,然后,还会新建类型为 FParallelAnimationCompletionTask
的任务,来等待计算结束:
void USkeletalMeshComponent::DispatchParallelEvaluationTasks(...) {
SwapEvaluationContextBuffers();
// 触发并行计算
ParallelAnimationEvaluationTask =
TGraphTask<FParallelAnimationEvaluationTask>::CreateTask()
.ConstructAndDispatchWhenReady(this);
// 设置计算结果依赖关系,等待计算任务结束后触发后续计算
FGraphEventArray Prerequistes;
Prerequistes.Add(ParallelAnimationEvaluationTask);
FGraphEventRef TickCompletionEvent =
TGraphTask<FParallelAnimationCompletionTask>::CreateTask(&Prerequistes)
.ConstructAndDispatchWhenReady(this);
}
FParallelAnimationEvaluationTask
的 DoTask
中会调用 USkeletalMeshComponent
中的 ParallelAnimationEvaluation
。这里会通过调用 PerformAnimationProcessing
间接调用到 UAnimInstance
的 ParallelUpdateAnimation
:
void USkeletalMeshComponent::PerformAnimationProcessing(...) {
// 并行更新主动画蓝图和后处理动画蓝图中动画所需的参数
if(InAnimInstance && InAnimInstance->NeedsUpdate()) {
InAnimInstance->ParallelUpdateAnimation();
}
if(ShouldPostUpdatePostProcessInstance()) {
PostProcessAnimInstance->ParallelUpdateAnimation();
}
// 动画计算,求出骨骼位置
if(bInDoEvaluation && OutSpaceBases.Num() > 0) {
// 分别计算主动画蓝图和后处理动画蓝图
EvaluateAnimation(...);
EvaluatePostProcessMeshInstance(...);
// 计算 local space transform
FinalizePoseEvaluationResult(...);
// 计算 component space transform
FillComponentSpaceTransforms(...);
}
}
UAnimInstance
的 ParallelUpdateAnimation
就是在调用 proxy 的 UpdateAnimation
:
void UAnimInstance::ParallelUpdateAnimation() {
GetProxyOnAnyThread<FAnimInstanceProxy>().UpdateAnimation();
}
这里面主要是从 root 节点开始,去遍历动画蓝图的节点,并进行状态更新:
代码语言:javascript复制void FAnimInstanceProxy::UpdateAnimation() {
// 调用 UpdateAnimation_WithRoot
UpdateAnimation_WithRoot(Context, RootNode, NAME_AnimGraph);
}
void FAnimInstanceProxy::UpdateAnimation_WithRoot(...) {
// 进行一些计算,触发对 bone 的 cache,CacheBone 内部会进行缓存状态判断,
// 只有在缓存失效的时候才会调用节点的 CacheBone_AnyThread
if(InRootNode == RootNode) {
CacheBones();
} else {
CacheBones_WithRoot(InRootNode);
}
// 前面提到的 ThreadSaveUpdateAnimation 函数
GetAnimInstanceObject()->
NativeThreadSafeUpdateAnimation(CurrentDeltaSeconds);
GetAnimInstanceObject()->
BlueprintThreadSafeUpdateAnimation(CurrentDeltaSeconds);
Update(CurrentDeltaSeconds);
// Update 节点
// 内部调用 InRootNode->Update_AnyThread(InContext);
if(InRootNode == RootNode) {
UpdateAnimationNode(InContext);
} else {
UpdateAnimationNode_WithRoot(InContext, InRootNode, InLayerName);
}
}
在调用 UAnimInstance
的 ParallelUpdateAnimation
更新动画状态之后,就是调用 USkeletalMeshComponent
的 EvaluateAnimation
进行动画计算,其中调用 UAnimInstance
的 ParallelEvaluateAnimation
:
void USkeletalMeshComponent::EvaluateAnimation(...) const {
if( InSkeletalMesh->GetSkeleton() &&
InAnimInstance &&
InAnimInstance->ParallelCanEvaluate(InSkeletalMesh)) {
FParallelEvaluationData EvaluationData = {
OutCurve, OutPose, OutAttributes };
InAnimInstance->ParallelEvaluateAnimation(
bForceRefpose, InSkeletalMesh, EvaluationData);
} else {
OutCurve.InitFrom(&CachedCurveUIDList);
}
}
ParallelEvaluateAnimation
中会调用 proxy 的 EvaluateAnimation
进行计算,然后把计算结果复制出去:
void UAnimInstance::ParallelEvaluateAnimation(...) {
FAnimInstanceProxy& Proxy = GetProxyOnAnyThread<FAnimInstanceProxy>();
OutEvaluationData.OutPose.SetBoneContainer(&Proxy.GetRequiredBones());
FPoseContext EvaluationContext(&Proxy);
EvaluationContext.ResetToRefPose();
// 具体 EvaluateAnimation
Proxy.EvaluateAnimation(EvaluationContext);
// 复制数据
OutEvaluationData.OutCurve.CopyFrom(EvaluationContext.Curve);
OutEvaluationData.OutPose.CopyBonesFrom(EvaluationContext.Pose);
// ...
}
到了 proxy 的 EvaluateAnimation
这里,就是调用节点的 Evaluate_AnyThread
来进行动画计算:
void FAnimInstanceProxy::EvaluateAnimation(FPoseContext& Output) {
EvaluateAnimation_WithRoot(Output, RootNode);
}
void FAnimInstanceProxy::EvaluateAnimation_WithRoot(...) {
// 这里同样也可能会触发 cache bone 的计算
if(InRootNode == RootNode) {
CacheBones();
} else {
CacheBones_WithRoot(InRootNode);
}
// 默认情况下,Evaluate_WithRoot 返回 false,
// 如果有 native 实现,则在此返回 true,可避免执行节点的 eval
if (!Evaluate_WithRoot(Output, InRootNode)) {
// 内部就是调用节点的 Evaluate_AnyThread
EvaluateAnimationNode_WithRoot(Output, InRootNode);
}
}
前面这几个过程涉及了 FAnimNode_Base
中几个需要子类实现的接口:
CacheBone_AnyThread
一般在一开始或者是 LOD 切换的时候被调用,用于给动画节点缓存骨骼信息,例如调用FBoneReference
的Initialize
,记录下引用骨骼的下标,后续查找的时候可以加速。Update_AnyThread
用于更新影响骨骼计算的参数数据,例如 blend weight。Evaluate_AnyThread
是主要计算发生的地方,根据前面Update_AnyThread
计算更新的参数来计算出 local space 的骨骼 transform,并输出到 pose,结果之后会被缓存到 component。
USkeletalMeshComponent
的 PerformAnimationProcessing
在执行完动画计算后,接下来会调用 FinalizePoseEvaluationResult
复制骨骼的 local space transform 到 component 里:
void USkeletalMeshComponent::FinalizePoseEvaluationResult(...) const {
OutBoneSpaceTransforms = InMesh->GetRefSkeleton().GetRefBonePose();
InFinalPose.NormalizeRotations();
for(auto BoneIndex : InFinalPose.ForEachBoneIndex()) {
FMeshPoseBoneIndex MeshPoseIndex =
InFinalPose.GetBoneContainer().MakeMeshPoseIndex(BoneIndex);
OutBoneSpaceTransforms[MeshPoseIndex.GetInt()] = InFinalPose[BoneIndex];
}
OutRootBoneTranslation =
OutBoneSpaceTransforms[0].GetTranslation() -
InMesh->GetRefSkeleton().GetRefBonePose()[0].GetTranslation();
}
这里的输出数组在之前的 ParallelAnimationEvaluation
中传入:
if (AnimEvaluationContext.bDoInterpolation) {
PerformAnimationProcessing(...,
AnimEvaluationContext.CachedBoneSpaceTransforms, ...);
} else {
PerformAnimationProcessing(...,
AnimEvaluationContext.BoneSpaceTransforms, ...);
}
在获得了骨骼的 local space transform 之后,就调用 FillComponentSpaceTransforms
来基于 local space 计算 component space transform:
void USkeletalMeshComponent::FillComponentSpaceTransforms(...) const {
const int32 NumBones = InBoneSpaceTransforms.Num();
const FTransform* LocalTransformsData = InBoneSpaceTransforms.GetData();
FTransform* ComponentSpaceData = OutComponentSpaceTransforms.GetData();
// 复制根节点数据
OutComponentSpaceTransforms[0] = InBoneSpaceTransforms[0];
if (bAnim_SkeletalMesh_ISPC_Enabled) {
// 这个分支使用 Intel ISPC 来实现,在 Intel CPU 上可以加速,参考:
// https://www.gdcvault.com/play/1026686/Intel-ISPC-in-Unreal-Engine
#if INTEL_ISPC
ispc::FillComponentSpaceTransforms(...);
#endif
} else {
// 一般的逻辑,0 是根骨骼,所以这里从 1 开始遍历
for (int32 i = 1; i < RequireBonesNum; i ) {
const int32 BoneIndex = RequiredBones[i];
FTransform* SpaceBase = ComponentSpaceData BoneIndex;
FPlatformMisc::Prefetch(SpaceBase);
// 计算每一个骨骼的 component space transform
// 也就是对应骨骼的父骨骼的 component space transform 乘以该骨骼
// 的 local space transform
const int32 ParentIndex =
InSkeletalMesh->GetRefSkeleton().GetParentIndex(BoneIndex);
FTransform* ParentSpaceBase = ComponentSpaceData ParentIndex;
FTransform::Multiply(
SpaceBase, LocalTransformsData BoneIndex, ParentSpaceBase);
SpaceBase->NormalizeRotation();
}
}
}
FParallelAnimationEvaluationTask
中的 DoTask
到此就结束了,接下来是看到 FParallelAnimationCompletionTask
的 DoTask
逻辑,这里会等待计算结束,然后调用 USkeletalMeshComponent
的 CompleteParallelAnimationEvaluation
进行后置处理:
void USkeletalMeshComponent::CompleteParallelAnimationEvaluation(...) {
ParallelAnimationEvaluationTask.SafeRelease();
if (...) {
SwapEvaluationContextBuffers();
PostAnimEvaluation(AnimEvaluationContext);
}
AnimEvaluationContext.Clear();
}
PostAnimEvaluation
会调用 UAnimInstance
的 PostUpdateAnimation
,这个函数会进一步调用到 proxy 的 PostUpdate
完成通知发送之类的后置工作。
void USkeletalMeshComponent::PostAnimEvaluation(...) {
// 调用 anim instance 的 PostUpdateAnimation
if (EvaluationContext.AnimInstance) {
EvaluationContext.AnimInstance->PostUpdateAnimation();
}
if (ShouldPostUpdatePostProcessInstance()) {
PostProcessAnimInstance->PostUpdateAnimation();
}
// 检查当前是否更新过骨骼 transform
// 在使用了 skeletal mesh budget 时,可能会出现跳过骨骼更新的情况,
// 此时,下面这段代码就不会执行
if (EvaluationContext.bDoEvaluation||EvaluationContext.bDoInterpolation) {
// 更新曲线
AnimScriptInstance->UpdateCurvesPostEvaluation();
for(UAnimInstance* LinkedInstance : LinkedInstances) {
LinkedInstance->CopyCurveValues(*AnimScriptInstance);
}
// 判断是否执行过动画计算,如果执行过,则需要执行 PostEvaluateAnimation
if (EvaluationContext.bDoEvaluation) {
DoInstancePostEvaluation();
}
// 更新物理
UpdateKinematicBonesToAnim(...);
UpdateRBJointMotors();
// 内部会调用 ConditionallyDispatchQueuedAnimEvents 发送通知
// 这里还会完成 buffer swap、更新包围盒等工作
FinalizeAnimationUpdate();
} else {
// 这个分支是在没有发生骨骼更新的时候调用的,依然可能发送通知
ConditionallyDispatchQueuedAnimEvents();
}
AnimEvaluationContext.Clear();
}
蒙皮计算 #
蒙皮计算通过 USkinnedMeshComponent
中持有的 MeshObject
实现,分为 CPU Skinning 和 GPU Skinning,分别对应于 FSkeletalMeshObjectCPUSkin
和 FSkeletalMeshObjectGPUSkin
,他们都继承自 FSkeletalMeshObject
,在 USkinnedMeshComponent
的 CreateRenderState_Concurrent
中进行初始化,然后调用 MeshObject
的 Update
函数更新动态数据:
void USkinnedMeshComponent::CreateRenderState_Concurrent(...) {
// 初始化 LOD
InitLODInfos();
// 如果用户指定了自己的 mesh object 构造器,就优先使用
if (MeshObjectFactory) {
MeshObject = MeshObjectFactory(...);
}
// 如果用户没有指定构造器,或者构造失败,就选择默认的
if (!MeshObject) {
if (bRenderStatic) {
MeshObject = ::new FSkeletalMeshObjectStatic(...);
} else if (ShouldCPUSkin()) {
MeshObject = ::new FSkeletalMeshObjectCPUSkin(...);
// 这里要确认数据符合 GPU skinning 的约束条件。
// 如果不满足,就直接不显示了,UE 不会自动将其换为 CPU 蒙皮。
// 这里会要求一个材质 section 所能使用的最大蒙皮骨骼数,
// 断点看到在 PC 平台和 Anroid 平台上,这个约束值为 256。
// 注意这里的骨骼数不是总骨骼数,而是实际有蒙皮的骨骼,
// 例如 A 骨骼有 B C 两个子骨骼,然后只有 B C 上刷了蒙皮权重,
// 那么虽然 A 也要参与动画计算,但并不影响此处的判定。
} else if (!SkelMeshRenderData->RequiresCPUSkinning(...)) {
MeshObject = ::new FSkeletalMeshObjectGPUSkin(...);
} else {
UE_LOG(LogSkinnedMeshComp, Warning, TEXT(...));
}
}
PostInitMeshObject(MeshObject);
Super::CreateRenderState_Concurrent(Context);
// 计算 LOD 等级
int32 ModifiedLODLevel = GetPredictedLODLevel();
ModifiedLODLevel = FMath::Clamp(ModifiedLODLevel, ...);
// 计算动态数据并发送到渲染线程
if(SkeletalMesh->IsValidLODIndex(ModifiedLODLevel)) {
MeshObject->Update(ModifiedLODLevel, this, ...);
}
}
Update
需要先在主线程计算动态数据,然后发送到渲染线程:
void FSkeletalMeshObjectGPUSkin::Update(...) {
// ...
// 构造新的用于发往渲染线程的临时动态数据
// 这些数据在下一次 update 的时候会释放掉
FDynamicSkelMeshObjectDataGPUSkin* NewDynamicData = /*...*/;
NewDynamicData->InitDynamicSkelMeshObjectDataGPUSkin(...);
// 将数据发往渲染线程
FSkeletalMeshObjectGPUSkin* MeshObject = this;
ENQUEUE_RENDER_COMMAND(SkelMeshObjectUpdateDataCommand)(
[...](FRHICommandListImmediate& RHICmdList) {
MeshObject->UpdateDynamicData_RenderThread(...);
}
);
}
在 InitDynamicSkelMeshObjectDataGPUSkin
中,会分别计算所有骨骼的 ref to local 矩阵,以及 local to world 矩阵。其中,ref to local 矩阵是到 local space 的蒙皮矩阵。叠加上 local to world 矩阵的变化后,就是完整的 world space 蒙皮矩阵。给定一个顶点和蒙皮权重,可以计算出动画播放后,该顶点在 world space 下的位置:
void
FDynamicSkelMeshObjectDataGPUSkin::InitDynamicSkelMeshObjectDataGPUSkin(...) {
LODIndex = InLODIndex;
// 一些额外需要计算的骨骼
const TArray<FBoneIndexType>* ExtraRequiredBoneIndices = /*...*/;
// 更新 ref to local 矩阵
UpdateRefToLocalMatrices(..., LODIndex, ExtraRequiredBoneIndices);
// ...
// 更新 local to world 矩阵
LocalToWorld = InMeshComponent ? InMeshComponent->GetComponentTransform().ToMatrixWithScale() : FMatrix::Identity;
// ...
}
UpdateRefToLocalMatrices
会将矩阵计算结果输出到传入的 ReferenceToLocal
数组中引用。需要注意的是,由于每一次 Update
都会申请新的 dynamic data,因此这个 ReferenceToLocal
数组每次调用都需要申请内存,如果骨骼数量过多,这里效率会比较低:
void UpdateRefToLocalMatrices(TArray<FMatrix44f>& ReferenceToLocal, ...) {
const USkeletalMesh* const ThisMesh = InMeshComponent->SkeletalMesh;
// Ref pose 矩阵的逆矩阵
const TArray<FMatrix44f>* RefBasesInvMatrix =
&ThisMesh->GetRefBasesInvMatrix();
// 如果用户设置了 ref pose 数据的 override,就在此处更改
if( InMeshComponent->GetRefPoseOverride() /*...*/ ) {
RefBasesInvMatrix =
&InMeshComponent->GetRefPoseOverride()->RefBasesInvMatrix;
}
// 这里申请内存,事实上对于内置的 skinning,这里传入的数组总是空的
if(ReferenceToLocal.Num() != RefBasesInvMatrix->Num()) {
ReferenceToLocal.Empty(RefBasesInvMatrix->Num());
ReferenceToLocal.AddUninitialized(RefBasesInvMatrix->Num());
for (int32 Index = 0; Index < ReferenceToLocal.Num(); Index) {
ReferenceToLocal[Index] = FMatrix44f::Identity;
}
}
// 具体计算 ref to local 矩阵
UpdateRefToLocalMatricesInner(ReferenceToLocal, ComponentTransform, ...);
}
这个 RefBaseInvMatrix
的数量由原始骨架中骨骼的数量决定:
void USkeletalMesh::CalculateInvRefMatrices() {
// 数量是 raw bone num,即原始骨架中的骨骼数
const int32 NumRealBones = GetRefSkeleton().GetRawBoneNum();
if (GetRefBasesInvMatrix().Num() != NumRealBones) {
GetRefBasesInvMatrix().Empty(NumRealBones);
GetRefBasesInvMatrix().AddUninitialized(NumRealBones);
for( int32 b=0; b<NumRealBones; b ) {
// 获取 bone space 的 ref pose 矩阵
ComposedRefPoseMatrices[b] = GetRefPoseMatrix(b);
// 如果不是根骨骼,那么 ref pose local space 矩阵是
// 自身的 bone space 和父骨骼的 local space 矩阵相乘
if( b>0 ) {
int32 Parent = GetRefSkeleton().GetRawParentIndex(b);
ComposedRefPoseMatrices[b] =
ComposedRefPoseMatrices[b] * ComposedRefPoseMatrices[Parent];
}
// 计算逆矩阵
GetRefBasesInvMatrix()[b] =
FMatrix44f(ComposedRefPoseMatrices[b].Inverse());
}
}
}
具体计算 ref to local 矩阵的逻辑在 UpdateRefToLocalMatricesInner
中实现,这里会先遍历所有会对蒙皮产生影响的骨骼,获取其 component space bone transform 对应的矩阵(即从 bone space 变到当前的 local space),对于不对蒙皮产生影响的骨骼,这里会保持为 identity 矩阵。然后,遍历所有骨骼,乘上 ref pose 下,bone space 到 local space 的变化矩阵的逆矩阵(即从 local space 变回 bone space)。
如果对骨骼蒙皮动画不了解的话,这里解释一下。我们假设一个顶点只会受一根骨骼影响,那么对于这根骨骼来说,动画前后顶点的相对位置是不变的,也就是说,这个顶点在这根骨骼的 bone space 下是静止的,所以我们先计算原始顶点从 local space 变到 bone space 下的位置,然后再应用从 bone space 到新 local space 的变化矩阵,得到动画播放后顶点的位置。过程如下图所示:
这里的紫色顶点跟随其中一根骨骼移动,对于多根骨骼影响一个顶点的情况,我们则是分别计算这几根骨骼的影响,并根据权重进行混合。其实这里 UE 的命名有点误导人,蒙皮矩阵计算前后都是 local space,只是一个是 ref pose,一个是当前 pose,也就是叠加动画后的 pose,但他将其命名为 ref to local,容易让人迷惑。总之,这两个矩阵相乘的结果就是将顶点从 local space 下 ref pose 状态的原始位置变到 local space 下当前状态的实际位置:
代码语言:javascript复制void UpdateRefToLocalMatricesInner(...) {
const FSkeletalMeshLODRenderData& LOD =
InSkeletalMeshRenderData->LODRenderData[LODIndex];
// 注意这里只会处理 active bones 和传入的 extra bones
const TArray<FBoneIndexType>* RequiredBoneSets[3] = { &LOD.ActiveBoneIndices, ExtraRequiredBoneIndices, NULL };
// 遍历所有骨骼集合
for (RequiredBoneIndices : RequiredBoneSets) {
// 遍历每个骨骼集合中的所有骨骼,乘上当前的变化矩阵(初始值是 identity 矩阵)
// 这里保留的是非 master pose 下的计算分支
for(BoneIndex=0; BoneIndex<RequiredBoneIndices.Num(); BoneIndex ) {
ThisBoneIndex = RequiredBoneIndices[BoneIndex];
ReferenceToLocal[ThisBoneIndex] =
(FMatrix44f)ComponentTransform[ThisBoneIndex].
ToMatrixWithScale();
}
}
// 将每个矩阵乘上从 ref pose 的逆矩阵
// 得到从 ref pose 到当前 local 的变化矩阵
for(BoneIndex=0; BoneIndex<ReferenceToLocal.Num(); BoneIndex) {
ReferenceToLocal[BoneIndex] =
(*RefBasesInvMatrix)[BoneIndex] * ReferenceToLocal[BoneIndex];
}
}
注意到在 LOD RenderData 中,有两个数据会影响骨骼动画的计算量,一个是 ActiveBoneIndices
一个是 RequiredBones
,其中,前者是被蒙皮的骨骼的下标,后者是参与到动画计算的骨骼的下标。
我们可以参考 FMeshUtilities::BuildSkeletalModelFromChunks
来看一下这二者的区别:
void FMeshUtilities::BuildSkeletalModelFromChunks(...) {
// 计算 active bones
for (int32 ChunkIndex = 0; ChunkIndex < Chunks.Num(); ChunkIndex) {
// ...
// 这里 bone map 就是用来给顶点蒙皮下标做映射的,
// 因此这里记录的就是参与了蒙皮的骨骼下标
for (int32 BoneIndex = 0; BoneIndex < BoneMap.Num(); BoneIndex) {
LODModel.ActiveBoneIndices.AddUnique(Section.BoneMap[BoneIndex]);
}
}
// 这里确保父骨骼也在 active bone indices 中
RefSkeleton.EnsureParentsExistAndSort(LODModel.ActiveBoneIndices);
// ...
// 计算 required bones
USkeletalMesh::CalculateRequiredBones(LODModel, RefSkeleton, NULL);
}
void USkeletalMesh::CalculateRequiredBones(...) {
int32 RequiredBoneCount = InRefSkeleton.GetRawBoneNum();
LODModel.RequiredBones.Empty(RequiredBoneCount);
// 用户在 LOD 设置里填入想要裁剪的骨骼后,
// bones to remove 中会记录这些骨骼以及他们的所有子孙骨骼
for(int32 i=0; i<RequiredBoneCount; i ) {
if (!BonesToRemove || BonesToRemove->Find(i) == NULL) {
LODModel.RequiredBones.Add(i);
}
}
LODModel.RequiredBones.Shrink();
}
即一根骨骼如果被加入了 BonesToRemove
列表,那么它就会被从 RequiredBones
中移除,不会参与到动画计算。但不参与动画计算仅仅意味着这根骨骼在 bone space 下的位置不被更新,只要它会影响到被蒙上了顶点,那么它就会被加入 ActiveBoneIndices
列表中,牵动被它影响的顶点。
接下来,UpdateDynamicData_RenderThread
会在渲染线程中处理具体的数据传输:
void FSkeletalMeshObjectGPUSkin::UpdateDynamicData_RenderThread(...) {
WaitForRHIThreadFenceForDynamicData();
if (DynamicData) {
// 前面提到每一次都会新建一份动态数据,上一次的数据在这里释放
FreeDynamicSkelMeshObjectDataGPUSkin(DynamicData);
}
// 更新为新的数据
DynamicData = InDynamicData;
// 具体处理数据传输
ProcessUpdatedDynamicData(EGPUSkinCacheEntryMode::Raster, GPUSkinCache, RHICmdList, FrameNumberToPrepare, RevisionNumber, bMorphNeedsUpdate, DynamicData->LODIndex);
}
ProcessUpdatedDynamicData
会将数据更新到顶点工厂的 shader data 中:
void FSkeletalMeshObjectGPUSkin::ProcessUpdatedDynamicData(...) {
FSkeletalMeshObjectLOD& LOD = LODs[LODIndex];
const FSkeletalMeshLODRenderData& LODData =
SkeletalMeshRenderData->LODRenderData[LODIndex];
const TArray<FSkelMeshRenderSection>& Sections =
GetRenderSections(LODIndex);
// LOD 顶点工厂列表
FVertexFactoryData& VertexFactoryData = LOD.GPUSkinVertexFactories;
bool bSkinCacheResult = true;
for (int32 SectionIdx = 0; SectionIdx < Sections.Num(); SectionIdx ) {
const FSkelMeshRenderSection& Section = Sections[SectionIdx];
// 当前 section 的顶点工厂
FGPUBaseSkinVertexFactory* VertexFactory;
VertexFactory = VertexFactoryData.VertexFactories[SectionIdx].Get();
// 更新 shader 数据
FGPUBaseSkinVertexFactory::FShaderDataType& ShaderData =
VertexFactory->GetShaderData();
bool bNeedFence = ShaderData.UpdateBoneData(...);
if (bNeedFence) {
RHIThreadFenceForDynamicData = RHICmdList.RHIThreadFence(true);
}
}
}
在 UpdateBoneData
中,UE 并不会将蒙皮矩阵全部传到 GPU,对于任意一个 section,UE 只会传递这个 section 用到的骨骼。这里传送的矩阵数量就是前面提到的 bone map 的长度,传送的具体蒙皮矩阵和 bone map 下标一一对应:
bool FGPUBaseSkinVertexFactory::FShaderDataType::UpdateBoneData(...) {
FMatrix3x4* ChunkMatrices = nullptr;
uint32 NumBones = BoneMap.Num();
uint32 NumVectors = NumBones*3;
// 计算总蒙皮矩阵 buffer 大小
uint32 VectorArraySize = NumVectors * sizeof(FVector4f);
ChunkMatrices = (FMatrix3x4*)RHILockBuffer(...VectorArraySize...);
if (bGPUSkin_CopyBones_ISPC_Enabled) {
// Intel ISPC 优化分支
#if INTEL_ISPC
ispc::UpdateBoneData_CopyBones(...);
#endif
} else {
for (uint32 BoneIdx = 0; BoneIdx < NumBones; BoneIdx ) {
// 找到蒙皮骨骼本来的下标
FBoneIndexType RefToLocalIdx = BoneMap[BoneIdx];
FMatrix3x4& BoneMat = ChunkMatrices[BoneIdx];
FMatrix44f& RefToLocal =
ReferenceToLocalMatrices[RefToLocalIdx];
// 拷贝骨骼蒙皮矩阵数据
RefToLocal.To3x4MatrixTranspose((float*)BoneMat.M);
}
}
}
在 UE 中,每个 LOD 的渲染数据都包含了这个 LOD 下所有顶点的全部信息,包括顶点位置、UV、顶点颜色和蒙皮信息等等。这些数据按材质被划分成不同的 render section,每一个 render section 中,都带有一个 bone map。每一个顶点的蒙皮信息中,InfluenceBones
数组记录的并不是骨骼下标,而是 bone map 的下标,bone map 中记录的才是具体的骨骼下标。结合上面 UpdateBoneData
的实现,我们才能理解为什么 UE 使用这样一个间接的蒙皮骨骼下标表示方法:
// 每个 LOD 的数据
class FSkeletalMeshLODRenderData : public FRefCountBase {
// 持有该 LOD 下每个 section 的渲染数据
TArray<FSkelMeshRenderSection> RenderSections;
// 持有顶点权重 buffer
FSkinWeightVertexBuffer SkinWeightVertexBuffer;
};
// 一个 LOD 下每个材质 section 的数据
struct FSkelMeshRenderSection {
TArray<FBoneIndexType> BoneMap;
};
// 顶点蒙皮信息 buffer
class FSkinWeightVertexBuffer {
void GetSkinWeights(TArray<FSkinWeightInfo>& OutVertices) const;
FSkinWeightInfo GetVertexSkinWeights(uint32 VertexIndex) const;
};
// 一个顶点的蒙皮权重数据
struct FSkinWeightInfo {
// 蒙皮骨骼在 bone map 中的下标
FBoneIndexType InfluenceBones[MAX_TOTAL_INFLUENCES];
// 对应骨骼的蒙皮权重
uint8 InfluenceWeights[MAX_TOTAL_INFLUENCES];
};
再往后就是 GPU 内部计算顶点位置的事情了。
References #
- UE4动画系统更新源码分析 - 知乎
- Exploring in UE 4 物理模块浅析 - 知乎
- UE4 图解动画系统源码 - 知乎
- UE4动画系统的那些事(一):UE4动画系统基础 - 知乎