這篇文章主要是深入到代碼裡面,來研究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之間的關系。首先,提出以下問題:
-
這種函數與普通Blend函數有差別麼?AccumulateWithShortestRotation
- 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