Unity 的動(dòng)畫(huà)圖(PlayableGraph)和人形(Humanoid)動(dòng)畫(huà)初探

概述

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

基本結(jié)構(gòu)

核心類(lèi)
  • 動(dòng)畫(huà)控制器(AnimController)類(lèi):所有動(dòng)畫(huà)代碼的驅(qū)動(dòng)者。根據(jù)動(dòng)畫(huà)圖資產(chǎn)、來(lái)構(gòu)建動(dòng)畫(huà)圖,并驅(qū)動(dòng)動(dòng)畫(huà)邏輯。將數(shù)據(jù)提供者、Transform 綁定等傳入動(dòng)畫(huà)圖實(shí)例。
  • 動(dòng)畫(huà)數(shù)據(jù)提供者(IAnimDataProvider) 接口:動(dòng)畫(huà)控制代碼通過(guò)這個(gè)接口,以 key-value 的形式來(lái)讀取業(yè)務(wù)邏輯設(shè)置的數(shù)據(jù)。具體的數(shù)據(jù)類(lèi)可以實(shí)現(xiàn)這個(gè)接口,并將其交給 AnimController 來(lái)使用。
  • 動(dòng)畫(huà)圖資產(chǎn)(AnimGraphAsset)基類(lèi):從動(dòng)畫(huà)圖中的節(jié)點(diǎn)抽象而成的可配置的模塊,運(yùn)行時(shí)可以生成實(shí)例。這個(gè)做法來(lái)自 [1]。
  • 動(dòng)畫(huà)圖實(shí)例(IAnimGraphInstance)接口:動(dòng)畫(huà)圖資產(chǎn)的實(shí)例,最終由這些實(shí)例在運(yùn)行時(shí)操作動(dòng)畫(huà)圖。
  • 節(jié)點(diǎn)綁定集合(TransformBindingCollection)類(lèi):將骨骼或其他節(jié)點(diǎn)通過(guò)鍵值方式存放,以便 AnimGraphAsset 只依賴(lài)節(jié)點(diǎn)的鍵就能在運(yùn)行時(shí)獲取 Transform,而不需要依賴(lài)某個(gè)具體的 Transform 對(duì)象。

下面類(lèi)圖簡(jiǎn)單表示了這些類(lèi)的關(guān)系:


類(lèi)圖
邏輯數(shù)據(jù)的獲取

如下 IAnimDataProvider 接口用來(lái)將數(shù)據(jù)傳遞給控制動(dòng)畫(huà)的代碼。

    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);
    }

使用這個(gè)接口就可以通過(guò)給定的關(guān)鍵字(key)去獲取相應(yīng)的數(shù)據(jù),以及獲取給定的一個(gè)狀態(tài)機(jī)的當(dāng)前狀態(tài)。具體的數(shù)據(jù)類(lèi)可以實(shí)現(xiàn)這個(gè)接口,每一幀由業(yè)務(wù)邏輯填充好數(shù)據(jù)。

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

未來(lái)還可以仿照 Animator 加入觸發(fā)器類(lèi)型的功能。

動(dòng)畫(huà)圖資產(chǎn)和動(dòng)畫(huà)圖實(shí)例

這部分內(nèi)容可以參考 [1] 中的代碼。動(dòng)畫(huà)圖資產(chǎn)(AnimGraphAsset)基類(lèi)繼承自 ScriptableObject,用于對(duì)動(dòng)畫(huà)進(jìn)行配置,如下:

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

從上面代碼可以看出,它可以根據(jù)若干參數(shù)構(gòu)造出 IAnimGraphInstance 的具體對(duì)象。IAnimGraphInstance 類(lèi)似下面的代碼:

    public interface IAnimGraphInstance
    {
        // 動(dòng)畫(huà)圖銷(xiāo)毀時(shí)做必要的清理。
        void Shutdown();
        
        // 設(shè)置 this 表示的動(dòng)畫(huà)子圖的輸入。
        void SetPlayableInput(int portId, Playable playable, int playablePort);

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

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

AnimGraphAsset 的每個(gè)具體子類(lèi)中,可以留配置數(shù)據(jù)字段,并且要有一個(gè)實(shí)現(xiàn)接口 IAnimGraphInstance 的子類(lèi)用于 AnimGraphAsset.CreateInstance 返回。AnimGraphAsset 資產(chǎn)文件之間可以具有無(wú)環(huán)的依賴(lài),以便 AnimController 可以在運(yùn)行時(shí),遞歸的創(chuàng)建必須的 IAnimGraphInstance 子類(lèi)的實(shí)例,并將它們連成樹(shù)狀。

舉例來(lái)說(shuō),角色四方向的移動(dòng)需要一個(gè)混合節(jié)點(diǎn),站立和四方向移動(dòng)的混合又是根據(jù) IAnimDataProvider 中讀到的某個(gè)狀態(tài)確定的。因此可以考慮一下幾種 AnimGraphAsset:

  • AnimGraph_Clip:很通用很簡(jiǎn)單的節(jié)點(diǎn),只是封裝一個(gè) AnimationClip 以及相應(yīng)的 AnimationClipPlayable。
  • AnimGraph_Move4Dir:四方向動(dòng)作融合。持有四個(gè) AnimationClip,和一個(gè)表示移動(dòng)方向角字段的關(guān)鍵字(用于從 IAnimDataProvder 里讀移動(dòng)方向角的值),并在其 AnimGraphInstance 內(nèi)部類(lèi)(實(shí)現(xiàn) IAnimGraphInstance 接口)中實(shí)現(xiàn)混合或切換這四個(gè) Clip 的邏輯。下圖是一個(gè)實(shí)際用例(忽略 Working Mode 部分)。
四方向跑的動(dòng)畫(huà)圖資產(chǎn)
  • AnimGraph_StateSelector(狀態(tài)選擇器):很通用的節(jié)點(diǎn),根據(jù)一個(gè)狀態(tài)關(guān)鍵字(用于從 IAnimDataProvider 中讀取相應(yīng)的狀態(tài) ID),以及每個(gè)狀態(tài)對(duì)應(yīng)的 AnimGraphAsset,來(lái)選擇一個(gè) AnimGraphAsset 來(lái)執(zhí)行。為了平滑過(guò)渡,其中的 AnimGraphInstance 類(lèi)可以實(shí)現(xiàn)這個(gè)漸變的過(guò)程(可以參考 [1] 中這個(gè)功能的實(shí)現(xiàn)方式)。下圖是一個(gè)實(shí)際用例:根據(jù) IAnimDataProvider 中的 LocomotiveState 狀態(tài)來(lái)選擇一個(gè)動(dòng)畫(huà)圖資產(chǎn)進(jìn)行播放。
狀態(tài)選擇器動(dòng)畫(huà)圖資產(chǎn)
動(dòng)畫(huà)控制器(AnimController)類(lèi)——整個(gè)系統(tǒng)的中樞

AnimController 繼承自 MonoBehaviour,持有數(shù)據(jù)的引用、Animator、節(jié)點(diǎn)綁定集合等(以便提供給 AnimGraphAsset 以及 IAnimGraphInstance),并持有一個(gè)作為根的 AnimGraphAsset。

  • 初始化時(shí),創(chuàng)建 PlayableGraph 對(duì)象,調(diào)用這個(gè)根 Asset 的 CreateInstance,得到根資產(chǎn)對(duì)應(yīng)的 IAnimGraphInstance,其中應(yīng)該遞歸的,創(chuàng)建被依賴(lài)的資產(chǎn)的 AnimGraphInstance,設(shè)置它們的內(nèi)部封裝的 Playable 的輸入輸出。這之后,PlayableGraph 就可以開(kāi)始播放了。
  • 運(yùn)行時(shí),每一個(gè) Update 都是調(diào)用根圖實(shí)例的 Update,里面遞歸的調(diào)用各個(gè)子節(jié)點(diǎn)的 Update。
  • 結(jié)束時(shí),將 PlayableGraph 銷(xiāo)毀,并遞歸調(diào)用各個(gè)圖實(shí)例的 Shutdown 方法進(jìn)行清理(這主要是為了清理各個(gè)圖實(shí)例中可能使用的 NativeArray)。

動(dòng)畫(huà)圖資產(chǎn)、實(shí)例和 Playable 的關(guān)系

設(shè)有 A, B, C 三種動(dòng)畫(huà)圖資產(chǎn)類(lèi),其 .asset 文件有如下依賴(lài)關(guān)系(這種依賴(lài)關(guān)系體現(xiàn)在編輯器拖拽的序列化字段上,箭頭方向表示持有/依賴(lài))。

動(dòng)畫(huà)圖資產(chǎn) .asset 文件的依賴(lài)關(guān)系

運(yùn)行時(shí)代碼中,D 的 CreateInstance 方法將多態(tài)地調(diào)用 B 和 C 的 CreateInstance,后兩者各自要調(diào)用 A 的 CreateInstance。因此作為 PlayableGraph 的子圖,各個(gè) IAnimGraphInstance 的關(guān)系如下所示。

IAnimGraphInstance 之間的邏輯關(guān)系

這里,箭頭表示的就是獲取輸入的來(lái)源。即 D 的輸入是 B, C 的輸出,B, C 的輸入分別是兩個(gè) A 實(shí)例的輸出。由于每個(gè) IAnimGraphInstance 表示的是 PlayableGraph 的一部分,一般都會(huì)有一個(gè) Playable 作為根節(jié)點(diǎn)(用于輸出到下一級(jí)),除此可能有若干其他 Playable 以代碼指定的方式連接起來(lái)。最終的 PlayableGraph 大致是下面這個(gè)樣子。

PlayableGraph

其他動(dòng)畫(huà)圖資產(chǎn)

線(xiàn)性連接

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

持槍的上下半身融合

這里嘗試了運(yùn)行時(shí)動(dòng)態(tài)改變 Playable 之間的連接。

在角色空手的站立和四向跑融合得到結(jié)果(記為 x)之后,希望根據(jù)它所持武器,將相應(yīng)的上半身動(dòng)畫(huà)和 x 融合。設(shè)該模塊的 IAnimationGraphInstance 子類(lèi)中,最終輸出的 Playable 為 out(這里使用一個(gè) AnimationLayerMixerPlayable 以便使用 AvatarMask)。將 x 的輸出 Playable 連接 out 的輸入端口 0,將第 k 種武器(k >= 1)的持槍動(dòng)畫(huà)(或者持槍動(dòng)畫(huà)和射擊動(dòng)畫(huà)的選擇結(jié)果)的 Playable 輸出連接 out 的輸入端口 k。對(duì)于 k > 0 的情況,設(shè)置層 k (也就是輸入端口 k 的 AvatarMask)即可。

上下半身融合
目視方向和瞄準(zhǔn)的 IK

這里分了三個(gè)階段實(shí)現(xiàn),每個(gè)階段對(duì)應(yīng)一個(gè) AnimationScriptPlayable。

  • 階段一:使用 Humanoid 自帶的 IK 來(lái)實(shí)現(xiàn)目視方向的 IK。在此階段的 Animation Job 的 ProcessAnimation 方法中,類(lèi)似如下實(shí)現(xiàn)。
var humanStream = stream.AsHuman();
humanStream.SetLookAtPosition(targetPos);
humanStream.SetLookAtEyesWeight(EyesWeight);
humanStream.SetLookAtHeadWeight(HeadWeight);
humanStream.SetLookAtBodyWeight(BodyWeight);
humanStream.SetLookAtClampWeight(ClampWeight);
humanStream.SolveIK();
  • 階段二:轉(zhuǎn)動(dòng)右肩膀,將槍的朝向指向目標(biāo)點(diǎn)。
  • 階段三:利用 Humanoid 自帶的 IK 功能來(lái)實(shí)現(xiàn)左手 IK 到槍上的指定參考點(diǎn)(Effector)。

這個(gè)實(shí)現(xiàn)有幾個(gè)問(wèn)題:

  • 執(zhí)行兩次 Humanoid IK,性能還不知道如何。
  • 多次執(zhí)行 Humanoid IK 還有一個(gè)問(wèn)題,就是后面的執(zhí)行要清空前面使用的參數(shù)。必須階段三需要把階段一設(shè)置過(guò)的那些權(quán)重參數(shù)都置為 0。目前我自己實(shí)現(xiàn)了一個(gè)擴(kuò)展方法用于清理 IK 數(shù)據(jù),但希望這件事能有更好的做法。在我的理解中,PlayableGraph 模糊了動(dòng)畫(huà)的 FK pass 和 IK pass,并不限制 IK 在哪里做,也不限制次數(shù)。
  • 階段三中,如果直接使用槍上的某個(gè)子節(jié)點(diǎn)作為參考點(diǎn),則相應(yīng) Animation Job 只能使用 TransformSceneHandle 來(lái)訪(fǎng)問(wèn)這個(gè)節(jié)點(diǎn),而不能使用 TransformStreamHandle [2],因?yàn)檫@個(gè)節(jié)點(diǎn)并不在當(dāng)前 Animator 控制的層次結(jié)構(gòu)中。而使用 TransformSceneHandle 有一個(gè)很?chē)?yán)重的問(wèn)題,就是你在下一幀才能獲取它在當(dāng)前幀的坐標(biāo)(或者至少是在 LateUpdate 中?),這就導(dǎo)致左手總是落后于槍的位置。因此,需要由動(dòng)畫(huà)師來(lái)將這個(gè)參考點(diǎn)做在人身上,或者根據(jù)已有的某個(gè)節(jié)點(diǎn),配置一個(gè)局部坐標(biāo)和局部旋轉(zhuǎn),計(jì)算出參考點(diǎn)的位置。對(duì)于后者,由于 Animation Job 中無(wú)法使用變換矩陣,所以只能(在所有節(jié)點(diǎn) Scale 都是 1 的情況下)如下計(jì)算:
var effectorRot = OtherHandEffector.GetRotation(input);
var goalPos = OtherHandEffector.GetPosition(input) + effectorRot * OtherHandEffectorLocalOffset;
var goalRot = effectorRot * OtherHandEffectorLocalRotation;

其他問(wèn)題

模型導(dǎo)入

導(dǎo)入模型 FBX 的時(shí)候,需要采取如下設(shè)置。


模型 FBX 導(dǎo)入設(shè)置

此后展開(kāi)模型 FBX 資產(chǎn),可以看到下面有一個(gè) Avatar 子節(jié)點(diǎn)。

這里有兩個(gè)一個(gè)額外的問(wèn)題

  • 按人形做 Rigging 會(huì)有一個(gè) Optimize Game Objects 選項(xiàng),勾選后可以不暴露任何子節(jié)點(diǎn)或者只暴露需要的子節(jié)點(diǎn)。但是在這種情況下,Animator 無(wú)法將這些子節(jié)點(diǎn)綁定成 TransformStreamHandle,因此在動(dòng)畫(huà)圖更新過(guò)程中手動(dòng)調(diào)整骨骼位置和旋轉(zhuǎn)(如上面調(diào)整肩膀的旋轉(zhuǎn)以將武器瞄準(zhǔn)到正確方向的功能)就無(wú)法實(shí)現(xiàn)。因此,目前沒(méi)有打開(kāi)這個(gè)選項(xiàng)。

  • 需要點(diǎn)擊 Configure... 按鈕進(jìn)入 Avatar 配置場(chǎng)景后,除了要檢查骨骼層級(jí)結(jié)構(gòu)是否映射正確,還要確定模型處于 T-pose。如果模型不在 T-pose 上,則需要在骨骼映射下方的 Pose 下拉菜單中選取 Enforce T-pose 項(xiàng)強(qiáng)制為 T-pose。不這樣做會(huì)導(dǎo)致動(dòng)畫(huà)播放不正常。

強(qiáng)制 T-pose
動(dòng)畫(huà)導(dǎo)入

導(dǎo)入動(dòng)畫(huà) FBX 時(shí),上面這個(gè) Rig 標(biāo)簽頁(yè)就需要將 Avatar Definition 改為 Copy From Other Avatar,意為使用其他的 Avatar。選次項(xiàng)后將上面生成的 Avatar 子節(jié)點(diǎn)拖上去即可。

動(dòng)畫(huà) FBX 的 Rig 選項(xiàng)卡

為了使得根節(jié)點(diǎn)沒(méi)有動(dòng)畫(huà)曲線(xiàn),除了需要在 Animator 上去掉 Apply Root Motion 選項(xiàng),對(duì)于使用了 Humanoid 導(dǎo)入的動(dòng)畫(huà),還需要在 FBX 文件 Inspector 中,選中動(dòng)畫(huà)選項(xiàng)卡,做如下設(shè)置:


動(dòng)畫(huà) FBX 的 Animation 選項(xiàng)卡

如果只是在 Animator 上去掉了 Apply Root Motion,而沒(méi)有做上述設(shè)置,Unity 仍然在計(jì)算時(shí)將一部分曲線(xiàn)算在根節(jié)點(diǎn)上,只是沒(méi)有應(yīng)用到渲染結(jié)果上,于是動(dòng)畫(huà)看起來(lái)會(huì)是很怪異的。

Animation Job 的可用性

實(shí)際上這是產(chǎn)品化問(wèn)題。我們不知道 Unity 什么時(shí)候會(huì)將 Animation Job 正式推出,目前它畢竟是試驗(yàn)性代碼,在名字空間 UnityEngine.Experimental.Animation 中。另外就是,在這個(gè)部分作為正式 API 之前,有沒(méi)有一種替代方式,能結(jié)合 PlayableGraph 實(shí)現(xiàn)上面提到的這些功能?

參考資料

[1] Unity 官方 FPS Demo

[2] TransformSceneHandle 和 TransformStreamHandle 的區(qū)別

[3] Unity 關(guān)于 RootMotion 的官方文檔

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀(guān)點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容