Avoiding Find() and SendMessage() at runtime

The SendMessage() method and family of GameObject.Find() methods are notoriously expensive and should be avoided at all costs. The SendMessage() method is about 2,000 times slower than a simple function call, and the cost of the Find() method scales very poorly with scene complexity since it must iterate through every GameObject in the scene. It is sometimes forgivable to call Find() during the initialization of a scene, such as during an Awake() or Start() callback. Even in this case, it should only be used to acquire objects that we know for certain already exist in the scene and for scenes that have only a handful of GameObjects in them. Regardless, using either of these methods for inter-object communication at runtime is likely to generate a very noticeable overhead and, potentially, dropped frames.

Relying on Find() and SendMessage() is typically symptomatic of poor design, inexperience in programming with C# and Unity, or just plain laziness during prototyping. Their usage has become something of an epidemic among beginner-level and intermediate-level projects, so much so that Unity Technologies feels the need to keep reminding users to avoid using them in a real game over and over again in their documentation and at their conferences. They only exist as a less programmer-y way to introduce new users to inter-object communication, and for some special cases where they can be used in a responsible way (which are few and far between). In other words, they're so ridiculously expensive that they break the rule of not pre-optimizing our code, and it's worth going out of our way to avoid using them if our project is going beyond the prototyping stage (which is a distinct possibility since you're reading this book).

To be fair, Unity targets a wide demographic of users, from hobbyists to students and professionals, to individual developers, to hundreds of people on the same team. This results in an incredibly wide range of software development ability. When you're starting out with Unity, it can be difficult to figure out on your own what you should be doing differently, especially given how the Unity engine does not adhere to the design paradigms of many other game engines we might be familiar with. It has some foreign and quirky concepts relating to scenes and Prefabs and does not have a built-in God class entry point, nor any obvious raw data storage systems to work with.

A God class is a fancy name for the first object we might create in our application and whose job would be to create everything else we need based on the current context (what level to load, which subsystems to activate, and so on). These can be particularly useful if we want a single centralized location that controls the order of events as they happen during the entire lifecycle of our application.

Understanding how to exchange messages between intricate software architecture components is not useful just for Unity's performance, but also for the design of any real-time event-driven system (including, but not limited to games), so it is worth exploring the subject in some detail, evaluating some alternative methods for inter-object communication.

Let's start by examining a worst-case example, which uses both Find() and SendMessage() to communicate between objects, and then look into ways to improve upon it.

The following is a class definition for a simple EnemyManagerComponent instance that tracks a list of GameObjects representing enemies in our game and provides a KillAll() method to destroy them all when needed:

using UnityEngine;
using System.Collections.Generic;

class EnemyManagerComponent : MonoBehaviour {
List<GameObject> _enemies = new List<GameObject>();

public void AddEnemy(GameObject enemy) {
if (!_enemies.Contains(enemy)) {
_enemies.Add(enemy);
}
}

public void KillAll() {
for (int i = 0; i < _enemies.Count; ++i) {
GameObject.Destroy(_enemies[i]);
}
_enemies.Clear();
}
}

We would then place a GameObject instance in our scene containing this component, and name it EnemyManager.

The following example method attempts to instantiate several enemies from a given Prefab, and then notifies the EnemyManager object of their existence:

public void CreateEnemies(int numEnemies) {
for(int i = 0; i < numEnemies; ++i) {
GameObject enemy = (GameObject)GameObject.Instantiate(_enemyPrefab,
5.0f * Random.insideUnitSphere,
Quaternion.identity);
string[] names = { "Tom", "Dick", "Harry" };
enemy.name = names[Random.Range(0, names.Length)];
GameObject enemyManagerObj = GameObject.Find("EnemyManager");
enemyManagerObj.SendMessage("AddEnemy",
enemy,
SendMessageOptions.DontRequireReceiver);
}
}

Initializing data and putting method calls inside any kind of loop, which always outputs to the same result, is a big red flag for poor performance, and when we're dealing with expensive methods, such as Find(), we should always look for ways to call them as few times as possible. Ergo, one improvement we can make is to move the Find() call outside of the for loop and cache the result in a local variable so that we don't need to keep reacquiring the EnemyManager object over and over again.

Moving the initialization of the names variable outside of the for loop is not necessarily critical since the compiler is often smart enough to realize it doesn't need to keep reinitializing data that isn't being changed elsewhere. However, it does often make the code easier to read.

Another big improvement we can implement is to optimize our usage of the SendMessage() method by replacing it with a GetComponent() call. This replaces a very costly method with an equivalent and much cheaper alternative.

This gives us the following result:

public void CreateEnemies(int numEnemies) {
GameObject enemyManagerObj = GameObject.Find("EnemyManager");
EnemyManagerComponent enemyMgr = enemyManagerObj.GetComponent<EnemyManagerComponent>();
string[] names = { "Tom", "Dick", "Harry" };

for(int i = 0; i < numEnemies; ++i) {
GameObject enemy = (GameObject)GameObject.Instantiate(_enemyPrefab,
5.0f * Random.insideUnitSphere,
Quaternion.identity);
enemy.name = names[Random.Range(0, names.Length)];
enemyMgr.AddEnemy(enemy);
}
}

If this method is called during the initialization of the scene, and we're not overly concerned with loading time, then we can probably consider ourselves finished with our optimization work.

However, we will often need new objects that are instantiated at runtime to find an existing object to communicate with. In this example, we want new enemy objects to register with our EnemyManagerComponent so that it can do whatever it needs to do to track and control the enemy objects in our scene. We would also like EnemyManager to handle all enemy-related behavior so that objects calling its functions don't need to perform work on its behalf. This will improve the coupling (how well our code base separates related behavior) and encapsulation (how well our classes prevent outside changes to the data they manage) of our application. The ultimate aim is to find a reliable and fast way for new objects to find existing objects in the scene without unnecessary usage of the Find() method so that we can minimize complexity and performance costs.

There are multiple approaches we can take to solving this problem, each with their own benefits and pitfalls:

  • Assign references to preexisting objects
  • Static classes
  • Singleton components
  • A global messaging system
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.141.200.180