1 资源是如何上传到GPU的
比起干说,还是结合Unity Profiler做实验来得直观和有说服力。
首先准备一个带Mesh的Prefab,保证从磁盘加载到CPU内存之前游戏场景中没有其他实例在使用该Prefab所指向的Mesh资源,然后在运行过程的某个时刻开始,先后调用Load和Unload逻辑,通过Unity的Profiler观察GPU内存总量变化和上传数据变化。
public class TestUpload : MonoBehaviour
{
private List<GameObject> tps = new List<GameObject>();
void Start()
{
StartCoroutine(CountDown());
}
IEnumerator CountDown()
{
yield return new WaitForSeconds(0.2f);
JustLoad("Assets/test.prefab");
yield return new WaitForSeconds(0.2f);
ReleaseAsset(0);
}
private void JustLoad(string path)
{
var go = AssetDatabase.LoadAssetAtPath<GameObject>(path); //只加载,甚至不激活和显示
tps.Add(go);
}
private void ReleaseAsset(int idx)
{
tps[idx] = null;
EditorUtility.UnloadUnusedAssetsImmediate(); //使用此接口及时触发GPU端资源释放
}
private void OnDestroy()
{
tps.Clear();
}
}
下图来自Profiler:
在Unity运行期间,加载资源(Mesh或Texture)完成的同时,无论当前帧是否有渲染目标Mesh或者使用目标Texture的需要(即便只是加载了资源,之后什么都不操作)都会触发向GPU上传数据的操作,并被保存在名叫GfxBuffer的内部类中。传输模式在Editor模式下是同步的,会在触发上传的同一帧内完成向GPU提交全部数据(Header和Binary)。 同样,当Unity不再持有该Mesh资源的引用,并且在寻求主动释放GPU资源时,显存中的关联资源(Vertex/Index Buffer等)才会被释放,在Editor模式下,这个操作对应了EditorUtility.UnloadUnusedAssetsImmediate()
方法。
为了知道Unity在上传GPU资源数据时到底干了什么,这边比较了一下上传关键帧和其他时间CPU部分负载的异同,利用Profiler自带Hierarchy查找关键词Mesh,比较异同后发现:处于上传负载的关键帧时期,Unity触发了2个独特的函数调用:
- Mesh.AwakeFromLoad
- Mesh.CreateMesh
这两个方法在Prolier中的具体执行层级如下图所示:
可见当读取磁盘数据的回调一旦完成,就触发了Mesh.AwakeFromLoad
,进而触发了Mesh.CreateMesh
,而这个方法内部主要负责将Mesh中的"Vertice"和"Index"数据依序通过GeometryBuffer
上传到GPU端。
进一步梳理下Mesh和Texture等资源的处理流程,可以区分为两种方式:
- 与场景同时加载
- 其本质也是从磁盘加载,但是随同场景出现而出现
- 在调用Profiler测试时,往往因为场景早于Profiler工作而出现,所以一部分基于场景的Mesh和Texture早已在GPU了
- 运行时由代码触发的从磁盘加载
- 这里如果是从构建好的AssetBundle中获取数据,那么就有2中不同的上传模式:
- Sync:
- 该模式在资源build时期(就是打AssetBundle时期)会将数据的Header和Binary全部打包到
.res
文件内, - 在游戏运行时,Unity从磁盘(Bundle)中读取这个文件到内存,之后会由主线程于一帧内将资源(Header和Binary)Upload到GPU。
- 该模式在资源build时期(就是打AssetBundle时期)会将数据的Header和Binary全部打包到
- Async
- 还是在资源build时期,会将Header写入
.res
文件中,Binary数据则写入.resS
文件中 - 在游戏运行时,Unity从磁盘(Bundle)中读取
.res
文件到内存,解析出Header数据,之后Unity采用streams的方式从.resS
文件中加载Binary数据到GPU,这个过程使用了一个固定大小的Ring Buffer(环形缓冲),而且还会利用多线程,分多帧处理。
- 还是在资源build时期,会将Header写入
- Sync:
- 如果是从Resources目录读取资源,或者在Editor模式下调用AssetDatabase获取资源,那么统一走Sync模式
- 这里如果是从构建好的AssetBundle中获取数据,那么就有2中不同的上传模式:
备注1 Unity重复使用一段环形缓冲作为流式(Streaming)上传数据到GPU的区域,这么做的主要目的是避免重复开辟新的内存。在ProjectSettings->Quality->AsyncAssetUpload->BufferSize可以控制环形缓冲的大小,默认是4MB,最小可调到2MB,最大则是2GB。当单个Mesh或Texture的尺寸超过环形缓冲大小时,Unity不得不重新开辟RingBuffer以适应上传数据大小,出现这种情况会导致效率下降,因此最佳策略是手动调整BufferSize,以满足场景内最大Mesh/Texture的尺寸。
备注2 Unity也提供了控制每帧Upload时间(ms)的接口和设置,可以在ProjectSettings->Quality->AsyncAssetUpload->TimeSlice中调节每一帧最大可占用的CPU时长,这个数值越大,意味着GPU将越快获得Mesh/Texture数据,代价是CPU在提交数据的这段时间内负荷增大。
注意只有在出发C#加载Mesh资源的那一帧(上传关键帧),系统才向GPU上传了一定数量是顶点和索引缓冲数据。那一帧过后,系统恢复“常态”。
部分关键参数名的含义参考如下官方文档:
Vertex Buffer Upload In Frame Count/Bytes | The amount of geometry that the CPU uploaded to the GPU in the frame. This represents the vertex/normal/texcoord data. There might already be some geometry on the GPU. This statistic only includes geometry that Unity transfers in a frame. |
---|---|
Index Buffer Upload In Frame Count/Bytes | The amount of geometry that the CPU uploaded to the GPU in the frame. This represents the triangle indices data. There might already be some geometry on the GPU. This statistic only includes geometry that Unity transfers in a frame. |
2 allowSceneActivation
这个值的作用参考文档即可:AsyncOperation.allowSceneActivation
简单来说,我们可以通过在加载场景前将该变量设置为false,从而控制Unity专心于该场景的异步加载,直到完成度达到90%后(也可以提前,但是不能延后)再通过将allowSceneActivation设置为true从而重启其他处于队列中等待的AsyncOperation,比如Unity官方提到的SceneManager.UnloadSceneAsync
。
我想说的是,有时候如果没有及时提前触发非场景类的AssetBundle异步加载,那么allowSceneActivation也会将这些Bundle的加载停住(stalled),由于是异步提交的,因此误停其他Bundle加载也很可能是偶发的,不一定在测试的时候必现,需要注意和提前规避。
3 ResetPreMappedBufferMemory
这个借口的作用参考这篇官方文档:ParticleSystem.ResetPreMappedBufferMemory
之所以提及这个接口是因为有项目遇到一个战斗中内存突然暴增的问题,特别是在以高倍速播放战斗画面的情况下,Gfx Memory会有倍增的恐怖效果,而且战斗结束一段时间后爆涨的内存仍然没有明显回落。 导致这个问题的原因是同屏粒子特效过多,使得Unity粒子系统底层申请了较大缓存用来存放Mesh等粒子渲染资源。
很显然Unity底层有专门算法控制额外内存申请量,我们的游戏战斗在高倍速播放过程中累积了大量同屏粒子特效的显示请求,这个请求量显然吓到了Unity。粗暴的解决方法是在内存申请高峰之后,适时的调用ParticleSystem.ResetPreMappedBufferMemory()
方法重置这部分额外开辟的内存。
一个疑问是Unity的这项预申请大量内存的优化是否仅针对大内存设备启用,或者会依据可用内存大小自动调整?因为如果缓存值是根据可用内存来的确定的上限的,那么短时间内存占用的爆发也算是一种可控范围内的技术处理,我们无需额外修正,不过后来的测试表明并不是(至少2021版还不是)
分析上图,没有在4G手机上找到明显的安可用比例申请内存大小的证据。
当然,优雅的解决方案是控制任何可能短时间内大量生成粒子特效的情景,这其中包括了高倍速播放战斗,也包括其他诸如同屏多角色释放大量粒子特效,甚至粒子特效的制作本身。