Unity网格内存优化
在渲染场景时,为了降低三角形渲染面片数,往往会使用LOD来实现不同距离下使用不同细节的Mesh来渲染物体,但是这样会造成多份Mesh在内存中同时存在,最终导致Mesh内存占用偏高的问题,针对这个问题,本篇文章给出了一个具体的解决方案。
功能简介
Unity网格渲染基础的优化由LODGroup提供,但是这个组件在做大世界海量物件渲染时存在3大缺陷。为了简化描述,以下用“内存”这个词来代表“内存(主存)+显存”。
- 只对单个Prefab做LOD,远处Mesh渲染顶点数减少,但对象数量没有减少,DrawCall或者说GPU状态切换并没减少。
- 在远处的长期只渲染LOD3的甚至Culled的Prefab,他的LOD0、LOD1和LOD2也一次性加载到内存。
- LOD的当前级别计算,每帧都会计算,实际上一般项目不需要如此精确地更新频率。根据距离不同,近处每帧计算是否切换LOD,而100米处1秒更新一次都可以,晚1秒从LOD3变到LOD2关系不大的。
针对1,我们做了HLOD来满足渲染性能,这个功能比较庞大这里不讨论。
这里就针对2实现LOD0的Mesh引用计数与动态加载卸载,因为LOD0 Mesh占用内存最多,可扩展到多个LOD加载卸载。同时用依赖距离的分帧计算优化下3。先看下最终效果对比。这里复制出8份不同的模型,模拟多种不同Mesh的情况,只是看起来一样,每种有8个实例,也就是Mesh内存是有8份的。
显示LOD1时,Assets只有2824,内存只有4.7MB
显示LOD0时,Assets有2896,内存有14MB
https://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_MeshStreaming/3.mp4
分包方式
Unity的AssetBundle有较多限制,比如:无法在不全局GC卡顿下卸载一个AssetBundle 内的Asset,强行这样操作,引用也会丢失。再次加载Asset后,比如一个Prefab就会丢失他的材质球引用,所以一般比较干净又不卡顿的卸载方式是直接卸载这个AssetBundle。这里对每个Asset单独一个AssetBundle来实现功能,具体项目会规划好一定颗粒度。物件Prefab是8个含有一个LODGroup的,但是他们LOD0的MeshFilter里要设置为空,这样打包的时候不会带有LOD0的数据,否则省不了内存。
8个 物件Prefab
写一个ScriptableObject来存放LOD0的Mesh,虽然用一个MeshFilter组件也能持有Mesh引用,但一些Prefab的LOD0有多个Renderer时候就比较麻烦,所以还是用ScriptableObject。然后创建8个MeshData实例,设置不同的8个LOD0的Mesh。
主要代码
因为场景物件难免同时存在多个实例,所以一般不会加载完一个就卸载AssetBundle ,而是长期缓存起来。这里加载LOD0 Mesh的AssetBundle也是这样,但要做个引用计数,当引用为0时再卸载。为了避免同时去加载,所以做个isLoading状态。一般最简单AssetBundle缓存就是这3个变量。
为了AssetBundle缓存设计一个类型
这里就是主要的加载/卸载逻辑,就是用rendererLods0[0].isVisible来获取是否需要渲染LOD0,如果需要并且LOD0 Mesh又不存在,那么去加载load0mesh。如果不需要显示LOD0,但load0mesh又存在,那么就卸载他,加载与卸载后都会更新existLod0的值。
LOD0 Mesh的主要加载与卸载逻辑
具体加载LOD0 Mesh过程
很常规的一种AssetBundle与Asset异步加载机制,同时解决并发冲突。就是有某个AssetBundle,如果别人已经加载完我就用它loadAsset,如果没人启动加载它我就加载它。另外特殊情况,如果别人已经加载中,我就等,等完再用。这里的特殊点是 lods[0].renderers = rendererLods0; ,为什么加载完要给LOD0指定为LOD0原来的Renderers。这是因为rendererLods0[0].isVisible的时机问题,因为这时候引擎这帧已经不渲染LOD1了,而LOD0我们又在加载中,所以Prefab会消失一下。为了避免消失,有2种做法:一种是自己做LOD计算并通过Forcelod来控制。就是LOD0 Mesh加载过程中也用LOD1先代替几帧渲染。这个完整LOD当前等级计算代码量又多起来,所以选了一种更简便的做法。就是平时让lods[0].renderers存放LOD1+LOD0(空),这样引擎切换到LOD0时 我们还没加载也能看到LOD1,不会闪一下。
加载LOD0 Mesh过程
具体卸载LOD0 Mesh过程
同样卸载时,会给lods[0].renderers = rendererLods0_1;,也就是放入LOD0和LOD1。另外引用次数为0时,会卸载AssetBundle实现内存的回收。另外有一个小技巧,是LOD0不存在时,要用LOD1的Mesh设置给LOD0的MeshFilter,并用不可见材质球。这是因为Unity的API没开放LOD Group的AABB设置。我们一旦让LOD0的Mesh为null,引擎自己计算的LOD等级结果就不同,认为AABB的size为0。
卸载LOD0 Mesh过程
分帧更新策略
分帧更新几乎是所有大世界游戏的通用策略,因为资源多又不想卡顿还不想提前等太久,所以都可以接受分帧了,比如一转头从模糊到清晰的RVT,SVT与TextureStreaming,以及UE新的VirtualShadowMap等。因为当我们把测试实例增加到800个,那么同时执行这份逻辑性能很差,需要1.65ms,而分帧后每帧只执行几个只需要0.02ms。
红框中为按距离分帧逻辑
每帧执行的性能
分帧策略下执行的性能
另外我写了自定义计算LOD当前等级配合forceLOD的做法,就不需要上面2处小技巧,整体更清晰合理。但严格的LOD计算,性能不如底层C++的计算,所以不建议那样做。
完整的逻辑类文件:
using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using UnityEngine; public class StreamLodMesh : MonoBehaviour { class SharedAssetBundle { internal bool isLoading = false; internal AssetBundle ab =null; internal int refCount =0; } static Dictionary<string, SharedAssetBundle> sharedAssets=new Dictionary<string, SharedAssetBundle>(); public string abName; LODGroup lODGroup; LOD[] lods; bool existLod0 = false; Renderer[] rendererLods0; Renderer[] rendererLods1; Renderer[] rendererLods0_1; SharedAssetBundle sab; void Start () { lODGroup = GetComponent<LODGroup>(); lods = lODGroup.GetLODs(); rendererLods0 = lods[0].renderers; rendererLods1 = lods[1].renderers; rendererLods0_1 = new Renderer[rendererLods0.Length + rendererLods1.Length]; rendererLods0.CopyTo(rendererLods0_1, 0); rendererLods1.CopyTo(rendererLods0_1, rendererLods0.Length); lods[0].renderers = rendererLods0_1; for (int i = 0, len = rendererLods0.Length; i < len; i++) { rendererLods0[i].GetComponent<MeshFilter>().sharedMesh = rendererLods1[i].GetComponent<MeshFilter>().sharedMesh; ; } lODGroup.SetLODs(lods); StartCoroutine(loop()); } IEnumerator loop() { float stepTime = 0.1f; while (true) { yield return new WaitForSeconds(stepTime); if (Camera.current == null) { yield return 0; continue; } float dis = Vector3.Distance(Camera.current.transform.position, transform.position); stepTime = Mathf.Clamp(dis* 0.01f, 0.05f,10); if (rendererLods0[0].isVisible) { if (!existLod0) yield return StartCoroutine(loading()); } else { if (existLod0) { unload(); } } } } private void unload() { existLod0 = false; for (int i = 0,len= rendererLods0.Length; i < len; i++) { rendererLods0[i].GetComponent<MeshFilter>().sharedMesh = rendererLods1[i].GetComponent<MeshFilter>().sharedMesh; } sab.refCount--; if (sab.refCount == 0) { sab.ab.Unload(true); sharedAssets.Remove(abName); } lods[0].renderers = rendererLods0_1; lODGroup.SetLODs(lods); } private IEnumerator loading() { if (sharedAssets.TryGetValue(abName, out sab)) { sab.refCount++; //如果已经正在加载 等加载完毕 while (sab.isLoading) { yield return 0; } } else { //如果不存在 也不在加载中 创建一个开始加载 sab = new SharedAssetBundle() { isLoading = true ,refCount=1}; sharedAssets.Add(abName, sab); var rq_ab = AssetBundle.LoadFromFileAsync(@"E:\temp\" + abName); yield return rq_ab; sab.ab = rq_ab.assetBundle; sab.isLoading = false; } var rq_as= sab.ab.LoadAssetAsync<MeshData>(abName); yield return rq_as; var meshs= (rq_as.asset as MeshData).lod0Meshs; for (int i = 0,len= rendererLods0.Length; i < len; i++) { rendererLods0[i].GetComponent<MeshFilter>().sharedMesh = meshs[i]; } lods[0].renderers = rendererLods0; lODGroup.SetLODs(lods); existLod0 = true; } }
这是侑虎科技第1246篇文章,感谢作者偶尔不帅供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页:https://www.zhihu.com/people/jackie-93-85-85
再次感谢偶尔不帅的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)