All tools
SceneManagerAsyncShader

Scene Transition Lab

Cover, load, activate, reveal — and the .unitypackage that does it.

idle · 1570 ms
Cover
Load
Reveal
0.9
activate
0 msFade · coroutine1570 ms

AsyncOperation.progress

op.progress0.00

stalls at 0.9 — the last tenth is the activation

what the player sees0.00

op.progress / 0.9, clamped

Total
1570 ms
Covered
990 ms
screen fully hidden
Waiting on you
0 ms
min display padding
Activation
90 ms
hidden under cover

What breaks

  • No known failure modes in this configuration.

Presets

The default that never looks wrong. Short, unscaled, blocks input.

Transition

Shape

One alpha tween on a full-screen Image. No material, no overdraw beyond the quad.

Timing
260 ms
320 ms
300 ms

Load

Async

Coroutine (no dependency)

900 ms
Behaviour
// Assets/SceneTransition/Runtime/SceneTransitionManager.cs
//
// Generated by burakkaan.dev/tools/scene-transition-lab
//
// One object owns the whole scene change: the cover, the async load, the
// minimum display time, the activation, and the reveal. It lives in
// DontDestroyOnLoad and bootstraps itself from Resources, so no scene has
// to carry a reference to it.

using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using System.Collections;

[DisallowMultipleComponent]
public class SceneTransitionManager : MonoBehaviour
{
    public static SceneTransitionManager Instance { get; private set; }

    [Header("Preset")]
    public TransitionPreset preset;

    [Header("Refs")]
    [Tooltip("Full-screen Image. Raycast Target must stay on — it is what blocks input.")]
    public Image overlay;
    public CanvasGroup group;
    [Tooltip("Optional. Image with Type = Filled, Fill Method = Horizontal.")]
    public Image progressFill;

    CanvasGroup _group;
    bool _busy;

    /// <summary>Raised with 0..1 while a load is running. Hook your label to it.</summary>
    public event System.Action<float> ProgressChanged;

    /// <summary>True between the first cover frame and the last reveal frame.</summary>
    public bool IsTransitioning => _busy;

    /* --------------------------------------------------------------- *
     * Bootstrap
     * --------------------------------------------------------------- */

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void Bootstrap()
    {
        if (Instance != null) return;
        var prefab = Resources.Load<GameObject>("TransitionCanvas");
        if (prefab == null)
        {
            Debug.LogWarning("[SceneTransition] Resources/TransitionCanvas.prefab is missing. " +
                             "Run Tools > Scene Transition > Create Transition Canvas.");
            return;
        }
        Instantiate(prefab);
    }

    void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);

        _group = group != null ? group : GetComponentInChildren<CanvasGroup>();

        SetCover(0f);
    }

    /* --------------------------------------------------------------- *
     * Entry points
     * --------------------------------------------------------------- */

    public void Load(string sceneName)
    {
        if (_busy)
        {
            // A second request mid-transition is almost always a double click.
            Debug.LogWarning($"[SceneTransition] Ignored Load({sceneName}) — already transitioning.");
            return;
        }
        StartCoroutine(RunAsync(sceneName));
    }

    IEnumerator RunAsync(string sceneName)
    {
        _busy = true;
        SetBlocking(true);

        yield return Cover(true);

        var op = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Single);
        // The load is not allowed to finish on its own: activation is what
        // costs the frame, and it has to land while the screen is covered.
        op.allowSceneActivation = false;

        float held = 0f;
        float minHold = preset != null ? preset.minDisplaySeconds : 0.300f;

        // progress stalls at 0.9 with activation disabled — the last tenth IS
        // the activation. Divide by 0.9 before showing it to a player.
        while (op.progress < 0.9f || held < minHold)
        {
            held += Time.unscaledDeltaTime;
            Report(op.progress);
            yield return null;
        }
        Report(1f);

        op.allowSceneActivation = true;
        while (!op.isDone) { yield return null; }

        // One frame of grace. Awake, Start, the first Update and shader warm-up
        // all land on the activation frame; revealing on top of it shows the hitch.
        yield return null;

        yield return Cover(false);

        SetBlocking(false);
        _busy = false;
    }

    /* --------------------------------------------------------------- *
     * Cover / reveal
     * --------------------------------------------------------------- */

    IEnumerator Cover(bool covering)
    {
        float duration = covering
            ? (preset != null ? preset.coverSeconds : 0.260f)
            : (preset != null ? preset.revealSeconds : 0.320f);

        var curve = preset != null
            ? (covering ? preset.coverEase : preset.revealEase)
            : null;

        if (duration <= 0f)
        {
            SetCover(covering ? 1f : 0f);
            yield break;
        }

        float t = 0f;
        while (t < duration)
        {
            t += Time.unscaledDeltaTime;
            float k = Mathf.Clamp01(t / duration);
            float eased = curve != null ? curve.Evaluate(k) : k;
            SetCover(covering ? eased : 1f - eased);
            yield return null;
        }
        SetCover(covering ? 1f : 0f);
    }

    /// <summary>0 = scene visible, 1 = fully covered.</summary>
    public void SetCover(float amount)
    {
        if (_group != null) _group.alpha = amount;
    }

    void Report(float raw)
    {
        float shown = Mathf.Clamp01(raw / 0.9f);
        if (progressFill != null) progressFill.fillAmount = shown;
        ProgressChanged?.Invoke(shown);
    }

    void SetBlocking(bool on)
    {
        if (_group != null)
        {
            _group.blocksRaycasts = on;
            _group.interactable = on;
        }
        if (overlay != null) overlay.raycastTarget = on;
    }

}

The whole scene change in one file. Drop it in, run the menu item, call SceneTransitionManager.Instance.Load("Level_01").

Download

A real .unitypackage — scripts, shader, mask and the editor menu item, with GUIDs already assigned so the references survive the import.

  • Runtime/SceneTransitionManager.cs
  • Runtime/TransitionPreset.cs
  • Editor/TransitionCanvasBuilder.cs
  • README.txt

After importing, run Tools > Scene Transition > Create Transition Canvas once. The prefab is built by that script rather than shipped, so it can never land as a missing-script placeholder.