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.
工具使用工作流:
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<AnimationClip> clips)
{
var animation = animationPrefab.GetComponent<Animation>();
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<AnimationClip>(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<MeshFilter>().mesh = AssetDatabase.LoadAssetAtPath<Mesh>(meshFileName);
var newMat = new Material(m_Shader);
newMat.SetTexture("_AnimTexArr", AssetDatabase.LoadAssetAtPath<Texture2DArray>(fileName));
newMat.SetFloat("_AnimMaxLen", baker.MaxAnimLength);
var matFileName = Path.Combine(outputDir, $"{newPrefab.name}.mat");
AssetDatabase.CreateAsset(newMat, matFileName);
newPrefab.GetComponent<MeshRenderer>().material = AssetDatabase.LoadAssetAtPath<Material>(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即可;
总结:
GPU Animation相比传统动画可以提升10 - 20多倍的性能,它是目前海量物体同屏的最佳方案,但不是完美的,如:不支持动画之间平滑过渡;没有骨骼,也就失去了Animator的所有高级功能;不过对于海量物体来说,通常不需要极致的细节。