-
Notifications
You must be signed in to change notification settings - Fork 0
Video Script
A common misconception is that coroutines are the same as multithreading. Another common misconception is that Unity cannot multithread at all. Coroutines and Threads are similar, but there are specific problems that each one is better at solving.
This means that everything in Unity happens sequentially. Every single line of code, object, method, everything is calculated one at a time. Unity is extremely efficient at this and there are some background processes that may not follow this rule, but any Monobehaviour/GUI-based logic follows this single-threaded requirement. Unity performs all this logic in a specific order, the ExecutionOrder. This is what tells your scripts to run all Awake methods before Start methods, and LateUpdate after all other Update methods have completed.
You might ask yourself "How does Unity move a bunch of different objects on the screen at the same time if it's all single-threaded?". This comes down to frame/fixed cycle logic. Logic is performed on 2 cycles: the frame cycle, and the physics cycle (sometimes called the fixed cycle).
If your game is stable at 60 fps, this means that all of the single-threaded frame cycle logic is taking 1/60th of a second. If a complicated process starts, this can result in the frame cycle logic taking longer than usualy. This is usually why you may notice reduced framerate when a lot of things start happening in a game. The frame cycle is dynamic, and will not be the same timestep every frame. Sometimes it could take 10ms to complete logic, and other times it can take 200ms.
The physics cycle occurs on a fixed timestep and performs different logic separately from the frame. While the fixed timestep is not guaranteed to be the same every cycle, it is much more stringent and is more reliable for perfoming physics-based logic like collissions and rigidbody logic. The default fixed timestep is 20ms or 50 steps per second.
If Unity performs everything in a single thread, what happens if there's a time-intensive process? Everything freezes. A coroutine allows you to spread logic across multiple cycles. Are you generating 1000 enemies in a level? Consider generating a handful per frame in a coroutine, and update the player with a progress bar letting them know that the game hasn't crashed.
Coroutines can also be used to create timed tasks, or temporary processes.
So Unity performs everything in a single thread. How can you have multiple Unity threads? Well, you can't have multiple threads performing Unity's logic as of today. However, you can have multiple threads that do not interact directly with the main Unity thread at all. Most commonly multiple threads are used for web-based logic, but this multi-threaded logic is usually wrapped in a prebuilt "async" method.
What about complex custom processes? We can do the same thing.
Golden Rules:
- The thread cannot run any Unity logic or methods (can't GetComponent, instantiate objects, or even get the transform of a GameObject). A good way to handle this rule is to strictly work with data.
- The thread needs to be handled by a monobehaviour on the main thread. Otherwise, if your program crashes or you cancel the game before the thread has a chance to finish, you could have a runaway thread.
- Data can only pass between threads using threadsafe objects or collections.
In this example, we perform a puzzle stamp on an image. In real life, puzzles are made by cutting or stamping out puzzle pieces from a completed image. This involves an "image" and a "stamp". This example does the same thing, but digitally. It takes in 2 images: the stamp and the image.
The design is a combination of the stack-based floodfill algorithm and masked blitting between textures.
Floodfill starts at a single pixel and with a "target color". It obtains the "source color" of the first pixel, and then flips that pixel to the new "target color". All neighboring pixels are then compared to the "source color" and if they match they are flipped as well. Then any neighboring pixels to those flipped pixels are checked, and this process repeats until there are no more neighbor pixels that match the source color.
The first instinct for many developers is to build this recursively. However, with thousands of pixels on the screen, you will quickly find yourself with a stack overflow error. In this example, I implement an alternative stack-based floodfill to avoid going too deep down a recursive nightmare.
Blit or Bit BLT, meaning bit block transfer, is the process of copying data between 2 bitmaps. In this example we use the the floodfilled piece on the stamp as a mask for blitting pixels from the image onto a canvas. In other words, whenever we find a pixel to "floodfill" on the stamp, we copy the corresponding pixel from the original image onto a blank canvas. When we are done, we will be left with a canvas that contains a cut out puzzle piece from the original image. There is some mathematics performed along the way to create a smaller rectangular texture in order to reduce the memory footprint of this process. In the end we are left with data containing a Color[] array, an x/y position, width, and height of the newly generated piece.
Once we've generated that data, we an then create a new 2D texture and then use Unity's Sprite.Create() method to generate a new sprite for that individual piece.
The process of floodfilling and blitting is not a very complicated process, but with millions of pixels in a single HD image it can take more than a handful of milliseconds. We could implement this logic in a coroutine, but even then we would likely run into frame stuttering and a bad user experience.
The exciting news is that the majority of the process only relies on data, and therefore we can shift some of it away from Unity's main thread! We can package all of this texture information into data structures like Color[] arrays and pass them off to a parallel thread. The parallel thread will handle floodfilling the entire image, blitting out pieces to new Color[] arrays, and reducing the memory footprint of the new textures. The process of instantiating game objects and creating sprites can be left to the parent coroutine.
At this point if you're still following along, you may be asking yourself "How does data pass between the parallel thread and the main thread?". The answer: ThreadSafe Collections. Specifically, a ConcurrentQueue. As the parallel thread generates new textures, they are packaged into a custom datatype and enqueued. The coroutine running on the main thread monitors that queue and dequeues them, instantiating game objects and generating sprites for each.
The functionality is driven by the PuzzleStamp.cs script.