Search Unity

Pushing result from a thread into a GameObject

Discussion in 'Scripting' started by SimRuJ, Jan 22, 2020.

  1. SimRuJ

    SimRuJ

    Joined:
    Apr 7, 2016
    Posts:
    247
    Hi everyone,

    I'm working on an app that reads a lot of .obj files from harddrive at runtime and displays the resulting mesh, letting you walk around it and look at it from different angles (input detection is run in "LateUpdate"). As reading these files is really slow, I changed it to serialize the GameObject's Vector2/3 arrays into a different file (using SerialFiller). Reading this new file is a lot faster but everything's still running on the main/UI thread, which causes the app to freeze every time serialized data is read/imported.

    My code:

    Code (CSharp):
    1.  
    2. public void ImportEverything() {
    3.     for(int i = 0; i<allObjects.Count; i++) {
    4.         GameObject go = new GameObject;
    5.         ImportObj(allObjects[i].path, go);
    6.         //Do some other stuff
    7.     }
    8. }
    9. private void ImportObj(String path, GameObjects go) {
    10.     byte[] bytes = File.ReadAllBytes(path);
    11.     SerialFiller.MakeDataFill(bytes);
    12.     Mesh me = new Mesh();
    13.     me.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
    14.     me.vertices = (Vector3[])SerialFiller.DataFill[0].Value;
    15.     me.uv = (Vector2[])SerialFiller.DataFill[1].Value;
    16.     me.normals = (Vector3[])SerialFiller.DataFill[2].Value;
    17.     me.triangles = (int[])SerialFiller.DataFill[3].Value;
    18.     go.AddComponent<MeshFilter>().GetComponent<MeshFilter>().mesh = me;
    19.     go.AddComponent<MeshFilter>().GetComponent<MeshRenderer>().material = someMaterial;
    20.     //Some more code that moves the object,...
    21. }
    I'm aware that you can't use Unity's UI stuff in a background thread (Vector2/Vector3 is fine, Mesh/GameObject isn't), so I tried to add a thread like this (according to this tutorial):

    Code (CSharp):
    1. private void ImportObj(String path, GameObjects go) {
    2.     Thread t = new Thread(() => ThreadStuff(path));
    3.     t.Start();
    4.     Thread.Sleep(0);
    5.     t.Join();
    6.  
    7.     Mesh me = new Mesh();
    8.     me.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
    9.     me.vertices = (Vector3[])SerialFiller.DataFill[0].Value;
    10.     me.uv = (Vector2[])SerialFiller.DataFill[1].Value;
    11.     me.normals = (Vector3[])SerialFiller.DataFill[2].Value;
    12.     me.triangles = (int[])SerialFiller.DataFill[3].Value;
    13.     go.AddComponent<MeshFilter>().GetComponent<MeshFilter>().mesh = me;
    14.     go.AddComponent<MeshFilter>().GetComponent<MeshRenderer>().material = someMaterial;
    15.     //Some more code that moves the object,...
    16. }
    17.  
    18. private static void ThreadStuff(String path) {
    19.     byte[] bytes = File.ReadAllBytes(path);
    20.     SerialFiller.MakeDataFill(bytes);
    21.     Thread.Sleep(0);
    22. }
    I didn't notice any difference though, there's still a pretty long freeze every time a new obj is loaded. Did I miss anything? How do I do this properly to make the freezes as short as possible (or maybe even git rid of them completely)?
     
    Last edited: Jan 22, 2020
  2. StarManta

    StarManta

    Joined:
    Oct 23, 2006
    Posts:
    8,775
    It looks like you're starting a new thread, and then just freezing the main thread until the new thread finishes, so you end up with the same effect. What you need to do is start the thread, allow the game to continue, and then come back to the thread later.

    What I like to do for this stuff is to set up a callback from the main thread. You can stash a lambda function in a callback, and call it the next time Update() runs, so that it will be on the main thread. So, something along these lines:
    Code (csharp):
    1. private void ImportObj(string path, GameObjects go) {
    2. Thread t = new Thread( () => ThreadStuff(path) );
    3. t.Start();
    4. }
    5.  
    6. private Action mainThreadCallback;
    7. void Update() {
    8. if (mainThreadCallback != null) {
    9. mainThreadCallback.Invoke();
    10. mainThreadCallback = null;
    11. }
    12. }
    13.  
    14. private static void ThreadStuff(string path) {
    15.     byte[] bytes = File.ReadAllBytes(path);
    16.     SerialFiller.MakeDataFill(bytes);
    17.  
    18.     mainThreadCallbacks = () => {
    19.     Mesh me = new Mesh();
    20.     me.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
    21.     me.vertices = (Vector3[])SerialFiller.DataFill[0].Value;
    22.     me.uv = (Vector2[])SerialFiller.DataFill[1].Value;
    23.     me.normals = (Vector3[])SerialFiller.DataFill[2].Value;
    24.     me.triangles = (int[])SerialFiller.DataFill[3].Value;
    25.     go.AddComponent<MeshFilter>().GetComponent<MeshFilter>().mesh = me;
    26.     go.AddComponent<MeshFilter>().GetComponent<MeshRenderer>().material = someMaterial;
    27.     //Some more code that moves the object,...
    28.     };
    29. }
     
  3. SimRuJ

    SimRuJ

    Joined:
    Apr 7, 2016
    Posts:
    247
    @StarManta
    Thanks for your reply! Unfortunately there's a problem: I left out a bunch of stuff for the sake of simplicity, so this won't work without breaking the whole app. "ImportObj" actually looks like this (there's still more stuff to it of course):

    Code (CSharp):
    1. private GameObject ImportObj(String path) {
    2.     if(.....) {
    3.         //Some code
    4.         if(.....) {
    5.             byte[] bytes = File.ReadAllBytes(path);
    6.             SerialFiller.MakeDataFill(bytes);
    7.             Mesh me = new Mesh();
    8.             me.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
    9.             me.vertices = (Vector3[])SerialFiller.DataFill[0].Value;
    10.             me.uv = (Vector2[])SerialFiller.DataFill[1].Value;
    11.             me.normals = (Vector3[])SerialFiller.DataFill[2].Value;
    12.             me.triangles = (int[])SerialFiller.DataFill[3].Value;
    13.             go.AddComponent<MeshFilter>().GetComponent<MeshFilter>().mesh = me;
    14.             go.AddComponent<MeshFilter>().GetComponent<MeshRenderer>().material = someMaterial;
    15.             //Some more code that moves the object,...
    16.         } else if(....) {
    17.             //Some code
    18.         } else {
    19.             //Some code
    20.         }
    21.         //Some important code that does stuff with the GameObject
    22.     } else {
    23.         //Some code
    24.     }
    25.     return null;
    26. }
    The code creating the GO has to finish before "Some important code that does stuff with the GameObject" and the "return" can run. Because of this my app has to wait for the whole thing to finish but at the same time it shouldn't block the camera movement, which sadly has to be on the main thread too because, well, it's moving the camera (which is also part of the "don't touch outside the main thread" group).

    To make stuff even more complex: "ImportObj" is also used inside a coroutine that creates the serialized files. This is only called on rare occasions though and always right after starting the app.
     
  4. StarManta

    StarManta

    Joined:
    Oct 23, 2006
    Posts:
    8,775
    Fundamentally, there's just no way to get a "return" without freezing the app until the processing is finished. Just can't be done, with the way a program works. "return" has to return before the program can do anything else. I would suggest backing up another step, rather than actually "returning" the GameObject, creating a callback so that the object can be created whenever the thread finishes loading it.
     
  5. SimRuJ

    SimRuJ

    Joined:
    Apr 7, 2016
    Posts:
    247
    @StarManta
    Sorry for the late reply!
    I tested it according to your suggestion in post #2. For that I put "Some important code that does stuff with the GameObject" into an extra function that's called from every "if" branch (I know, it's not "pretty") and for the special branch at the end of the callback. The problem is that the code inside the "mainThreadCallback" or even the "Update" function are never called. So while the .obj file is read, my app is stuck with the empty GameObject.
    The class this code is in derives from "ScriptableObject" and an instance is created with "ScriptableObject.CreateInstance<MyClass>()" inside a "public static class" (I use that one to pass around data between scenes) because it must not be destroyed at any point. Is that the reason why it's not working?

    Edit:
    I changed everything, so the "MyClass" derives from MonoBehaviour and is now attached to a "DontDestroyOnLoad" GameObject, which enabled "Update" of course. A lot of GameObjects are being skipped though: For 17 that were imported within a short period of time (a couple of seconds), only 3 actually got their callback. The other 14 remained as empty GameObject. Is the "mainThreadCallback" actually collecting callbacks or what's going on here?
     
    Last edited: Jan 28, 2020
  6. SimRuJ

    SimRuJ

    Joined:
    Apr 7, 2016
    Posts:
    247
    I fixed it. :)

    First I added a coroutine, so I would be able to yield but as "ImportEverything" has a loop it just created multiple coroutines, plus the callback wasn't reliable enough: If you add some sort of list that stores all the byte arrays (like a "todo" list, because the callback doesn't happen instantly of course), some of them were left over in the end because they were added after the last loop iteration had already finished, which would have required another loop iteration.

    What I ended up with:
    1. "MyClass" derives from MonoBehaviour and is attached to a "DontDestroyOnLoad" GameObject.
    2. "ImportObj" still runs on the main thread but instead of actually reading the file it only adds important infos for the import to the end of a list ("A").
    3. A thread that is started inside "Start" and loops through a certain amount of items in list A (it only reads the infos at index 0!) to import the files into byte arrays, which are added to the end of list "B". Once it's done, it sleeps for a second.
    4. A coroutine inside a coroutine that are also started inside "Start" - the 1st yields the 2nd, so yields inside the 2nd don't skip back to outside the 1st. Coroutine #2 loops through list B, reads the byte array at index 0, pushes it into SerialFiller and builds the GameObjects with the resulting i.a. vertices arrays. Afterwards that list item is removed. After the loop is done, the inner coroutine sleeps for 1 second. There are also plenty of yields to give my app time to tick.
    5. Important: Don't forget to add a semaphore whenever list A or B are accessed/changed!
    Comparison to my old code:
    • + I don't use "Update", so I have (semi) full control over when I yield.
    • + I don't use "Update", I can easily stop the thread/coroutine.
    • + There are no callbacks, so at one point or another all of the meshes are imported and none are left behind.
    • + There are no more complete freezes whenever a mesh is imported, even though the fps drop noticeably unfortunately but not sure if there's anything else I can do about that.
    • - Sometimes GameObjects don't show up when I'm walking over them in-app because they're added to the end of the lists and others are simply imported first. So unless you walk slowly, there's a slight delay (but hey, no more freezes!).
    • - A lot of data is stored in the RAM (byte arrays and finished GameObjects).
     
    Last edited: Feb 20, 2020