Unreal 骨骼动画源码剖析

2023-10-20 09:18:33 浏览数 (1)

概览 #

在 UE 中,骨骼动画相关类型关系如下:

其中,USkeletalMesh 是骨架网格体模型数据对象。USkinnedMeshComponent 支持了对骨架网格体的渲染,通过 FSkeletalMeshObject 将渲染所需数据发送到渲染线程,具体的渲染方式也由这个对象决定,例如使用 CPU 还是 GPU 进行渲染。 USkeletalMeshComponent 在此基础上支持了骨骼动画播放,具体动画播放逻辑由 UAnimInstance 实现。

USkinnedMeshComponentTickComponent 中,会根据当前渲染状态和 tick 设置去决定是否要调用 TickPoseRefreshBoneTransforms。例如我们可以配置 Only Tick Pose When Rendered 来避免一个对象在不被渲染的时候 tick 动画。另外,当一个对象被配置了 master pose component 的时候,RefreshBoneTransforms 这个函数就不会被回调,引擎会直接使用 master pose component 的 transform 数据。

这里的 RefreshBoneTransforms 需要每个继承了 USkinnedMeshComponent 的类型自行实现,用以更新骨骼的位置。除此之外,USkeletalMeshComponent 也重写了 TickPose,在里面调用了 TickAnimation 函数更新动画。

动画更新 #

USkeletalMeshComponentTickAnimation 调用了 TickAnimInstances,这个是动画的主逻辑:

代码语言:javascript复制
void USkeletalMeshComponent::TickAnimation(...) {
	if (!AreRequiredCurvesUpToDate()) {
		// 基于 RequiredBones 计算所需要更新的曲线
		RecalcRequiredCurves();
	}
	TickAnimInstances(DeltaTime, bNeedsValidRootMotion);
	// ...
}

TickAnimInstances 会触发 UAnimInstanceUpdateAnimation 以计算当前帧动画的变量、收集动画通知、更新动画曲线等,这里会分别调用几个 animation instance 的 UpdateAnimation 方法:

代码语言:javascript复制
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 是主动画蓝图对象,主要的动画计算都在此完成。

UAnimInstanceUpdateAnimation 这个过程分为几个阶段:

  • Pre Update
  • Update
  • Parallel Update
  • Post Update
代码语言:javascript复制
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 进行代理更新,这个代理的更新其实就是进行常规赋值操作,比如给 RootMotionModeSkelMeshCompLocalToWorldUngroupedActivePlayersSyncGroupsComponentTransformComponentRelativeTransformActorTransform 等赋值。

UAnimInstance::NativeUpdateAnimation 用于给子类增加 C 层的计算逻辑,一般会在这里实现数据收集工作,然后在 UAnimInstanceNativeThreadSafeUpdateAnimation 函数中具体进行动画处理。这个 NativeThreadSafeUpdateAnimation 会在工作线程中被 proxy 对象在 UpdateAnimation_WithRoot 中调用。这两个函数实现在 C 层,其蓝图对应为 BlueprintUpdateAnimationBlueprintThreadSafeUpdateAnimation 。这两个蓝图的对应函数往往为实际使用最多的部分,蓝图中的状态机、动画节点均在此进行控制。

接下来会根据是否使用并行动画计算来决定是否在此处调用 ParallelUpdateAnimationPostUpdateAnimation,一般来说,都不会在此进行。接下来应该就会进入前面提到的 RefreshBoneTransforms 这一步了。

更新骨骼 Transform #

USkeletalMeshComponentRefreshBoneTransforms 中会确定当前帧是否需要更新骨骼 transform 数据,例如在执行 URO 的时候,可能这一帧会被跳过,在需要更新时,根据配置确定是并行更新还是串行更新(一般都是并行):

代码语言:javascript复制
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 的任务,来等待计算结束:

代码语言:javascript复制
void USkeletalMeshComponent::DispatchParallelEvaluationTasks(...) {
	SwapEvaluationContextBuffers();
	// 触发并行计算
	ParallelAnimationEvaluationTask = 
	  TGraphTask<FParallelAnimationEvaluationTask>::CreateTask()
	  .ConstructAndDispatchWhenReady(this);
	// 设置计算结果依赖关系,等待计算任务结束后触发后续计算
	FGraphEventArray Prerequistes;
	Prerequistes.Add(ParallelAnimationEvaluationTask);
	FGraphEventRef TickCompletionEvent =
	  TGraphTask<FParallelAnimationCompletionTask>::CreateTask(&Prerequistes)
	  .ConstructAndDispatchWhenReady(this);
}

FParallelAnimationEvaluationTaskDoTask 中会调用 USkeletalMeshComponent 中的 ParallelAnimationEvaluation。这里会通过调用 PerformAnimationProcessing 间接调用到 UAnimInstanceParallelUpdateAnimation

代码语言:javascript复制
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(...);
	}
}

UAnimInstanceParallelUpdateAnimation 就是在调用 proxy 的 UpdateAnimation

代码语言:javascript复制
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);
	}
}

在调用 UAnimInstanceParallelUpdateAnimation 更新动画状态之后,就是调用 USkeletalMeshComponentEvaluateAnimation 进行动画计算,其中调用 UAnimInstanceParallelEvaluateAnimation

代码语言:javascript复制
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 进行计算,然后把计算结果复制出去:

代码语言:javascript复制
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 来进行动画计算:

代码语言:javascript复制
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 切换的时候被调用,用于给动画节点缓存骨骼信息,例如调用 FBoneReferenceInitialize,记录下引用骨骼的下标,后续查找的时候可以加速。
  • Update_AnyThread 用于更新影响骨骼计算的参数数据,例如 blend weight。
  • Evaluate_AnyThread 是主要计算发生的地方,根据前面 Update_AnyThread 计算更新的参数来计算出 local space 的骨骼 transform,并输出到 pose,结果之后会被缓存到 component。

USkeletalMeshComponentPerformAnimationProcessing 在执行完动画计算后,接下来会调用 FinalizePoseEvaluationResult 复制骨骼的 local space transform 到 component 里:

代码语言:javascript复制
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 中传入:

代码语言:javascript复制
if (AnimEvaluationContext.bDoInterpolation) {
	PerformAnimationProcessing(..., 
		AnimEvaluationContext.CachedBoneSpaceTransforms, ...);
} else {
	PerformAnimationProcessing(..., 
		AnimEvaluationContext.BoneSpaceTransforms, ...);
}

在获得了骨骼的 local space transform 之后,就调用 FillComponentSpaceTransforms 来基于 local space 计算 component space transform:

代码语言:javascript复制
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 到此就结束了,接下来是看到 FParallelAnimationCompletionTaskDoTask 逻辑,这里会等待计算结束,然后调用 USkeletalMeshComponentCompleteParallelAnimationEvaluation 进行后置处理:

代码语言:javascript复制
void USkeletalMeshComponent::CompleteParallelAnimationEvaluation(...) {
	ParallelAnimationEvaluationTask.SafeRelease();
	if (...) {
		SwapEvaluationContextBuffers();
		PostAnimEvaluation(AnimEvaluationContext);
	}

	AnimEvaluationContext.Clear();
}

PostAnimEvaluation 会调用 UAnimInstancePostUpdateAnimation,这个函数会进一步调用到 proxy 的 PostUpdate 完成通知发送之类的后置工作。

代码语言:javascript复制
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,分别对应于 FSkeletalMeshObjectCPUSkinFSkeletalMeshObjectGPUSkin,他们都继承自 FSkeletalMeshObject,在 USkinnedMeshComponentCreateRenderState_Concurrent 中进行初始化,然后调用 MeshObjectUpdate 函数更新动态数据:

代码语言:javascript复制
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 需要先在主线程计算动态数据,然后发送到渲染线程:

代码语言:javascript复制
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 下的位置:

代码语言:javascript复制
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 数组每次调用都需要申请内存,如果骨骼数量过多,这里效率会比较低:

代码语言:javascript复制
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 的数量由原始骨架中骨骼的数量决定:

代码语言:javascript复制
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 来看一下这二者的区别:

代码语言:javascript复制
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 会在渲染线程中处理具体的数据传输:

代码语言:javascript复制
void FSkeletalMeshObjectGPUSkin::UpdateDynamicData_RenderThread(...) {
	WaitForRHIThreadFenceForDynamicData();
	if (DynamicData) {
		// 前面提到每一次都会新建一份动态数据,上一次的数据在这里释放
		FreeDynamicSkelMeshObjectDataGPUSkin(DynamicData);
	}
	// 更新为新的数据
	DynamicData = InDynamicData;
	// 具体处理数据传输
	ProcessUpdatedDynamicData(EGPUSkinCacheEntryMode::Raster, GPUSkinCache, RHICmdList, FrameNumberToPrepare, RevisionNumber, bMorphNeedsUpdate, DynamicData->LODIndex);
}

ProcessUpdatedDynamicData 会将数据更新到顶点工厂的 shader data 中:

代码语言:javascript复制
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 下标一一对应:

代码语言:javascript复制
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 使用这样一个间接的蒙皮骨骼下标表示方法:

代码语言:javascript复制
// 每个 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动画系统基础 - 知乎

0 人点赞