这篇文章主要是深入到代码里面,来研究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