0
\$\begingroup\$

I thought I'd understood how coroutines work but I'm still experiencing problems getting them to run when I want them to run:

There are two scenes in my game: The main menu one and a game one. The main menu consists of a couple of buttons that let you choose a txt file and if you click on one of them, the "game" scene is loaded (SceneManager.LoadScene("Game");), which first thing reads the chosen txt file from HDD. Afterwards there's some other stuff that has to be finished (FinishRest()) but it's pretty quick anyway.

I want to display a loading screen while it's reading the file (no need to update it), which can take a while. Clicking on the button freezes the game in the main menu while the txt file is being read and it never displays the loading screen. That's why I put the actual reading part inside a coroutine and start it as soon as the "game" scene is loaded:

//In SciptA (derives from MonoBehaviour, is on the main camera)
void Awake() {
    loadingOperationReadTxt = StartCoroutine(ClassB.ReadTxtCoroutine());
    FinishRest();
}

//In ClassB (derives from ScriptableObject)
public IEnumerator ReadTxtCoroutine() {
    loadingPanel.SetActive(true);
    loadingPanel.GetComponentInChildren<Text>().text = "Reading txt file...";
    output = ClassC.ReadTXT(); //ClassC derives from nothing
    loadingPanel.SetActive(false);
    yield return null;
}

The problem is that it never displays the loading screen like this (I'm guessing because I don't let it tick until after I'm already done), so I tried to fix it by putting another yield return null; right before output = ClassC.ReadTXT();. Now it does display the loading screen but skips the rest of the coroutine and goes right to FinishRest(), which fails, of course, because it's missing the data from the txt file.

I want to display a loading screen but I also have to wait for the coroutine to finish before actually calling FinishRest() (I miss Tasks...). How can I accomplish this?

\$\endgroup\$
1
\$\begingroup\$

There are a couple of errors here:

  1. StartCoroutine does just that - it starts the coroutine, running it up to its next yield statement and queuing it up to resume at the appropriate time.

    It does not wait until the coroutine finishes completely before proceeding up to the next line of your Awake method, otherwise your game would freeze if you tried to start a long-running coroutine.

    If you want to delay some work like FinishRest() until a coroutine has finished, you can either put that work at the end of the coroutine itself, or yield within an outer coroutine:

    IEnumerator DoThisThenThat() {
        // Start doing "this" work, possibly spread across several frames.
        Coroutine thisWork = StartCoroutine(DoThisWork());
        // Let the game loop keep running normally
        // until "this" work has finished, before proceeding to the next line.
        yield return thisWork;
        // Now "this" work is done, and we can start "that" work.
        DoThatWork();
    }
  2. You're missing a yield somewhere inside your text-reading coroutine.

    Remember, coroutines aren't threads. They're not running in parallel with the game's main loop, but taking turns within it.

    So when you call StartCoroutine(ReadTxtCoroutine()), it's going to do all the work up to its first yield statement before returning control to the main game loop.

    Since your only yield statement is at the very end, that means everything that ReadTxtCoroutine() does happens immediately, back-to-back, without a chance for the game loop to tick or display a frame in between.

    If your ReadTXT() does some work asynchronously, you'll want to yield until that work is done before proceeding. Otherwise loadingPanel.SetActive(true) and loadingPanel.SetActive(false) will get called back-to-back in the same game frame, and you won't see the active state because it's been switched off again before the frame gets rendered.

    If ReadTXT() does its work synchronously (ie. a blocking call), then your game will stall until it's done, because you haven't given the main game loop a chance to take its turn in-between by yielding back to it. In cases like this we'll usually want to either run the blocking task on a thread instead, so it can happen in parallel, or chop it into small steps that we can yield in between so we don't impact the framerate or responsiveness.


Q&A:

Isn't there some way to make some work wait until after the coroutine finishes?

Yes. The way I showed above, where you make another coroutine call the first coroutine, yield it until it's done, then do the work that's supposed to wait.

I tried putting a yield right before ReadTXT(), which showed the loading screen but then didn't wait of course.

It sounds ReadTXT might be finishing its work asynchronously. In that case, you'll need to instrument it in some way so that your coroutine can detect when it's done and keep yielding to the game loop until then. To show you how to do that, we'll need you to show us what it's doing by including all relevant code in your question.

It's not that easy to get coroutines to return stuff according to other posts.

Programming by hearsay will get you into trouble in Unity - there's a lot of flat-out wrong advice out there.

It's not hard to get data out of a coroutine, but because they don't execute synchronously, you need to not think of it as a return value.

Instead, you could pass in a status object or callback delegate when you start the coroutine, and the coroutine can store values into that for your code elsewhere to read them, or fire callback methods as needed.

Or you can have your coroutine fire an event or set flag variables when it gets data it wants to pass on to somewhere else.

If ReadTXT throws an exception, then FinishRest and anything after the yield isn't called. Why is that?

Remember that the StartCoroutine call synchronously runs the coroutine up to its first yield statement.

So when your first yield is after ReadTXT, this is what your callstack looks like when the exception is thrown:

...Unity game loop stuff
    ScriptA.Awake
       StartCoroutine
          IEnumerator.MoveNext (ie. ClassB.ReadTextCoroutine)
             ClassC.ReadTXT

If none of those levels catches the exception, then the whole callstack is unwound up into the Unity game loop where it's finally output as an error message in your console log. Note that all this happens before Awake has had a chance to move on to the line after StartCoroutine, so it's aborted before it can FinishRest().

\$\endgroup\$
  • \$\begingroup\$ Thanks for your answer - I got it now (going to post it as an answer, so it's easier to find)! 1. I know that Coroutines don't wait but I need mine to - if there was a way to make it wait completely (without calling "Start" methods after yield) that would be even better. I'm only using a Coroutine, so I don't completely block the UI/main thread and if there was another way to display a loading screen with a single tick beforehand, I'd use it (is there? Can you use C# Tasks in Unity - like this?). \$\endgroup\$ – Neph Oct 4 '18 at 9:48
  • \$\begingroup\$ 2. I tried putting a yield right before ReadTxt(), which showed the loading screen but then didn't wait of course. I even tried to "trick" it by only have the loading screen plus a tick in the Coroutine, which didn't work properly either. ReadTxt() returns error messages, so while it might be better to make it a Coroutine directly to yield more often, it's not that easy to get Coroutines to actually return stuff like Strings according to other posts. \$\endgroup\$ – Neph Oct 4 '18 at 10:02
  • \$\begingroup\$ I just noticed: If I leave the yield at the very end in and my ReadTxt catches an Exception/returns an "Error" String, then FinishRest() (your DoThatWork()) is never called, it doesn't even output any Debug.Logs I put after that last yield. Shouldn't it still be doing that? \$\endgroup\$ – Neph Oct 4 '18 at 10:40
  • \$\begingroup\$ I've added answers to your follow-up questions to the end of the answer above. \$\endgroup\$ – DMGregory Oct 4 '18 at 11:27
  • \$\begingroup\$ Thanks again for your long answer! 1.Sorry, I meant in a different way. With the code below (which uses your suggestion) the loading panel shows up properly but of course it calls "ScriptA's" Start() method after the first yield, which caused a couple of problems and I ended up moving everything from it to FinishRest(). Luckily there are no other active scripts in the scene at that time but if I could prevent it from following the "game loop", that would be even better - in Java you can do myThread.join() to actually wait for a thread to finish, that's what I'm looking for. \$\endgroup\$ – Neph Oct 4 '18 at 11:47
0
\$\begingroup\$

This is what I ended up with (with DMGregory's help):

//In SciptA (derives from MonoBehaviour, is on the main camera)
void Awake() {
    waitingCoroutine = StartCoroutine(WaitForTxtToFinishCoroutine());
}
private IEnumerator WaitForTxtToFinishCoroutine() {
    Coroutine waitingForTxt = StartCoroutine(ClassB.ReadTxtCoroutine());
    yield return waitingForTxt;
    FinishRest();
}

//In ClassB (derives from ScriptableObject)
public IEnumerator ReadTxtCoroutine() {
    loadingPanel.SetActive(true);
    loadingPanel.GetComponentInChildren<Text>().text = "Reading txt file...";
    yield return null;
    output = ClassC.ReadTXT();
    loadingPanel.SetActive(false);
    yield return null;
}

//In ClassC (derives from nothing and isn't static)
public String ReadTXT() {
    StreamReader reader = new StreamReader(path);
    String line;
    int ii = 0;

    while((line = reader.ReadLine()) != null) {
        error = ProcessLine(line.Trim(),ii);

        if(!error.Equals("")) {
            return error;
        } else {
            ii++;
        }
    }
}
\$\endgroup\$
  • \$\begingroup\$ Note that with this approach the game will still stall while ReadTXT completes, since there are no yields inside it for it to say, read part of the file, then let a frame render, then read a bit more, then render another frame... This is probably a job for a thread so the two can run in parallel instead of taking turns. \$\endgroup\$ – DMGregory Oct 4 '18 at 12:35
  • \$\begingroup\$ That's fine. The only thing that has to be displayed while ReadTxt is doing its thing is the loading screen, everything else (all the GameObjects) depends on the contents of the txt file and is then created at runtime after the txt files has been processed completely. \$\endgroup\$ – Neph Oct 4 '18 at 12:39

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.