【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}] 資源釋放完成");}}
}