UE里动画状态机节点权重研究

这篇文章主要是深入到代码里面,来研究UE里的动画状态机里的权重计算问题,还有使用BlendProfile时各个Bone的权重计算,结尾顺便学习了一下遇到的Conduit动画节点

此篇文章是为了解决这么几个具体问题:

  • 多个Transition链条的条件同时触发时,状态机应该如何处理
  • 动画状态机如何分配State的权重
  • State、Pose和Bone之间的差别与权重的计算方法

多个Transition同时触发

假设有这么个状态机,当我触发条件时,这两条红色箭头指向的Transition的条件会同时被触发:
在这里插入图片描述
那么此时的PositionOne和PositionTwo的权重应该怎么变化?难道是PositionOne的权重变化完了才变化PositionTwo的?

Unity里不知道怎么处理这个情况的,我看了下UE5,PositionOne和PositionTwo的权重是同时变化的。这个过程类似于一个水流的走向,如下图所示,是我截取到的一个中间状态,可以看到,这里的权重总和为1,我发现在这个转态过程中,PositionTwo的权重往往都比PositionOne要高一些:
在这里插入图片描述
图上有个Active for…s的标志,它代表了State Machine当前的动画flow的方向的终点(类似于水流的终点),在StateMachineNode里有个相关的数据:

// State machine node
USTRUCT()
struct ENGINE_API FAnimNode_StateMachine : public FAnimNode_Base
{
...
protected:
	// The current state within the state machine
	// 很重要的数据, 其实就是代表了状态机要播放的State(或者说最终动画流入的End), 当CurrentState
	// 对应出口的TransitionRule返回true时, 动画流向下一个State, 此CurrentState值开始记录下一个State的id
	// 此值只会在FAnimNode_StateMachine的Update_AnyThread节点在do while循环里被SetState函数修改
	// 在动画状态机唯一展示的Active For ...s是State应该就是此CurrentState对应的State
	int32 CurrentState;
	...
}

至于具体State的权重怎么算的,后面会再提到


关于FAnimationActiveTransitionEntry.Alpha

在UE源码里,这里的每个转态对应一个FAnimationActiveTransitionEntry对象,里面有这么个数据,代表动画过渡的Blend权重值:

// 此对象会在FAnimNode_StateMachine::Update_AnyThread函数里被创建, 应该是在动画状态机里发生动画转态时
// 会基于FAnimationTransitionBetweenStates, 使用placement new实例化一个此对象, 替代原本的ActiveTransitionArray
// Information about an active transition on the transition stack
USTRUCT()
struct FAnimationActiveTransitionEntry
{
	GENERATED_USTRUCT_BODY()

	// Elapsed time for this transition
	float ElapsedTime;

	// The transition alpha between next and previous states
	float Alpha;
	...
}

当动画状态机节点里的图如下所示时,此时的StateID顺序是0->2->1:
在这里插入图片描述
出现了奇怪的情况,我理解的是这俩Transition,其Alpha应该是0.04和0.96才对,但是代码里的值却是这样的:
在这里插入图片描述
由于是一起触发Transition条件的,这里的ElapsedTime的值一样我是能理解的,但是这里的Alpha值为啥是一样的?

仔细分析了一下,我发现上图所示的进度0%、4%、96%,代表的是Slate对应动画的权重,而这里的Alpha,代表的是State根据时间获取到的播放进度,这个播放进度是由以下数据得到的:

  • Transition开始之后过去的时间:ElapsedTime
  • Transition设置是CrossFade的时间:CrossfadeDuration
  • Blend权重的方式:BlendOption

默认的BlendOption是Cubic的方式,比如我如果选择线性的,那么这个PositionTwo的进度条会跟时间的值一样,如下图所示:
在这里插入图片描述
这里的Alpha的具体计算代码如下所示:

// If non-zero, calculate the query alpha
float QueryAlpha = 0.0f;
if (CrossfadeDuration > 0.0f)
{
	// QueryAlpha是当前播放进度的x值
	QueryAlpha = ElapsedTime / CrossfadeDuration;
}

// 基于QueryAlpha, 计算得到实际Blend的weight值(所以整体动画的weight值跟Blend这个FAlphaBlend对象是没关系的)
Alpha = FAlphaBlend::AlphaToBlendOption(QueryAlpha, Blend.GetBlendOption(), Blend.GetCustomCurve());

所以说,这里的FAnimationActiveTransitionEntry.Alpha就是动画转态的转态进度,它是随着CrossFade的时间来不断增长的,当增长到1时,说明动画完成了转态过程。前面的例子里,俩Transition由于是一起触发Transition条件的,且CrossFade时间和BlenOption也相同,那算得的ElapsedTime的值也是一样的,所以Alpha值也是一样的


动画状态机里State权重的计算方法

如下图所示,还是前面那个状态机,此时选用的BlendOption为Linear,可以看到,PositionTwo为最终要过度到的State,它的权重为60%,但是其他的24%和16%,我不知道怎么算出来的:
在这里插入图片描述
算法应该是这样,一切以最终State的进度为主,比如这里PositionTwo的进度为6.044/10 = 60.44%,那么剩下的39.56%的权重就交给PositionOne和PositionZero来分,同样的,PositionOne此时的权重为39.56% * 0.6044 = 23.91%,那么最后剩下的就是39.56% - 23.91% = 15.64%

核心函数为FAnimNode_StateMachine::GetStateWeight,这个函数被调用很多次,最后得到最终的权重:

// - Callded in FAnimNode_StateMachine::SetState
// - Callded in FAnimNode_StateMachine::Update_AnyThread
// - Callded in FAnimNode_StateMachine::CacheBones_AnyThread
// - Callded in FAnimNode_StateMachine::UpdateTransitionStates
// - Callded in FAnimNode_StateMachine::GatherDebugData
// Returns the blend weight of the specified state, as calculated by the last call to Update()
float FAnimNode_StateMachine::GetStateWeight(int32 StateIndex) const
{
	const int32 NumTransitions = ActiveTransitionArray.Num();
	if (NumTransitions > 0)
	{
		// 遍历所有ActiveTransition
		// Determine the overall weight of the state here.
		float TotalWeight = 0.0f;
		for (int32 Index = 0; Index < NumTransitions; ++Index)
		{
			const FAnimationActiveTransitionEntry& Transition = ActiveTransitionArray[Index];

			// SourceWeight应该指的是Transition里PreviousState的Weight
			float SourceWeight = (1.0f - Transition.Alpha);

			// After the first transition, so source weight is the fraction of how much all previous transitions contribute to the final weight.
			// So if our second transition is 50% complete, and our target state was 80% of the first transition, then that number will be multiplied by this weight
			if (Index > 0)
			{
				TotalWeight *= SourceWeight;
			}
			// 在第一个Transition里, 1 - Alpha的值代表其Previous State的权重
			//during the first transition the source weight represents the actual state weight
			else if (Transition.PreviousState == StateIndex)
			{
				TotalWeight += SourceWeight;// 第一次计算权重时, 使用加法
			}

			// The next state weight is the alpha of this transition. We always just add the value, it will be reduced down if there are any newer transitions
			if (Transition.NextState == StateIndex)
			{
				TotalWeight += Transition.Alpha;
			}
		}

		return FMath::Clamp<float>(TotalWeight, 0.0f, 1.0f);
	}
	else
	{
		return (StateIndex == CurrentState) ? 1.0f : 0.0f;
	}
}

看了下,在动画状态机转态这个过程中,它先后会被以下地方调用:

  • FAnimNode_StateMachine::Update_AnyThread里针对每个ActiveTransition对象调用FAnimNode_StateMachine::UpdateTransitionStates
  • FAnimNode_StateMachine::Update_AnyThread里,在完成transition的update后,遍历StatePoseLinks对应的每个FPoseLink,record相关state weight

StateMachineNode里State、Pose和Bone之间的权重计算

实在是搞得有点晕,必须在这里写下来整理一下

准确的说,这里的Pose其实是大多数Bone(不设BlendProfile)的那些Bone的权重,所以这里其实是探究State权重与Bone之间的关系。首先,提出以下问题:

  • AccumulateWithShortestRotation这种函数与普通Blend函数有区别么?
  • StateMachine里不同state一起带着权重生效时,是否就是单纯按照其权重,再把每个State的输出Pose进行Blend?
  • State的权重与Bone之间的权重如何一起影响最终的Pose?是相乘的关系吗,如果是的话,那么是不是state之间的权重和为1,而且sate乘以BoneWeight的权重总和也要为1?

AccumulateWithShortestRotation与普通Blend函数
Accumalte类的函数其实是增量Blend,其实在Pose进行Blend时,有两种做法:

  • 直接计算,就是我预先已经把要Blend的所有Pose和权重都计算好了,那么我直接把它们基于权重累加起来,写入FinalPose的buffer里就行了
  • 累加计算,比如我先算最第一个的Pose,此时乘以对应权重,再Overwrite到FinalPose的buffer上,至于后面的Pose,我再慢慢算,算出来再乘以对应的权重,Accumulate到FinalPose的buffer上即可。

代码如下,其实就是baseTransform.Add(deltaAdditiveTransform, additiveWeight)

struct alignas( TAlignOfTransform<T>::Value ) TTransform
{
	/**
	 * Accumulates another transform with this one, with an optional blending weight
	 *
	 * Rotation is accumulated additively, in the shortest direction (Rotation = Rotation +/- DeltaAtom.Rotation * Weight)
	 * Translation is accumulated additively (Translation += DeltaAtom.Translation * Weight)
	 * Scale3D is accumulated additively (Scale3D += DeltaAtom.Scale * Weight)
	 *
	 * @param DeltaAtom The other transform to accumulate into this one
	 * @param Weight The weight to multiply DeltaAtom by before it is accumulated.
	 */
	FORCEINLINE void AccumulateWithShortestRotation(const TTransform<T>& DeltaAtom, const ScalarRegister& BlendWeightScalar)
	{
		const TransformVectorRegister BlendWeight(BlendWeightScalar.Value);
		// 貌似这里的transform的rotation是用vector表示的, 应该是表示的四元数吧
		const TransformVectorRegister BlendedRotation = VectorMultiply(DeltaAtom.Rotation, BlendWeight);

		Rotation = VectorAccumulateQuaternionShortestPath(Rotation, BlendedRotation);

		Translation = VectorMultiplyAdd(DeltaAtom.Translation, BlendWeight, Translation);
		Scale3D = VectorMultiplyAdd(DeltaAtom.Scale3D, BlendWeight, Scale3D);

		DiagnosticCheckNaN_All();
	}
	...
}

State和Bone之间的权重计算
State的权重其实就是直接代表了绝大多数没有使用到BlendProfile的那些Bone在参与Blend时的权重,这里稍微复杂一些的其实是BlendProfile影响的Bone,其实思路很简单,多个Pose进行Blend时,这里有两种weight数组:

  • 普通的不受BlendProfile影响的Bone的weight数组,里面每个元素对应一个SampleData的TotalWeight
  • 受BlendProfile影响的Bone的weight数组,每个Bone对应一个Weight数组,存在SampleDataList[PoseIndex].PerBoneBlendData

解释到最后,无论是State还是Pose的权重,最终代表的还是Bone的权重,归一化的核心原理就是,Blend时,所有相同Bone的权重和必须为1,这样就好说了,它们各自计算自己的权重,然后各自在对应的weight数组里归一化即可,核心代码就是FBlendSampleData::NormalizeDataWeight(TArray<FBlendSampleData>& SampleDataList)函数:

// NormalizeDataWeight是FBlendSampleData类提供的静态函数, 会被以下函数调用(也是所有会使用Blend的地方):
// - UBlendSpace::UpdateBlendSamples_Internal
// - FAnimNode_BlendListBase::Update_AnyThread, FAnimNode_BlendListBase也是多个BlendNode的基类
// - FAnimationActiveTransitionEntry::Update会在动画状态机里发生动画转态时被调用
// 核心原理是, 无论是State还是Pose的权重,最终代表的还是Bone的权重,归一化的逻辑是,Blend时,所有相同Bone的权重和必须为1
// 这里的Pose Weight, 即TotalWeight , 它代表的是那些没有被BlendScale影响的Bone的weight
// 而PerBoneTotalSum对应的是那些有BlendScale影响的Bone的weight, 各个Bone在不同BlendSample上的权重值总和应该为1
// 受BlendScale影响的每个Bone各自对应一个权重数组, 数组里的权重和应该为1
void FBlendSampleData::NormalizeDataWeight(TArray<FBlendSampleData>& SampleDataList)
{
	float TotalSum = 0.f;// TotalSum代表所有Sample对应的权重和

	// 1. 获取skeleton的Bone的个数, 这里的Bone是指参与了PerBoneBlend的Bone, 并不是所有Skeleton的Bone
	check(SampleDataList.Num() > 0);
	const int32 NumBones = SampleDataList[0].PerBoneBlendData.Num();

	// 2. 创建float数组, 每个Bone对应一个float, 记录的各个Sample里相同Bone的权重值的累加
	TArray<float> PerBoneTotalSums;
	PerBoneTotalSums.AddZeroed(NumBones);

	// 3. 遍历输入的要进行Blend的SampleData
	for (int32 PoseIndex = 0; PoseIndex < SampleDataList.Num(); PoseIndex++)
	{
		// 3.1 确认骨骼数目相同
		checkf(SampleDataList[PoseIndex].PerBoneBlendData.Num() == NumBones, TEXT("Attempted to normalise a blend sample list, but the samples have differing numbers of bones."));

		// 3.2 TotalSum代表所有Sample对应的权重和
		TotalSum += SampleDataList[PoseIndex].TotalWeight;

		// 3.3 累计每个SampleData里bone的权重
		if (SampleDataList[PoseIndex].PerBoneBlendData.Num() > 0)// 正常肯定是大于0的
		{
			// now interpolate the per bone weights
			for (int32 BoneIndex = 0; BoneIndex<NumBones; BoneIndex++)
			{
				PerBoneTotalSums[BoneIndex] += SampleDataList[PoseIndex].PerBoneBlendData[BoneIndex];
			}
		}
	}


	// 4. 根据TotalSum把各个Sample的Weight归一化
	// Re-normalize Pose weight
	if (TotalSum > ZERO_ANIMWEIGHT_THRESH)
	{
		if (FMath::Abs<float>(TotalSum - 1.f) > ZERO_ANIMWEIGHT_THRESH)
		{
			for (int32 PoseIndex = 0; PoseIndex < SampleDataList.Num(); PoseIndex++)
			{
				SampleDataList[PoseIndex].TotalWeight /= TotalSum;
			}
		}
	}
	else
	{
		for (int32 PoseIndex = 0; PoseIndex < SampleDataList.Num(); PoseIndex++)
		{
			SampleDataList[PoseIndex].TotalWeight = 1.0f / SampleDataList.Num();
		}
	}

	// 5. 根据各个PerBoneBlendData数组把各个Sample里的Bone的Weight归一化
	// Re-normalize per bone weights.
	for (int32 BoneIndex = 0; BoneIndex < NumBones; BoneIndex++)
	{
		if (PerBoneTotalSums[BoneIndex] > ZERO_ANIMWEIGHT_THRESH)
		{
			if (FMath::Abs<float>(PerBoneTotalSums[BoneIndex] - 1.f) > ZERO_ANIMWEIGHT_THRESH)
			{
				for (int32 PoseIndex = 0; PoseIndex < SampleDataList.Num(); PoseIndex++)
				{
					SampleDataList[PoseIndex].PerBoneBlendData[BoneIndex] /= PerBoneTotalSums[BoneIndex];
				}
			}
		}
		else
		{
			for (int32 PoseIndex = 0; PoseIndex < SampleDataList.Num(); PoseIndex++)
			{
				SampleDataList[PoseIndex].PerBoneBlendData[BoneIndex] = 1.0f / SampleDataList.Num();
			}
		}
	}
}

关于Conduit

参考:https://forums.unrealengine.com/t/animbp-what-is-a-conduit-and-why-do-you-need-it/363568
参考:https://docs.unrealengine.com/4.27/en-US/AnimatingObjects/SkeletalMeshAnimation/StateMachines/Overview/

动画状态机节点里可以右键添加Conduit State,如下图所示,前面有个拉分支线的图标:
在这里插入图片描述

Conduits serve as a more advanced and sharable transition resource. Where a Transition Rule is going to be a simple 1-to-1 relationship form one state to another, a Conduit can have 1-to-many, many-to-1, or many-to-many.

They’re basically similar to a transition rule only they can have multiple inputs and / or outputs so you can branch into different animation states depending on various checks.

Conduit翻译过来是管道的意思,动画状态机里的Conduit其实就是可以共享的Transition Rule而已,这个节点点进去的界面和点击Transition的图标进去的界面是一样的:
在这里插入图片描述
用的时候,只需要把原本State A到State B的连线,改成State A到Conduit State,再到State B的连线即可,如下图所示,左边是原本的,右边是使用Conduit之后的连线:
在这里插入图片描述
注意这里的Transition可以点击的图标从一个变成了两个,出Conduit的State的Transition图标和左边的PositionZero指向PositionOne的Transition的图标效果是相同的,都可以用来设置Position Zero转到Position One的转态时间和BlendOption选项,但是另外一个新增的Icon可以设置的内容就少了,如下图所示:
在这里插入图片描述
这里的Conduit对应在代码里还有一个特性,正常的TransitionRule会根据条件判断返回True或者False,但是任何连向Conduit State的Transition Rule都永远返回True,因为任何State都可以转态到Conduit State

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值