随着Live2D在项目研发中被广泛使用,其性能优化的需求已经不容忽视。笔者通过模型资源、Mesh、RenderTexture、Material和CPU耗时这5个方面来阐述优化的过程,并且最终实现了低端机上6个模型30帧的效果,值得大家参考。
优化结果
测试机型:低端机——红米4X
测试样例:同样的6个模型(游戏中同屏最多6个模型)
版本:3.2.05
CPU优化主要在CubismModel.Update、CubismModel.OnRenderObject;
内存优化主要在Texture2D、Mesh、RenderTexture、Material以及Mono内存分配。
接下来介绍从模型资源、Mesh、RenderTexture、Material和CPU耗时5个方面来优化性能。
1、模型资源
项目前期没有制定Live2D美术规范,美术导出文件的时候,直接使用自动布局顶点的方式,而Live2D自动生成Mesh的时候对于每个部件的内外边界各生成一份顶点,发现如下问题:
1、模型Mesh数量太多
2、模型的总顶点数达到6K左右
后续跟美术沟通之后,对Live2D模型(后续的模型都指Live2D模型)的导出规定如下:
1、手动布局顶点
2、ArtMesh数量控制在100以内(之前的模型由于改动的工作量太大,暂时不做修改)
3、模型顶点数控制在2k面以内(游戏最多有6个模型)
4、Edit Texture Altas中贴图大小1024*1024,自动布局且Margin为5px
5、为了避免多贴图导致DrawCall升高,一个模型只使用一张贴图
2小时后,通过Live2D Cubism Editor->File->Model Statistics查看优化结果如下:
性能数据测试结果如下:
2、Mesh
测试发现游戏一个模型的Mesh数量在80-200之间,同屏最多有6个模型,通过UWA GOT测试发现Mesh峰值达到107MB(27380个),Material数量达到10.2MB(14968个),其中Unlit(Instance)数量14563个明显异常。
研究CubismRenderer.cs发现如下问题:
1、运行时创建了两份Mesh内存
2、Mesh资源未销毁,导致内存泄漏
3、顶点色每帧都会重新赋值
针对问题1,经测试发现,改成一份Mesh并没有明显的CPU变化,Mesh内存减少一半,由于每一帧都有顶点变化,_meshFilter.mesh = FrontMesh每帧700次的CPU消耗也可以省去。
针对问题2,可以在OnDestroy中调用Destroy接口销毁Mesh。
针对问题3,使用标记控制是否更新顶点色,减少顶点色赋值的0.5ms左右CPU消耗。
另外值得一提的是,Mesh中使用了顶点色处理透明和颜色变化,但是同一个Mesh的顶点色全部相同,可以想到在Shader中控制颜色,实际上为了动态合批不得不使用顶点色,合批之后控制在10个DrawCall左右,不合批每个模型DrawCall数量达到上百个。
3、RenderTexture
CubismMaskTexture.cs中创建的RenderTexture大小默认为1024*1024,项目中遮罩效果大部分使用在眼睛,在实际测试之后有如下优化:
1、将GlobalMaskTexture.asset的Size改成128,实际数值可以根据项目需要修改。
2、CubismMaskTexture.cs中RemoveSource函数加上如下检查,在没有遮罩模型的时候可以释放RenderTexture。
if(Sources.Count == 0)
{
if(_renderTexture != null)
{
RenderTexture.ReleaseTemporary(_renderTexture);
_renderTexture = null;
}
}
3、CubismMaskTexture.cs中OnDisable()和RefreshRenderTexture()函数中同样添加释放RT的接口。
4、CubismMaskController.cs做如下修改,避免RenderTexture无法释放,同时避免切换模型且未销毁时CubismMaskCommandBuffer.Lateupdate持续CPU消耗。
修改后:
4、Material
通过UWA GOT测试发现材质数量也达到1w多个,研究发现材质有明显的泄漏问题,解决方案如下:
1、CubismRenderer.cs中Material属性
public Material Material
{
get
{
return MeshRenderer.material;
}
set
{
MeshRenderer.material = value;
}
}
修改成:
public Material Material
{
get
{
return MeshRenderer.sharedMaterial;
}
set
{
MeshRenderer.sharedMaterial = value;
}
}
2、模型导入引擎后,将相同材质的MeshRender缓存,在运行时的Start函数中实例化材质,对所有同材质的MeshRender赋值同一个实例,离线实例化的问题时可能出现两个以上相同的模型共享材质。
3、模型销毁的时候同时将实例化的材质销毁。
5、CPU耗时
通过UnityProfiler发现CPU消耗主要在:
1、CubismModel.OnRenderObject[9.92ms]
一部分时间消耗在CubismCoreDll.UpdateModel(Ptr)调用,该接口为Live2D底层封装暂时无法修改,只能通过减少Mesh数量减少CPU时间。
另一部分消耗DynamicDrawableData.ReadFrom(UnmanagedModel),通过分析代码发现这里只是复制数据到DynamicDrawableData,则可以省去该CPU消耗. 同时在使用DynamicDrawableData的逻辑中用UnmanagedModel替代。
2、CubismModel.Update[9.64ms]
这个接口最终调用到CubismRenderController.cs文件的OnDynamicDrawableData接口,其逻辑主要是同步底层数据变化同时更新Mesh信息,优化思路如下:
一方面更新是否可见,渲染顺序,透明度,顶点位置信息. 其中从C++底层获取数据的时候每一次都会进行范围合法性检查,此处可以在循环外对数组进行统一检查,有部分Mesh不可见,可以在Mesh不可见的时候避免更新。
具体逻辑如下:
///CubismUnmanagedArrayView.cs
public unsafe T this[int index]
{
get
{
return Address[index];
}
}
///CubismRenderController.cs
private void OnDynamicDrawableData(CubismModel sender, CubismUnmanagedModel unmanagedModel)
{
var dataDrawables = unmanagedModel.Drawables;
var iLen = dataDrawables.Count;
var flags = dataDrawables.DynamicFlags;
var opacities = dataDrawables.Opacities;
var renderOrders = dataDrawables.RenderOrders;
var vertexPositions = dataDrawables.VertexPositions;
if(!flags.IsValid)
{
throw new InvalidOperationException("flags Array is empty, or not valid.");
}
if (!opacities.IsValid)
{
throw new InvalidOperationException("opacities Array is empty, or not valid.");
}
if (!renderOrders.IsValid)
{
throw new InvalidOperationException("renderOrders Array is empty, or not valid.");
}
if (flags.Length < iLen)
{
throw new InvalidOperationException(string.Format("flags Array Length[{0}] < iLen[{1}]", flags.Length, iLen));
}
if (opacities.Length < iLen)
{
throw new InvalidOperationException(string.Format("opacities Array Length[{0}] < iLen[{1}]", opacities.Length, iLen));
}
if (renderOrders.Length < iLen)
{
throw new InvalidOperationException(string.Format("renderOrders Array Length[{0}] < iLen[{1}]", renderOrders.Length, iLen));
}
// Get drawables.
var renderers = Renderers;
// Handle render data changes.
for (var i = 0; i < iLen; ++i)
{
var curRenderer = renderers[i];
var curFlags = flags[i];
// Skip completely non-dirty data.
if (curFlags.HasAnyFlag())
{
// Update visibility.
if (curFlags.HasVisibilityDidChangeFlag())
{
curRenderer.OnDrawableVisiblityDidChange(curFlags.HasIsVisibleFlag());
}
// Update render order.
if (curFlags.HasRenderOrderDidChangeFlag())
{
curRenderer.OnDrawableRenderOrderDidChange(renderOrders[i]);
}
// Update opacity.
if (curFlags.HasOpacityDidChangeFlag())
{
curRenderer.OnDrawableOpacityDidChange(opacities[i]);
}
// Update vertex positions.
if (curFlags.HasVertexPositionsDidChangeFlag())
{
curRenderer.OnDrawableVertexPositionsDidChange(vertexPositions[i]);
}
}
if (curRenderer.UpdateVisibility())
{
curRenderer.UpdateRenderOrder();
curRenderer.UpdateVertexColors();
curRenderer.UpdateVertexPositions();
}
}
// Pass draw order changes to handler (if available).
var drawOrderHandler = DrawOrderHandlerInterface;
if (drawOrderHandler != null)
{
var senderDrawables = sender.Drawables;
var drawOrders = dataDrawables.DrawOrders;
for (var i = 0; i < iLen; ++i)
{
var curData = flags[i];
if (curData.HasDrawOrderDidChangeFlag())
{
drawOrderHandler.OnDrawOrderDidChange(this, senderDrawables[i], drawOrders[i]);
}
}
}
dataDrawables.ResetDynamicFlags();
}
///CubismRenderer.cs
internal unsafe void OnDrawableVertexPositionsDidChange(Core.Unmanaged.CubismUnmanagedFloatArrayView newVertexPositions)
{
if (!newVertexPositions.IsValid)
{
throw new InvalidOperationException("srcVertexPositions Array is empty, or not valid.");
}
// Copy vertex positions.
var iLen = newVertexPositions.Length >> 1;
if (newVertexPositions.Length < iLen)
{
throw new InvalidOperationException(string.Format("newVertexPositions Array Length[{0}] < iLen[{1}]", newVertexPositions.Length, iLen));
}
if (VertexPositions.Length != iLen)
{
Debug.LogErrorFormat("TranslateVertexPositions dont same length iLen={0}|dstMesh.vertexCount={1}"
, iLen, VertexPositions.Length);
}
// Copy vertex positions.
fixed (Vector3* pDstVertexPositions = VertexPositions)
{
for (var v = 0; v < iLen; ++v)
{
var pDst = (pDstVertexPositions + v);
var offset = v << 1;
pDst->x = newVertexPositions[offset];
pDst->y = newVertexPositions[offset | 1];
}
}
// Set swap flag.
SetNewVertexPositions();
}
另一方面同步Mesh的信息,源代码使用了双缓冲Mesh优化性能,由于每一帧都有顶点变化,则每帧调用700次左右的Meshes[BackMesh].colors = VertexColors和MeshFilter.mesh = mesh占用大约一半的时间,这里我改成一份Mesh并没有发现明显的CPU变化,Mesh内存减少一半,同时MeshFilter.mesh = mesh的CPU消耗也可以省去。
总结
在低端机红米4X上:
CPU主要耗时优化到45%(30.2ms->13.5ms)
内存优化到32%(13.6MB->4.3MB)
内存和材质泄漏已解决,同时Mesh内存大量减少。
到此Live2D的优化告一段落,在低端机上6个模型已经可以达到30帧。
封面图来源于网络
https://www.live2d.com/zh-CHS/about/
这是侑虎科技第659篇文章,感谢作者晨星供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!