【Unity优化】Unity多场景加载优化与资源释放完整指南:解决Additive加载卡顿、预热、卸载与内存释放问题
【Unity优化】Unity多场景加载优化与资源释放完整指南:解决Additive加载卡顿、预热、卸载与内存释放问题
本文将完整梳理 Unity 中通过
SceneManager.LoadSceneAsync
使用 Additive 模式加载子场景时出现的卡顿问题,分析其本质,提出不同阶段的优化策略,并最终实现一个从预热、加载到资源释放的高性能、低内存场景管理系统。本文适用于(不使用Addressables 的情况下)需要频繁加载子场景的 VR/AR/大地图/分区模块化项目。
前文主要是一些发现问题,解决问题的文档记录。
查看源码,请跳转至文末!
文章目录
- 【Unity优化】Unity多场景加载优化与资源释放完整指南:解决Additive加载卡顿、预热、卸载与内存释放问题
- 一、问题起点:LoadSceneAsync 导致的卡顿
- 二、卡顿原因分析
- 三、常规优化尝试
- 1. allowSceneActivation = false
- 2. 延迟帧 / 加载动画
- 四、核心解决方案:预热 + 资源卸载
- 1. 什么是场景预热(Prewarm)?
- 2. 场景资源未释放问题
- 五、完善场景管理系统:SceneFlowManager
- 1. 支持配置化管理 EqSceneConfig
- 2. 支持 Key 方式加载
- 3. 支持场景预热接口
- 六、新增释放资源接口
- 七、完整流程总结
- 八、性能实测对比
- 九、扩展:自动预热与内存调度
- 十、结语:让 Unity 多场景系统真正高效
- 1. 总结
- 2. 源码
一、问题起点:LoadSceneAsync 导致的卡顿
在项目开发过程中,当我们使用如下代码进行 Additive 场景加载时:
AsyncOperation asyncLoad = SceneManager.LoadSceneAsync("YourScene", LoadSceneMode.Additive);
你会发现:
- 第一次加载某个场景时卡顿极为明显;
- 后续加载相同场景不卡顿,表现正常;
- 即使使用
allowSceneActivation = false
先加载至 0.9,再激活,也无法解决卡顿。
二、卡顿原因分析
Unity 场景加载包括两个阶段:
- 资源加载阶段(读取场景所需的纹理、Mesh、Prefab 等)
- 激活阶段(触发 Awake/Start、构建场景结构)
而第一次加载时会触发:
- Shader Compile
- 静态 Batching
- Occlusion Culling 计算
- 实例化所有场景对象
这些过程即使异步,也依然可能在 allowSceneActivation=true
时集中执行,导致帧冻结。
三、常规优化尝试
1. allowSceneActivation = false
asyncLoad.allowSceneActivation = false;
while (asyncLoad.progress < 0.9f) yield return null;
yield return new WaitForSeconds(0.5f);
asyncLoad.allowSceneActivation = true;
结果:激活时依旧卡顿。
2. 延迟帧 / 加载动画
只能缓解体验,不能真正解决第一次激活的卡顿。
四、核心解决方案:预热 + 资源卸载
1. 什么是场景预热(Prewarm)?
在用户进入目标场景之前,提前加载该场景、触发资源加载、初始化内存,再卸载掉。
这样用户真正进入场景时:
- 所有资源都在缓存中(Unity 会延后释放)
- 场景结构早已解析,第二次加载快很多
IEnumerator PrewarmSceneCoroutine(string sceneName)
{var loadOp = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);loadOp.allowSceneActivation = true;while (!loadOp.isDone) yield return null;yield return null;yield return null; // 等待几帧确保初始化var unloadOp = SceneManager.UnloadSceneAsync(sceneName);while (!unloadOp.isDone) yield return null;
}
2. 场景资源未释放问题
你会发现:预热+卸载后并不会立即释放资源!
Unity 会保留一部分资源在内存中,直到调用:
Resources.UnloadUnusedAssets();
所以你必须加入如下逻辑:
yield return Resources.UnloadUnusedAssets();
五、完善场景管理系统:SceneFlowManager
在项目中,我们将所有的加载逻辑封装在 SceneFlowManager
中。
1. 支持配置化管理 EqSceneConfig
[System.Serializable]
public class EqSceneEntry
{public string key;public string sceneName;
}[CreateAssetMenu]
public class EqSceneConfig : ScriptableObject
{public List<EqSceneEntry> scenes;
}
2. 支持 Key 方式加载
public void LoadSceneAdditiveByKey(string key) => LoadSceneAdditive(GetSceneNameByKey(key));
3. 支持场景预热接口
public void PrewarmScene(string sceneName)
{if (IsSceneLoaded(sceneName)) return;StartCoroutine(PrewarmSceneCoroutine(sceneName));
}
六、新增释放资源接口
为了真正释放场景相关的资源,新增 ReleaseSceneResources
方法:
public void ReleaseSceneResources(string sceneName)
{if (IsSceneLoaded(sceneName)){StartCoroutine(UnloadAndReleaseCoroutine(sceneName));}else{StartCoroutine(ReleaseOnlyCoroutine());}
}private IEnumerator UnloadAndReleaseCoroutine(string sceneName)
{yield return SceneManager.UnloadSceneAsync(sceneName);yield return Resources.UnloadUnusedAssets();
}private IEnumerator ReleaseOnlyCoroutine()
{yield return Resources.UnloadUnusedAssets();
}
七、完整流程总结
-
项目启动时:
- 初始化 SceneFlowManager
- 预热即将访问的场景(不会激活)
-
进入新场景:
- 调用
LoadSceneAdditiveByKey(key)
平滑加载场景
- 调用
-
离开场景:
- 调用
ReleaseSceneResourcesByKey(key)
卸载并释放内存
- 调用
-
避免过早 Resources.UnloadUnusedAssets()
- 建议只在真正切场景后调用,避免误删仍在用资源
八、性能实测对比
流程 | 首次加载帧耗时 | 第二次加载帧耗时 | 内存占用 | 卡顿感受 |
---|---|---|---|---|
直接加载 | 80ms+ | 40ms+ | 300MB↑ | 明显卡顿 |
预热+加载 | 30ms↓ | 20ms↓ | 200MB | 几乎无卡顿 |
加载+释放资源 | 40ms | 40ms | 150MB↓ | 无卡顿 |
直接加载,出现卡顿(掉帧)
预热+加载,无掉帧
九、扩展:自动预热与内存调度
你可以设置:
- 定时自动预热(玩家未操作时)
- 内存压力大时调用
ReleaseSceneResources
- 按访问频率记录预热优先级
十、结语:让 Unity 多场景系统真正高效
1. 总结
本方案从 SceneManager.LoadSceneAsync
的卡顿问题出发,经历:
- allowSceneActivation 控制加载
- 手动预热场景
- 引入资源释放
最终构建了一个完整的 SceneFlowManager
。
2. 源码
完整代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;namespace Eqgis.Runtime.Scene
{public class SceneFlowManager : MonoBehaviour{public static SceneFlowManager Instance { get; private set; }[Tooltip("常驻场景名称,不参与卸载")]private string persistentSceneName;[Tooltip("场景配置文件")]public EqSceneConfig sceneConfig;private Dictionary<string, string> keyToSceneMap;public void Awake(){// 自动记录当前激活场景为 PersistentScenepersistentSceneName = SceneManager.GetActiveScene().name;Android.EqLog.d("SceneFlowManager", $"[SceneFlowManager] PersistentScene 自动设置为:{persistentSceneName}");if (Instance != null && Instance != this){Destroy(gameObject);return;}Instance = this;DontDestroyOnLoad(gameObject);InitSceneMap();}private void InitSceneMap(){keyToSceneMap = new Dictionary<string, string>();if (sceneConfig != null){foreach (var entry in sceneConfig.scenes){if (!keyToSceneMap.ContainsKey(entry.key)){keyToSceneMap.Add(entry.key, entry.sceneName);}else{Debug.LogWarning($"重复的场景 Key:{entry.key}");}}}else{Debug.LogWarning("未指定 EqSceneConfig,SceneFlowManager 无法使用 key 加载场景");}}// 根据 key 获取真实场景名private string GetSceneNameByKey(string key){if (keyToSceneMap != null && keyToSceneMap.TryGetValue(key, out var sceneName))return sceneName;Debug.LogError($"未找到 key 对应的场景名: {key}");return null;}// 通过 Key 加载 Additive 场景public void LoadSceneAdditiveByKey(string key){string sceneName = GetSceneNameByKey(key);if (!string.IsNullOrEmpty(sceneName)){LoadSceneAdditive(sceneName);}}// 通过 Key 加载 Single 场景public void LoadSceneSingleByKey(string key){string sceneName = GetSceneNameByKey(key);if (!string.IsNullOrEmpty(sceneName)){LoadSceneSingle(sceneName);}}// 通过 Key 卸载场景public void UnloadSceneByKey(string key){string sceneName = GetSceneNameByKey(key);if (!string.IsNullOrEmpty(sceneName)){UnloadScene(sceneName);}}// 加载场景名(Additive)public void LoadSceneAdditive(string sceneName){if (!IsSceneLoaded(sceneName)){//SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);StartCoroutine(LoadSceneAdditiveCoroutine(sceneName));}}// 加载场景名(Additive)private IEnumerator LoadSceneAdditiveCoroutine(string sceneName){AsyncOperation asyncLoad = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);//asyncLoad.allowSceneActivation = false;//while (asyncLoad.progress < 0.9f)//{// yield return null; // 等待加载完成(进度最多到0.9)//}//// 此时可以延迟几帧或做加载动画等处理//yield return new WaitForSeconds(0.5f);//asyncLoad.allowSceneActivation = true; // 手动激活场景// 参考:https://docs.unity3d.com/2021.3/Documentation/ScriptReference/SceneManagement.SceneManager.LoadSceneAsync.htmlwhile (!asyncLoad.isDone){yield return null;}}// 加载场景名(Single)public void LoadSceneSingle(string sceneName){if (!IsSceneLoaded(sceneName)){SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Single);}}// 卸载指定场景public void UnloadScene(string sceneName){if (sceneName == persistentSceneName) return;if (IsSceneLoaded(sceneName)){SceneManager.UnloadSceneAsync(sceneName);}}// 卸载所有非常驻场景public void UnloadAllNonPersistentScenes(){StartCoroutine(UnloadAllExceptPersistent());}private IEnumerator UnloadAllExceptPersistent(){List<string> scenesToUnload = new List<string>();for (int i = 0; i < SceneManager.sceneCount; i++){var scene = SceneManager.GetSceneAt(i);if (scene.name != persistentSceneName){scenesToUnload.Add(scene.name);}}foreach (string sceneName in scenesToUnload){AsyncOperation op = SceneManager.UnloadSceneAsync(sceneName);while (!op.isDone){yield return null;}}}public bool IsSceneLoaded(string sceneName){for (int i = 0; i < SceneManager.sceneCount; i++){if (SceneManager.GetSceneAt(i).name == sceneName)return true;}return false;}public void SetActiveScene(string sceneName){if (IsSceneLoaded(sceneName)){SceneManager.SetActiveScene(SceneManager.GetSceneByName(sceneName));}}public void SetActiveSceneByKey(string key){string sceneName = GetSceneNameByKey(key);if (!string.IsNullOrEmpty(sceneName)){SetActiveScene(sceneName);}}// 通过 Key 预热一个场景(Additive 预加载后立即卸载)public void PrewarmSceneByKey(string key){string sceneName = GetSceneNameByKey(key);if (!string.IsNullOrEmpty(sceneName)){PrewarmScene(sceneName);}}// 通过场景名预热一个场景public void PrewarmScene(string sceneName){// 若已加载,无需预热if (IsSceneLoaded(sceneName)){Debug.Log($"[SceneFlowManager] 场景 {sceneName} 已加载,跳过预热");return;}StartCoroutine(PrewarmSceneCoroutine(sceneName));}private IEnumerator PrewarmSceneCoroutine(string sceneName){Android.EqLog.d("SceneFlowManager", "[SceneFlowManager] 开始预热场景:{sceneName}");AsyncOperation loadOp = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);loadOp.allowSceneActivation = true;while (!loadOp.isDone)yield return null;// 延迟几帧以确保资源初始化完成yield return null;yield return null;Android.EqLog.d("SceneFlowManager", "[SceneFlowManager] 场景 {sceneName} 加载完毕,开始卸载");AsyncOperation unloadOp = SceneManager.UnloadSceneAsync(sceneName);while (!unloadOp.isDone)yield return null;Android.EqLog.d("SceneFlowManager", "[SceneFlowManager] 场景 {sceneName} 预热完成并卸载");}/// <summary>/// 释放指定场景对应的未被引用资源,确保卸载后内存回收/// </summary>public void ReleaseSceneResourcesByKey(string key){string sceneName = GetSceneNameByKey(key);if (!string.IsNullOrEmpty(sceneName)){ReleaseSceneResources(sceneName);}}public void ReleaseSceneResources(string sceneName){if (sceneName == persistentSceneName){Debug.LogWarning($"不能释放常驻场景[{sceneName}]的资源");return;}if (IsSceneLoaded(sceneName)){// 场景已加载,先卸载后释放资源AsyncOperation unloadOp = SceneManager.UnloadSceneAsync(sceneName);StartCoroutine(ReleaseResourcesAfterUnload(unloadOp, sceneName));}else{// 场景已卸载,直接释放资源StartCoroutine(ReleaseResourcesDirect(sceneName));}}private IEnumerator ReleaseResourcesAfterUnload(AsyncOperation unloadOp, string sceneName){yield return unloadOp;Android.EqLog.d("SceneFlowManager", $"场景 [{sceneName}] 已卸载,开始释放未使用资源");AsyncOperation unloadUnused = Resources.UnloadUnusedAssets();yield return unloadUnused;Android.EqLog.d("SceneFlowManager", $"场景 [{sceneName}] 资源释放完成");}private IEnumerator ReleaseResourcesDirect(string sceneName){Android.EqLog.d("SceneFlowManager", $"场景 [{sceneName}] 已卸载,直接释放未使用资源");AsyncOperation unloadUnused = Resources.UnloadUnusedAssets();yield return unloadUnused;Android.EqLog.d("SceneFlowManager", $"场景 [{sceneName}] 资源释放完成");}}
}