【USparkle專欄】如果你深懷絕技,愛“搞點研究”,樂于分享也博采眾長,我們期待你的加入,讓智慧的火花碰撞交織,讓知識的傳遞生生不息!
這是侑虎科技第1815篇文章,感謝作者zd304供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯系我們,一起探討。(QQ群:793972859)
作者主頁:
https://www.zhihu.com/people/zhang-dong-13-77
眾所周知,Unity的骨骼動畫是基于SkinnedMeshRenderer實現的。SkinnedMeshRenderer的問題在于,如果要繪制大批量角色時,GPU的繪制效率不高。通常情況下,其瓶頸主要在于CPU將繪制數據提交給GPU,而SkinnedMeshRenderer不支持靜態合批、動態合批、GPU Instancing,導致以上問題無法解決。
目前已經有一些方案來提高大批量繪制骨骼蒙皮動畫的繪制效率,比如將動畫烘焙成紋理,通過GPU Instancing來繪制骨骼蒙皮動畫。但是這種方法有很明顯的局限性,比如這種方法要實現動畫的淡入淡出(Cross Fade)就比較麻煩,也不那么高效。
實際上,Unity的DOTS已經提供了一套方案來實現大批量骨骼動畫的繪制,但是目前僅僅是一個名為EntityComponentSystemSamples/Deformation[1]的GitHub示例,其功能離項目可用還有一段距離。
雖然現在還沒有DOTS版本的Animator,不過Unity的Roadmap已經預告,正在開發中(正式發布就不知道是什么時候了)。在這之前我們可以根據需要,自己實現一套骨骼動畫系統,詳情可以關注Unity的Roadmap動態[2]。
本文基于官方Sample搭建上層功能,實現一個最小可用的骨骼動畫播放系統,并在項目里面應用。以下是本系統源碼,Entities版本為1.0.16,推薦使用Unity 2022.3.5f1c1打開,源碼已添加使用范例:
https://github.com/zd304/DOTS_Animation_Sample
一、骨骼動畫基本原理
文章開始先簡單回顧一下骨骼動畫的基本原理。
首先從程序的角度來思考,如果要實現骨骼動畫,最簡單的辦法就是所有骨骼初始都處于模型坐標系的原點,這樣計算會很簡單。因為AnimationClip的每一幀數據,保存的就是對應骨骼的變換矩陣,我們標記為Mbone。動畫播放到某一幀,直接讀取這一幀對應骨骼變換矩陣,乘以綁定到這個骨骼的所有頂點的初始坐標Pvertex,就可以獲得頂點的最終坐標Pfinal。即:
如果程序是這么實現骨骼蒙皮動畫的話,那美術人員就該反對了,因為把骨骼全部放到模型坐標系的原點,這個模型就沒法看了,如下圖所示。
為了降低骨骼模型的制作難度,美術人員在模型制作軟件里面就需要可以正常擺放骨骼,美術人員擺放的這個初始姿勢我們命名為BindPose。對于人型模型,其姿勢形似字母“T”,因此也叫T-Pose。
這樣模型制作軟件在導出模型的時候,就需要連同模型骨骼的“從BindPose轉換回局部空間的變換矩陣”MbindPose一起導出來。
即美術不做的事情改由增加程序計算來做,這樣才能在游戲運行時播放動畫。也就是說,在動畫的某一幀,計算頂點位置的時候,需要先把頂點位置從“BindPose位置”變換到“相對于模型局部空間原點的位置”,再將“相對于模型局部空間原點的位置”變換到“動畫當前幀的位置”。
以上變換過程用公式描述為:
下圖以角色手部的空間變換為例,展示骨骼蒙皮到動畫播放的完整變換過程。
二、搭建Deformation底層
本文是基于Unity官方的EntityComponentSystemSamples/Deformation[1]示例進行開發的,實現系統的第一步就是將示例代碼移植過來。
2.1 Deformation數據
在Unity項目里,通過Package Manager安裝com.unity.entities.graphics包后,運行時會自動為所有SkinnedMeshRenderer生成Entity,詳細代碼可以看com.unity.entities.graphics包里面的源碼SkinnedMeshRendererBaking.cs。
根據SkinnedMeshRenderer自動生成的組件里,最重要的一個就是SkinMatrix。
public struct SkinMatrix : IBufferElementData { /// /// 蒙皮變換的矩陣。 /// public float3x4 Value; }
這個組件保存了當前幀蒙皮變換后的矩陣,也就是前文提到的MbindPose·Mbone。這個矩陣會通過PushSkinMatrixSystem(詳細代碼可以看com.unity.entities.graphics包里面的源碼PushSkinMatrixSystem.cs)提交到GPU,在Shader里通過矩陣變換來變換頂點位置。
在播放骨骼動畫的時候,為了收集骨骼當前的運動Pose來計算Mbone,需要在SkinnedMeshRenderer生成的Entity上綁以下兩個組件,以便獲得骨骼信息。
/// /// 非根骨骼組件,用于獲取骨骼Entity /// internal struct BoneEntity : IBufferElementData { public Entity entity; } /// /// 根骨骼組件,用于獲取根骨骼Entity /// internal struct RootEntity : IComponentData { public Entity value; }
同時,為了計算MbindPose,需要在SkinnedMeshRenderer生成的Entity上綁以下DynamicBuffer,來獲得所有骨骼的BindPose逆矩陣。
/// /// BindPose逆矩陣 /// internal struct BindPose : IBufferElementData { public float4x4 value; }
2.2 Deformation蒙皮數據烘焙
這個烘焙過程就是將SkinnedMeshRenderer的數據烘焙成Entities可以訪問的組件數據。
public classSkinnedMeshAnimationAuthoring : MonoBehaviour { /// /// 默認動畫名稱 /// public string defaultAnimation; /// /// 默認動畫的播放層級 /// publicintdefaultAnimationLayer=1; } internal classSkinnedMeshAnimationBaker : Baker { public override voidBake(SkinnedMeshAnimationAuthoring authoring) { varskinnedMeshRenderer= GetComponent (); if (skinnedMeshRenderer == null) { return; } DependsOn(skinnedMeshRenderer.sharedMesh); boolhasSkinning= skinnedMeshRenderer.bones.Length > 0 && skinnedMeshRenderer.sharedMesh.bindposes.Length > 0; if (hasSkinning) { Entityentity= GetEntity(TransformUsageFlags.Dynamic); // 接收動畫請求,決定動畫系統播放指定動畫,以及如何播放 varrequestBuffer= AddBuffer (entity); if (!string.IsNullOrEmpty(authoring.defaultAnimation) && authoring.defaultAnimationLayer > 0) { // 如果Prefab上配置了默認動畫,則播放默認動畫 requestBuffer.Add(newAnimationRequest() { animationName = authoring.defaultAnimation, fadeoutTime = 0.0f, speed = 1.0f, layer = authoring.defaultAnimationLayer }); } // 添加BoneBakedTag組件,表明該SkinnedMesh已經烘焙完成,可以交給ComputeSkinMatricesBakingSystem去初始化了 AddComponent(entity, newBoneBakedTag()); // 獲得根骨骼的引用 TransformrootTransform= skinnedMeshRenderer.rootBone ? skinnedMeshRenderer.rootBone : skinnedMeshRenderer.transform; EntityrootEntity= GetEntity(rootTransform, TransformUsageFlags.Dynamic); AddComponent(entity, newRootEntity { value = rootEntity }); // 獲得所有骨骼的引用 DynamicBuffer boneEntities = AddBuffer (entity); boneEntities.ResizeUninitialized(skinnedMeshRenderer.bones.Length); for (intboneIndex=0; boneIndex < skinnedMeshRenderer.bones.Length; ++boneIndex) { varbone= skinnedMeshRenderer.bones[boneIndex]; // 為每根骨骼創建Entity varboneEntity= GetEntity(bone, TransformUsageFlags.Dynamic); boneEntities[boneIndex] = newBoneEntity { entity = boneEntity }; } // 獲得每一根骨骼的BindPose逆矩陣 DynamicBuffer bindPoseArray = AddBuffer (entity); bindPoseArray.ResizeUninitialized(skinnedMeshRenderer.bones.Length); for (intboneIndex=0; boneIndex != skinnedMeshRenderer.bones.Length; ++boneIndex) { Matrix4x4bindPose= skinnedMeshRenderer.sharedMesh.bindposes[boneIndex]; bindPoseArray[boneIndex] = newBindPose { value = bindPose }; } } } }
以上代碼邏輯很簡單,不再詳細解釋。最后,將SkinnedMeshRenderer烘焙成Entity后,所有組件數據如下圖所示。
這里面多了一個AnimationRequest組件是之前沒提到過的,它是用來接收其他系統發送來的動畫請求的DynamicBuffer,后續講到動畫的章節會詳細介紹。
2.3 Deformation骨骼數據初始化
以上數據烘焙的過程,主要是生成SkinnedMeshRenderer對應的Entity上面的組件。而每一根骨骼也需要初始化,就需要單獨的一個System在蒙皮數據烘焙完成后,初始化每一根骨骼的Entity上面的組件數據。
上面2.2小節會在已經完成數據烘焙的Entity上綁一個名為BoneBakedTag的組件,有這個組件的Entity才會進入本小節的流程。
public partial classComputeSkinMatricesBakingSystem : SystemBase { protected override voidOnUpdate() { varecb=newEntityCommandBuffer(Allocator.TempJob); // 只有蒙皮數據被烘焙完成后,這個Job才會被執行 Entities .WithAll () .ForEach((Entity entity, in RootEntity rootEntity, in DynamicBuffer bones) => { // 在骨骼的Entity上綁RootTag,標記這個Entity是根骨骼 ecb.AddComponent (rootEntity.value); // 給所有骨骼加上一個Tag,以便當計算SkinMatrices的時候可以獲取到 for (intboneIndex=0; boneIndex < bones.Length; ++boneIndex) { // 獲取所有骨骼的Entity varboneEntity= bones[boneIndex].entity; // 調試用,這個組件可有可無 ecb.AddComponent(boneEntity, newBoneIndex { value = boneIndex }); // 在骨骼的Entity上綁BoneTag,標記這個Entity是非根骨骼 ecb.AddComponent(boneEntity, newBoneTag()); // 在骨骼的Entity上綁SkinnedMeshAnimationController,用來控制骨骼隨著動畫幀運動 ecb.AddComponent(boneEntity, newSkinnedMeshAnimationController() { enable = false }); // 骨骼當前受影響的動畫曲線(AnimationCurve) DynamicBuffer buffer = ecb.AddBuffer (boneEntity); } // 移除BoneBakedTag,避免這個Entity重復執行本Job ecb.RemoveComponent (entity); ecb.SetName(rootEntity.value, "RootBone"); ecb.SetName(entity, "SkinnedMesh"); }).WithEntityQueryOptions(EntityQueryOptions.IncludeDisabledEntities).WithStructuralChanges().Run(); ecb.Playback(EntityManager); ecb.Dispose(); } }
以上過程主要做了兩個事情:
為每根骨骼打上標記(RootTag和BoneTag),后續運行時會通過EntityQuery查找到這些骨骼,用于計算當前動畫的變換矩陣。
為每根骨骼綁上播放動畫相關的組件,包括SkinnedMeshAnimationController和SkinnedMeshAnimationCurve,后續會詳細講解這些組件的用途。
2.4 計算變換矩陣
準備好了以上數據,就可以計算SkinMatrix了,也就是最終的變換矩陣:
[RequireMatchingQueriesForUpdate] [WorldSystemFilter(WorldSystemFilterFlags.Default | WorldSystemFilterFlags.Editor)] [UpdateInGroup(typeof(PresentationSystemGroup))] [UpdateBefore(typeof(DeformationsInPresentation))] partial classCalculateSkinMatrixSystemBase : SystemBase { EntityQuery m_BoneEntityQuery; EntityQuery m_RootEntityQuery; protected override voidOnCreate() { // 查詢所有非根骨骼 m_BoneEntityQuery = GetEntityQuery( ComponentType.ReadOnly (), ComponentType.ReadOnly () ); // 查詢所有根骨骼 m_RootEntityQuery = GetEntityQuery( ComponentType.ReadOnly (), ComponentType.ReadOnly () ); } protected override voidOnUpdate() { vardependency= Dependency; // 收集所有非根骨骼的變換矩陣 varboneCount= m_BoneEntityQuery.CalculateEntityCount(); varbonesLocalToWorld=newNativeParallelHashMap (boneCount, Allocator.TempJob); varbonesLocalToWorldParallel= bonesLocalToWorld.AsParallelWriter(); varbone= Entities .WithName("GatherBoneTransforms") .WithAll () .ForEach((Entity entity, in LocalToWorld localToWorld) => { bonesLocalToWorldParallel.TryAdd(entity, localToWorld.Value); }).ScheduleParallel(dependency); // 收集所有根骨骼的變換矩陣 varrootCount= m_RootEntityQuery.CalculateEntityCount(); varrootWorldToLocal=newNativeParallelHashMap (rootCount, Allocator.TempJob); varrootWorldToLocalParallel= rootWorldToLocal.AsParallelWriter(); varroot= Entities .WithName("GatherRootTransforms") .WithAll () .ForEach((Entity entity, in LocalToWorld localToWorld) => { rootWorldToLocalParallel.TryAdd(entity, math.inverse(localToWorld.Value)); }).ScheduleParallel(dependency); // 以上兩個Job執行完成才能執行下面的Job dependency = JobHandle.CombineDependencies(bone, root); // 計算SkinMatrix dependency = Entities .WithName("CalculateSkinMatrices") .WithReadOnly(bonesLocalToWorld) .WithReadOnly(rootWorldToLocal) .WithBurst() .ForEach((ref DynamicBuffer skinMatrices, in DynamicBuffer bindPoses, in DynamicBuffer bones, in RootEntity rootEtt) => { // 循環遍歷每一根骨骼 for (inti=0; i < skinMatrices.Length; ++i) { // 非根骨骼 varboneEntity= bones[i].entity; // 根骨骼Entity varrootEntity= rootEtt.value; // #TODO: this is necessary for LiveLink? if (!bonesLocalToWorld.ContainsKey(boneEntity) || !rootWorldToLocal.ContainsKey(rootEntity)) return; // 骨骼的世界空間變換矩陣 varmatrix= bonesLocalToWorld[boneEntity]; // 將世界矩空間轉換到模型局部空間的變換矩陣 varrootMatrixInv= rootWorldToLocal[rootEntity]; // 獲得骨骼的模型局部空間的變換矩陣 matrix = math.mul(rootMatrixInv, matrix); // BindPose的逆矩陣 varbindPose= bindPoses[i].value; // 獲得動畫當前幀的最終變換矩陣,傳入Shader和頂點Position相乘,獲得最終位置 matrix = math.mul(matrix, bindPose); // 獎最終變換矩陣賦值給SkinMatrix skinMatrices[i] = newSkinMatrix { Value = newfloat3x4(matrix.c0.xyz, matrix.c1.xyz, matrix.c2.xyz, matrix.c3.xyz) }; } }).ScheduleParallel(dependency); Dependency = JobHandle.CombineDependencies(bonesLocalToWorld.Dispose(dependency), rootWorldToLocal.Dispose(dependency)); } }
通過CalculateSkinMatrixSystemBase的計算,CPU端的矩陣數據已經準備好了,接下來看一下Shader里如何使用這些數據。
2.5 Shader
前文提到CPU通過將數據組織成由SkinMatrix組成的DynamicBuffer傳遞到GPU,那么Shader里面是如何接收這些數據的呢?
Shader Model 5(也就是Shader Target 4.5)引進了一種更為原始的訪問數據的方式,Shader里可以直接訪問CPU端傳入的二進制byte數據,在Shader里面需要自行解析這些數據。微軟在HLSL里引進了ByteAddressBuffer類型,在Shader里可以自行解析來訪問這些數據。這種自行解析的二進制數據類型適合保存所有骨骼的最終變換矩陣數組,因此本Shader可以使用ByteAddressBuffer來接收SkinMatrix數據。
uniform ByteAddressBuffer _SkinMatrices;
注意:這里使用了ByteAddressBuffer,也就是說不兼容Shader Model 5的設備無法運行本游戲。
下面看一下Shader里如何利用這些數據,來求出蒙皮骨骼模型最終的頂點位置。
half3x4 LoadSkinMatrix(int index) { intoffset= index * 48; half4p1= asfloat(_SkinMatrices.Load4(offset + 0 * 16)); half4p2= asfloat(_SkinMatrices.Load4(offset + 1 * 16)); half4p3= asfloat(_SkinMatrices.Load4(offset + 2 * 16)); return half3x4(p1.x, p1.w, p2.z, p3.y,p1.y, p2.x, p2.w, p3.z,p1.z, p2.y, p3.x, p3.w); } voidUnity_LinearBlendSkinning_float(int4 indices, half4 weights, half3 positionIn, half3 normalIn, out half3 positionOut, out half3 normalOut) { positionOut = 0; normalOut = 0; // 每個頂點最多受四根骨骼影響 for (inti=0; i < 4; ++i) { // 通過InstanceID獲得當前頂點的蒙皮索引 intskinMatrixIndex= indices[i] + UNITY_ACCESS_HYBRID_INSTANCED_PROP(_SkinMatrixIndex, int); // 獲取當前索引對應的最終變換矩陣 half3x4skinMatrix= LoadSkinMatrix(skinMatrixIndex); // 最終變換矩陣乘以頂點位置,獲取當前骨骼影響下當前頂點的最終位置 half3vtransformed= mul(skinMatrix, half4(positionIn, 1)); half3ntransformed= mul(skinMatrix, half4(normalIn, 0)); // 當前骨骼影響下當前頂點的最終位置乘以骨骼影響權重,求得頂點最終位置的一個分量 positionOut += vtransformed * weights[i]; normalOut += ntransformed * weights[i]; } }
如果熟悉GPU骨骼蒙皮的話,相信從以上過程很容易看出來,Unity_LinearBlendSkinning_float函數就是經典的GPU骨骼蒙皮算法,只需要在Vertex Shader里調用該函數就可以實現蒙皮了,這里不再贅述。如果要優化低端機效率,可以考慮減少受影響的骨骼數量。
需要注意的是LoadSkinMatrix函數,通過調用ByteAddressBuffer的Load4函數,每次取出4個float值,總共取3次組成該骨骼的最終變換矩陣。
Shader的其他部分和Deformation沒有太大關系,這里不再展開,有興趣可以下載官方例程學習。
三、動畫控制
接下來進入本文的重點章節。本章將會在以上基礎上實現一個動畫播放器,讓骨骼蒙皮動畫在Entities里高效地執行。
本章節將實現一套類似Unity自帶的Animation的功能,包括以下子功能:
基礎功能:根據動畫路徑播放動畫,并可以控制播放速度、過渡時間等
動畫漸入漸出(Cross Fade)
動畫層之間的動畫混合(Blend)
動畫分層(Layer)播放
Avatar Mask
暫未實現的功能:動畫層之間的動畫疊加(Additive)。
3.1 自定義動畫格式
由以上內容可知,動畫要控制的目標就是每一根骨骼上面的LocalTransform組件的數據。只要骨骼的LocalTransform組件的數據發生改變,CalculateSkinMatrixSystemBase就會通過查詢BoneTag,將改變的數據提交到GPU,從而表現到圖形上。
而為了控制骨骼的LocalTransform組件,已經無法再使用傳統的Aniamtion或者Animator了,需要自己寫一套類似的代碼來實現。因此,需要自定義一種類似于AnimationClip的動畫格式,提供給Entities計算。下面使用ScriptableObject來定義這種動畫格式。
[Serializable] publicclassBakedKeyframe { /// /// 幀時間 /// publicfloat time; /// /// 關鍵幀的值 /// public Vector4 value; /// /// 關鍵幀進入的曲線切線 /// public Vector4 inTangent; /// /// 關鍵幀出去的曲線切線 /// public Vector4 outTangent; } [Serializable] publicclassBakedCurve { /// /// 曲線作用的骨骼索引 /// publicint boneIndex; /// /// 曲線的所有關鍵幀 /// public List keyframes = newList (); } publicclassSkinnedMeshAnimationClip : ScriptableObject { /// /// 動畫長度 /// publicfloat length; /// /// 動畫包裝模式 /// public AnimationWrapType wrapType; /// /// 所有骨骼的位置和縮放曲線:xyz保存位置信息,w保存縮放信息 /// public BakedCurve[] posAndSclCurves; /// /// 所有骨骼的旋轉曲線:xyzw保存四元數信息 /// public BakedCurve[] rotCurves; }
根據以上數據的定義可知,新的動畫片段的格式為SkinnedMeshAnimationClip,其成員posAndSclCurves和rotCurves是兩個曲線數組,這兩個數組的Length等于骨骼數量,數組的索引就是骨骼索引。
前文提到過AnimationRequest,這個類里面就包含了一個字符串用來指定要播放的動畫路徑,也就是SkinnedMeshAnimationClip的路徑。
public struct AnimationRequest : IBufferElementData { /// /// 動畫路徑 /// public FixedString128Bytes animationName; public int layer; public float speed; public float fadeoutTime; public FixedString128Bytes maskPath; }
當本動畫系統接收到動畫播放請求的時候,就會根據這個路徑去加載SkinnedMeshAnimationClip動畫片段數據。
加載完成的動畫數據是SkinnedMeshAnimationClip類型的,這是一個class。但是為了利用Burst編譯,需要將其轉換成struct提供給Entities使用。
于是需要定義一套對應的struct數據。
/// /// 運行時關鍵幀 /// public struct Keyframe { publicfloat time; public float4 value; public float4 inTangent; public float4 outTangent; } /// /// 運行時曲線 /// public struct AnimationCurve { /// /// 關鍵幀數據 /// public BlobArray keyframes; publicint boneIndex; /// /// 曲線類型:PositionAndScale或者Rotation /// public KeyframePropertyType propertyType; } /// /// 運行時動畫曲線組件 /// internal struct SkinnedMeshAnimationCurve : IBufferElementData { /// /// 曲線所處的動畫層 /// publicint layer; /// /// 曲線的開始播放時間 /// publicfloat startTime; /// /// 曲線的持續時間 /// publicfloat duration; /// /// 曲線的播放速度 /// publicfloat speed; /// /// 曲線的包裝類型 /// public AnimationWrapType wrapType; /// /// 曲線的幀數據 /// public BlobAssetReference curveRef; /// /// 運行時臨時變量:正在進行Cross Fade的動畫信息 /// public SkinnedMeshLayerFadeout layerFadeout; /// /// 運行時臨時變量:即將取代當前曲線的下一條曲線信息 /// public SkinnedMeshAninationFadeout nextCurve; } /// /// 對應動畫片段的緩存數據 /// internal classAnimationCurveCache { /// /// 動畫片段長度 /// publicfloat length; /// /// 動畫包裝類型 /// public AnimationWrapType wrapType; /// /// Entities可以使用的位置和縮放曲線 /// public SkinnedMeshAnimationCurve[] posAndSclCurves; /// /// Entities可以使用的旋轉曲線 /// public SkinnedMeshAnimationCurve[] rotCurves; }
由以上數據定義可知,SkinnedMeshAnimationClip加載后將會轉化成運行時數據AnimationCurveCache。AnimationCurveCache之所以是class而不是struct,是因為這個類也并非直接傳入Entities使用的。真正傳入Entities使用的是動畫片段的每一條動畫曲線SkinnedMeshAnimationCurve。也就是說,骨骼當前正在播放的動畫,會拆分到曲線這么細的粒度作為組件,存在于骨骼Entity上。
BakedCurve對應的就是SkinnedMeshAnimationCurve,這個結構體里有兩個臨時對象layerFadeout和nextCurve,后文會介紹,這里先略過,其他成員對象都比較好理解,不一一解釋。需要理解的是,每一根骨骼上,同一時刻,通常情況只會存在“2×layer數量”個正在播放的SkinnedMeshAnimationCurve對象,也就是一組PositionAndScale曲線和一組Rotation曲線,除非這根骨骼正處于Cross Fade階段。
下面看一下加載代碼。
// 請求的動畫名稱 stringanimationName= currentReq.animationName.ToString(); // 查詢該動畫曲線資源是否已經被加載過 if (!animationCurveCache.TryGetValue(animationName, out AnimationCurveCache animCache)) { // 通過SkinnedMeshAnimationClip類型的Asset來初始化動畫曲線組件 SkinnedMeshAnimationClipclip= Resources.Load (animationName); if (clip == null) { Debug.LogError($"Loading {animationName} failed!"); return; } animCache = newAnimationCurveCache() { length = clip.length, wrapType = clip.wrapType, posAndSclCurves = newSkinnedMeshAnimationCurve[clip.posAndSclCurves.Length], rotCurves = newSkinnedMeshAnimationCurve[clip.rotCurves.Length], }; // 加載(初始化)動畫曲線資源 InitAnimationCache(animCache, clip, clip.posAndSclCurves.Length); animationCurveCache.Add(animationName, animCache); }
其中InitAnimationCache函數的功能就是將BakedCurve數據拷貝到SkinnedMeshAnimationCurve,代碼量較多且邏輯簡單,這里不再贅述,詳細實現請看源碼。
3.2 播放動畫
當動畫系統接收到播放動畫的請求后,如上文所述,動畫系統將會去獲取動畫的所有曲線數據。獲得曲線數據之后,就需要將曲線數據設置到對應的每一根骨骼上作為組件,通過專門的System來執行動畫播放邏輯,從而修改骨骼的LocalTransform。
骨骼的Entity綁定了一個DynamicBuffer 類型的組件,來保存當前正在播放的動畫曲線。
SkinnedMeshAnimationCurve有兩個重要的成員:
Layer:當前曲線正在播放的動畫層級;
PropertyType:當前動畫曲線的數據類型。
3.2.1 動畫Cross Fade
將要添加到骨骼上面的動畫曲線會先查看當前骨骼正在播放的動畫曲線,是否有和自己相同Layer和PropertyType的動畫曲線。如果有的話,就說明需要進行動畫曲線替換。
動畫曲線的替換如果僅僅是簡單的賦值替換,表現上可能會出現動畫銜接生硬,動畫有“跳幀”的感覺。因此需要引入Cross Fade的概念,來讓動畫的替換呈現平滑過渡的效果。因此,將要替換的動畫,會先保存到將要被替換的SkinnedMeshAnimationCurve里的nextCurve字段里(也就是3.1小節提到后文會介紹的字段)。此時內存里同時存在一老一新兩個SkinnedMeshAnimationCurve,程序就可以根據播放時間,對兩個動畫進行動畫融合,實現Cross Fade的效果。
當過渡時間結束,將會用SkinnedMeshAnimationCurve.nextCurve替換原來的SkinnedMeshAnimationCurve。
過渡時間內,曲線Value進行的是簡單的線性插值:
3.2.2 動畫分層
前文2.3小節提到過,每一根骨骼上會有一個名為SkinnedMeshAnimationController的組件,該組件的功能之一就是用來決定骨骼當前播放的動畫層是哪一層。
public struct SkinnedMeshAnimationController : IComponentData { /// /// 標記當前骨骼是否受動畫影響 /// public bool enable; /// /// 當前骨骼正在播放的層級 /// public int currentLayer; }
骨骼上每次發生動畫曲線的變動,比如動畫曲線替換、動畫曲線播放結束等,都會重新計算當前正在播放的動畫層。計算方法很簡單,就是遍歷所有動畫曲線,取最大值賦值給SkinnedMeshAnimationController.currentLayer即可。
如果曲線的SkinnedMeshAnimationCurve.layer低于SkinnedMeshAnimationController.currentLayer,則該曲線不再執行動畫。
總結下來,對于骨骼來說,如果同時存在多個層級的動畫曲線,只播放層級最高的那條動畫曲線。
3.2.3 動畫層Blend
正常來說動畫層Blend是需要為動畫層設置權重,來讓多個層進行動畫混合的。本動畫系統由于項目需要,不需要設置動畫層權重,因此動畫層的Blend僅僅用在:當某一層動畫即將播放結束時,漸漸過渡到下一層動畫。也就是說,本動畫系統使用動畫層Blend來實現動畫跨層的Cross Fade。
為了實現動畫層之間的Cross Fade,動畫曲線在添加到骨骼上之前,就需要先做排序。
將不同PropertyType的曲線放到相鄰位置,也就是在DynamicBuffer 內部根據PropertyType進行分組存放;
根據Layer進行降序排序。
排好序的動畫曲線,在進行for循環遍歷計算時候,以下計算過程的時間復雜度將會是O(1)。
排好序的動畫曲線,在同一個PropertyType分組內,就可以計算當前播放層的動畫是否即將結束,如果動畫即將結束了,下一層動畫就不要跳過執行。下一層動畫執行的結果和當前層動畫的執行結果進行線性差值,使層和層之間的動畫過渡也變得平滑:
3.2.4 Avatar Mask
由于無法使用Animation和Animator,因此Avatar Mask的數據也需要自定義。
public class SkinnedMeshBoneMask : ScriptableObject { /// /// 允許播放動畫的骨骼index /// public List
mask; }
SkinnedMeshBoneMask保存了允許播放動畫的所有骨骼索引。開始播放動畫的時候,如前文所述,需要把動畫的曲線添加到骨骼上,與此同時,加載SkinnedMeshBoneMask并且過濾允許播放動畫的骨骼,在這個索引列表里的骨骼才允許將動畫曲線添加到該骨骼上,通過這種方法實現Avatar Mask。
for (inti=0; i < bonesBuffer.Length; ++i) { BoneEntitybone= bonesBuffer[i]; // Avatar Mask包含此骨骼才允許更新此骨骼的動畫 if (maskAsset != null && !maskAsset.mask.Contains(i)) { continue; } ... // 獲取當前骨骼上所有正在播放的動畫曲線的數組 if (!curveLookup.TryGetBuffer(bone.entity, out DynamicBuffer curveBuffer)) { continue; } for (intcIndex=0; cIndex < curveBuffer.Length; ++cIndex) { SkinnedMeshAnimationCurvecurve= curveBuffer[cIndex]; ...... curveBuffer.Add(curve); } ...... }
四、調用播放動畫接口
讓指定角色播放某個動畫,需要兩步操作:
獲得角色的DynamicBuffer 組件;
往DynamicBuffer 組件里添加元素,即播放動畫請求。
// 獲得DynamicBuffer if (!animRequestLookup.TryGetBuffer(characterBody.body, out DynamicBuffer animRequest)) { return; } // 往DynamicBuffer 里添加播放動畫請求 animRequest.Add(newAnimationRequest() { animationName = animPath, // 動畫片段的路徑 fadeoutTime = 0.5f, // Cross Fade的持續時間 speed = 1.0f, // 播放速度,默認1.0 layer = 1, // 動畫層,高層數動畫覆蓋低層數動畫 maskPath = maskPath // Avatar Mask的路徑 });
下圖為本系統的應用范例,其中邊移動邊施放閃電應用了動畫分層和Avatar Mask,從移動動畫過渡到站立動畫應用了Cross Fade。
通過Frame Debugger可以看到,200+個角色只有1個Batch,也就是達到了合批的目的。
五、總結
本文基于Unity的官方例程,實現了一個類似于Unity的Animation的骨骼蒙皮動畫系統,滿足項目的特定需求。這套系統的特點是,在滿足基本功能的前提下,能夠高效地渲染出骨骼蒙皮動畫。這套系統在項目上補全了DOTS欠缺的基礎系統,為開發者使用DOTS制作3D游戲提供基礎設施。
參考:
[1]EntityComponentSystemSamples/Deformation
https://github.com/Unity-Technologies/EntityComponentSystemSamples/tree/master/GraphicsSamples/URPSamples/Assets/SampleScenes/5.%20Deformation
[2]Unity的Roadmap動態
https://unity.com/cn/roadmap#unity-platform
文末,再次感謝zd304 的分享, 作者主頁: https://www.zhihu.com/people/zhang-dong-13-77, 如果您有任何獨到的見解或者發現也歡迎聯系我們,一起探討。(QQ群: 793972859 )。
近期精彩回顧
【學堂上新】
【厚積薄發】
【萬象更新】
【學堂上新】
特別聲明:以上內容(如有圖片或視頻亦包括在內)為自媒體平臺“網易號”用戶上傳并發布,本平臺僅提供信息存儲服務。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.