1
\$\begingroup\$

I'm trying to create a simple scene transition. On entering a collider, there is a black canvas that's slowly faded in over the duration of 3 seconds. Then, a new scene is loaded, and the scene slowly fades from black to invisible. Now, I've almost finished it. However, I'm running into a small problem I cannot seem to fix.

The animation consists out of two text elements and one image. The hierarchy looks like this:

enter image description here

SceneLoader is an empty object, SceneFader is a canvas. Image is a black image stretched across the screen, and RegionName and RegionSubtext are two text elements that are filled as soon as the transition begins.

The code I'm using to load the scene is this:

private IEnumerator _loadSceneWithTransition(string sceneToLoad, Vector3 spawnCoordinates, string regionName, string regionSubtext) {
    this.screenFade.SetTrigger(SceneLoader.AnimatorTrigger);

    GameObject.Find("SceneLoader/SceneFader/Image/RegionName").GetComponent<Text>().text = regionName;
    GameObject.Find("SceneLoader/SceneFader/Image/RegionSubtext").GetComponent<Text>().text = regionSubtext;

    yield return new WaitForSeconds(4f);

    SceneManager.LoadScene(sceneToLoad);

    GameObject.Find("SceneLoader/SceneFader/Image/RegionName").GetComponent<Text>().text = regionName;
    GameObject.Find("SceneLoader/SceneFader/Image/RegionSubtext").GetComponent<Text>().text = regionSubtext;

    GameObject.Find("Player").GetComponent<PlayerMovementController>().spawnCharacterToLocation(spawnCoordinates);
}

Now, this doesn't work as I'd expect it to. The first part runs perfectly fine: The animation is started, the text is displayed and the scene changes. However, that's the part where everything breaks. As soon as the scene loads, there is no text. Also, the player position is not set to where it should be.

I really do not know why, the only reason I could think of was that the script was called in the first scene, and therefore, the GameObject.Find() calls return the elements in that scene, not in the currently used one (actually, I'm fairly sure of that because GetInstanceID() returns the same for both calls). Now, the problem at this part is that I do not know how to make that work properly. One idea I had was to do try and fetch a new instance of the script with something like: GameObject.Find("SceneLoader").GetComponent<BaseSceneLoader>() and work with that, but then I remembered I still need GameObject.Find() for this, which would probably yield the result of the old scene, so it's not helping at all. On top of that, I'm not even sure if that is the actual problem.

I'm kinda out of ideas here. I also do not know what's the best practice in that situation, and I couldn't find anything meaningful on DDG either.

Lastly, I've created a small video clip showing off the problem. I hope this helps in identifying the issue.

https://i.sstatic.net/eds9b.jpg

\$\endgroup\$
4
  • 1
    \$\begingroup\$ And this is why I advise Unity devs to never look up objects by name. It is not a reliable, bug-resistant approach \$\endgroup\$
    – DMGregory
    May 29, 2020 at 19:36
  • \$\begingroup\$ @DMGregory So, you're saying I should tag the regionName and regionSubtext and use the tags instead of the name? I can't really come up with another idea of how to find a specific game object in my scene, and always thought that, if I have like 250 bazillion tags, the entire game will become extremely messy. \$\endgroup\$ May 29, 2020 at 19:40
  • \$\begingroup\$ Tags are only marginally better than names. Better yet, use hard references, events, and the like. I'll demonstrate in an answer once I'm off the clock tonight, if no one else beats me to it. ;) \$\endgroup\$
    – DMGregory
    May 29, 2020 at 19:42
  • \$\begingroup\$ That would be amazing, because I have no idea what a "hard reference" is, and I'm also unsure how an event could help me find a specific object, still fairly new to unity, but I'll gladly take your advice once you're off :) \$\endgroup\$ May 29, 2020 at 19:45

1 Answer 1

2
\$\begingroup\$

The reason this happens is due to a few factors:

  • Scene loads in Unity are really just a shorthand for "destroy all the objects in the old scene I'm unloading, and instantiate all the objects in the new scene I'm loading"

  • Destroying an object in Unity doesn't delete it instantaneously or put it into an invalid state (that could make systems blow up if they're still trying to use it this frame), instead it just marks the object to-be-destroyed, then actually removes it at the end of the frame to make a clean break.

  • Unity callback methods and coroutines without a yield in-between will run all the way to completion in the frame they're called, even if there's a major/time-consuming operation in the middle of them (that will just delay the frame rendering until after the method finishes its work)

  • Find() methods search through the open scenes in order, and return the first match they find.

Bringing that all together: when you call SceneManager.LoadScene(), you mark all objects in the current scene to be destroyed, then instantiate all the items for the new scene. Immediately after that, without waiting a frame, you ask GameObject.Find() to give you the first object it finds with a particular name - which is still the object from the previous scene, because we haven't reached the end of the frame yet when all those marked objects actually get removed, and all the new objects have spawned after them in the hierarchy.

A quick and dirty fix you can try is to just wait a frame between loading the scene and wiring up your new references. Then the old scene's objects have actually been destroyed, and won't pollute your Find() search. That can be accomplished with a simple yield statement (assuming this script itself survives across the scene load - it's not clear to me which object it's sitting on), something like this:

// ...

yield return new WaitForSeconds(4f);

SceneManager.LoadScene(sceneToLoad);

// Wait one frame for the junk from the old scene to be cleared away.
yield return null;

// From here on in, only the new scene is present!
GameObject.Find("SceneLoader/SceneFader/Image/RegionName").GetComponent<Text>().text = regionName;
GameObject.Find("SceneLoader/SceneFader/Image/RegionSubtext").GetComponent<Text>().text = regionSubtext;

GameObject.Find("Player").GetComponent<PlayerMovementController>().spawnCharacterToLocation(spawnCoordinates);

But this is pretty brittle.

A better solution is to not destroy and recreate things when you could just keep them around!

For example, we can set aside a separate scene for loading. We'll put our scene loader script and its fade-in/out UI elements there. The script can hold a direct reference to the UI elements it needs, that you wire up ahead of time in the Inspector, so it doesn't need to search for them at runtime. Something like this:

public class SceneLoader : MonoBehaviour {

    // Quick & dirty singleton pattern, to make it easy for other scripts to find this one.
    public static SceneLoader instance { get; private set; }

    void Awake() {
        if (instance != null && instance != this) {
            Debug.LogError("You have two scene loaders present when there should be only one!");
        }
        instance = this;
    }

    // Inspector fields - way better than searching!
    public Text regionNameField;
    public Text regionSubtextField;

    public Animator screenFade;
    public string animatorTrigger;

    // Keep track of the last scene we loaded, so we know which one to unload later.
    Scene _currentScene;

    public static void LoadScene(string sceneToLoad, Vector3 spawnCoordinates,
                                 string regionName, string regionSubtext) {
        Scene scene = SceneManager.GetSceneByName(sceneToLoad);
        instance.StartCoroutine(instance.LoadScene(scene, spawnCoordinates, 
                                                   regionName, regionSubtext));
    }

    IEnumerator LoadScene(Scene nextScene, Vector3 spawnCoordinates, 
                          string regionName, string regionSubtext) {
        screenFade.SetTrigger(animatorTrigger);

        yield return new WaitForSeconds(4f);

        if(_currentScene != null)
            SceneManager.UnloadSceneAsync(_currentScene);

        yield return null;

        SceneManager.Load(nextScene, LoadSceneMode.Additive);
        _currentScene = nextScene;

        // No searching! Since we always keep the same ones, we can just use our cached references.
        regionNameField.text = regionName;
        regionSubtextField.name = regionSubtext;

        // One little search, but at least it's not by name. ;)
        // Or, as an alternative, PlayerMovementController's Awake/Start 
        // can ask for SceneLoader.instance.GetSpawnCoordinates();
        FindObjectOfType<PlayerMovementController>().SpawnCharacterToLocation(spawnCoordinates);
    }
}

Notice that by using additive scene loading, we don't automatically remove all the current loaded scenes. That gives us the power to pick and choose what we want to unload when. Here, we unload _currentScene, but crucially, not the loader scene that this script and all its UI elements live in. That helps with efficiency - we're not destroying and recreating - Reduce Reuse Recycle! And it also keeps our Inspector references stable, so we don't have to scramble to find our new dependencies in the new scene.

\$\endgroup\$
5
  • \$\begingroup\$ Welp, that's an extensive reply. I would lie if I said I understood that all. However, I have two questions 1. You said: "assuming this script itself survives across the scene load - it's not clear to me which object it's sitting on" - Does that mean that, if a game object holding a script is destroyed, the script just exits without finishing? 2. If I use a seperate scene for loading, how would I be able to get that fade out effect? If I switch to another scene without this animation, I'd just immediatly jump to black, or do I misunderstand something here? \$\endgroup\$ May 29, 2020 at 23:30
  • \$\begingroup\$ 1) If an object running a coroutine is destroyed, there's nothing to resume the coroutine next frame. It won't interrupt the method except at a yield statement where the coroutine is suspended. 2) The animation/black card is in your loading scene, so it's not in either the outgoing nor incoming scene. It gets to exist over the whole duration of the transition. \$\endgroup\$
    – DMGregory
    May 30, 2020 at 0:03
  • \$\begingroup\$ Aaaaah, so with LoadSceneMode.Additive I can have two or more scenes in the game at once. That's something I didn't know about, now I understood what you're doing. So, basically, you're using one "MainScene" which is loading and unloading every other scene itself, instead of transitioning between the scenes, right? \$\endgroup\$ May 30, 2020 at 0:09
  • \$\begingroup\$ You got it spot on. :) \$\endgroup\$
    – DMGregory
    May 30, 2020 at 0:18
  • 1
    \$\begingroup\$ Aight, amazing. I'm not as dumb as I thought. The code does work like a charm, I had to make a few adjustments, but in general, it's working. Thank's a lot! \$\endgroup\$ May 30, 2020 at 0:31

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .