Unity Game Optimization
上QQ阅读APP看书,第一时间看更新

Update, coroutines, and InvokeRepeating

Another habit that's easy to fall into is to call something repeatedly in an Update() callback way more often than is needed. For example, we may start with a situation like this:

void Update() {
ProcessAI();
}

In this case, we're calling some custom ProcessAI() subroutine every single frame. This may be a complex task, requiring the AI system to check some grid system to figure out where it's meant to move or determine some fleet maneuvers for a group of spaceships or whatever our game needs for its AI.

If this activity is eating into our frame rate budget too much, and the task can be completed less frequently than every frame with no significant drawbacks, then a good trick to improve performance is to simply reduce the frequency at which that ProcessAI() gets called:

private float _aiProcessDelay = 0.2f;
private float _timer = 0.0f;

void Update() {
_timer += Time.deltaTime;
if (_timer > _aiProcessDelay) {
ProcessAI();
_timer -= _aiProcessDelay;
}
}

In this case, we've reduced the Update() callback's overall cost by only invoking ProcessAI() about five times every second, which is an improvement over the previous situation, at the expense of code that can take a bit of time to understand at first glance, and a little extra memory to store some floating-point data—although, at the end of the day, we're still having Unity call an empty callback function more often than not.

This function is a perfect example of a function, which can be converted into a coroutine to make use of their delayed invocation properties. As mentioned previously, coroutines are typically used to script a short sequence of events, either as a one-time or repeated action. They should not be confused with threads, which would run on a completely different CPU core concurrently, and multiple threads can be running simultaneously. Instead, coroutines run on the main thread in a sequential manner such that only one coroutine is handled at any given moment, and each coroutine decides when to pause and resume via yield statements. The following code is an example of how we might rewrite the preceding Update() callback in the form of a coroutine:

void Start() {
StartCoroutine(ProcessAICoroutine ());
}

IEnumerator ProcessAICoroutine () {
while (true) {
ProcessAI();
yield return new WaitForSeconds(_aiProcessDelay);
}
}

The preceding code demonstrates a coroutine that calls ProcessAI(), then pauses at the yield statement for the given number of seconds (the value of _aiProcessDelay) before the main thread resumes the coroutine again, at which point, it will return to the start of the loop, call ProcessAI(), pause on the yield statement again, and repeat forever (via the while(true) statement) until asked to stop.

The main benefit of this approach is that this function will only be called as often as dictated by the value of _aiProcessDelay, and it will sit idle until that time, reducing the performance hit inflicted in most of our frames. However, this approach has its drawbacks.

For one, starting a coroutine comes with an additional overhead cost relative to a standard function call (around three times as slow), as well as some memory allocations to store the current state in memory until it is invoked the next time. This additional overhead is also not a one-time cost because coroutines often constantly call yield, which inflicts the same overhead cost again and again, so we need to ensure that the benefits of reduced frequency outweigh this cost.

In a test of 1,000 objects with empty Update() callbacks, it took 1.1 milliseconds to process, whereas 1,000 coroutines yielding on WaitForEndOfFrame (which has an identical frequency to Update() callbacks) took 2.9 milliseconds. So, the relative cost is almost three times as much.

Secondly, once initialized, coroutines run independently of the triggering MonoBehaviour component's Update() callback and will continue to be invoked regardless of whether the component is disabled or not, which can make them unwieldy if we're performing a lot of GameObject construction and destruction.

Thirdly, the coroutine will automatically stop the moment the GameObject instance that contains it is made inactive for whatever reason (whether it was set inactive or one of its parents was) and will not automatically restart if GameObject is set to active again.

Finally, by converting a method into a coroutine, we may have reduced the performance hit inflicted during most of our frames, but if a single invocation of the method body causes us to break our frame rate budget, then it will still be exceeded no matter how rarely we call the method. Therefore, this approach is best used for situations where we are only breaking our frame rate budget because of the sheer number of times the method is called in a given frame, not because the method is too expensive on its own. In those cases, we have no option but to either dig into and improve the performance of the method itself or reduce the cost of other tasks to free up the time it needs to complete its work.

There are several yield types available to us when generating coroutines. WaitForSeconds is fairly self-explanatory; the coroutine will pause at the yield statement for a given number of seconds. It is not really an exact timer, however, so expect a small amount of variation when this yield type actually resumes.

WaitForSecondsRealTime is another option and is different from WaitForSeconds only in that it uses unscaled time. WaitForSeconds compares against scaled time, which is affected by the global Time.timeScale property while WaitForSecondsRealTime is not, so be careful about which yield type you use if you're tweaking the time scale value (for example, for slow-motion effects).

There is also WaitForEndOfFrame, which would continue at the end of the next Update() callback, and then there's WaitForFixedUpdate, which would continue at the end of the next FixedUpdate() invocation. Lastly, Unity 5.3 introduced WaitUntil and WaitWhile, where we provide a delegate function, and the coroutine will pause until the given delegate returns true or false, respectively. Note that the delegates provided to these yield types will be executed for each Update() until they return the Boolean value needed to stop them, which makes them very similar to a coroutine using WaitForEndOfFrame in a while loop that ends on a certain condition. Of course, it is also important that the delegate function we provide is not too expensive to execute.

Delegate functions are incredibly useful constructs in C# that allow us to pass local methods around as arguments to other methods and are typically used for callbacks. Check out the MSDN C# Programming Guide for more information on delegates at https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/delegates/.

The way that some Update() callbacks are written could probably be condensed down into simple coroutines that always call yield on one of these types, but we should be aware of the drawbacks mentioned previously. Coroutines can be tricky to debug since they don't follow normal execution flow; there's no caller in the callstack we can directly blame for why a coroutine triggered at a given time, and if coroutines perform complex tasks and interact with other subsystems, then they can result in some impossibly difficult bugs because they happened to be triggered at a moment that some other code didn't expect, which also tend to be the kinds of bugs that are painstakingly difficult to reproduce. If you do wish to make use of coroutines, the best advice is to keep them simple and independent of other complex subsystems.

Indeed, if our coroutine is simple enough that it can be boiled down to a while loop that always calls yield on WaitForSeconds or WaitForSecondsRealtime, as in the preceding example, then we can usually replace it with an InvokeRepeating() call, which is even simpler to set up and has a slightly lower overhead cost. The following code is functionally equivalent to the previous implementation that used a coroutine to regularly invoke a ProcessAI() method:

void Start() {
InvokeRepeating("ProcessAI", 0f, _aiProcessDelay);
}

An important difference between InvokeRepeating() and coroutines is that InvokeRepeating() is completely independent of the states of both MonoBehaviour and GameObject. The only two ways to stop an InvokeRepeating() call is to either call CancelInvoke(), which stops all InvokeRepeating() callbacks initiated by the given MonoBehaviour (note that they cannot be canceled individually) or to destroy the associated MonoBehaviour or its parent GameObject. Disabling either MonoBehaviour or GameObject does not stop InvokeRepeating().

A test of 1,000  InvokeRepeating()  calls was processed in about 2.6 milliseconds; this is slightly faster than 1,000 equivalent coroutine  yield  calls, which took 2.9 milliseconds.

That covers most of the useful information related to the Update() callback. Let's look into some other useful scripting tips.