我上一篇关于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编辑器剖析器,看看是否有任何大规模、显著、低悬挂的可视化胜利。

并且,哦天哪,我们找到了它们

  1. 纹理膨胀:我们之前的开发中做了些混杂的工作,我马上就看到了一大堆2K纹理和法线图像,每个图像都在42.7MB,分布在各种材料上。我们需要保留它们以便PC玩家,但它们正在杀死Steam Deck。
  2. 粒子噩梦:剖析器报告了一个令人惊讶的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优化的完美基准值)。

从玩家角度来看,过渡是完全无感知的,但硬件正在呼吸一个巨大的呼吸。

如果你正在构建一个内容丰富的游戏,请注意你的脚本对象引用!