感謝作者加菲教主供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:793972859)

作者主頁:https://www.jianshu.com/u/56cdb766653,作者也是U Sparkle活動參與者,UWA歡迎更多開發朋友加入U Sparkle開發者計劃,這個舞臺有你更精彩!


概述

最近在試做一個射擊遊戲的人物動畫Demo,嘗試使用了部分Unity的人形動畫(Humanoid),以及 Playable Graph + Animation Job的功能。目前和美術同事配合,在Unity 2018.3.0f2中初步實現了空手移動和持槍瞄準的功能,在此做個小結。為簡單起見,不使用Root motion,使用原地動畫,並將動畫部分視為表現層,可以讀取邏輯層提供的數據,但是不寫入這些數據。


基本結構

1. 核心類

動畫控制器(AnimController)類:所有動畫代碼的驅動者。根據動畫圖資產、來構建動畫圖,並驅動動畫邏輯。將數據提供者、Transform綁定等傳入動畫圖實例。

動畫數據提供者(IAnimDataProvider)介面:動畫控制代碼通過這個介面,以key-value的形式來讀取業務邏輯設置的數據。具體的數據類可以實現這個介面,並將其交給AnimController來使用。

動畫圖資產(AnimGraphAsset)基類:從動畫圖中的節點抽象而成的可配置的模塊,運行時可以生成實例。這個做法來自(1)。

動畫圖實例(IAnimGraphInstance)介面:動畫圖資產的實例,最終由這些實例在運行時操作動畫圖。

節點綁定集合(TransformBindingCollection)類:將骨骼或其他節點通過鍵值方式存放,以便 AnimGraphAsset 只依賴節點的鍵就能在運行時獲取 Transform,而不需要依賴某個具體的 Transform 對象。

下面類圖簡單表示了這些類的關係:

類圖

2. 邏輯數據的獲取

如下IAnimDataProvider介面用來將數據傳遞給控制動畫的代碼。

public interface IAnimDataProvider
{
float GetFloat(string key);
float GetFloat(int keyId);
int GetInt(string key);
int GetInt(int keyId);
bool GetBool(string key);
bool GetBool(int keyId);
int GetStateId(int stateGroupId);
int GetStateId(string stateGroupName);
}

使用這個介面就可以通過給定的關鍵字(key)去獲取相應的數據,以及獲取給定的一個狀態機的當前狀態。具體的數據類可以實現這個介面,每一幀由業務邏輯填充好數據。

為什麼每個函數有兩個重載版本呢?這是仿照Animator和Material中查找屬性的思路,如果具體數據提供者類是以散列表(如 Dictionary)實現,其關鍵字可用int而非String,使用的時候可以將 key 用 Animator.StringToHash 轉換為int緩存起來,以提高性能。畢竟求string的散列值比較費時。

未來還可以仿照 Animator 加入觸發器類型的功能。

3. 動畫圖資產和動畫圖實例

這部分內容可以參考 (1)中的代碼。動畫圖資產(AnimGraphAsset)基類繼承自 ScriptableObject,用於對動畫進行配置,如下:

public abstract class AnimGraphAsset : ScriptableObject
{
public abstract IAnimGraphInstance CreateInstance(IAnimDataProvider animDataProvider,
TransformBindingCollection transformBindings,
Animator animator, PlayableGraph playableGraph);
}

從上面代碼可以看出,它可以根據若干參數構造出 IAnimGraphInstance 的具體對象。IAnimGraphInstance 類似下面的代碼:

public interface IAnimGraphInstance
{
// 動畫圖銷毀時做必要的清理。
void Shutdown();

// 設置 this 表示的動畫子圖的輸入。
void SetPlayableInput(int portId, Playable playable, int playablePort);

// 獲取 this 表示的動畫子圖的輸出。
void GetPlayableOutput(int portId, ref Playable playable, ref int playablePort);

// 輪詢。
void Update(float deltaTime);
}

AnimGraphAsset 的每個具體子類中,可以留配置數據欄位,並且要有一個實現介面 IAnimGraphInstance 的子類用於 AnimGraphAsset.CreateInstance 返回。AnimGraphAsset 資產文件之間可以具有無環的依賴,以便 AnimController 可以在運行時,遞歸的創建必須的 IAnimGraphInstance 子類的實例,並將它們連成樹狀。

舉例來說,角色四方向的移動需要一個混合節點,站立和四方向移動的混合又是根據 IAnimDataProvider 中讀到的某個狀態確定的。因此可以考慮一下幾種 AnimGraphAsset:

  • AnimGraph_Clip:很通用很簡單的節點,只是封裝一個 AnimationClip 以及相應的 AnimationClipPlayable。
  • AnimGraph_Move4Dir:四方向動作融合。持有四個 AnimationClip,和一個表示移動方向角欄位的關鍵字(用於從 IAnimDataProvder 裏讀移動方向角的值),並在其 AnimGraphInstance 內部類(實現 IAnimGraphInstance 介面)中實現混合或切換這四個 Clip 的邏輯。下圖是一個實際用例(忽略 Working Mode 部分)。
四方向跑的動畫資產
  • AnimGraph_StateSelector(狀態選擇器):很通用的節點,根據一個狀態關鍵字(用於從 IAnimDataProvider中讀取相應的狀態 ID),以及每個狀態對應的AnimGraphAsset,來選擇一個 AnimGraphAsset 來執行。為了平滑過渡,其中的AnimGraphInstance類可以實現這個漸變的過程(可以參考(1)中這個功能的實現方式)。下圖是一個實際用例:根據IAnimDataProvider中的 LocomotiveState 狀態來選擇一個動畫圖資產進行播放。
狀態選擇器動畫圖資產

4.動畫控制器(AnimController)類——整個系統的中樞

AnimController繼承自MonoBehaviour,持有數據的引用、Animator、節點綁定集合等(以便提供給AnimGraphAsset以及IAnimGraphInstance),並持有一個作為根的AnimGraphAsset。

  • 初始化時,創建PlayableGraph對象,調用這個根Asset的CreateInstance,得到根資產對應的 IAnimGraphInstance,其中應該遞歸的,創建被依賴的資產的AnimGraphInstance,設置它們的內部封裝的Playable的輸入輸出。這之後,PlayableGraph就可以開始播放了。
  • 運行時,每一個Update都是調用根圖實例的Update,裡面遞歸的調用各個子節點的Update。
  • 結束時,將PlayableGraph銷毀,並遞歸調用各個圖實例的Shutdown方法進行清理(這主要是為了清理各個圖實例中可能使用的NativeArray)。

5. 動畫圖資產、實例和Playable的關係

設有 A, B, C 三種動畫圖資產類,其.asset文件有如下依賴關係(這種依賴關係體現在編輯器拖拽的序列化欄位上,箭頭方向表示持有/依賴)。
動畫圖資產.asset文件的依賴關係

運行時代碼中,D的CreateInstance方法將多態地調用B和C的CreateInstance,後兩者各自要調用A 的CreateInstance。因此作為PlayableGraph的子圖,各個IAnimGraphInstance的關係如下所示。

IAnimGraphInstance之間的邏輯關係

這裡,箭頭表示的就是獲取輸入的來源。即D的輸入是B,C的輸出,B,C的輸入分別是兩個A實例的輸出。由於每個IAnimGraphInstance表示的是PlayableGraph的一部分,一般都會有一個Playable作為根節點(用於輸出到下一級),除此可能有若干其他Playable以代碼指定的方式連接起來。最終的 PlayableGraph大致是下面這個樣子。

PlayableGraph

其他動畫圖資產

1. 線性連接

除了狀態選擇器(AnimGraph_StateSelector),目前我還照搬了(1)中的 AnimGraph_Stack,這是將其依賴的若干AnimGraphAsset線性連接,將前一個作為後一個的輸入。運行的時候,就是第i個 AnimGraphAsset生成的IAnimGraphInstance的輸出(即實現GetOutputPlayable方法得到的Playable的輸出)作為第i+1個AnimGraphAsset生成的IAnimGraphInstance的輸入(實現 SetInputPlayable方法)。

2. 持槍的上下半身融合

這裡嘗試了運行時動態改變Playable之間的連接。

在角色空手的站立和四向跑融合得到結果(記為 x)之後,希望根據它所持武器,將相應的上半身動畫和 x 融合。設該模塊的 IAnimationGraphInstance 子類中,最終輸出的Playable為out(這裡使用一個AnimationLayerMixerPlayable以便使用AvatarMask)。將x的輸出Playable連接out的輸入埠0,將第k種武器(k>=1)的持槍動畫(或者持槍動畫和射擊動畫的選擇結果)的Playable輸出連接 out的輸入埠k。對於k>0的情況,設置層(也就是輸入埠k的AvatarMask)即可。

上下半身融合

3. 目視方向和瞄準的IK

這裡分了三個階段實現,每個階段對應一個AnimationScriptPlayable。
  • 階段一:使用Humanoid自帶的IK來實現目視方向的IK。在此階段的Animation Job的ProcessAnimation方法中,類似如下實現。

var humanStream = stream.AsHuman();
humanStream.SetLookAtPosition(targetPos);
humanStream.SetLookAtEyesWeight(EyesWeight);
humanStream.SetLookAtHeadWeight(HeadWeight);
humanStream.SetLookAtBodyWeight(BodyWeight);
humanStream.SetLookAtClampWeight(ClampWeight);
humanStream.SolveIK();

  • 階段二:轉動右肩膀,將槍的朝向指向目標點。
  • 階段三:利用Humanoid自帶的IK功能來實現左手IK到槍上的指定參考點(Effector)。

這個實現有幾個問題:

  • 執行兩次Humanoid IK,性能還不知道如何。
  • 多次執行Humanoid IK還有一個問題,就是後面的執行要清空前面使用的參數。必須階段三需要把階段一設置過的那些權重參數都置為0。目前我自己實現了一個擴展方法用於清理IK數據,但希望這件事能有更好的做法。在我的理解中,PlayableGraph模糊了動畫的FK pass和IK pass,並不限制IK在哪裡做,也不限制次數。
  • 階段三中,如果直接使用槍上的某個子節點作為參考點,則相應Animation Job只能使用TransformSceneHandle來訪問這個節點,而不能使用TransformStreamHandle (2),因為這個節點並不在當前Animator控制的層次結構中。而使用TransformSceneHandle有一個很嚴重的問題,就是你在下一幀才能獲取它在當前幀的坐標(或者至少是在LateUpdate中?),這就導致左手總是落後於槍的位置。因此,需要由動畫師來將這個參考點做在人身上,或者根據已有的某個節點,配置一個局部坐標和局部旋轉,計算出參考點的位置。對於後者,由於Animation Job中無法使用變換矩陣,所以只能(在所有節點Scale都是1的情況下)如下計算:

var effectorRot = OtherHandEffector.GetRotation(input);
var goalPos = OtherHandEffector.GetPosition(input) + effectorRot * OtherHandEffectorLocalOffset;
var goalRot = effectorRot * OtherHandEffectorLocalRotation;

其他問題

1. 模型導入

導入模型FBX的時候,需要採取如下設置。
模型 FBX 導入設置

此後展開模型FBX資產,可以看到下面有一個Avatar子節點。

這裡有兩個額外的問題:

  • 按人形做Rigging會有一個Optimize Game Objects選項,勾選後可以不暴露任何子節點或者只暴露需要的子節點。但是在這種情況下,Animator無法將這些子節點綁定成TransformStreamHandle,因此在動畫圖更新過程中手動調整骨骼位置和旋轉(如上面調整肩膀的旋轉以將武器瞄準到正確方向的功能)就無法實現。因此,目前沒有打開這個選項。
  • 需要點擊Configure... 按鈕進入Avatar配置場景後,除了要檢查骨骼層級結構是否映射正確,還要確定模型處於T-pose。如果模型不在T-pose上,則需要在骨骼映射下方的Pose下拉菜單中選取Enforce T-pose項強製為T-pose。不這樣做會導致動畫播放不正常。
強制T-pose

2. 動畫導入

導入動畫FBX時,上面這個Rig標籤頁就需要將Avatar Definition改為Copy From Other Avatar,意為使用其他的Avatar。選次項後將上面生成的Avatar子節點拖上去即可。
動畫FBX的Rig選項卡

為了使得根節點沒有動畫曲線,除了需要在Animator上去掉Apply Root Motion選項,對於使用了 Humanoid導入的動畫,還需要在FBX文件Inspector中,選中動畫選項卡,做如下設置:

動畫FBX的Animation 選項卡

如果只是在Animator上去掉了Apply Root Motion,而沒有做上述設置,Unity仍然在計算時將一部分曲線算在根節點上,只是沒有應用到渲染結果上,於是動畫看起來會是很怪異的。

3. Animation Job的可用性

實際上這是產品化問題。我們不知道Unity什麼時候會將Animation Job正式推出,目前它畢竟是試驗性代碼,在名字空間UnityEngine.Experimental.Animation中。另外就是,在這個部分作為正式 API 之前,有沒有一種替代方式,能結合PlayableGraph 實現上面提到的這些功能?


參考資料

(1) Unity 官方 FPS Demo

(2) TransformSceneHandle 和 TransformStreamHandle 的區別

(3) Unity 關於 RootMotion 的官方文檔


文末,再次感謝加菲教主的分享,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:793972859)

也歡迎大家來積極參與U Sparkle開發者計劃,簡稱「US」,代表你和我,代表UWA和開發者在一起!

封面圖來源:Procedural Dance Animation(舞蹈動畫的程序實現實驗)

推薦閱讀:

相關文章