0

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?

Neph
  • 267
  • 2
  • 12

2 Answers2

1

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().

DMGregory
  • 134,153
  • 22
  • 242
  • 357
  • 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?). – Neph Oct 04 '18 at 09:48
  • 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.
  • – Neph Oct 04 '18 at 10:02
  • 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? – Neph Oct 04 '18 at 10:40
  • I've added answers to your follow-up questions to the end of the answer above. – DMGregory Oct 04 '18 at 11:27
  • 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. – Neph Oct 04 '18 at 11:47
  • Sorry, that's just what I tried but didn't work. Your 'yield return thisWork;' fixed it and it's now waiting for the Coroutine to finish before it calls FinishRest(). ReadTxt can take a couple of seconds to finish if it's a big file but it's just a normal method and there's nothing asynchronous going on. I added some code below. 3. I am using a static class every other script can access but wouldn't that mean also using a while to check for results?
  • – Neph Oct 04 '18 at 12:05
  • 4.To be exact: The exception is a FormatException that is thrown if the text can't be parsed into an int in ProcessLine. In the catch part it instantly returns one of my error messages, which breaks the while in ReadTxt() with another return error; This error is stored in my static class and is then checked in FinishRest() to either continue or activate an error panel. Now, if I leave the last yield in the ReadTxtCoroutine() in, it never even reaches FinishRest, if I don't, it shows the panel but the buttons on it don't work (they did before I added the Coroutine). – Neph Oct 04 '18 at 12:17
  • "in Java you can do myThread.join() to actually wait for a thread to finish, that's what I'm looking for" Then don't use Coroutines if what you want are threads. They're different tools for different jobs, as described at the link above. Overall, it looks like you have more questions than we can address well in a comment thread. You should be able to access [chat] to get help in a more interactive manner. – DMGregory Oct 04 '18 at 12:32
  • You can't access the Unity UI in a different thread. :/ I don't need a thread in a way that I need 2 things running at the same time, I want to do this: Unload the old scene - load the new scene - display the loading screen (includes a tick) - read the txt file - deactivate the loading screen. At first I did it like this (use async scene loading, then abuse the freeze to display the loading screen) but I wanted to do it properly. Now the loading screen works properly but the rest is messed up. :/ – Neph Oct 04 '18 at 12:46
  • Say you wanted to add a progress bar to your loading screen. Doing it the way you have it now, you can't animate progress between the start and the end of the load, because the game's not able to render a new frame until the loading is done. There's nothing wrong though with logging progress made in your thread, then reading that progress on the main game loop to update your UI. – DMGregory Oct 04 '18 at 13:28
  • For the bigger waiting times I'm using a simple "x/y ..." (or %) but even the biggest txt file I'm testing my game with takes less than 10 seconds to load on a normal HDD (for the smaller ones it's usually 1-2 seconds), so while some kind of "the main menu isn't completely frozen, it's just loading the file" indication is a must, a loading bar is just overkill imo. If I want to add an actual progress bar at one point and can't get it done on my own, I'll open a new question. ;) For now it's more important to me to figure out why the error panel isn't displayed/the buttons don't work. – Neph Oct 04 '18 at 13:44
  • "the main menu isn't completely frozen, it's just loading the file" still requires ticking the main game loop during the load - something you don't get with coroutines unless you explicitly yield during the load. That might be why your buttons aren't working - since your main thread is busy in your loading coroutine, the input and rendering systems don't get a chance to take a turn. – DMGregory Oct 04 '18 at 13:59
  • "Completely frozen" as in "stuck in the main menu with the button in the pressed state without reason". I don't care if it's "frozen" while it's displaying the loading panel because there's a loading indicator. No, the loading is aborted: As soon as the exception is caught, ProcessLine() returns the error message to ReadTxt(), which breaks the loop and returns the error to the innermost Coroutine in ClassB. With the very last yield there it never displays the error panel, which is what confuses me. Without it does but the buttons don't work, even though they work in normal gameplay. – Neph Oct 04 '18 at 14:14
  • This discussion is extending beyond the scope we can handle well in comments, so I recommend either heading to [chat] or posting a new question specific to the issue you're tackling now. – DMGregory Oct 04 '18 at 14:21
  • I just noticed that the buttons also aren't responsive (they don't even switch to their highlighted states when I hover over them) if I load the txt file without the Coroutine, so looks like it's indeed something better handled in in a new question. Do you have any quick suspicion why the yield at the end would prevent it from reaching FinishRest() in ScriptA, even though ReadTxt() clearly finishes? Thanks again for your help! – Neph Oct 04 '18 at 14:36
  • I finally found out why the buttons weren't reacting - after checking everything regarding the EventSystem (including this list - the EventSystem didn't show anything): I was logging the exact error message to console using Log.LogError. What I didn't know: Unity treats this as if some random exception was thrown and freezes the UI/EventSystem. I replaced it with a normal Log.Log and the buttons are working properly again. – Neph Nov 09 '18 at 12:50