我最后一篇关于Steam Deck减少GPU负载的文章的回应远远超过了我的期望,所以我想分享一下快速的跟进文章!

我的最后一篇文章谈到了我们最近取得的GPU突破,但今天我想揭开我们几周前对我们的游戏Spooker进行的大规模内存审核的帷幕(基于我的手机屏幕截图,时间约为5月14日)。

昨天的文章有一点点的剧透,展示了我们的当前内存,舒适地坐在 2.4GB VRAM 和 6.4GB RAM 上。但是在中五月,情况却非常糟糕。我们被膨胀到了 4.3GB VRAM 和 7.9GB RAM

下面是我们如何挖出这个洞穴。

为什么 RAM 和 VRAM 在 Steam Deck 上重要

与传统的 PC 设备不同,Steam Deck 使用 统一内存架构

TL;DR: GPU 没有自己的物理内存池。相反,CPU 和 GPU 动态共享一个 16GB 快速 RAM 的单个池。

因为它们共享同一物理高速公路,CPU 的 RAM 过载可以直接挤压 GPU,导致巨大的性能下降和卡顿。如果您想在 Deck 上保持 60fps,绝对必须尊严地对待共享池。

步骤 1:打破内存剖析的“基准规则”

内存剖析的第一条规则永远是:“在目标硬件上进行剖析。”我违反了它。首先打开 Unity 编辑器剖析器,看看是否有任何明显、低于 42.7MB 的可视化胜利。

好吧,我们找到了它们

  1. 纹理膨胀: 我们在开发过程中进行了一些拆卸工作,立即看到一大堆 2K 纹理和法线图像,每个都有 42.7MB。我们需要保持它们在 PC 玩家中保持清晰,但它们正在杀死 Deck。
  2. 颗粒噩梦: 剖析器报告了惊人的 1.47GB 颗粒32,648 颗粒对象 在启动时存活在内存中。我重启了 Unity,运行它一次。同样的结果。绝对恐慌模式。

纹理问题的解决方案:多重映射流

为了解决纹理问题而不损失 PC 质量,我们启用了 Unity 的 多重映射流

我快速在我们的主资产目录中进行了 t:纹理 搜索,选择了我们的重资产,启用了 生成多重映射(根据它们在游戏中是否至关重要的优先级分配)。然后,我进入了项目设置,启用了多重映射流,并设置了流动预算为 2048

如果您对多重映射的概念只是“纹理的 LOD,远处使用小版本”,您完全正确。但通常,Unity 还是强制将整个文件(包括巨大的 2K 原始文件)加载到内存中,以防您走近它

启用多重映射流改变了它,以便 Unity 只加载特定的低分辨率或高分辨率片段,而不需要加载整个文件。如果一个桌子正面对您,Unity 就会给您一个清晰的 2K 纹理;如果它远处,Unity 就不会将重资产加载到内存中。

然后,它缓存这些纹理在 GPU 上,以便不需要不断从磁盘中拉取它们,这对于保持 Steam Deck 的共享 RAM 池不被高分辨率资产压制至关重要。

总而言之,这样做允许 Unity 根据相机距离计算出实际需要的分辨率多重映射,并在内存紧张时流式传输较低分辨率的片段。它缓存这些纹理在 GPU 上以节省磁盘到 CPU 周期,这对移动和手持芯片来说是一个巨大的胜利。

颗粒问题的解决方案:杀死脚本对象陷阱

接下来就是那可怕的 1.47GB 颗粒泄漏。

背景:我们的架构相当干净(至少从主观上来说):我们使用一个单独的启动场景,运行 VContainer,在场景之间注册跨场景依赖项作为 POCO。每个个体游戏场景都以子时间范围加载。

那么为什么在启动时内存会被淹没?

我们的游戏特点是拥有大量不同桌子的池(类似于迷你高尔夫布局,但用于池)。当检查环境集合时,我注意到加载到新桌子时 绝不会改变 内存。

罪魁祸首: 我们的脚本对象直接使用 GameObject 前缀引用来定义桌子。因为这些脚本对象被加载, 每个桌子前缀(以及它们相关的颗粒系统、网格和纹理)都被固定在内存中

是时候进行紧急的 Addressables 重构了。

移动到 Addressables 和预热

首先,我们删除了旧的 Resources 文件夹并将其移动到一个专门的游戏数据文件夹中。 (友好的提示:任何在 Resources 文件夹中的内容都会被锁定在内存中,Unity 已经在多年来向我们呼吁不要使用它。) 在这里没有多少东西,但任何在此文件夹中的内容都是一个坏主意。

接下来,我们在我们的 ScriptableObject 中将原始 GameObject 序列化字段替换为 AssetReferenceGameObject。这保持了在调试器中使用的漂亮拖放工作流,但停止 Unity 强制将资产加载到内存中。

因为 Addressables 在异步加载,实例化它们会在资产从磁盘加载时引起微小的卡顿。为了让玩家体验保持流畅,我们编写了一个 预热系统 来在转场屏幕后台加载下一个桌子。

以下是简化的预热、释放和异步实例化的示例:

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 优化的理想基准。

从玩家的角度来看,转场是完全不可察觉的,但硬件正在呼吸一口气。

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