我上一篇关于Steam Deck减少GPU负载的帖子收到了比我希望的更好的反馈,所以我想分享一下快速的更新。
上一篇文章谈到了我们最近取得的GPU突破,但今天我想揭开我们几个星期前(大约5月14日)对我们的游戏Spooker进行的大规模内存审计的帷幕。
昨天的帖子有一点点的剧透——展示了我们当前的内存,舒适地位于2.4GB VRAM和6.4GB RAM。但是在中午5月,情况远远不妙。我们被膨胀到4.3GB VRAM和7.9GB RAM。
这是我们如何从这个深深的坑中挣扎出来的。
为什么RAM和VRAM在Steam Deck上很重要
与传统的PC设置不同,Steam Deck使用统一内存架构。
TL;DR:GPU没有自己的物理专用VRAM池。相反,CPU和GPU动态共享一个16GB的快速RAM池。
因为它们共享同一物理高速公路,CPU的重度RAM使用直接会挨饿GPU,导致性能大幅下降和抖动。如果你想要在Steam Deck上保持60fps,那么你必须尊严地对待共享池。
步骤1:打破内存剖析的“第一定律”
内存剖析的第一定律是:在目标硬件上进行剖析。我破坏了它。首先,我打开Unity编辑器剖析器,看看是否有任何大规模、显著、低悬挂的可视化胜利。
并且,哦天哪,我们找到了它们
- 纹理膨胀:我们之前的开发中做了些混杂的工作,我马上就看到了一大堆2K纹理和法线图像,每个图像都在42.7MB,分布在各种材料上。我们需要保留它们以便PC玩家,但它们正在杀死Steam Deck。
- 粒子噩梦:剖析器报告了一个令人惊讶的1.47GB粒子和32,648粒子对象在启动时存活在内存中。我重启了Unity,运行它,再次。同样的结果。绝望模式。
纹理解决方案:纹理流式传输
为了解决纹理的重量而不损害PC质量,我们打开Unity的纹理流式传输。
我快速在我们的主资产目录中搜索t:texture,选择了我们的重资产,启用了Generate Mipmaps(根据游戏对他们的重要性分配优先级从0到10)。然后,我进入项目设置,启用了纹理流式传输,并设置了流式传输预算为2048。
如果你的纹理流式传输模型只是“LOD纹理,使用远处小版本”,你完全正确,但通常,Unity仍然强迫整个文件(包括大2K原始文件)进入内存以便在你走近它时使用
启用纹理流式传输改变了它,以便Unity只实际加载所需的低分辨率或高分辨率片段。例如,如果一个桌子正面对你,你会得到清晰的2K纹理;如果它远处,Unity根本不将重资产加载到内存中。
然后,它缓存这些纹理在GPU中,以便不必不断从磁盘拉取它们,这对于保持Steam Deck共享RAM池不被高分辨率资产占据是一个绝对的救命稻草。
总的来说,这允许Unity根据摄像机距离计算出实际需要的分辨率的纹理流式传输片段,流式传输低分辨率片段时,当远处或内存紧张时。它缓存这些在GPU中以节省磁盘到CPU的循环——这是一个巨大的胜利,尤其是在移动/手持芯片上。
粒子解决方案:杀死脚本对象陷阱
接下来就是那场粒子噩梦。
在上下文中,我们的架构相当干净(至少是主观的):我们使用一个单独的启动场景,运行VContainer,注册跨场景依赖项作为POCO。每个个体游戏场景都以子生命期范围加载。
那么为什么内存在启动时会被淹没呢?
我们的游戏具有大量不同的池桌(类似于迷你高尔夫布局,但为溜冰)。当检查环境集合时,我注意到加载到新桌子中完全没有改变任何内存。
罪魁祸首:我们的脚本对象使用直接GameObject预设引用来定义桌子。因为这些脚本对象被加载,每个桌子预设(以及它们相关的粒子系统、网格和纹理)都被锁定在内存中。
是时候进行紧急的地址表重构了。
移动到地址表和预热
首先,我们删除了旧的Resources文件夹,移动了所有内容到一个专门的游戏数据文件夹。(友好的提示:任何在Resources文件夹中的内容都将被锁定在内存中,Unity已经要求我们停止使用它多年了。)那里没有太多,但任何内容都是一种坏主意。
接下来,我们用AssetReferenceGameObject替换了脚本对象中的原始GameObject序列化字段。这保留了在调试器中进行拖放的好处,但停止了Unity在加载脚本对象时将其强迫到内存中的行为。
因为地址表异步加载,实例化它们时会在加载资产从磁盘时引起微小的抖动。为了保持对玩家的完全无感知,我们编写了一个预热系统,在转场屏幕后在后台加载下一个桌子。
这是简化的预热、释放和异步实例化的UniTask示例:
public AsyncOperationHandle<GameObject> AddWarmedTable(ISpookerNode nodeData)
{
if (warmedTables.TryGetValue(nodeData, out var table))
{
return table;
}
if (nodeData.Prefab is not AssetReferenceGameObject prefab)
{
return default;
}
var loader = prefab.LoadAssetAsync();
warmedTables.TryAdd(nodeData, loader);
return loader;
}
public void RemoveWarmedTable(ISpookerNode nodeData)
{
if (!warmedTables.TryGetValue(nodeData, out var loader))
{
return;
}
if (loader.IsValid())
{
loader.Release();
}
warmedTables.Remove(nodeData);
}
public void UnloadWarmedTables()
{
foreach (var loader in warmedTables.Values)
{
if (loader.IsValid())
{
loader.Release();
}
}
warmedTables.Clear();
}
async UniTask LoadNode(AsyncOperationHandle<GameObject> handle, ISpookerNode node)
{
while (!handle.IsDone && !isDisposed)
{
await UniTask.Yield();
}
if (isDisposed)
{
return;
}
var previous = loaded;
var assetRef = node.Prefab;
Addressables.InstantiateAsync(assetRef).Completed += (resultHandle) =>
{
loaded = resultHandle.Result;
loaded.transform.position = Vector3.zero;
loaded.transform.rotation = Quaternion.identity;
if (previous != null)
{
Addressables.ReleaseInstance(previous);
}
Loaded.Invoke(loaded.GetComponent<SpookerNodeBehaviour>());
};
}
收获
通过分离我们的预设从我们的数据容器,我们从内存中挣扎出来,脱离了数百个不必要的对象。我们只剩下一个活跃的桌子被加载。
结果立即可见:
- 粒子数量:下降了超过3万个对象。
- 编辑器内存:报告了一个巨大的3.02GB的减少。
- Steam Deck指标:将我们带到了2.9GB VRAM和6.9GB RAM(这是我们后来的GPU优化的完美基准值)。
从玩家角度来看,过渡是完全无感知的,但硬件正在呼吸一个巨大的呼吸。
如果你正在构建一个内容丰富的游戏,请注意你的脚本对象引用!
评论 (0)