创建大量角色的GPU动画系统

创建大量角色的GPU动画系统

导读

Unity中创建的动画角色数量的提升,往往受到DrawCall、IK效果和CPU Skinning等CPU端的性能限制。本文介绍的项目提供了一种使用GPU进行动画渲染的方法,减轻CPU负担,从而能够创建上万的数量级的动画角色。

开源库链接:https://lab.uwa4d.com/lab/5d0167a272745c25a80ac832

数据结构的准备

1、结构体LODData,用来存储不同细节要求的Mesh。

public struct LodData
{
        public Mesh Lod1Mesh;
        public Mesh Lod2Mesh;
        public Mesh Lod3Mesh;

        public float Lod1Distance;
        public float Lod2Distance;
        public float Lod3Distance;
}

2、结构体AnimationTextures,用于储存转换成Texture2D数据的动画片段,每个动画片段会在顶点处进行三次采样。

public struct AnimationTextures : IEquatable<AnimationTextures>
{
        public Texture2D Animation0;
        public Texture2D Animation1;
        public Texture2D Animation2;
}

3、结构体AnimationClipData,存储原始的动画片段和该动画片段在Texture2D中对应的起始和终止像素。

public class AnimationClipData
{
        public AnimationClip Clip;
        public int PixelStart;
        public int PixelEnd;
}

4、结构体BakedData,存储转换成Texture2D变量后的动画片段数据和Mesh、LOD、帧率等。

public class BakedData
{
        public AnimationTextures AnimationTextures;
        public Mesh NewMesh;
        public LodData lods;
        public float Framerate;
        ...
}

5、结构体BakedAnimationClip,存储Animation Clip数据在材质中的具体位置信息。

public struct BakedAnimationClip
{
        internal float TextureOffset;
        internal float TextureRange;
        internal float OnePixelOffset;
        internal float TextureWidth;
        internal float OneOverTextureWidth;
        internal float OneOverPixelOffset;
        public float AnimationLength;
        public bool  Looping;
        ...
}

6、结构体GPUAnimationState,存储动画片段的持续时间和编号。

 public struct GPUAnimationState : IComponentData
   {
        public float Time;
        public int   AnimationClipIndex;
        ...
    }

7、结构体RenderCharacter,准备好Material、Animation Texture、Mesh之后就可以准备进行绘制了。

struct RenderCharacter : ISharedComponentData, IEquatable<RenderCharacter>
{
        public Material                Material;
        public AnimationTextures        AnimationTexture;
        public Mesh                Mesh;
        ...
}

函数方法的准备

1、CreateMesh()

从已有的SkinnedMeshRenderer和一个Mesh创建一个新的Mesh。如果第二个参数Mesh为空,则生成的新的newMesh是原来Renderer的sharedMesh的复制。

private static Mesh CreateMesh(SkinnedMeshRenderer originalRenderer, Mesh mesh = null)

通过boneWeights的boneIndex0和boneIndex1生成boneIDs,通过weight0和weight1生成boneInfluences,作为newMesh的UV2和UV3存储起来。

boneIds[i] = new Vector2((boneIndex0 + 0.5f) / bones.Length, (boneIndex1 + 0.5f) / bones.Length);
float mostInfluentialBonesWeight = boneWeights[i].weight0 + boneWeights[i].weight1;
boneInfluences[i] = new Vector2(boneWeights[i].weight0 / mostInfluentialBonesWeight, boneWeights[i].weight1 / mostInfluentialBonesWeight);
...
newMesh.uv2 = boneIds;
newMesh.uv3 = boneInfluences;

如果第二个参数Mesh非空,找到Mesh在sharedMesh中对应的bindPoses,把boneIndex0和boneIndex1映射到给定的Mesh上。

...
boneRemapping[i] = Array.FindIndex(originalBindPoseMatrices, x => x == newBindPoseMatrices[i]);
boneIndex0 = boneRemapping[boneIndex0];
boneIndex1 = boneRemapping[boneIndex1];
...

2、SampleAnimationClip()

SampleAnimationClip方法接收动画对象,单个动画片段,SkinnedMeshRenderer,帧率作为输入,输出动画片段采样过后生成的boneMatrices

private static Matrix4x4[,] SampleAnimationClip(GameObject root, AnimationClip clip, SkinnedMeshRenderer renderer, float framerate)
...
//选取当前所在帧的clip数据作为一段时间的采样
  float t = (float)(i - 1) / (boneMatrices.GetLength(0) - 3);
  clip.SampleAnimation(root, t * clip.length);

3、BakedClips()

BakedClips方法,接收动画根对象,动画片段数组,帧率,LOD数据作为输入,输出BakedData。

public static BakedData BakeClips(GameObject animationRoot, AnimationClip[] animationClips, float framerate, LodData lods)

//该方法首先获取动画根对象子对象的SkinMeshRenderer
        var skinRenderer = instance.GetComponentInChildren<SkinnedMeshRenderer>();

//利用这个skinRenderer作为CreateMesh方法的参数生成 BakedData的NewMesh
        bakedData.NewMesh = CreateMesh(skinRenderer);

//BakedData的LodData结构体中的mesh成员也使用CreateMesh方法生成,只不过需要的第二个参数是输入lod的mesh成员
        var lod1Mesh = CreateMesh(skinRenderer, lods.Lod1Mesh);
       ...
        bakedData.lods = new LodData(lod1Mesh, lod2Mesh, lod3Mesh, lods.Lod1Distance, lods.Lod2Distance, lods.Lod3Distance);
        
//BakedData的framerate直接使用输入的framerate
        bakedData.Framerate = framerate;

//使用SampleAnimationClip方法对每个动画片段采样得到sampledMatrix,然后添加到list中
        var sampledMatrix = SampleAnimationClip(instance, animationClips[i], skinRenderer, bakedData.Framerate);
        sampledBoneMatrices.Add(sampledMatrix);

//使用sampledBoneMatrices的维数参数作为关键帧和骨骼的数目统计
        numberOfKeyFrames += sampledMatrix.GetLength(0);
        int numberOfBones = sampledBoneMatrices[0].GetLength(1);

//使用骨骼数和关键帧数作为大小创建材质
        var tex0 = bakedData.AnimationTextures.Animation0 = new Texture2D(numberOfKeyFrames, numberOfBones, TextureFormat.RGBAFloat, false);

//将sampledBoneMatrices的数据全部存入到材质颜色中
        texture0Color[index] = sampledBoneMatrices[i][keyframeIndex, boneIndex].GetRow(0);

//创建Dictionary字段
        bakedData.AnimationsDictionary = new Dictionary<string, AnimationClipData>();

//生成AnimationClipData需要的开始结束位置
        PixelStart = runningTotalNumberOfKeyframes + 1,
        PixelEnd = runningTotalNumberOfKeyframes + sampledBoneMatrices[i].GetLength(0) - 1

至此完成BakedData的生成。

4、AddCharacterComponents()

//Add方法是把角色转换成可以使用GPU渲染的关键
public static void AddCharacterComponents(EntityManager manager, Entity entity, GameObject characterRig, AnimationClip[] clips, float framerate)

//利用manager在entity中依次添加animation state,texturecoordinate,rendercharacter 
var animState = default(GPUAnimationState);
animState.AnimationClipSet = CreateClipSet(bakedData);
manager.AddComponentData(entity, animState);
manager.AddComponentData(entity, default(AnimationTextureCoordinate));
manager.AddSharedComponentData(entity, renderCharacter);

5、InstancedSkinningDrawer()

public unsafe InstancedSkinningDrawer(Material srcMaterial, Mesh meshToDraw, AnimationTextures animTexture)

//需要的ComputeBuffer只有76个字节,这也是CPU占用低的主要原因,传递的数据是顶点的转移矩阵和它在材质中的坐标
objectToWorldBuffer = new ComputeBuffer(PreallocatedBufferSize, 16 * sizeof(float));
textureCoordinatesBuffer = new ComputeBuffer(PreallocatedBufferSize, 3 * sizeof(float));

调用DrawMeshInstancedIndirect方法实现在场景中绘制指定数量的角色。

Graphics.DrawMeshInstancedIndirect(mesh, 0, material, new Bounds(Vector3.zero, 1000000 * Vector3.one), argsBuffer, 0, new MaterialPropertyBlock(), shadowCastingMode, receiveShadows);

绘制

1、创建绘制的角色列表

private List<RenderCharacter> _Characters = new List<RenderCharacter>();
private Dictionary<RenderCharacter, InstancedSkinningDrawer> _Drawers = new Dictionary<RenderCharacter, InstancedSkinningDrawer>();
 private EntityQuery m_Characters;

2、对要绘制的角色实例化一个Drawer

drawer = new InstancedSkinningDrawer(character.Material, character.Mesh, character.AnimationTexture);

3、传输坐标和LocalToWorld矩阵

var coords = m_Characters.ToComponentDataArray<AnimationTextureCoordinate>(Allocator.TempJob, out jobA);
var localToWorld = m_Characters.ToComponentDataArray<LocalToWorld>(Allocator.TempJob, out jobB);

4、调用Draw()方法

即是DrawMeshInstancedIndirect()方法。

drawer.Draw(coords.Reinterpret_Temp<AnimationTextureCoordinate, float3>(), localToWorld.Reinterpret_Temp<LocalToWorld, float4x4>(), character.CastShadows, character.ReceiveShadows);

效果展示


(角色数量400)


(角色数量10000)

性能分析

考虑到Android端GPU性能的不足,适当减少了生成角色的数量并且采用了较少细节的LOD模型。角色数量减少为100个,LOD面片数量约180个,动画片段保持不变。

测试机型为红米4X、红米Note2和小米8:

同时由于该项目使用了Unity的Jobs系统,大量的计算工作被迁移到Worker线程中,大大节省了CPU主线程的耗时。

快用UWA Lab合辑Mark好项目!

请输入图片描述

今天的推荐就到这儿啦,或者它可直接使用,或者它需要您的润色,或者它启发了您的思路......

请不要吝啬您的点赞和转发,让我们知道我们在做对的事。当然如果您可以留言给出宝贵的意见,我们会越做越好。


【博物纳新】是UWA旨在为开发者推荐新颖、易用、有趣的开源项目,帮助大家在项目研发之余发现世界上的热门项目、前沿技术或者令人惊叹的视觉效果,并探索将其应用到自己项目的可行性。很多时候,我们并不知道自己想要什么,直到某一天我们遇到了它。

更多精彩内容请关注:lab.uwa4d.com