头图

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


众所周知,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
{
    /// <summary>
    /// 蒙皮变换的矩阵。
    /// </summary>
    public float3x4 Value;
}

这个组件保存了当前帧蒙皮变换后的矩阵,也就是前文提到的MbindPose·Mbone。这个矩阵会通过PushSkinMatrixSystem(详细代码可以看com.unity.entities.graphics包里面的源码PushSkinMatrixSystem.cs)提交到GPU,在Shader里通过矩阵变换来变换顶点位置。

在播放骨骼动画的时候,为了收集骨骼当前的运动Pose来计算Mbone,需要在SkinnedMeshRenderer生成的Entity上绑以下两个组件,以便获得骨骼信息。

/// <summary>
/// 非根骨骼组件,用于获取骨骼Entity
/// </summary>
internal struct BoneEntity : IBufferElementData
{
    public Entity entity;
}

/// <summary>
/// 根骨骼组件,用于获取根骨骼Entity
/// </summary>
internal struct RootEntity : IComponentData
{
    public Entity value;
}

同时,为了计算MbindPose,需要在SkinnedMeshRenderer生成的Entity上绑以下DynamicBuffer,来获得所有骨骼的BindPose逆矩阵。

/// <summary>
/// BindPose逆矩阵
/// </summary>
internal struct BindPose : IBufferElementData
{
    public float4x4 value;
}

2.2 Deformation蒙皮数据烘焙
这个烘焙过程就是将SkinnedMeshRenderer的数据烘焙成Entities可以访问的组件数据。

public classSkinnedMeshAnimationAuthoring : MonoBehaviour
{
    /// <summary>
    /// 默认动画名称
    /// </summary>
    public string defaultAnimation;
    /// <summary>
    /// 默认动画的播放层级
    /// </summary>
    publicintdefaultAnimationLayer=1;
}

internal classSkinnedMeshAnimationBaker : Baker<SkinnedMeshAnimationAuthoring>
{
    public override voidBake(SkinnedMeshAnimationAuthoring authoring)
    {
        varskinnedMeshRenderer= GetComponent<SkinnedMeshRenderer>();
        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<AnimationRequest>(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<BoneEntity> boneEntities = AddBuffer<BoneEntity>(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<BindPose> bindPoseArray = AddBuffer<BindPose>(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<BoneBakedTag>()
            .ForEach((Entity entity, in RootEntity rootEntity, in DynamicBuffer<BoneEntity> bones) =>
            {
                // 在骨骼的Entity上绑RootTag,标记这个Entity是根骨骼
                ecb.AddComponent<RootTag>(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<SkinnedMeshAnimationCurve> buffer = ecb.AddBuffer<SkinnedMeshAnimationCurve>(boneEntity);
                }
                // 移除BoneBakedTag,避免这个Entity重复执行本Job
                ecb.RemoveComponent<BoneBakedTag>(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<LocalToWorld>(),
                ComponentType.ReadOnly<BoneTag>()
            );

        // 查询所有根骨骼
        m_RootEntityQuery = GetEntityQuery(
                ComponentType.ReadOnly<LocalToWorld>(),
                ComponentType.ReadOnly<RootTag>()
            );
    }

    protected override voidOnUpdate()
    {
        vardependency= Dependency;

        // 收集所有非根骨骼的变换矩阵
        varboneCount= m_BoneEntityQuery.CalculateEntityCount();
        varbonesLocalToWorld=newNativeParallelHashMap<Entity, float4x4>(boneCount, Allocator.TempJob);
        varbonesLocalToWorldParallel= bonesLocalToWorld.AsParallelWriter();
        varbone= Entities
            .WithName("GatherBoneTransforms")
            .WithAll<BoneTag>()
            .ForEach((Entity entity, in LocalToWorld localToWorld) =>
            {
                bonesLocalToWorldParallel.TryAdd(entity, localToWorld.Value);
            }).ScheduleParallel(dependency);

        // 收集所有根骨骼的变换矩阵
        varrootCount= m_RootEntityQuery.CalculateEntityCount();
        varrootWorldToLocal=newNativeParallelHashMap<Entity, float4x4>(rootCount, Allocator.TempJob);
        varrootWorldToLocalParallel= rootWorldToLocal.AsParallelWriter();
        varroot= Entities
            .WithName("GatherRootTransforms")
            .WithAll<RootTag>()
            .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<SkinMatrix> skinMatrices, in DynamicBuffer<BindPose> bindPoses, in DynamicBuffer<BoneEntity> 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
{
    /// <summary>
    /// 帧时间
    /// </summary>
    publicfloat time;
    /// <summary>
    /// 关键帧的值
    /// </summary>
    public Vector4 value;
    /// <summary>
    /// 关键帧进入的曲线切线
    /// </summary>
    public Vector4 inTangent;
    /// <summary>
    /// 关键帧出去的曲线切线
    /// </summary>
    public Vector4 outTangent;
}

[Serializable]
publicclassBakedCurve
{
    /// <summary>
    /// 曲线作用的骨骼索引
    /// </summary>
    publicint boneIndex;
    /// <summary>
    /// 曲线的所有关键帧
    /// </summary>
    public List<BakedKeyframe> keyframes = newList<BakedKeyframe>();
}

publicclassSkinnedMeshAnimationClip : ScriptableObject
{
    /// <summary>
    /// 动画长度
    /// </summary>
    publicfloat length;
    /// <summary>
    /// 动画包装模式
    /// </summary>
    public AnimationWrapType wrapType;

    /// <summary>
    /// 所有骨骼的位置和缩放曲线:xyz保存位置信息,w保存缩放信息
    /// </summary>
    public BakedCurve[] posAndSclCurves;
    /// <summary>
    /// 所有骨骼的旋转曲线:xyzw保存四元数信息
    /// </summary>
    public BakedCurve[] rotCurves;
}

根据以上数据的定义可知,新的动画片段的格式为SkinnedMeshAnimationClip,其成员posAndSclCurves和rotCurves是两个曲线数组,这两个数组的Length等于骨骼数量,数组的索引就是骨骼索引。

前文提到过AnimationRequest,这个类里面就包含了一个字符串用来指定要播放的动画路径,也就是SkinnedMeshAnimationClip的路径。

public struct AnimationRequest : IBufferElementData
{
    /// <summary>
    /// 动画路径
    /// </summary>
    public FixedString128Bytes animationName;
    public int layer;
    public float speed;
    public float fadeoutTime;
    public FixedString128Bytes maskPath;
}

当本动画系统接收到动画播放请求的时候,就会根据这个路径去加载SkinnedMeshAnimationClip动画片段数据。

加载完成的动画数据是SkinnedMeshAnimationClip类型的,这是一个class。但是为了利用Burst编译,需要将其转换成struct提供给Entities使用。

于是需要定义一套对应的struct数据。

/// <summary>
/// 运行时关键帧
/// </summary>
public struct Keyframe
{
    publicfloat time;
    public float4 value;
    public float4 inTangent;
    public float4 outTangent;
}

/// <summary>
/// 运行时曲线
/// </summary>
public struct AnimationCurve
{
    /// <summary>
    /// 关键帧数据
    /// </summary>
    public BlobArray<Keyframe> keyframes;
    publicint boneIndex;
    /// <summary>
    /// 曲线类型:PositionAndScale或者Rotation
    /// </summary>
    public KeyframePropertyType propertyType;
}

/// <summary>
/// 运行时动画曲线组件
/// </summary>
internal struct SkinnedMeshAnimationCurve : IBufferElementData
{
    /// <summary>
    /// 曲线所处的动画层
    /// </summary>
    publicint layer;
    /// <summary>
    /// 曲线的开始播放时间
    /// </summary>
    publicfloat startTime;
    /// <summary>
    /// 曲线的持续时间
    /// </summary>
    publicfloat duration;
    /// <summary>
    /// 曲线的播放速度
    /// </summary>
    publicfloat speed;
    /// <summary>
    /// 曲线的包装类型
    /// </summary>
    public AnimationWrapType wrapType;
    /// <summary>
    /// 曲线的帧数据
    /// </summary>
    public BlobAssetReference<AnimationCurve> curveRef;

    /// <summary>
    /// 运行时临时变量:正在进行Cross Fade的动画信息
    /// </summary>
    public SkinnedMeshLayerFadeout layerFadeout;
    /// <summary>
    /// 运行时临时变量:即将取代当前曲线的下一条曲线信息
    /// </summary>
    public SkinnedMeshAninationFadeout nextCurve;
}

/// <summary>
/// 对应动画片段的缓存数据
/// </summary>
internal classAnimationCurveCache
{
    /// <summary>
    /// 动画片段长度
    /// </summary>
    publicfloat length;
    /// <summary>
    /// 动画包装类型
    /// </summary>
    public AnimationWrapType wrapType;

    /// <summary>
    /// Entities可以使用的位置和缩放曲线
    /// </summary>
    public SkinnedMeshAnimationCurve[] posAndSclCurves;
    /// <summary>
    /// Entities可以使用的旋转曲线
    /// </summary>
    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<SkinnedMeshAnimationClip>(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>类型的组件,来保存当前正在播放的动画曲线。

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
{
    /// <summary>
    /// 标记当前骨骼是否受动画影响
    /// </summary>
    public bool enable;
    /// <summary>
    /// 当前骨骼正在播放的层级
    /// </summary>
    public int currentLayer;
}

骨骼上每次发生动画曲线的变动,比如动画曲线替换、动画曲线播放结束等,都会重新计算当前正在播放的动画层。计算方法很简单,就是遍历所有动画曲线,取最大值赋值给SkinnedMeshAnimationController.currentLayer即可。

如果曲线的SkinnedMeshAnimationCurve.layer低于SkinnedMeshAnimationController.currentLayer,则该曲线不再执行动画。

总结下来,对于骨骼来说,如果同时存在多个层级的动画曲线,只播放层级最高的那条动画曲线。

3.2.3 动画层Blend
正常来说动画层Blend是需要为动画层设置权重,来让多个层进行动画混合的。本动画系统由于项目需要,不需要设置动画层权重,因此动画层的Blend仅仅用在:当某一层动画即将播放结束时,渐渐过渡到下一层动画。也就是说,本动画系统使用动画层Blend来实现动画跨层的Cross Fade。

为了实现动画层之间的Cross Fade,动画曲线在添加到骨骼上之前,就需要先做排序。

  • 将不同PropertyType的曲线放到相邻位置,也就是在DynamicBuffer<SkinnedMeshAnimationCurve>内部根据PropertyType进行分组存放;
  • 根据Layer进行降序排序。

排好序的动画曲线,在进行for循环遍历计算时候,以下计算过程的时间复杂度将会是O(1)。

排好序的动画曲线,在同一个PropertyType分组内,就可以计算当前播放层的动画是否即将结束,如果动画即将结束了,下一层动画就不要跳过执行。下一层动画执行的结果和当前层动画的执行结果进行线性差值,使层和层之间的动画过渡也变得平滑:

3.2.4 Avatar Mask
由于无法使用Animation和Animator,因此Avatar Mask的数据也需要自定义。

public class SkinnedMeshBoneMask : ScriptableObject
{
    /// <summary>
    /// 允许播放动画的骨骼index
    /// </summary>
    public List<int> 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<SkinnedMeshAnimationCurve> curveBuffer))
    {
        continue;
    }

    for (intcIndex=0; cIndex < curveBuffer.Length; ++cIndex)
    {
        SkinnedMeshAnimationCurvecurve= curveBuffer[cIndex];

        ......
        curveBuffer.Add(curve);
    }

    ......
}

四、调用播放动画接口

让指定角色播放某个动画,需要两步操作:

  • 获得角色的DynamicBuffer<AnimationRequest>组件;
  • 往DynamicBuffer<AnimationRequest>组件里添加元素,即播放动画请求。
// 获得DynamicBuffer<AnimationRequest>
if (!animRequestLookup.TryGetBuffer(characterBody.body,
    out DynamicBuffer<AnimationRequest> animRequest))
{
    return;
}

// 往DynamicBuffer<AnimationRequest>里添加播放动画请求
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

[2] Unity的Roadmap动态


这是侑虎科技第1815篇文章,感谢作者zd304供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/zhang-dong-13-77

再次感谢zd304的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)


侑虎科技
65 声望23 粉丝

UWA官网:[链接]