【Unity】渲染性能开挂GPU Animation, 动画渲染合批GPU Instance

GPU Instance和SRP Batcher合批渲染只对静态MeshRenerer有效,对SkinMeshRenderer无效。蒙皮动画性能堪忧,对于海量动画物体怎么解决呢?针对这个问题,GPU Animation就是一个常见又简单的解决方案。

GPU动画实现原理:

实现原理也是简单粗暴,把每一帧动画时刻SkinMeshRenderer所有的顶点坐标写入到Texture2D, 贴图UV中,U按顶点顺序保存顶点坐标,V是第几帧,然后在顶点着色器中读取所有顶点的坐标,根据时间轮流在动画帧数区间从动画Texture2D采样,这样就实现了基于GPU的顶点动画。

优化前后性能对比:

分别使用Animator(新版动画系统)、Animation(旧版动画系统)、GPU动画、BRG + GPU动画,10000个动画单位全部在相机视口内播放相同动画的帧数做比较:

Animator Animation

GPU动画

(MeshRenderer)

GPU动画

(Batch Renderer Group)

帧数 9 10 135 202

1. Animator动画系统,9 fps:

2. Animation(旧版动画系统),10 fps:

3. GPU动画,使用MeshRenderer渲染组件 135 fps:

4. GPU动画,使用Batch Renderer Group合批渲染 202 fps:

 GPU动画功能实现:

GPU动画原理已经明确,首先第一步就是把Animation Clip动画每一帧的顶点烘焙到Texture2D中,推荐大家参考github目前star最多的gpu动画开源方案:https://github.com/chenjd/Render-Crowd-Of-Animated-Characters

 不过,此开源方案目前仅支持把旧版Animation Clip烘焙成贴图,不支持Animator动画,并且是每个Animation Clip烘焙成一张Texture文件,对于动画切换不是很友好。

作为一个设计开发工程师这样的工作流是难以忍受的,首先需要解决以下问题:

1. 支持Animator动画烘焙。

2. 更友好的动画切换,通过修改shader参数AnimIndex来切换不同动画,并且支持设置动画速度。

3. 自动把Animator中Animation Clips放入烘焙列表,以及其它用户体验优化功能。

4. 一键生成动画贴图资源、材质球、prefab预制体。

5. 为gpu动画封装一个Amplify Shader Editor函数节点,便于用于使用和扩展自定义shader.

 工具使用工作流:

【Unity】渲染性能开挂GPU Animation, 动画渲染合批GPU Instance_第1张图片

1. 支持Animator动画烘焙:

Asset Store有现成的插件,可以将各种Animation类型humanoid ⇆ generic ⇆ legacy相互转换:Animation Converter | Animation Tools | Unity Asset Store

只需用插件将Animator的动画转换为Legacy Animation,然后就可以把Legacy Animating烘焙到贴图了;

AnimationConverter.Convert(animationClips, config, out string logMessage);

自动化生成带有Animation组件的prefab,并把转换生成的Legacy Animation Clips赋值到Animation组件:

private void TryAssignAnimationClips(string clipsDir, GameObject animationPrefab, IList clips)
    {
        var animation = animationPrefab.GetComponent();
        AnimationClip[] newClips = new AnimationClip[clips.Count];
        for (int i = 0; i < clips.Count; i++)
        {
            var clipAsset = Path.Combine(clipsDir, $"{clips[i].name}.anim");
            newClips[i] = AssetDatabase.LoadAssetAtPath(clipAsset);
        }
        AnimationUtility.SetAnimationClips(animation, newClips);
        EditorUtility.SetDirty(animationPrefab);
    }

烘焙动画,根据Animation的time,每次采样间隔时间为:animClip.length / (Mathf.CeilToInt(animClip.frameRate * animClip.length)). 即个动画帧采样一次。将当前的SkinMeshRenderer通过BakeMesh得到一个当前动画帧Mesh,然后把Mesh的vertices坐标写入Texture2D。

2. 实现动画切换

 实现用index切换动画,只需创建一个Texture2DArray, 把每个Animation Clip生成的Texture2D写入Texture2DArray,就可以通过index来切换动画贴图了。需要注意的是,Texture2DArray中Texture2D的宽高必须与Texture2DArray宽高一样。也就是说Texture2DArray的宽高需为最大子贴图的宽和高。

对于宽高填充不满的贴图,用程序以Repeat方式填充像素即可,可以有效防止动画贴图采样超过有效区域导致的顶点抖动问题。

动画贴图FilterMode使用Bilinear,线性插值可以让顶点动画更加平滑;需要注意的是,由于Biliner进行了插值过渡,在使用VertexID在贴图U轴采样时需要+0.5偏移,取两个像素的中间值。

另外gpu动画shader中还需要访问每个动画的帧数和时间长度,由于gpu动画只用到了xyz,即像素的rgb通道,可以把动画的帧数和时间长度信息直接存入动画贴图的alpha通道,这样就能在Shader读取使用了。

把多个Animation Clip烘焙到Texture2DArray并保存:

public void BakeAnimation(GameObject animationPrefab, string outputDir)
    {
        Quaternion quaternion = Quaternion.Euler(m_FixRotation);
        var baker = new GPUAnimationBaker();
        baker.SetAnimData(animationPrefab, m_TexPowerOfTwo);
        var bakedDataArr = baker.Bake(m_FixScale, quaternion);
        if (bakedDataArr == null || bakedDataArr.Count < 1)
        {
            return;
        }
        var tex2d = bakedDataArr[0].RawAnimTex;
        Texture2DArray tex2DArray = new Texture2DArray(tex2d.width, tex2d.height, bakedDataArr.Count, tex2d.format, false);
        tex2DArray.filterMode = tex2d.filterMode;
        tex2DArray.wrapMode = tex2d.wrapMode;
        for (int i = 0; i < bakedDataArr.Count; i++)
        {
            var bakeDt = bakedDataArr[i];
            tex2DArray.SetPixelData(bakeDt.RawAnimMap, 0, i);
        }
        tex2DArray.Apply();
        string fileName = Path.Combine(outputDir, $"{animationPrefab.name}_anim_tex.asset");
        AssetDatabase.CreateAsset(tex2DArray, fileName);


        string meshFileName = Path.Combine(outputDir, $"{animationPrefab.name}_mesh.asset");
        var newMesh = Instantiate(baker.AnimBakeData.SkinMesh.sharedMesh);
        var points = newMesh.vertices;
        for (int i = 0; i < points.Length; i++)
        {
            var point = points[i];
            point.Scale(m_FixScale);
            points[i] = quaternion * point;
        }
        newMesh.vertices = points;
        newMesh.RecalculateBounds();
        newMesh.RecalculateNormals();
        newMesh.RecalculateTangents();
        AssetDatabase.CreateAsset(newMesh, meshFileName);

        var newPrefabName = $"{m_Config.Prefabs[0].SourcePrefab.name}_gpu_anim";
        var newPrefab = new GameObject(newPrefabName, typeof(MeshFilter), typeof(MeshRenderer));
        newPrefab.GetComponent().mesh = AssetDatabase.LoadAssetAtPath(meshFileName);
        var newMat = new Material(m_Shader);
        newMat.SetTexture("_AnimTexArr", AssetDatabase.LoadAssetAtPath(fileName));
        newMat.SetFloat("_AnimMaxLen", baker.MaxAnimLength);
        var matFileName = Path.Combine(outputDir, $"{newPrefab.name}.mat");
        AssetDatabase.CreateAsset(newMat, matFileName);

        newPrefab.GetComponent().material = AssetDatabase.LoadAssetAtPath(matFileName);
        PrefabUtility.SaveAsPrefabAsset(newPrefab, Path.Combine(outputDir, $"{newPrefabName}.prefab"));
        DestroyImmediate(newPrefab);
    }

3. GPU动画Shader实现:

本来想用Shader Graph和Amplify Shader Editor各实现一份便于使用。但是Shader Graph Bug太离谱了,遇到几次报错,Shader编辑器无响应,导致所有节点丢失。最离谱的是uint类型不能转换为float,VertexID(uint类型)与float值参与任何运算后,会出现数据类型匹配的情况下,节点却无法连接上。WTF?

果断换用Amplify Shader Editor,就一切正常了。实现如图,待到Shader Graph修复bug可以参考Shader节点移植到Shader Graph:

用法:

使用上图自定义函数GetGPUAnimVertex从动画贴图读取顶点坐标,直接赋值到Vertex Position即可;

【Unity】渲染性能开挂GPU Animation, 动画渲染合批GPU Instance_第2张图片

总结: 

GPU Animation相比传统动画可以提升10 - 20多倍的性能,它是目前海量物体同屏的最佳方案,但不是完美的,如:不支持动画之间平滑过渡;没有骨骼,也就失去了Animator的所有高级功能;不过对于海量物体来说,通常不需要极致的细节。

你可能感兴趣的:(Unity,unity,游戏引擎)