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]]