UE4/UE5 動(dòng)畫的原理和性能優(yōu)化

動(dòng)畫在UE4/UE5項(xiàng)目中,往往不僅是GPU和渲染線程開銷大戶,也是游戲線程的開銷大戶。按照我的經(jīng)驗(yàn),大型游戲項(xiàng)目(尤其是手游)做到中后期,整個(gè)項(xiàng)目優(yōu)化工作做得差不多的時(shí)候,你應(yīng)該也會發(fā)現(xiàn)動(dòng)畫的開銷會占到整個(gè)GameThread的二分之一到三分之二。動(dòng)畫到底是做了什么會產(chǎn)生這么多的開銷?項(xiàng)目里關(guān)于動(dòng)畫的優(yōu)化也是最容易扯皮的一件事,開發(fā)給美術(shù)說要砍資源,減少骨骼數(shù),要減少蒙皮面數(shù),否則游戲跑不動(dòng),而美術(shù)說骨骼數(shù)不夠根本做不出好的效果,不能優(yōu)化。但是為什么骨骼數(shù),蒙皮面數(shù)會影響到動(dòng)畫的性能呢?難道除了砍資源之外,就沒有別的優(yōu)化手段了嗎?為了回答這些問題,我覺得很有必要說一說動(dòng)畫在虛幻引擎內(nèi)部的執(zhí)行流程,最后也會說下我在虛幻引擎動(dòng)畫這塊推薦的優(yōu)化手段。

骨骼動(dòng)畫的本質(zhì)

UE4/UE5的骨骼動(dòng)畫其實(shí)都是通過SkeletalMeshComponent來實(shí)現(xiàn)的。這個(gè)組件內(nèi)部會引用到一個(gè)SkeletalMesh資源,就像StaticMeshComponent一樣,也有一個(gè)StaticMesh資源,從資源層面來說SkeletalMesh和StaticMesh的區(qū)別就是多了骨骼。Component相對于資源來說,可以理解為對象實(shí)例和類的關(guān)系,同一個(gè)資源可以有很多個(gè)Component實(shí)例。

直觀的來說,一個(gè)Mesh想要?jiǎng)悠饋?,那么就需要去對每個(gè)頂點(diǎn)做Transform(位移/旋轉(zhuǎn)/縮放),當(dāng)我們連續(xù)做很多幀這樣的Transform并按順序播放,就變成了動(dòng)畫。但是一個(gè)幾萬面的Mesh,在資源層面一幀就要保存幾萬個(gè)Transform,即使引入關(guān)鍵幀,也肯定還會占非常多的空間,因此這個(gè)做法明顯不現(xiàn)實(shí)。那么能想到最直接的解決辦法就是對這幾萬個(gè)Transform數(shù)據(jù)做壓縮,把相同的歸并在一起。

骨骼這個(gè)概念,本質(zhì)上就是壓縮相同頂點(diǎn)的Transform的一種方式。具體來說,就是把Mesh上一部分的頂點(diǎn)和其中一個(gè)或多個(gè)骨骼做綁定,那么我們只要記錄這個(gè)骨骼的Transform就好了,這樣一個(gè)Mesh就被劃分成了多個(gè)部分,不同部分受不同的骨骼影響。最后計(jì)算頂點(diǎn)的實(shí)際位置時(shí)就只需要讓頂點(diǎn)乘以關(guān)聯(lián)的骨骼Transform就可以了。如果頂點(diǎn)是和多個(gè)骨骼關(guān)聯(lián),那么也可以分別乘以不同骨骼的Transform以及受影響的百分比,再求和,就可以得到最終的頂點(diǎn)位置。

進(jìn)一步來看,如果每個(gè)動(dòng)畫都記錄全局Transform數(shù)組,可能數(shù)據(jù)量還是會有些大且不規(guī)律。骨骼和骨骼之間也可以記錄相對的Transform,也就是讓每一級都在父級的局部空間內(nèi)做Transform,這樣每一級坐標(biāo)的范圍也會明顯變小,而且也很像動(dòng)物的關(guān)節(jié)一樣一節(jié)一節(jié)動(dòng),比較符合實(shí)際情況,骨骼數(shù)組就變成了一棵樹,當(dāng)我們記錄動(dòng)畫時(shí)就會更容易,而計(jì)算實(shí)際的Transform時(shí),只要遞歸把所有父級的Transform乘在一起,就得到了最終的Transform。

這個(gè)通過骨骼Transform計(jì)算出實(shí)際頂點(diǎn)的過程,叫做Skin(蒙皮)。而這個(gè)骨骼Transform數(shù)組,叫做Pose(姿勢)。UE4/UE5的SkeletalMeshComponent,其實(shí)就是把美術(shù)做的多個(gè)動(dòng)畫原始的Pose資源(AnimSequence),通過動(dòng)畫藍(lán)圖做混合,得到最終的一個(gè)Pose,再根據(jù)這個(gè)Pose做蒙皮求得每個(gè)頂點(diǎn)實(shí)際位置并繪制的過程。

具體來說,就是下面這兩個(gè)步驟:

1. 先在游戲線程中TickComponent求得當(dāng)前幀的最終Pose

2. 再在渲染線程中根據(jù)最終Pose做CPUSkin或GPUSkin算出頂點(diǎn)信息,并進(jìn)行繪制

當(dāng)然上面這些過程描述只是我自己的理解,從細(xì)節(jié)上來看可能不那么專業(yè),這里只要大致理解原理,重點(diǎn)知道這兩個(gè)步驟就好。

動(dòng)畫的執(zhí)行流程

GameThread

先來說第一個(gè)步驟,在GameThread上,每幀主要是通過TickComponent來執(zhí)行的。當(dāng)然這個(gè)類里也有一些其它的Tick函數(shù),比如布料和物理動(dòng)畫有單獨(dú)的Tick函數(shù),開啟對應(yīng)功能后才會Tick,這里就不細(xì)說了。

因此可以簡單說,想要優(yōu)化動(dòng)畫的GameThread性能,其實(shí)就是要減少TickComponent函數(shù)的耗時(shí)。我們也知道藍(lán)圖里的動(dòng)畫藍(lán)圖節(jié)點(diǎn)數(shù)量以及路徑的多少和復(fù)雜程度也會直接影響到動(dòng)畫的效率。那么只要搞清楚TickComponent里面到底干了什么,是怎么驅(qū)動(dòng)動(dòng)畫藍(lán)圖節(jié)點(diǎn)執(zhí)行的,我們就可以做一些針對性優(yōu)化了。

下面就是我整理出來的一幀,從TickComponent到動(dòng)畫節(jié)點(diǎn)的時(shí)序圖:

可以看到,完整的動(dòng)畫Tick主要分了幾個(gè)大塊,一開始先在GameThread更新動(dòng)畫藍(lán)圖里面的變量。然后根據(jù)情況在子線程或GameThread去Update,Evaluate,最后再回到GameThread去通知Notify/NotifyState。

如果有物理動(dòng)畫,還會在EndPhysics時(shí)調(diào)用EndPhysicsTickComponent,將物理的結(jié)果和動(dòng)畫做Blend,這部分不細(xì)說。當(dāng)然還有關(guān)于URO的一些跳幀函數(shù),為了簡單起見我也沒有畫出來。

上圖中比較關(guān)鍵的兩個(gè)步驟:UpdateAnimation,EvaluateAnimation是遞歸調(diào)用動(dòng)畫藍(lán)圖里面的節(jié)點(diǎn)的Update,Evaluate。

- UpdateAnimation:主要作用就是用DeltaTime更新動(dòng)畫進(jìn)度,算權(quán)重以及計(jì)算動(dòng)畫藍(lán)圖執(zhí)行路徑(動(dòng)畫藍(lán)圖里各種Blend節(jié)點(diǎn)),讓每個(gè)執(zhí)行到的節(jié)點(diǎn)更新內(nèi)部的成員變量。

- EvaluateAnimation:根據(jù)前面算的權(quán)重或路徑,解算實(shí)際的Pose,其實(shí)就是求每根骨骼這一幀最終的Transform值。

這兩個(gè)步驟,是可以放在子線程的。

RenderThread

然后再來說說SkeletalMeshComponent渲染線程做的事情。其實(shí)和靜態(tài)網(wǎng)格或其它可繪制的SceneComponent一樣,在渲染線程都是通過PrimitiveSceneInfo繪制的,通過SkeletalMeshSceneProxy把GameThread和RenderThread連接起來。

常規(guī)的PrimitiveComponent構(gòu)建渲染信息,其實(shí)就是在CreateRenderState_Concurrent里構(gòu)造SceneProxy和SceneInfo,繪制時(shí)會通過調(diào)用SceneProxy::DrawStaticElements或者SceneProxy::GetDynamicMeshElements獲取對應(yīng)的Batch。當(dāng)位置或者資源發(fā)生變化時(shí)候,Component就通過各種MarkXXXDirty函數(shù)來讓渲染線程刷新數(shù)據(jù)。這里細(xì)節(jié)很多,以后有機(jī)會單獨(dú)再講。

這里相比于靜態(tài)網(wǎng)格的繪制,比較關(guān)鍵的是多了一個(gè)SkeletalMeshObject這樣的結(jié)構(gòu)。這個(gè)數(shù)據(jù)也是在CreateRenderState_Concurrent構(gòu)建的,本身作為DynamicData傳給渲染線程的。在CreateRenderState_Concurrent里可以看到,這個(gè)類分成了幾個(gè)子類,根據(jù)情況不同來選擇創(chuàng)建,如下圖MeshObject。

通過名字我們可以知道,這個(gè)對象做的就是蒙皮的工作。GPUSkin就是在Shader里做蒙皮,而CPUSkin就是在渲染線程里做蒙皮,另外一個(gè)Static,就是把SkeletalMeshComponent當(dāng)成StaticMeshComponent一樣來繪制。

在SendRenderDynamicData_Concurrent里,我們可以看到會調(diào)用MeshObject -> Update,這個(gè)函數(shù)就是把算好的Pose給刷到渲染線程上的,內(nèi)部流程很長,最終會把骨骼數(shù)據(jù)存到FDynamicSkelMeshObjectDataGPUSkin的Reference To Local上,當(dāng)然中間還有一堆MorphTarget,布料之類的數(shù)據(jù)就先忽略。

在頂點(diǎn)工廠FGPUBaseSkinVertexFactory的UpdateBoneData函數(shù)中,會把上面構(gòu)造好的Reference To Local傳到BoneBuffer上,如果支持SRV這個(gè)BoneBuffer就是SRV,否則會用UniformBuffer,超過4個(gè)骨骼時(shí)候會額外帶個(gè)BlendIndicesExtra和BlendWeightsExtra。然后在著色器代碼GpuSkinVertexFactory.ush里下面這兩個(gè)函數(shù)可以看到蒙皮的做法,就是把每個(gè)骨骼的Transform乘以對應(yīng)權(quán)重并求和,如下圖,可以看到GetBoneMatrix里的宏根據(jù)是否支持SRV來從不同Buffer里獲取實(shí)際的骨骼矩陣數(shù)據(jù)。

計(jì)算頂點(diǎn)位置時(shí)候,就乘以剛才算的那個(gè)最終的骨骼矩陣,就是當(dāng)前幀實(shí)際的頂點(diǎn)位置,如下圖:

CPUSkin本質(zhì)一樣,只不過把前面CalcBoneMatrix放到了渲染線程C++代碼里,這里就不細(xì)說了。

優(yōu)化

整個(gè)流程通了,就可以來具體說說動(dòng)畫的優(yōu)化了,我們只要圍繞整個(gè)流程中每個(gè)步驟做針對性優(yōu)化就可以。下面就是一些具體做法:

1.將UpdateAnimation和EvaluateAnimation放到子線程上面去,這樣相當(dāng)于轉(zhuǎn)移了游戲線程的開銷。但是如果動(dòng)畫藍(lán)圖本身很復(fù)雜,游戲線程還是會空等的。當(dāng)然動(dòng)畫藍(lán)圖里面的節(jié)點(diǎn)也要盡量搞成FastPath,這個(gè)不用細(xì)說了就是常規(guī)做法。UE5也已經(jīng)支持了動(dòng)畫藍(lán)圖里面在子線程更新變量,基本可以讓事件圖表什么都不做或只做很簡單的事情,這樣游戲線程在動(dòng)畫更新前基本上可以做到?jīng)]開銷。

2.根據(jù)情況可以減少動(dòng)畫藍(lán)圖里的Notify/NotifyState。從流程可以看到,動(dòng)畫通知都是等到動(dòng)畫從子線程回來后才在GameThread做的,這些通知也是通過遍歷的時(shí)候觸發(fā),如果通知回調(diào)的邏輯非常復(fù)雜,那么這塊的開銷也一定會很重,當(dāng)然這個(gè)優(yōu)化也要根據(jù)Stat數(shù)據(jù)來看。

3.盡量不要用RootMotion,從流程上也可以看到RootMotion可能會影響到是否能在子線程上執(zhí)行,要用也最多只用蒙太奇的RootMotion。另外本身RootMotion也會有一些網(wǎng)絡(luò)同步的問題。

4.開啟URO。上面流程里雖然沒說,但其實(shí)這個(gè)功能非常關(guān)鍵且效果非常明顯,尤其是高幀率模式的游戲不開URO可能都跑不到目標(biāo)幀率。URO其實(shí)是讓動(dòng)畫可以跳幀,遠(yuǎn)處的屏占比比較低的動(dòng)畫更新頻率低一些,這樣可以顯著節(jié)省Tick的開銷。當(dāng)然更推薦結(jié)合官方的動(dòng)畫預(yù)算分配器插件來使用,可以規(guī)劃好每幀動(dòng)畫固定的預(yù)算,根據(jù)重要度來動(dòng)態(tài)調(diào)整URO甚至關(guān)閉Tick。另外需要注意的是,這個(gè)優(yōu)化是有損體驗(yàn)的,但是卻是效果最顯著的做法。

動(dòng)畫預(yù)算分配器

5.從流程上來說,動(dòng)畫Tick也分為下面這幾種選項(xiàng):

可以考慮將這個(gè)選項(xiàng)切換成下面幾種。最后一個(gè)OnlyTickPoseWhenRendered最省,第一個(gè)AlwaysTickPoseAndRefreshBones最耗。中間兩個(gè)稍微有些區(qū)別,OnlyTickMontagesWhenNotRendered在不渲染的時(shí)候只調(diào)用Update,而不Evaluate。

6.如果單個(gè)動(dòng)畫本身不怎么耗,但是量非常大且很多實(shí)體基本是一樣的,可以考慮做一些公用的SkeletalMeshComponent,然后真正的實(shí)體去CopyPose來避免內(nèi)部動(dòng)畫的計(jì)算。官方也提供了一個(gè)動(dòng)畫共享插件,專門做這件事的,本身原理也是通過CopyPose來實(shí)現(xiàn)。

虛幻引擎中的動(dòng)畫共享插件

7.從動(dòng)畫本身來說,也盡可能讓動(dòng)畫藍(lán)圖做得簡單一些,盡量讓最經(jīng)常運(yùn)行的那條路徑短一些。也可以繼承AnimInstance并封裝或合并一些計(jì)算的函數(shù)。

8.如果GPU沒壓力,CPU壓力很大,動(dòng)畫又比較簡單,可以考慮烘培頂點(diǎn)動(dòng)畫,具體做法可以參考我之前的一篇,即使不用MassAI也能單獨(dú)拿出來用:

UE5 CitySample的MassAI海量人群繪制

這個(gè)做法雖然合了Instance,但不支持BlendPose。如果能改源碼也可以考慮自己做個(gè)ComputeShader來實(shí)現(xiàn)簡單的混合,不過因?yàn)橛谢貙?,用CS做Blend在手機(jī)上也有可能是負(fù)優(yōu)化。

9.從資源層面入手,讓美術(shù)砍掉多余的骨骼以及減面,也會有一些效果,但是也得具體情況具體分析,因?yàn)樘摶靡姹旧韺γ鏀?shù)不敏感,美術(shù)對于減骨骼和減面可能拉扯幾個(gè)月也就摳出來一點(diǎn)點(diǎn),大部分情況這個(gè)操作不會起很大作用。當(dāng)然資源也不要做的太離譜,比如做了個(gè)千足蜈蚣這樣的怪物,每條腿都有好幾節(jié)骨骼,這種情況就只能砍資源了,再從其它方面優(yōu)化也不會起作用的。

10.如果SkeletalMeshComponent是動(dòng)態(tài)掛載動(dòng)畫、網(wǎng)格這些資源的,也包括動(dòng)態(tài)Link Layer,要考慮將這些額外的資源做異步加載,不要出現(xiàn)游戲線程Flush資源的操作。

11.每個(gè)動(dòng)畫節(jié)點(diǎn)都有個(gè)Initialize_AnyThread函數(shù),默認(rèn)會在InitAnim里觸發(fā)。尤其是上面第10點(diǎn)這些動(dòng)態(tài)掛載的操作,一定會頻繁觸發(fā)到這些初始化。如果能改源碼,可以考慮將初始化屏蔽,第一次執(zhí)行Update_AnyThread的時(shí)候再調(diào)用,這樣平時(shí)跑不到的節(jié)點(diǎn)就任何開銷都沒有了,當(dāng)然這里改的時(shí)候也有不少細(xì)節(jié)需要注意,可能會引起崩潰,改完要多測。

12.如果有Cosmetics這種換裝系統(tǒng),一個(gè)Mesh上掛的組件太多了也會造成很大開銷??梢钥紤]在玩家換完裝備的時(shí)候,通過USkeletalMergingLibrary的Merge功能,將多個(gè)基于同樣骨架的SkeletalMesh合并成一個(gè)Mesh,這樣也能省掉多個(gè)組件的Tick開銷。這個(gè)合并資源的操作也可以在運(yùn)行時(shí)做,相當(dāng)于用內(nèi)存換CPU。如果能改源碼,也可以將這個(gè)工作放在子線程上做,不過要注意涉及到UObject的一些操作只能在GameThread做。

這些都是一些我目前能想到的做動(dòng)畫優(yōu)化時(shí)候比較有用的方案,當(dāng)然實(shí)際也不止這么多做法,而且也不見得對每個(gè)項(xiàng)目都管用。但是總的來說還是要了解清楚引擎內(nèi)部的原理,根據(jù)實(shí)際問題抓性能數(shù)據(jù)來做針對性分析。


這是侑虎科技第1290篇文章,感謝作者quabqi供稿。歡迎轉(zhuǎn)發(fā)分享,未經(jīng)作者授權(quán)請勿轉(zhuǎn)載。如果您有任何獨(dú)到的見解或者發(fā)現(xiàn)也歡迎聯(lián)系我們,一起探討。

作者主頁:https://www.zhihu.com/people/quabqi

再次感謝quabqi的分享,如果您有任何獨(dú)到的見解或者發(fā)現(xiàn)也歡迎聯(lián)系我們,一起探討。

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

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

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