在渲染场景时,为了降低三角形渲染面片数,往往会使用LOD来实现不同距离下使用不同细节的Mesh来渲染物体,但是这样会造成多份Mesh在内存中同时存在,最终导致Mesh内存占用偏高的问题,针对这个问题,本篇文章给出了一个具体的解决方案。
功能简介
Unity网格渲染基础的优化由LODGroup提供,但是这个组件在做大世界海量物件渲染时存在3大缺陷。为了简化描述,以下用“内存”这个词来代表“内存(主存)+显存”。
1. 只对单个Prefab做LOD,远处Mesh渲染顶点数减少,但对象数量没有减少,DrawCall或者说GPU状态切换并没减少。
2. 在远处的长期只渲染LOD3的甚至Culled的Prefab,他的LOD0、LOD1和LOD2也一次性加载到内存。
3. 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分包方式
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++的计算,所以不建议那样做。
完整的逻辑类文件:
这是侑虎科技第1246篇文章,感谢作者偶尔不帅供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。
作者主页:https://www.zhihu.com/people/jackie-93-85-85
再次感谢偶尔不帅的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。
网友评论