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,跑了它一次。结果相同。绝望模式。
纹理的修复:mipmap流
为了解决纹理的重量而不损害PC质量,我们开启了Unity的mipmap流。
我快速在我们的主资产目录中搜索t:纹理,选择了我们的重资产,启用了生成mipmap(根据它们在游戏中的重要性分配优先级从0到10)。然后,我进入了项目设置,启用了mipmap流,并设置了流媒体预算为2048。
如果你对mipmap的概念只是“纹理LOD,远处使用小版本”,你完全正确——但通常,Unity仍然会强制整个文件(包括巨大的2K原始)进入内存,以便当你走近它时,它仍然可以使用
开启mipmap流改变了它,Unity只会实际加载特定的低分辨率或高分辨率片段,而不是摄像机需要的精确秒。例如,如果一个桌子正面对你,你会得到清晰的2K纹理;如果它远离你,Unity根本不会将高分辨率数据载入内存。
它然后缓存这些纹理在GPU上,以便不必不断从磁盘读取,这对于保持Steam Deck共享RAM池不被高分辨率资产所“饿”至关重要。
总之,这允许Unity根据摄像机距离计算出需要哪种分辨率的mipmap,并在内存紧张时流式传输低分辨率版本。它缓存这些纹理在GPU上以节省磁盘到CPU的周期——这是对移动/手持芯片的巨大胜利。
粒子的修复:杀死ScriptableObject陷阱
接下来就是那个可怕的1.47GB粒子泄漏。
为了背景,我们的架构相当干净(至少主观上):我们使用一个单独的启动场景,运行VContainer,在各个场景之间注册交叉依赖项作为POCO。每个游戏场景都以子级生命周期范围加载。
那么为什么内存会在启动时被淹没?
我们的游戏包含大量不同的小桌子(想象一下,迷你高尔夫场景,但用于桌球)。当检查环境集合时,我注意到加载到一个新桌子上完全没有改变内存。
罪魁祸首: 我们的ScriptableObject使用直接的GameObject预制体引用来定义桌子。因为那些ScriptableObject被加载,每个桌子预制体(及其相关粒子系统、网格和纹理)都被永久固定在内存中。
时候到了进行紧急的Addressables重构。
移动到Addressables & 预热
首先,我们删除了旧的Resources文件夹,并将所有内容移到一个专门的游戏数据文件夹中。(友好的提醒:任何在Resources文件夹中的内容都会被锁定在内存中,Unity已经在多年前要求我们停止使用它。)那里没有多少东西,但任何内容都是一种坏主意。
接下来,我们在我们的ScriptableObject中用AssetReferenceGameObject替换了原始的GameObject序列化字段。这保持了在检查器中使用的漂亮拖放工作流,但阻止了Unity强制加载资产到内存中。
因为Addressables异步加载,实例化它们会在资产从磁盘加载时造成微小的抖动。为了保持对玩家的流畅体验,我们编写了一个预热系统,在转场屏幕后台加载下一个桌子。
这是简化的预热、释放和异步实例化的方式,使用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>());
};
}
回报
通过解耦我们的预制体与数据容器,我们从内存中清除了数百个不需要的对象,剩下了只有一个激活的桌子。
结果立即显现:
- 粒子数量:下降了超过30,000个对象。
- 编辑器内存:报告了巨大的3.02GB减少。
- Steam Deck指标:将我们带到了2.9GB VRAM和6.9GB RAM(这是我们后来进行的GPU优化的完美基准值!)。
从玩家的角度来看,过渡是完全不可察觉的,但硬件却在大呼救命。
如果你正在构建一个内容丰富的游戏,请注意你的ScriptableObject引用!
评论 (0)