# SceneManagement in Unity 6 **Created for:** Unity 6 (6000.x). Uses `UnityEngine.SceneManagement`, uGUI for the loading screen, and Unity 6 `Awaitable` for async code. You'll build a **persistent bootstrap scene** that never unloads, with gameplay scenes loaded **additively** on top, a **fade transition**, a **loading screen with real progress**, and a **ScriptableObject** to hand data from one scene to the next. This is the same idea as [[Level Manager]], but structured for larger projects. Docs: [SceneManager](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/SceneManagement.SceneManager.html) · [LoadSceneAsync](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/SceneManagement.SceneManager.LoadSceneAsync.html) · [AsyncOperation](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/AsyncOperation.html) · [allowSceneActivation](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/AsyncOperation-allowSceneActivation.html) · [UnloadSceneAsync](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/SceneManagement.SceneManager.UnloadSceneAsync.html) · [Awaitable](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Awaitable.html) ## Key concepts - **Single vs Additive.** `LoadSceneMode.Single` unloads everything else; `LoadSceneMode.Additive` stacks the new scene on top of the loaded ones. - **Active scene.** Exactly one loaded scene is "active": newly `Instantiate`d objects go there, and its Lighting settings (skybox, ambient) are used. After an additive load you usually call `SceneManager.SetActiveScene`. - **Progress quirk.** With `allowSceneActivation = false`, `AsyncOperation.progress` stops at **0.9** and `isDone` stays false until you set it back to `true`. Normalise progress as `progress / 0.9f`. - **Scenes must be in the build list** (**File → Build Profiles → Scene List**) to load by name or index. ## Step 1 - Scenes Create and add to the Scene List in this order: | Index | Scene | Contents | |---|---|---| | 0 | `Bootstrap` | `SceneLoader`, `Fader` canvas, `LoadingScreen` canvas, EventSystem, persistent audio | | 1 | `MainMenu` | menu UI, its own camera | | 2 | `Level01` | gameplay, camera, lights | | 3 | `Level02` | gameplay | Bootstrap has **no camera** of its own (or a camera that the loader disables) so it doesn't fight the content scenes. Each content scene has one EventSystem *only if* Bootstrap doesn't - pick one place (Bootstrap is best). ## Step 2 - Scene handoff data (ScriptableObject) See [[ScriptableObject Architecture]] for the pattern. This asset carries "where should I go and with what" between scenes. ```csharp using System; using UnityEngine; [CreateAssetMenu(fileName = "SceneHandoff", menuName = "Game/Scene Handoff")] public class SceneHandoff : ScriptableObject { [NonSerialized] public string spawnPointId; [NonSerialized] public int score; [NonSerialized] public string previousScene; void OnEnable() => Clear(); public void Clear() { spawnPointId = null; score = 0; previousScene = null; } } ``` Create one asset: **Create → Game → Scene Handoff**. Because the Bootstrap scene's `SceneLoader` references it, it stays loaded for the whole session (an unreferenced SO could be unloaded and lose its data). ## Step 3 - Fader ```csharp using UnityEngine; [RequireComponent(typeof(CanvasGroup))] public class Fader : MonoBehaviour { [SerializeField] float duration = 0.35f; CanvasGroup group; void Awake() { group = GetComponent<CanvasGroup>(); group.alpha = 0f; group.blocksRaycasts = false; } public async Awaitable FadeTo(float target) { group.blocksRaycasts = true; // swallow clicks during transition float start = group.alpha, t = 0f; while (t < duration) { t += Time.unscaledDeltaTime; // works while the game is paused group.alpha = Mathf.Lerp(start, target, t / duration); await Awaitable.NextFrameAsync(); } group.alpha = target; group.blocksRaycasts = target > 0.01f; } } ``` Setup: in Bootstrap create **UI → Canvas** named `FaderCanvas` (Sort Order 100), add a full-screen black **Image**, add `CanvasGroup` and `Fader` to the canvas. ## Step 4 - Loading screen ```csharp using TMPro; using UnityEngine; using UnityEngine.UI; public class LoadingScreen : MonoBehaviour { [SerializeField] GameObject root; [SerializeField] Slider bar; [SerializeField] TMP_Text label; void Awake() => Hide(); public void Show(string sceneName) { root.SetActive(true); bar.value = 0f; label.text = quot;Loading {sceneName}..."; } public void SetProgress(float normalized) => bar.value = normalized; public void Hide() => root.SetActive(false); } ``` Setup: canvas `LoadingCanvas` (Sort Order 90, below the fader), child panel `Root` with a **Slider** (disable Interactable, remove handle) and a **TextMeshPro** label. Assign the references. ## Step 5 - SceneLoader ```csharp using System; using UnityEngine; using UnityEngine.SceneManagement; public class SceneLoader : MonoBehaviour { public static SceneLoader Instance { get; private set; } [SerializeField] string firstScene = "MainMenu"; [SerializeField] Fader fader; [SerializeField] LoadingScreen loadingScreen; [SerializeField] SceneHandoff handoff; [SerializeField] float minimumLoadingTime = 0.5f; // avoid a one-frame flash public event Action<string> ContentSceneLoaded; string currentContentScene; bool busy; void Awake() { if (Instance != null) { Destroy(gameObject); return; } Instance = this; // Bootstrap is never unloaded, so DontDestroyOnLoad isn't required here. } async void Start() { // If you pressed Play inside a content scene (see Step 7), it's already loaded. for (int i = 0; i < SceneManager.sceneCount; i++) { var s = SceneManager.GetSceneAt(i); if (s != gameObject.scene) { currentContentScene = s.name; SceneManager.SetActiveScene(s); return; } } await LoadAsync(firstScene); } public async void Load(string sceneName) { try { await LoadAsync(sceneName); } catch (Exception e) { Debug.LogException(e); busy = false; } } public async Awaitable LoadAsync(string sceneName) { if (busy) return; if (!Application.CanStreamedLevelBeLoaded(sceneName)) { Debug.LogError(quot;Scene '{sceneName}' is not in the Scene List."); return; } busy = true; await fader.FadeTo(1f); loadingScreen.Show(sceneName); await fader.FadeTo(0f); float startTime = Time.unscaledTime; // 1. Unload the old content scene. if (!string.IsNullOrEmpty(currentContentScene)) { handoff.previousScene = currentContentScene; await SceneManager.UnloadSceneAsync(currentContentScene); } // 2. Load the new one additively, holding activation. AsyncOperation op = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive); op.allowSceneActivation = false; while (op.progress < 0.9f) { loadingScreen.SetProgress(op.progress / 0.9f); await Awaitable.NextFrameAsync(); } loadingScreen.SetProgress(1f); while (Time.unscaledTime - startTime < minimumLoadingTime) await Awaitable.NextFrameAsync(); // 3. Activate (Awake/OnEnable/Start of the new scene run around here). op.allowSceneActivation = true; await op; // AsyncOperation is awaitable in Unity 6 Scene loaded = SceneManager.GetSceneByName(sceneName); SceneManager.SetActiveScene(loaded); currentContentScene = sceneName; // 4. Free assets the old scene used. await Resources.UnloadUnusedAssets(); await fader.FadeTo(1f); loadingScreen.Hide(); await fader.FadeTo(0f); busy = false; ContentSceneLoaded?.Invoke(sceneName); } } ``` **Explanation of the order:** fade covers the cut to the loading screen; unloading first keeps peak memory low (load-then-unload is smoother if memory allows - swap steps 1 and 2); holding activation lets the bar reach 100% and enforces a minimum display time; `SetActiveScene` makes the new scene's lighting apply and puts runtime-spawned objects in it so they're unloaded with it. Setup: on a `SceneLoader` GameObject in Bootstrap, assign Fader, LoadingScreen and the `SceneHandoff` asset. ## Step 6 - Using it from gameplay Door/portal that passes a spawn point: ```csharp using UnityEngine; public class ScenePortal : MonoBehaviour { [SerializeField] string targetScene = "Level02"; [SerializeField] string targetSpawnId = "FromLevel01"; [SerializeField] SceneHandoff handoff; void OnTriggerEnter(Collider other) { if (!other.CompareTag("Player")) return; handoff.spawnPointId = targetSpawnId; SceneLoader.Instance.Load(targetScene); } } ``` Spawn point that reads it in the destination scene: ```csharp using UnityEngine; public class SpawnPoint : MonoBehaviour { [SerializeField] string id = "FromLevel01"; [SerializeField] SceneHandoff handoff; [SerializeField] Transform player; void Start() { if (handoff.spawnPointId != id) return; player.SetPositionAndRotation(transform.position, transform.rotation); handoff.spawnPointId = null; // consume it } } ``` Menu button: hook a Button's OnClick to a tiny script calling `SceneLoader.Instance.Load("Level01")` (the loader lives in another scene, so you can't drag it into the OnClick slot directly). ```csharp using UnityEngine; public class LoadSceneButton : MonoBehaviour { [SerializeField] string sceneName; public void Go() => SceneLoader.Instance.Load(sceneName); } ``` ## Step 7 - Always start from Bootstrap in the Editor Pressing Play in `Level01` would skip Bootstrap. This Editor script loads Bootstrap first, then the loader's `Start` (Step 5) detects the already-open content scene. Put it in an `Editor` folder. ```csharp #if UNITY_EDITOR using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine.SceneManagement; [InitializeOnLoad] static class BootstrapPlayModeLoader { const string BootstrapPath = "Assets/_Project/Scenes/Bootstrap.unity"; static BootstrapPlayModeLoader() { EditorApplication.playModeStateChanged += OnPlayModeChanged; } static void OnPlayModeChanged(PlayModeStateChange state) { if (state != PlayModeStateChange.ExitingEditMode) return; if (SceneManager.GetSceneByPath(BootstrapPath).isLoaded) return; if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) { EditorApplication.isPlaying = false; return; } // Open Bootstrap additively in the Editor so both are loaded when Play starts. var boot = EditorSceneManager.OpenScene(BootstrapPath, OpenSceneMode.Additive); EditorSceneManager.MoveSceneBefore(boot, SceneManager.GetSceneAt(0)); } } #endif ``` (An alternative is `EditorSceneManager.playModeStartScene`, which always starts in a given scene but replaces, rather than keeps, the scene you were editing.) Docs: [EditorSceneManager](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/SceneManagement.EditorSceneManager.html) · [playModeStartScene](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/SceneManagement.EditorSceneManager-playModeStartScene.html) ## Step 8 - SceneManager events ```csharp using UnityEngine; using UnityEngine.SceneManagement; public class SceneEventLogger : MonoBehaviour { void OnEnable() { SceneManager.sceneLoaded += OnLoaded; SceneManager.sceneUnloaded += OnUnloaded; SceneManager.activeSceneChanged += OnActiveChanged; } void OnDisable() { SceneManager.sceneLoaded -= OnLoaded; SceneManager.sceneUnloaded -= OnUnloaded; SceneManager.activeSceneChanged -= OnActiveChanged; } void OnLoaded(Scene s, LoadSceneMode mode) => Debug.Log(quot;Loaded {s.name} ({mode})"); void OnUnloaded(Scene s) => Debug.Log(quot;Unloaded {s.name}"); void OnActiveChanged(Scene from, Scene to) => Debug.Log(quot;Active: {from.name} -> {to.name}"); } ``` - `sceneLoaded` fires **after** the scene's objects get `Awake`/`OnEnable` but **before** `Start`. - These are static events: always unsubscribe, especially with domain reload disabled. Docs: [sceneLoaded](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/SceneManagement.SceneManager-sceneLoaded.html) · [activeSceneChanged](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/SceneManagement.SceneManager-activeSceneChanged.html) ## Moving objects between scenes - `DontDestroyOnLoad(go)` moves a root object into the special DontDestroyOnLoad scene. With a bootstrap scene you rarely need it. - `SceneManager.MoveGameObjectToScene(go, scene)` moves a **root** GameObject into another loaded scene (e.g. carry the player from Level01 to Level02 before unloading Level01). ## Common pitfalls 1. **Progress stuck at 0.9** - expected with `allowSceneActivation = false`; normalise by 0.9. 2. **While activation is held, other async loads queue behind it**, including `UnloadSceneAsync`. Don't start a second load until the first is activated. 3. **Wrong lighting / objects spawning in the wrong scene** - you forgot `SetActiveScene` after an additive load. 4. **Duplicate EventSystems, AudioListeners, cameras** across additive scenes - keep one owner per singleton-ish component. 5. **Cross-scene references** can't be serialised. Use SOs (handoff/event channels), a static locator like `SceneLoader.Instance`, or lookup on `sceneLoaded`. 6. **`async void` swallowing exceptions** - wrap in try/catch as `Load` does, and never `await` the same `Awaitable` twice (Awaitables are pooled). 7. **Destroyed objects after await** - if the scene owning a script unloads while it awaits, check `this != null` (or use `destroyCancellationToken`) before touching it. 8. **Memory** - unloading a scene doesn't free assets; call `Resources.UnloadUnusedAssets()` (it's slow; do it behind the loading screen). ## Related - [[ScriptableObject Architecture]] - [[Level Manager]]