在渲染场景时,为了降低三角形渲染面片数,往往会使用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份的。
![](https://i-blog.csdnimg.cn/blog_migrate/bd6a1ce9c217f288120c628e6498edca.png)
![](https://i-blog.csdnimg.cn/blog_migrate/dbbe392c721451bceed8b0e8cf528bc3.png)
分包方式
Unity的AssetBundle有较多限制,比如:无法在不全局GC卡顿下卸载一个AssetBundle 内的Asset,强行这样操作,引用也会丢失。再次加载Asset后,比如一个Prefab就会丢失他的材质球引用,所以一般比较干净又不卡顿的卸载方式是直接卸载这个AssetBundle。这里对每个Asset单独一个AssetBundle来实现功能,具体项目会规划好一定颗粒度。物件Prefab是8个含有一个LODGroup的,但是他们LOD0的MeshFilter里要设置为空,这样打包的时候不会带有LOD0的数据,否则省不了内存。
![](https://i-blog.csdnimg.cn/blog_migrate/2faa2788345c442092dc66fad42e323c.png)
写一个ScriptableObject来存放LOD0的Mesh,虽然用一个MeshFilter组件也能持有Mesh引用,但一些Prefab的LOD0有多个Renderer时候就比较麻烦,所以还是用ScriptableObject。然后创建8个MeshData实例,设置不同的8个LOD0的Mesh。
主要代码
因为场景物件难免同时存在多个实例,所以一般不会加载完一个就卸载AssetBundle ,而是长期缓存起来。这里加载LOD0 Mesh的AssetBundle也是这样,但要做个引用计数,当引用为0时再卸载。为了避免同时去加载,所以做个isLoading状态。一般最简单AssetBundle缓存就是这3个变量。
![](https://i-blog.csdnimg.cn/blog_migrate/892d9b61cadf379c3eec604be2faae08.png)
这里就是主要的加载/卸载逻辑,就是用rendererLods0[0].isVisible来获取是否需要渲染LOD0,如果需要并且LOD0 Mesh又不存在,那么去加载load0mesh。如果不需要显示LOD0,但load0mesh又存在,那么就卸载他,加载与卸载后都会更新existLod0的值。
![](https://i-blog.csdnimg.cn/blog_migrate/953bdd377608f8383a2db76a5228e30e.png)
具体加载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,不会闪一下。
![](https://i-blog.csdnimg.cn/blog_migrate/b3d1676e7898ed44288a1d24b9631c14.png)
具体卸载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。
![](https://i-blog.csdnimg.cn/blog_migrate/77eb90b51ea5c8e3315e87c8a7b63021.png)
分帧更新策略
分帧更新几乎是所有大世界游戏的通用策略,因为资源多又不想卡顿还不想提前等太久,所以都可以接受分帧了,比如一转头从模糊到清晰的RVT,SVT与TextureStreaming,以及UE新的VirtualShadowMap等。因为当我们把测试实例增加到800个,那么同时执行这份逻辑性能很差,需要1.65ms,而分帧后每帧只执行几个只需要0.02ms。
![](https://i-blog.csdnimg.cn/blog_migrate/df5ceeaf39e61555d92d230c79328b5f.png)
![](https://i-blog.csdnimg.cn/blog_migrate/c84fac2e9af9db3ceaa86f080166e168.png)
![](https://i-blog.csdnimg.cn/blog_migrate/38bdcb78d528648bf1b27477c1da0156.png)
另外我写了自定义计算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篇文章,感谢作者偶尔不帅供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。
作者主页:jackie 偶尔不帅 - 知乎jackie 偶尔不帅 - 知乎jackie 偶尔不帅 - 知乎
再次感谢偶尔不帅的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。