找回密码
 立即注册
查看: 384|回复: 13

浅析Unity引擎视角下的游戏内存优化

[复制链接]
发表于 2023-2-7 12:03 | 显示全部楼层 |阅读模式
试想一个场景,你们的游戏在PC上推出后大火,领导们决定拓展一下游戏的市场,让游戏能够触及更多的平台和机型,例如手机,主机等。这时候你所在的团队接到了这样的任务,一边是制作标准极高,视觉效果高大上的游戏,另一边是性能捉鸡的新目标平台,你们需要在保留原有游戏的所有内容的同时,还要持续跟进游戏内容的更新,同时不影响到现有平台的运行。这看起来就是一个非常艰巨的任务。
假设你所在的团队已经完成了前期的跨平台移植工作,游戏已经能够在新平台跑起来了,这个时候往往会遇到的第一个问题就是内存不足。目前配备独立显卡的PC平台的可用内存,基本都是8GB以上的系统内存+3GB以上的显存,实在不够用还可以交换到磁盘上,所以一般PC端的游戏,不会特别考虑内存不足的情况。



目前PC平台主流内存/显存配置占比

而在移动端和主机端这样的平台上,可用内存往往捉襟见肘,还需要同时兼顾CPU和GPU的使用。作为一个引擎开发工程师,该如何从引擎端进行内存的优化呢?笔者认为,可以从以下几个方面考虑。
1、关注各种粗放的使用

这个方法比较适合在优化的前期搞,也是比较常规的优化方法。在这个阶段仔细分析一下memory profile中的数据,往往就能看到很多“铺张浪费”的地方,可能只需稍加改动,就可以节省几百兆的内存。笔者觉得几个可以特别关注的部分:

  • 纹理,RT
在往配置较低的平台进行移植的时候,纹理上往往可以压缩出很多的空间。随着渲染分辨率的降低,纹理也可以适当降低分辨率。尤其是一些在屏幕空间上占比很小的物体、特效。在1080P分辨率下,对于有着比较复杂细节的时装,512x512的分辨率足够保持其细节;对于细节不太丰富的时装,如一些大块色块拼接的时装,256x256甚至128x128也足够了。在720P分辨率下,可以相较1080P均降一级。如果还不能满足内存要求,可以在保持主贴图(diffuse)分辨率不变的情况下,压缩其他贴图的分辨率。
RT也是一个可以压榨出许多内存的地方,可以多多留意各张RT的大小,及时发现其中不合理的地方,例如下图中,R8格式的RT居然比同样分辨率的ARGB32的大小还大,仔细研究后会发现,R8格式的RT带有depth,而这张MaterialId RT显然不需要depth buffer。


多张同分辨率的RT之间也可以尽可能复用,例如后处理阶段,往往需要多个屏幕大小的RT,后面的后处理效果就可以复用已经完成的后处理效果使用过的RT。可以实现一个全局的RT pool来对这些临时的RT进行管理,根据RT的分辨率,格式,mipmap,depth bits来计算hash,实现帧内复用的效果。同时对pool内的RT的使用情况进行统计,这样就可以自动清理长时间没有使用的RT。

  • 压缩选项
Unity3D中,一些压缩选项对内存也有着比较大的影响。比如Unity3D针对mesh提供了vertex compression和mesh compression两种压缩。其中vertex compression是可以开启的,对内存占用有一定的优化效果,代价是损失vertex的精度,实测下来精度的影响并不大;而mesh compression开启后,文件的体积会减小,但是加载到内存中占用的内存空间会增大,实测内存占用可能会相差接近一倍,因此mesh compression可以关闭。





  • 一些数据结构的开销
一些容器,比如hash table,可能原先使用的key是字符串,如果容纳的东西很多的话,存储字符串就是一笔很大的开销;一些结构中可能存在一些不必要的字符串或是大体积的其他属性,可以直接去掉,以减少存储开销;有一些会被大量分配出来的struct,可以尝试去除struct的padding,也能减少一定的内存占用。
2、提高内存分配器的空间利用率

内存分配器是引擎中最常使用到的模块之一了,实际项目中,内存分配器往往要在分配性能和空间利用率上寻求平衡,因此对于内存不足的平台而言,需要特别注意内存分配器上的内存开销。如果通过memory profiler分析发现,游戏的内存分配器空间利用率很低的话,就可以考虑对内存分配器进行优化。


对于Unity3D而言,其用于分配persistent native memory的分配器主要是dynamic heap allocator和bucket allocator两种(具体参考Memory allocator customization)。其中bucket allocator是针对字节级别的小内存进行分配的,dynamic heap allocator则对较大的内存进行分配。Unity3D的dynamic heap allocator使用TLSF算法分配内存,TLSF算法的具体细节这里就不展开赘述了,相关源码可以查看这个repo:https://github.com/mattconte/tlsf。
简单来说,TLSF会以block的形式对内存进行管理,即先从系统申请一块较大空间的内存,再在这块空间内部进行划分以满足不同大小的内存的分配请求。通常,为了兼顾分配的性能,block的大小为数兆字节(Unity3D默认为16MB),而TLSF内部内存分配的粒度相对来说又较大,这样分配器内部就会产生一些无法利用的空间。
实际项目中,通过统计各个大小区间的内存分配数量,也可以观察到,Unity3D的native内存分配时,1k字节以下的内存分配占据了绝大多数。如果对每个block内的分配情况进行统计,也可以观察到很多block被几个几十,几百字节的对象占住无法释放的情况,大概类似这样的情形:


可以将TLSF的每个block的大小适当减小,减少这部分的内存占用。但实际测试下来,这部分的提升并不明显。这个时候就可以使用Unity3D提供的另外一个分配器:bucket allocator。Bucket allocator是一个无锁的,专门用于小内存分配的分配器,由于内存分配的粒度可以设置得比较精细,内部的碎片率会比TLSF的分配器更低一些。


Unity3D为bucket allocator提供了分配粒度,bucket数量,block大小和数量四个配置选项,其中block的概念和TLSF的block类似,bucket allocator会一次性向系统申请一块较大的内存,再将block划分为一个个不同大小的bucket,在实际进行内存分配的时候,返回大小最贴近的bucket的地址。
Bucket的大小则由分配粒度和bucket数量共同决定,以默认的16B和8个为例,8个buckets的大小分别为:16B,32B,48B,64B,80B,96B,112B和128B。这两个参数也决定了bucket allocator能够分配的内存大小的上限,即128B。Block数量和block大小则共同决定了bucket allocator一共能够分配多少内存,以默认的4MB和1个为例,就是总共能够分配4MB的内存。超出的部分就会使用TLSF进行分配。
默认的参数对于一些很小的项目来说也许是够用的,但是在我们项目中这个设定太过保守了,实际测试下来,1k字节以下的内存分配大小可以达到400~500MB,让这部分内存使用TLSF进行分配是不划算的。通过将bucket数量设为64,bucket能够分配的总大小设成512MB后,满足了相应的要求,也减少了native内存的reserve率。


3、去GameObject

首先思考一下,一般而言,一个Unity的场景是如何组织的:美术将制作好的场景物体的FBX导入到Unity3D工程中后,还会制作对应的prefab,对LOD,材质,场景中位置等相关的一些参数进行调整;运行时,程序编写的相关逻辑会将prefab加载上来,实例化成一个个GameObject到场景中。
对于一个大型的场景而言,同时管理的GamaObject数量可能有上万个,通常会使scene streaming去管理这些GameObject,即整个场景划分为m x m的区块,以角色为中心加载周围n x n的区块,其它区域则使用HLOD。区块的加载窗口一般为5 x 5、3 x 3和1 x 1等,加载卸载可能还有更复杂的判断机制,这里不展开了。
对内存进行分析后可以看到,仅Mesh数量就高达一万多个,占据了800多MB的内存,GameObject、Transform以及MeshRenderer更是有数万个,如此众多的数量不仅会占用内存,也会使得内部的内存碎片化现象加剧,影响性能。因此,对这一部分进行优化是至关重要的。
数量占用内存
Mesh101690.82GB
GameObject8507118.8MB
Transform7464120.9MB
MeshRenderer3489220.9MB
总内存(包含其他未列出项)/5.4GB
优化的方向不仅仅是减少不必要的GameObject的加载,也需要注意到,这些GameObject中,相当大的一部分都是静态的物体,对于这些静态的物体而言,实际上运行时我们仅仅只需要它们的mesh,material,以及transform数据就足够了,GameObject本身及其身上挂的component在引擎内部除了自身的内存开销之外,还有因为instanceID,依赖的bundle增加之后,导致内部一些容器被撑大的隐形开销等。
因此我们就可以从两个角度来进行优化:使用自己的管线对静态物体进行渲染,避开Unity GameObject的开销;对场景中不必要的物体进行卸载。
要做到第一点,首先需要将场景组织数据从GameObject导出到自己管理的数据结构。原本的GameObject(以prefab的形式)的组织结构如下图所示。每级LOD分别对应了多个renderer,每个renderer又有自己对应的transform,mesh和material。


我们可以分别定义RendererData,ScenePrefab和SceneObject三个结构体,将原先GameObject中的各个LOD level的renderer对应mesh和material数据单独提出到两个列表里,将对应的meshIndex,materialIndex以及transform的数据记录到RendererData中。GameObject每一级LOD包含的renderer则平铺保存到另一个RendererData 数组allRenderers里,并将各个LOD在allRenderers中对应的lodXRendererStart和lodXRendererLength记录到ScenePrefab中。最后用SceneObject替代原先的GameObject在运行时对物体进行描述,方便Scene streaming动态处理。
public struct RendererData
{
    public int prefabIndex;
    public int meshIndex;
    public int materialIndex;

    public ShadowCastingMode shadowCastMode;

    public Unity.Mathematics.float4x4 localToObject;
    public short enableTransparent;
}

struct ScenePrefab
{
    public int prefabValidFlag;

    public int lod0RendererStart;
    public int lod0RendererLength;
    public int lod1RendererStart;
    public int lod1RendererLength;
    public int lod2RendererStart;
    public int lod2RendererLength;

    public float lod0Distance;
    public float lod1Distance;
    public float lod2Distance;
}

public struct SceneObject
{
    public float loadPriority;
    public float3 worldPosition;
    public quaternion worldQuaternion;
    public float3 worldScale;

    public float3 aabbCenter;
    public float3 aabbExtents;

    public int prefabIndex;
    public int idInScene;
}
原先的数据在经过处理后,将重新组织成以下的形式。这样一来,我们就把原先以GameObject(prefab)组织的场景数据替换成了我们自己的场景数据,抛却了使用GameObject带来的额外开销,也方便我们按需加载/卸载相应的资源。
例如,不同层级的LOD仅需加载当前显示的LOD层级所需的资源,实际测试中发现,场景中60%以上的物体是LOD2,相比LOD1和LOD0,LOD2的体积无疑是很小的。而原生的LOD group需要将所有LOD层级引用的mesh都加载上来,按需加载LOD能节省至少一半的内存。不过需要额外注意的是,Unity3D导入FBX后,在AssetDatabase中,FBX本身是main asset,其中的mesh、animation clip等都是sub asset。
由于此时我们已经没有GameObject了,需要直接通过自定义数据结构索引mesh,而Unity3D在加载资源时仅能使用main asset路径加载,如AssetBundle.LoadAsync<Mesh>("xxxxxx.fbx")。但由于Unity3D的加载机制会将整个FBX所有的sub asset加载上来,所以无法达到按需加载的目的,因此需要实现新的接口实现精准加载一个sub asset,或者对FBX的mesh进行预处理,保存成新的asset。


运行时,scene streaming仍然以原先的逻辑对场景进行管理,只是当一个加载请求发起时,如果要加载的prefab不在预先处理的资源列表里,则还使用原先的加载、渲染流程,否则跳过原有的流程,等待新的流程进行管理,相关伪代码如下。需要注意的是,伪代码中的foreach/for仅作演示,实际使用中需要使用Job System对其进行并行化。
void MainTick()
{
    ...
    // Scene streaming更新, 更新相机位置,要加载的区块等
    SceneStreamingUpdate();

    // loading流程
    while (loadTasks.Count > 0)
    {
        var loadTask = loadTasks.Dequeue();
         var prefabIndex = GetLoadingPrefabIndex(loadTask);
         // allSceneObjectPrefabIndex预处理时生成, 记录了所有SceneObject的prefabIndex
         if (allSceneObjectPrefabIndex.Contains(prefabIndex))
             continue;
         // 原先的加载逻辑
         LoadPrefab(prefabIndex);
         ...
    }

    // 新的加载流程
    // 从SceneStreaming中获得当前加载的Section,将原先的GameObject信息转为SceneObject格式
    foreach (var sectionIndex in loadingSectionIndices)
    {
        // sectionStaticObjects预处理时生成,记录了每个区块中的静态物体
        foreach(SceneObject staticObj in sectionStaticObjects[sectionIndex])
        {
            staticObjectInSections.Add(staticObj);
        }
    }
    // 对要加载的区块内的物体进行裁剪
    foreach (SceneObject sceneObj in staticObjectInSections)
    {
        if(IsVisible())
            visibleObjects.Add(sceneObj);
    }
    // 计算每个可见物体的LOD
    for (int i = 0; i < visibleObjects.Count; ++i)
    {
        int lod = CalculateLOD(visibleObjects, currentCamera);
        visibleObjectLODs = lod;
    }
    // 根据LOD,索引得到正确的ScenePrefab和RendererData数据
    for (int objIndex = 0; objIndex < visibleObjects.Count; ++objIndex)
    {
        SceneObject sceneObj = visibleObjects[objIndex];
        // allPrefabs预处理时生成
        ScenePrefab prefab = allPrefabs[sceneObj.prefabIndex]
        int lod = visibleObjectLODs[objIndex];
        // 从ScenePrefab获取对应LOD的lodXRendererStart和lodXRendererLength
        LODToRendererInfo(lod, ref prefab, out int rendererStart, out int rendererLength);
        for (int i = rendererStart; i < rendererLength; ++i)
        {
            int rendererIndex = rendererStart + i;
            // allRenderers即上文的allRenderers
            RendererData renderer = allRenderers[rendererIndex];
            // 判断一下当前LOD的sceneObj是否需要加载
            if (NeedLoadRenderer(lod, sceneObj))
                needLoadRenderers.Add(renderer);
            // 将renderer添加到待渲染列表
            validRenderers.Add(renderer);
        }
    }

    // 加载renderers
    LoadRenderers(needLoadRenderers);

    // 渲染renderers
    DrawRenderers(vaildRenderers);
}
在对场景内的物体进行裁剪时,对于一些拥有SSD的设备,可以用IO来换内存。因此对于待加载区块中的对象,可以提前做一次视锥剔除,视锥外面没有被渲染的mesh和材质经过一段时间之后,直接进入卸载队列;另外PVS剔除的对象也可以直接进入卸载队列,这样做在视野内有大面积遮挡时,裁剪效果非常好。除了视锥剔除,还要做灯光方向的阴影剔除,以保留在屏幕外但阴影仍然在屏幕内的物体。
对于没有SSD的设备,受限于HDD的IO速度,视锥之外的物体全部卸载的策略会导致视角转动时,新的mesh来不及加载。因此剔除策略也需要做一定的修改,保留相机附近一定范围内的对象,保证近处物体正常渲染,远处物体则通过异步IO逐渐加载。
完成优化后,再对内存进行统计,mesh不论是数量合适占用的内存都得到了大幅的减少,GameObject、Transform以及MeshRenderer的数量都减少了一半以上,总内存占用减少了881MB,优化效果非常明显。
优化前数量优化后数量减少优化前内存优化后内存减少
Mesh10169309770720.82GB281.5MB558.18MB
GameObject85071383524671918.8MB10.1MB8.7MB
Transform74641280174662420.9MB10.0MB10.9MB
MeshRenderer3489248853000720.9MB2.9MB18MB
总内存(包含其他未列出项)///5.29GB4.43GB881MB
总结

本文对Unity3D中进行内存优化的一些办法进行了介绍,内存优化可以入手的方式非常多,往往需要根据项目的实际情况进行具体的分析,抓代码中的各种细节,因此一篇文章很难进行全面的总结,笔者也只是从自己的实际项目出发,对一些可能的方法进行了讲解,希望能够给大家在进行类似优化的时候提供一些可能的思路。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2023-2-7 12:13 | 显示全部楼层
好文
发表于 2023-2-7 12:18 | 显示全部楼层
欢迎关注[赞同]
发表于 2023-2-7 12:20 | 显示全部楼层
建议多多分享 感谢
发表于 2023-2-7 12:21 | 显示全部楼层
我爱雷火,雷火爱我。
发表于 2023-2-7 12:27 | 显示全部楼层
[赞同]干货
发表于 2023-2-7 12:34 | 显示全部楼层
去gameobject应该是定制一套管线+控制逻辑就好了?脑补对引擎改造程度应该不大?
发表于 2023-2-7 12:41 | 显示全部楼层
写的真好,就是对比图字好小(
发表于 2023-2-7 12:47 | 显示全部楼层
必须滴!
发表于 2023-2-7 12:53 | 显示全部楼层
么么哒[红心]
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-6-27 21:57 , Processed in 0.149398 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表